From 2406052742d7d0b77db974d990cef9729ad33fad Mon Sep 17 00:00:00 2001 From: syc0123 Date: Wed, 11 Mar 2026 10:15:17 +0900 Subject: [PATCH 01/14] refactor: Enhance rack structure component with format configuration and segment handling - Introduced `FormatSegment` and `LocationFormatConfig` types to manage the formatting of location codes and names. - Added `defaultFormatConfig` to provide default segment configurations for location codes and names. - Implemented `buildFormattedString` function to generate formatted strings based on active segments and their configurations. - Updated `RackStructureComponent` to utilize the new formatting logic for generating location codes and names. - Enhanced `RackStructureConfigPanel` to allow users to edit format settings for location codes and names using `FormatSegmentEditor`. These changes improve the flexibility and usability of the rack structure component by allowing dynamic formatting of location identifiers. --- .../LFC[계획]-위치포맷-사용자설정.md | 374 ++++++++++++++++++ .../LFC[맥락]-위치포맷-사용자설정.md | 123 ++++++ .../LFC[체크]-위치포맷-사용자설정.md | 84 ++++ .../RFO[계획]-렉구조-층필수해제.md | 350 ++++++++++++++++ .../RFO[맥락]-렉구조-층필수해제.md | 92 +++++ .../RFO[체크]-렉구조-층필수해제.md | 57 +++ .../v2-rack-structure/FormatSegmentEditor.tsx | 203 ++++++++++ .../RackStructureComponent.tsx | 45 +-- .../RackStructureConfigPanel.tsx | 42 +- .../components/v2-rack-structure/config.ts | 101 ++++- .../components/v2-rack-structure/types.ts | 25 +- frontend/lib/utils/buttonActions.ts | 22 +- 12 files changed, 1471 insertions(+), 47 deletions(-) create mode 100644 docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md create mode 100644 docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md create mode 100644 docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md create mode 100644 docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md create mode 100644 docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md create mode 100644 docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md create mode 100644 frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx diff --git a/docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md b/docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md new file mode 100644 index 00000000..d5a44b05 --- /dev/null +++ b/docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md @@ -0,0 +1,374 @@ +# [계획서] 렉 구조 위치코드/위치명 포맷 사용자 설정 + +> 관련 문서: [맥락노트](./LFC[맥락]-위치포맷-사용자설정.md) | [체크리스트](./LFC[체크]-위치포맷-사용자설정.md) + +## 개요 + +물류관리 > 창고정보 관리 > 렉 구조 등록 모달에서 생성되는 **위치코드(`location_code`)와 위치명(`location_name`)의 포맷을 관리자가 화면 디자이너에서 자유롭게 설정**할 수 있도록 합니다. + +현재 위치코드/위치명 생성 로직은 하드코딩되어 있어, 구분자("-"), 세그먼트 순서(창고코드-층-구역-열-단), 한글 접미사("구역", "열", "단") 등을 변경할 수 없습니다. + +--- + +## 현재 동작 + +### 1. 타입/설정에 패턴 필드가 정의되어 있지만 사용하지 않음 + +`types.ts`(57~58행)에 `codePattern`/`namePattern`이 정의되어 있고, `config.ts`(14~15행)에 기본값도 있으나, 실제 컴포넌트에서는 **전혀 참조하지 않음**: + +```typescript +// types.ts:57~58 - 정의만 있음 +codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}") +namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단") + +// config.ts:14~15 - 기본값만 있음 +codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}", +namePattern: "{zone}구역-{row:02d}열-{level}단", +``` + +### 2. 위치 코드 생성 하드코딩 (RackStructureComponent.tsx:494~510) + +```tsx +const generateLocationCode = useCallback( + (row: number, level: number): { code: string; name: string } => { + const warehouseCode = context?.warehouseCode || "WH001"; + const floor = context?.floor; + const zone = context?.zone || "A"; + + const floorPrefix = floor ? `${floor}` : ""; + const code = `${warehouseCode}-${floorPrefix}${zone}-${row.toString().padStart(2, "0")}-${level}`; + + const zoneName = zone.includes("구역") ? zone : `${zone}구역`; + const floorNamePrefix = floor ? `${floor}-` : ""; + const name = `${floorNamePrefix}${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`; + + return { code, name }; + }, + [context], +); +``` + +### 3. ConfigPanel에 포맷 관련 설정 UI 없음 + +`RackStructureConfigPanel.tsx`에는 필드 매핑, 제한 설정, UI 설정만 있고, `codePattern`/`namePattern`을 편집하는 UI가 없음. + +--- + +## 변경 후 동작 + +### 1. ConfigPanel에 "포맷 설정" 섹션 추가 + +화면 디자이너 좌측 속성 패널의 v2-rack-structure ConfigPanel에 새 섹션이 추가됨: + +- 위치코드/위치명 각각의 세그먼트 목록 +- 최상단에 컬럼 헤더(`라벨` / `구분` / `자릿수`) 표시 +- 세그먼트별로 **드래그 순서변경**, **체크박스로 한글 라벨 표시/숨김**, **라벨 텍스트 입력**, **구분자 입력**, **자릿수 입력** +- 자릿수 필드는 숫자 타입(열, 단)만 활성화, 나머지(창고코드, 층, 구역)는 비활성화 +- 변경 시 실시간 미리보기로 결과 확인 + +### 2. 컴포넌트에서 config 기반 코드 생성 + +`RackStructureComponent`의 `generateLocationCode`가 하드코딩 대신 `config.formatConfig`의 세그먼트 배열을 순회하며 동적으로 코드/이름 생성. + +### 3. 기본값은 현재 하드코딩과 동일 + +`formatConfig`가 설정되지 않으면 기본 세그먼트가 적용되어 현재와 완전히 동일한 결과 생성 (하위 호환). + +--- + +## 시각적 예시 + +### ConfigPanel UI (화면 디자이너 좌측 속성 패널) + +``` +┌─ 포맷 설정 ──────────────────────────────────────────────┐ +│ │ +│ 위치코드 포맷 │ +│ 라벨 구분 자릿수 │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ ☰ 창고코드 [✓] [ ] [ - ] [ 0 ] (비활성) │ │ +│ │ ☰ 층 [✓] [ 층 ] [ ] [ 0 ] (비활성) │ │ +│ │ ☰ 구역 [✓] [구역 ] [ - ] [ 0 ] (비활성) │ │ +│ │ ☰ 열 [✓] [ ] [ - ] [ 2 ] │ │ +│ │ ☰ 단 [✓] [ ] [ ] [ 0 ] │ │ +│ └──────────────────────────────────────────────────┘ │ +│ 미리보기: WH001-1층A구역-01-1 │ +│ │ +│ 위치명 포맷 │ +│ 라벨 구분 자릿수 │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ ☰ 구역 [✓] [구역 ] [ - ] [ 0 ] (비활성) │ │ +│ │ ☰ 열 [✓] [ 열 ] [ - ] [ 2 ] │ │ +│ │ ☰ 단 [✓] [ 단 ] [ ] [ 0 ] │ │ +│ └──────────────────────────────────────────────────┘ │ +│ 미리보기: A구역-01열-1단 │ +│ │ +└───────────────────────────────────────────────────────────┘ +``` + +### 사용자 커스터마이징 예시 + +| 설정 변경 | 위치코드 결과 | 위치명 결과 | +|-----------|-------------|------------| +| 기본값 (변경 없음) | `WH001-1층A구역-01-1` | `A구역-01열-1단` | +| 구분자를 "/" 로 변경 | `WH001/1층A구역/01/1` | `A구역/01열/1단` | +| 층 라벨 해제 | `WH001-1A구역-01-1` | `A구역-01열-1단` | +| 구역+열 라벨 해제 | `WH001-1층A-01-1` | `A-01-1단` | +| 순서를 구역→층→열→단 으로 변경 | `WH001-A구역1층-01-1` | `A구역-1층-01열-1단` | +| 한글 라벨 모두 해제 | `WH001-1A-01-1` | `A-01-1` | + +--- + +## 아키텍처 + +### 데이터 흐름 + +```mermaid +flowchart TD + A["관리자: 화면 디자이너 열기"] --> B["RackStructureConfigPanel\n포맷 세그먼트 편집"] + B --> C["componentConfig.formatConfig\n에 세그먼트 배열 저장"] + C --> D["screen_layouts_v2.layout_data\nDB JSONB에 영구 저장"] + D --> E["엔드유저: 렉 구조 모달 열기"] + E --> F["RackStructureComponent\nconfig.formatConfig 읽기"] + F --> G["generateLocationCode\n세그먼트 배열 순회하며 동적 생성"] + G --> H["미리보기 테이블에 표시\nlocation_code / location_name"] +``` + +### 컴포넌트 관계 + +```mermaid +graph LR + subgraph designer ["화면 디자이너 (관리자)"] + CP["RackStructureConfigPanel"] + FE["FormatSegmentEditor\n(신규 서브컴포넌트)"] + CP --> FE + end + subgraph runtime ["렉 구조 모달 (엔드유저)"] + RC["RackStructureComponent"] + GL["generateLocationCode\n(세그먼트 기반으로 교체)"] + RC --> GL + end + subgraph storage ["저장소"] + DB["screen_layouts_v2\nlayout_data.overrides.formatConfig"] + end + + FE -->|"onChange → componentConfig"| DB + DB -->|"config prop 전달"| RC +``` + +> 노란색 영역은 없음. 기존 설정-저장-전달 파이프라인을 그대로 활용. + +--- + +## 변경 대상 파일 + +| 파일 | 수정 내용 | 수정 규모 | +|------|----------|----------| +| `frontend/lib/registry/components/v2-rack-structure/types.ts` | `FormatSegment`, `LocationFormatConfig` 타입 추가, `RackStructureComponentConfig`에 `formatConfig` 필드 추가 | ~25줄 | +| `frontend/lib/registry/components/v2-rack-structure/config.ts` | 기본 코드/이름 세그먼트 상수 정의 | ~40줄 | +| `frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx` | **신규** - grid 레이아웃 + 컬럼 헤더 + 드래그 순서변경 + showLabel 체크박스 + 라벨/구분/자릿수 고정 필드 + 자릿수 비숫자 타입 비활성화 + 미리보기 | ~200줄 | +| `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | "포맷 설정" 섹션 추가, FormatSegmentEditor 배치 | ~30줄 | +| `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | `generateLocationCode`를 세그먼트 기반으로 교체 | ~20줄 | + +### 변경하지 않는 파일 + +- `buttonActions.ts` - 생성된 `location_code`/`location_name`을 그대로 저장하므로 변경 불필요 +- 백엔드 전체 - 포맷은 프론트엔드에서만 처리 +- DB 스키마 - `screen_layouts_v2.layout_data` JSONB에 자동 포함 + +--- + +## 코드 설계 + +### 1. 타입 추가 (types.ts) + +```typescript +// 포맷 세그먼트 (위치코드/위치명의 각 구성요소) +export interface FormatSegment { + type: 'warehouseCode' | 'floor' | 'zone' | 'row' | 'level'; + enabled: boolean; // 이 세그먼트를 포함할지 여부 + showLabel: boolean; // 한글 라벨 표시 여부 (false면 값에서 라벨 제거) + label: string; // 한글 라벨 (예: "층", "구역", "열", "단") + separatorAfter: string; // 이 세그먼트 뒤의 구분자 (예: "-", "/", "") + pad: number; // 최소 자릿수 (0 = 그대로, 2 = "01"처럼 2자리 맞춤) +} + +// 위치코드 + 위치명 포맷 설정 +export interface LocationFormatConfig { + codeSegments: FormatSegment[]; + nameSegments: FormatSegment[]; +} +``` + +`RackStructureComponentConfig`에 필드 추가: + +```typescript +export interface RackStructureComponentConfig { + // ... 기존 필드 유지 ... + codePattern?: string; // (기존, 하위 호환용 유지) + namePattern?: string; // (기존, 하위 호환용 유지) + formatConfig?: LocationFormatConfig; // 신규: 구조화된 포맷 설정 +} +``` + +### 2. 기본 세그먼트 상수 (config.ts) + +```typescript +import { FormatSegment, LocationFormatConfig } from "./types"; + +// 위치코드 기본 세그먼트 (현재 하드코딩과 동일한 결과) +export const defaultCodeSegments: FormatSegment[] = [ + { type: "warehouseCode", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 0 }, + { type: "floor", enabled: true, showLabel: true, label: "층", separatorAfter: "", pad: 0 }, + { type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 }, + { type: "row", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 2 }, + { type: "level", enabled: true, showLabel: false, label: "", separatorAfter: "", pad: 0 }, +]; + +// 위치명 기본 세그먼트 (현재 하드코딩과 동일한 결과) +export const defaultNameSegments: FormatSegment[] = [ + { type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 }, + { type: "row", enabled: true, showLabel: true, label: "열", separatorAfter: "-", pad: 2 }, + { type: "level", enabled: true, showLabel: true, label: "단", separatorAfter: "", pad: 0 }, +]; + +export const defaultFormatConfig: LocationFormatConfig = { + codeSegments: defaultCodeSegments, + nameSegments: defaultNameSegments, +}; +``` + +### 3. 세그먼트 기반 문자열 생성 함수 (config.ts) + +```typescript +// context 값에 포함된 한글 접미사 ("1층", "A구역") +const KNOWN_SUFFIXES: Partial> = { + floor: "층", + zone: "구역", +}; + +function stripKnownSuffix(type: FormatSegmentType, val: string): string { + const suffix = KNOWN_SUFFIXES[type]; + if (suffix && val.endsWith(suffix)) { + return val.slice(0, -suffix.length); + } + return val; +} + +export function buildFormattedString( + segments: FormatSegment[], + values: Record, +): string { + const activeSegments = segments.filter( + (seg) => seg.enabled && values[seg.type], + ); + + return activeSegments + .map((seg, idx) => { + // 1) 원본 값에서 한글 접미사를 먼저 벗겨냄 ("A구역" → "A", "1층" → "1") + let val = stripKnownSuffix(seg.type, values[seg.type]); + + // 2) showLabel이 켜져 있고 label이 있으면 붙임 + if (seg.showLabel && seg.label) { + val += seg.label; + } + + if (seg.pad > 0 && !isNaN(Number(val))) { + val = val.padStart(seg.pad, "0"); + } + + if (idx < activeSegments.length - 1) { + val += seg.separatorAfter; + } + return val; + }) + .join(""); +} +``` + +### 4. generateLocationCode 교체 (RackStructureComponent.tsx:494~510) + +```typescript +// 변경 전 (하드코딩) +const generateLocationCode = useCallback( + (row: number, level: number): { code: string; name: string } => { + const warehouseCode = context?.warehouseCode || "WH001"; + const floor = context?.floor; + const zone = context?.zone || "A"; + const floorPrefix = floor ? `${floor}` : ""; + const code = `${warehouseCode}-${floorPrefix}${zone}-...`; + // ... + }, + [context], +); + +// 변경 후 (세그먼트 기반) +const formatConfig = config.formatConfig || defaultFormatConfig; + +const generateLocationCode = useCallback( + (row: number, level: number): { code: string; name: string } => { + const values: Record = { + warehouseCode: context?.warehouseCode || "WH001", + floor: context?.floor || "", + zone: context?.zone || "A", + row: row.toString(), + level: level.toString(), + }; + + const code = buildFormattedString(formatConfig.codeSegments, values); + const name = buildFormattedString(formatConfig.nameSegments, values); + + return { code, name }; + }, + [context, formatConfig], +); +``` + +### 5. ConfigPanel에 포맷 설정 섹션 추가 (RackStructureConfigPanel.tsx:284행 위) + +```tsx +{/* 포맷 설정 - UI 설정 섹션 아래에 추가 */} +
+
포맷 설정
+

+ 위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고, + 구분자/라벨을 편집할 수 있습니다 +

+ + handleFormatChange("codeSegments", segs)} + sampleValues={sampleValues} + /> + + handleFormatChange("nameSegments", segs)} + sampleValues={sampleValues} + /> +
+``` + +### 6. FormatSegmentEditor 서브컴포넌트 (신규 파일) + +- `@dnd-kit/core` + `@dnd-kit/sortable`로 드래그 순서변경 +- 프로젝트 표준 패턴: `useSortable`, `DndContext`, `SortableContext` 사용 +- **grid 레이아웃** (`grid-cols-[16px_56px_18px_1fr_1fr_1fr]`): 드래그핸들 / 타입명 / 체크박스 / 라벨 / 구분 / 자릿수 +- 최상단에 **컬럼 헤더** (`라벨` / `구분` / `자릿수`) 표시 — 각 행에서 텍스트 라벨 제거하여 공간 절약 +- 라벨/구분/자릿수 3개 필드는 **항상 고정 표시** (빈 값이어도 입력 필드가 사라지지 않음) +- 자릿수 필드는 숫자 타입(열, 단)만 활성화, 비숫자 타입은 `disabled` + 회색 배경 +- 하단에 `buildFormattedString`으로 실시간 미리보기 표시 + +--- + +## 설계 원칙 + +- `formatConfig` 미설정 시 `defaultFormatConfig` 적용으로 **기존 동작 100% 유지** (하위 호환) +- 포맷 설정은 **화면 디자이너 ConfigPanel에서만** 편집 (프로젝트의 설정-사용 분리 관행 준수) +- `componentConfig` → `screen_layouts_v2.layout_data` 저장 파이프라인을 **그대로 활용** (추가 인프라 불필요) +- 기존 `codePattern`/`namePattern` 문자열 필드는 삭제하지 않고 유지 (하위 호환) +- v2-pivot-grid의 `format` 설정 패턴과 동일한 구조: ConfigPanel에서 설정 → 런타임에서 읽어 사용 +- `@dnd-kit` 드래그 구현은 `SortableCodeItem.tsx`, `useDragAndDrop.ts`의 기존 패턴 재사용 +- 백엔드 변경 없음, DB 스키마 변경 없음 diff --git a/docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md b/docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md new file mode 100644 index 00000000..73c79cef --- /dev/null +++ b/docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md @@ -0,0 +1,123 @@ +# [맥락노트] 렉 구조 위치코드/위치명 포맷 사용자 설정 + +> 관련 문서: [계획서](./LFC[계획]-위치포맷-사용자설정.md) | [체크리스트](./LFC[체크]-위치포맷-사용자설정.md) + +--- + +## 왜 이 작업을 하는가 + +- 위치코드(`WH001-1층A구역-01-1`)와 위치명(`A구역-01열-1단`)의 포맷이 하드코딩되어 있음 +- 회사마다 구분자("-" vs "/"), 세그먼트 순서, 한글 라벨 유무 등 요구사항이 다름 +- 현재는 코드를 직접 수정하지 않으면 포맷 변경 불가 → 관리자가 화면 디자이너에서 설정할 수 있어야 함 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 엔드유저 모달이 아닌 화면 디자이너 ConfigPanel에 설정 UI 배치 + +- **결정**: 포맷 편집 UI를 렉 구조 등록 모달이 아닌 화면 디자이너 좌측 속성 패널(ConfigPanel)에 배치 +- **근거**: 프로젝트의 설정-사용 분리 패턴 준수. 모든 v2 컴포넌트가 ConfigPanel에서 설정하고 런타임에서 읽기만 하는 구조를 따름 +- **대안 검토**: 모달 안에 포맷 편집 UI 배치(방법 B) → 기각 (프로젝트 관행에 맞지 않음, 매번 설정해야 함, 설정이 휘발됨) + +### 2. 패턴 문자열이 아닌 구조화된 세그먼트 배열 사용 + +- **결정**: `"{warehouseCode}-{floor}{zone}-{row:02d}-{level}"` 같은 문자열 대신 `FormatSegment[]` 배열로 포맷 정의 +- **근거**: 관리자가 패턴 문법을 알 필요 없이 드래그/토글/Input으로 직관적 편집 가능 +- **대안 검토**: 기존 `codePattern`/`namePattern` 문자열 활용 → 기각 (관리자가 패턴 문법을 모를 수 있고, 오타 가능성 높음) + +### 2-1. 체크박스는 한글 라벨 표시/숨김 제어 (showLabel) + +- **결정**: 세그먼트의 체크박스는 `showLabel` 속성을 토글하며, 세그먼트 자체를 제거하지 않음 +- **근거**: "A구역-01열-1단"에서 "구역", "열" 체크 해제 시 → "A-01-1단"이 되어야 함 (값은 유지, 한글만 제거) +- **주의**: `enabled`는 세그먼트 자체의 포함 여부, `showLabel`은 한글 라벨만 표시/숨김. 혼동하지 않도록 분리 + +### 2-2. 라벨/구분/자릿수 3개 필드 항상 고정 표시 + +- **결정**: 라벨 필드를 비워도 입력 필드가 사라지지 않고, 3개 필드(라벨, 구분, 자릿수)가 모든 세그먼트에 항상 표시 +- **근거**: 라벨을 지웠을 때 "라벨 없음"이 뜨면서 입력 필드가 사라지면 다시 라벨을 추가할 수 없는 문제 발생 +- **UI 개선**: 컬럼 헤더를 최상단에 배치하고, 각 행에서는 "구분", "자릿수" 텍스트를 제거하여 공간 확보 + +### 2-3. stripKnownSuffix로 원본 값의 한글 접미사를 먼저 벗긴 뒤 라벨 붙임 + +- **결정**: `buildFormattedString`에서 값을 처리할 때, 먼저 `KNOWN_SUFFIXES`(층, 구역)를 벗겨내고 순수 값만 남긴 뒤, `showLabel && label`일 때만 라벨을 붙이는 구조 +- **근거**: context 값이 "1층", "A구역"처럼 한글이 이미 포함된 상태로 들어옴. 이전 방식(`if (seg.label)`)은 라벨 필드가 빈 문자열이면 조건을 건너뛰어서 한글이 제거되지 않는 버그 발생 +- **핵심 흐름**: 원본 값 → `stripKnownSuffix` → 순수 값 → `showLabel && label`이면 라벨 붙임 + +### 2-4. 자릿수 필드는 숫자 타입만 활성화 + +- **결정**: 자릿수(pad) 필드는 열(row), 단(level)만 편집 가능, 나머지(창고코드, 층, 구역)는 disabled + 회색 배경 +- **근거**: 자릿수(zero-padding)는 숫자 값에만 의미가 있음. 비숫자 타입에 자릿수를 설정하면 혼란을 줄 수 있음 + +### 3. 기존 codePattern/namePattern 필드는 삭제하지 않고 유지 + +- **결정**: `types.ts`의 `codePattern`, `namePattern` 필드를 삭제하지 않음 +- **근거**: 하위 호환. 기존에 이 필드를 참조하는 코드가 없지만, 향후 다른 용도로 활용될 수 있음 + +### 4. formatConfig 미설정 시 기본값으로 현재 동작 유지 + +- **결정**: `config.formatConfig`가 없으면 `defaultFormatConfig` 사용 +- **근거**: 기존 화면 설정을 수정하지 않아도 현재와 동일한 위치코드/위치명이 생성됨 (무중단 배포 가능) + +### 5. UI 라벨에서 "패딩" 대신 "자릿수" 사용 + +- **결정**: ConfigPanel UI에서 숫자 제로패딩 설정을 "자릿수"로 표시 +- **근거**: 관리자급 사용자가 "패딩"이라는 개발 용어를 모를 수 있음. "자릿수: 2 → 01, 02, ... 99"가 직관적 +- **코드 내부**: 변수명은 `pad` 유지 (개발자 영역) + +### 6. @dnd-kit으로 드래그 구현 + +- **결정**: `@dnd-kit/core` + `@dnd-kit/sortable` 사용 +- **근거**: 프로젝트에 이미 설치되어 있고(`package.json`), `SortableCodeItem.tsx`, `useDragAndDrop.ts` 등 표준 패턴이 확립되어 있음 +- **대안 검토**: 위/아래 화살표 버튼으로 순서 변경 → 기각 (프로젝트에 이미 DnD 패턴이 있으므로 일관성 유지) + +### 7. v2-pivot-grid의 format 설정 패턴을 참고 + +- **결정**: ConfigPanel에서 설정 → componentConfig에 저장 → 런타임에서 읽어 사용하는 흐름 +- **근거**: v2-pivot-grid가 필드별 `format`(type, precision, thousandSeparator 등)을 동일한 패턴으로 구현하고 있음. 가장 유사한 선례 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 타입 정의 | `frontend/lib/registry/components/v2-rack-structure/types.ts` | FormatSegment, LocationFormatConfig 타입 | +| 기본 설정 | `frontend/lib/registry/components/v2-rack-structure/config.ts` | 기본 세그먼트 상수, buildFormattedString 함수 | +| 신규 컴포넌트 | `frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx` | 포맷 편집 UI 서브컴포넌트 | +| 설정 패널 | `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | FormatSegmentEditor 배치 | +| 런타임 컴포넌트 | `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | generateLocationCode 세그먼트 기반 교체 | +| DnD 참고 | `frontend/hooks/useDragAndDrop.ts` | 프로젝트 표준 DnD 패턴 | +| DnD 참고 | `frontend/components/admin/SortableCodeItem.tsx` | useSortable 사용 예시 | +| 선례 참고 | `frontend/lib/registry/components/v2-pivot-grid/` | ConfigPanel에서 format 설정하는 패턴 | + +--- + +## 기술 참고 + +### 세그먼트 기반 문자열 생성 흐름 + +``` +FormatSegment[] → filter(enabled && 값 있음) → map(stripKnownSuffix → showLabel && label이면 라벨 붙임 → 자릿수 → 구분자) → join("") → 최종 문자열 +``` + +### componentConfig 저장/로드 흐름 + +``` +ConfigPanel onChange + → V2PropertiesPanel.onUpdateProperty("componentConfig", mergedConfig) + → layout.components[i].componentConfig.formatConfig + → convertLegacyToV2 → screen_layouts_v2.layout_data.overrides.formatConfig (DB) + → convertV2ToLegacy → componentConfig.formatConfig (런타임) + → RackStructureComponent config.formatConfig (prop) +``` + +### context 값 참고 + +``` +context.warehouseCode = "WH001" (창고 코드) +context.floor = "1층" (층 라벨 - 값 자체에 "층" 포함) +context.zone = "A구역" 또는 "A" (구역 라벨 - "구역" 포함 여부 불확실) +row = 1, 2, 3, ... (열 번호 - 숫자) +level = 1, 2, 3, ... (단 번호 - 숫자) +``` diff --git a/docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md b/docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md new file mode 100644 index 00000000..b904d815 --- /dev/null +++ b/docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md @@ -0,0 +1,84 @@ +# [체크리스트] 렉 구조 위치코드/위치명 포맷 사용자 설정 + +> 관련 문서: [계획서](./LFC[계획]-위치포맷-사용자설정.md) | [맥락노트](./LFC[맥락]-위치포맷-사용자설정.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (완료) +- 현재 단계: 완료 + +--- + +## 구현 체크리스트 + +### 1단계: 타입 및 기본값 정의 + +- [x] `types.ts`에 `FormatSegment` 인터페이스 추가 +- [x] `types.ts`에 `LocationFormatConfig` 인터페이스 추가 +- [x] `types.ts`의 `RackStructureComponentConfig`에 `formatConfig?: LocationFormatConfig` 필드 추가 +- [x] `config.ts`에 `defaultCodeSegments` 상수 정의 (현재 하드코딩과 동일한 결과) +- [x] `config.ts`에 `defaultNameSegments` 상수 정의 (현재 하드코딩과 동일한 결과) +- [x] `config.ts`에 `defaultFormatConfig` 상수 정의 +- [x] `config.ts`에 `buildFormattedString()` 함수 구현 (stripKnownSuffix 방식) + +### 2단계: FormatSegmentEditor 서브컴포넌트 생성 + +- [x] `FormatSegmentEditor.tsx` 신규 파일 생성 +- [x] `@dnd-kit/sortable` 기반 드래그 순서변경 구현 +- [x] 세그먼트별 체크박스로 한글 라벨 표시/숨김 토글 (showLabel) +- [x] 라벨/구분/자릿수 3개 필드 항상 고정 표시 (빈 값이어도 입력 필드 유지) +- [x] 최상단 컬럼 헤더 추가 (라벨 / 구분 / 자릿수), 각 행에서 텍스트 라벨 제거 +- [x] grid 레이아웃으로 정렬 (`grid-cols-[16px_56px_18px_1fr_1fr_1fr]`) +- [x] 자릿수 필드: 숫자 타입(열, 단)만 활성화, 비숫자 타입은 disabled + 회색 배경 +- [x] `buildFormattedString`으로 실시간 미리보기 표시 + +### 3단계: ConfigPanel에 포맷 설정 섹션 추가 + +- [x] `RackStructureConfigPanel.tsx`에 FormatSegmentEditor import +- [x] UI 설정 섹션 아래에 "포맷 설정" 섹션 추가 +- [x] 위치코드 포맷용 FormatSegmentEditor 배치 +- [x] 위치명 포맷용 FormatSegmentEditor 배치 +- [x] `onChange`로 `formatConfig` 업데이트 연결 + +### 4단계: 컴포넌트에서 세그먼트 기반 코드 생성 + +- [x] `RackStructureComponent.tsx`에서 `defaultFormatConfig` import +- [x] `generateLocationCode` 함수를 세그먼트 기반으로 교체 +- [x] `config.formatConfig || defaultFormatConfig` 폴백 적용 + +### 5단계: 검증 + +- [x] formatConfig 미설정 시: 기존과 동일한 위치코드/위치명 생성 확인 +- [x] ConfigPanel에서 구분자 변경: 미리보기에 즉시 반영 확인 +- [x] ConfigPanel에서 라벨 체크 해제: 한글만 사라지고 값은 유지 확인 (예: "A구역" → "A") +- [x] ConfigPanel에서 순서 드래그 변경: 미리보기에 반영 확인 +- [x] ConfigPanel에서 라벨 텍스트 변경: 미리보기에 반영 확인 +- [x] 설정 저장 후 화면 재로드: 설정 유지 확인 +- [x] 렉 구조 모달에서 미리보기 생성: 설정된 포맷으로 생성 확인 +- [x] 렉 구조 저장: DB에 설정된 포맷의 코드/이름 저장 확인 + +### 6단계: 정리 + +- [x] 린트 에러 없음 확인 +- [x] 미사용 import 제거 (FormatSegmentEditor.tsx: useState) +- [x] 파일 끝 불필요한 빈 줄 제거 (types.ts, config.ts) +- [x] 계획서/맥락노트/체크리스트 최종 반영 +- [x] 이 체크리스트 완료 표시 업데이트 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-10 | 계획서, 맥락노트, 체크리스트 작성 완료 | +| 2026-03-10 | 1~4단계 구현 완료 (types, config, FormatSegmentEditor, ConfigPanel, Component) | +| 2026-03-10 | showLabel 로직 수정: 체크박스가 세그먼트 제거가 아닌 한글 라벨만 표시/숨김 처리 | +| 2026-03-10 | 계획서, 맥락노트, 체크리스트에 showLabel 변경사항 반영 | +| 2026-03-10 | UI 개선: 3필드 고정표시 + 컬럼 헤더 + grid 레이아웃 + 자릿수 비숫자 비활성화 | +| 2026-03-10 | 계획서, 맥락노트, 체크리스트에 UI 개선사항 반영 | +| 2026-03-10 | 라벨 필드 비움 시 한글 미제거 버그 수정 (stripKnownSuffix 도입) | +| 2026-03-10 | 코드 정리 (미사용 import, 빈 줄) + 문서 최종 반영 | +| 2026-03-10 | 5단계 검증 완료, 전체 작업 완료 | diff --git a/docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md b/docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md new file mode 100644 index 00000000..74b9b6a8 --- /dev/null +++ b/docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md @@ -0,0 +1,350 @@ +# [계획서] 렉 구조 등록 - 층(floor) 필수 입력 해제 + +> 관련 문서: [맥락노트](./RFO[맥락]-렉구조-층필수해제.md) | [체크리스트](./RFO[체크]-렉구조-층필수해제.md) + +## 개요 + +탑씰 회사의 물류관리 > 창고정보 관리 > 렉 구조 등록 모달에서, "층" 필드를 필수 입력에서 선택 입력으로 변경합니다. 현재 "창고 코드 / 층 / 구역" 3개가 모두 필수로 하드코딩되어 있어, 층을 선택하지 않으면 미리보기 생성과 저장이 불가능합니다. + +--- + +## 현재 동작 + +### 1. 필수 필드 경고 (RackStructureComponent.tsx:291~298) + +층을 선택하지 않으면 빨간 경고가 표시됨: + +```tsx +const missingFields = useMemo(() => { + const missing: string[] = []; + if (!context.warehouseCode) missing.push("창고 코드"); + if (!context.floor) missing.push("층"); // ← 하드코딩 필수 + if (!context.zone) missing.push("구역"); + return missing; +}, [context]); +``` + +> "다음 필드를 먼저 입력해주세요: **층**" + +### 2. 미리보기 생성 차단 (RackStructureComponent.tsx:517~521) + +`missingFields`에 "층"이 포함되어 있으면 `generatePreview()` 실행이 차단됨: + +```tsx +if (missingFields.length > 0) { + alert(`다음 필드를 먼저 입력해주세요: ${missingFields.join(", ")}`); + return; +} +``` + +### 3. 위치 코드 생성 (RackStructureComponent.tsx:497~513) + +floor가 없으면 기본값 `"1"`을 사용하여 위치 코드를 생성: + +```tsx +const floor = context?.floor || "1"; +const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; +// 예: WH001-1층A구역-01-1 +``` + +### 4. 기존 데이터 조회 (RackStructureComponent.tsx:378~432) + +floor가 비어있으면 기존 데이터 조회 자체를 건너뜀 → 중복 체크 불가: + +```tsx +if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) { + setExistingLocations([]); + return; +} +``` + +### 5. 렉 구조 화면 감지 (buttonActions.ts:692~698) + +floor가 비어있으면 렉 구조 화면으로 인식하지 않음 → 일반 저장으로 빠짐: + +```tsx +const isRackStructureScreen = + context.tableName === "warehouse_location" && + context.formData?.floor && // ← floor 없으면 false + context.formData?.zone && + !rackStructureLocations; +``` + +### 6. 저장 전 중복 체크 (buttonActions.ts:2085~2131) + +floor가 없으면 중복 체크 전체를 건너뜀: + +```tsx +if (warehouseCode && floor && zone) { + // 중복 체크 로직 +} +``` + +--- + +## 변경 후 동작 + +### 1. 필수 필드에서 "층" 제거 + +- "창고 코드"와 "구역"만 필수 +- 층을 선택하지 않아도 경고가 뜨지 않음 + +### 2. 미리보기 생성 정상 동작 + +- 층 없이도 미리보기 생성 가능 +- 위치 코드에서 층 부분을 생략하여 깔끔하게 생성 + +### 3. 위치 코드 생성 규칙 변경 + +- 층 있을 때: `WH001-1층A구역-01-1` (기존과 동일) +- 층 없을 때: `WH001-A구역-01-1` (층 부분 생략) + +### 4. 기존 데이터 조회 (중복 체크) + +- 층 있을 때: `warehouse_code + floor + zone`으로 조회 (기존과 동일) +- 층 없을 때: `warehouse_code + zone`으로 조회 (floor 조건 제외) + +### 5. 렉 구조 화면 감지 + +- floor 유무와 관계없이 `warehouse_location` 테이블 + zone 필드가 있으면 렉 구조 화면으로 인식 + +### 6. 저장 시 floor 값 + +- 층 선택함: `floor = "1층"` 등 선택한 값 저장 +- 층 미선택: `floor = NULL`로 저장 + +--- + +## 시각적 예시 + +| 상태 | 경고 메시지 | 미리보기 | 위치 코드 | DB floor 값 | +|------|------------|---------|-----------|------------| +| 창고+층+구역 모두 선택 | 없음 | 생성 가능 | `WH001-1층A구역-01-1` | `"1층"` | +| 창고+구역만 선택 (층 미선택) | 없음 | 생성 가능 | `WH001-A구역-01-1` | `NULL` | +| 창고만 선택 | "구역을 먼저 입력해주세요" | 차단 | - | - | +| 아무것도 미선택 | "창고 코드, 구역을 먼저 입력해주세요" | 차단 | - | - | + +--- + +## 아키텍처 + +### 데이터 흐름 (변경 전) + +```mermaid +flowchart TD + A[사용자: 창고/층/구역 입력] --> B{필수 필드 검증} + B -->|층 없음| C[경고: 층을 입력하세요] + B -->|3개 다 있음| D[기존 데이터 조회
warehouse_code + floor + zone] + D --> E[미리보기 생성] + E --> F{저장 버튼} + F --> G[렉 구조 화면 감지
floor && zone 필수] + G --> H[중복 체크
warehouse_code + floor + zone] + H --> I[일괄 INSERT
floor = 선택값] +``` + +### 데이터 흐름 (변경 후) + +```mermaid +flowchart TD + A[사용자: 창고/구역 입력
층은 선택사항] --> B{필수 필드 검증} + B -->|창고 or 구역 없음| C[경고: 해당 필드를 입력하세요] + B -->|창고+구역 있음| D{floor 값 존재?} + D -->|있음| E1[기존 데이터 조회
warehouse_code + floor + zone] + D -->|없음| E2[기존 데이터 조회
warehouse_code + zone] + E1 --> F[미리보기 생성] + E2 --> F + F --> G{저장 버튼} + G --> H[렉 구조 화면 감지
zone만 필수] + H --> I{floor 값 존재?} + I -->|있음| J1[중복 체크
warehouse_code + floor + zone] + I -->|없음| J2[중복 체크
warehouse_code + zone] + J1 --> K[일괄 INSERT
floor = 선택값] + J2 --> K2[일괄 INSERT
floor = NULL] +``` + +### 컴포넌트 관계 + +```mermaid +graph LR + subgraph 프론트엔드 + A[폼 필드
창고/층/구역] -->|formData| B[RackStructureComponent
필수 검증 + 미리보기] + B -->|locations 배열| C[buttonActions.ts
화면 감지 + 중복 체크 + 저장] + end + subgraph 백엔드 + C -->|POST /dynamic-form/save| D[DynamicFormApi
데이터 저장] + D --> E[(warehouse_location
floor: nullable)] + end + + style B fill:#fff3cd,stroke:#ffc107 + style C fill:#fff3cd,stroke:#ffc107 +``` + +> 노란색 = 이번에 수정하는 부분 + +--- + +## 변경 대상 파일 + +| 파일 | 수정 내용 | 수정 규모 | +|------|----------|----------| +| `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | 필수 검증에서 floor 제거, 위치 코드 생성 로직 수정, 기존 데이터 조회 로직 수정 | ~20줄 | +| `frontend/lib/utils/buttonActions.ts` | 렉 구조 화면 감지 조건 수정, 중복 체크 조건 수정 | ~10줄 | + +### 사전 확인 필요 + +| 확인 항목 | 내용 | +|----------|------| +| DB 스키마 | `warehouse_location.floor` 컬럼이 `NULL` 허용인지 확인. NOT NULL이면 `ALTER TABLE` 필요 | + +--- + +## 코드 설계 + +### 1. 필수 필드 검증 수정 (RackStructureComponent.tsx:291~298) + +```tsx +// 변경 전 +const missingFields = useMemo(() => { + const missing: string[] = []; + if (!context.warehouseCode) missing.push("창고 코드"); + if (!context.floor) missing.push("층"); + if (!context.zone) missing.push("구역"); + return missing; +}, [context]); + +// 변경 후 +const missingFields = useMemo(() => { + const missing: string[] = []; + if (!context.warehouseCode) missing.push("창고 코드"); + if (!context.zone) missing.push("구역"); + return missing; +}, [context]); +``` + +### 2. 위치 코드 생성 수정 (RackStructureComponent.tsx:497~513) + +```tsx +// 변경 전 +const floor = context?.floor || "1"; +const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; + +// 변경 후 +const floor = context?.floor; +const floorPrefix = floor ? `${floor}` : ""; +const code = `${warehouseCode}-${floorPrefix}${zone}-${row.toString().padStart(2, "0")}-${level}`; +// 층 있을 때: WH001-1층A구역-01-1 +// 층 없을 때: WH001-A구역-01-1 +``` + +### 3. 기존 데이터 조회 수정 (RackStructureComponent.tsx:378~432) + +```tsx +// 변경 전 +if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) { + setExistingLocations([]); + return; +} + +const searchParams = { + warehouse_code: { value: warehouseCodeForQuery, operator: "equals" }, + floor: { value: floorForQuery, operator: "equals" }, + zone: { value: zoneForQuery, operator: "equals" }, +}; + +// 변경 후 +if (!warehouseCodeForQuery || !zoneForQuery) { + setExistingLocations([]); + return; +} + +const searchParams: Record = { + warehouse_code: { value: warehouseCodeForQuery, operator: "equals" }, + zone: { value: zoneForQuery, operator: "equals" }, +}; +if (floorForQuery) { + searchParams.floor = { value: floorForQuery, operator: "equals" }; +} +``` + +### 4. 렉 구조 화면 감지 수정 (buttonActions.ts:692~698) + +```tsx +// 변경 전 +const isRackStructureScreen = + context.tableName === "warehouse_location" && + context.formData?.floor && + context.formData?.zone && + !rackStructureLocations; + +// 변경 후 +const isRackStructureScreen = + context.tableName === "warehouse_location" && + context.formData?.zone && + !rackStructureLocations; +``` + +### 5. 저장 전 중복 체크 수정 (buttonActions.ts:2085~2131) + +```tsx +// 변경 전 +if (warehouseCode && floor && zone) { + const existingResponse = await DynamicFormApi.getTableData(tableName, { + search: { + warehouse_code: { value: warehouseCode, operator: "equals" }, + floor: { value: floor, operator: "equals" }, + zone: { value: zone, operator: "equals" }, + }, + // ... + }); +} + +// 변경 후 +if (warehouseCode && zone) { + const searchParams: Record = { + warehouse_code: { value: warehouseCode, operator: "equals" }, + zone: { value: zone, operator: "equals" }, + }; + if (floor) { + searchParams.floor = { value: floor, operator: "equals" }; + } + + const existingResponse = await DynamicFormApi.getTableData(tableName, { + search: searchParams, + // ... + }); +} +``` + +--- + +## 적용 범위 및 영향도 + +### 이번 변경은 전역 설정 + +방법 B는 렉 구조 컴포넌트 코드에서 직접 "층 필수"를 제거하는 방식이므로, 이 컴포넌트를 사용하는 **모든 회사**에 동일하게 적용됩니다. + +| 회사 | 변경 후 | +|------|--------| +| 탑씰 | 층 안 골라도 됨 (요청 사항) | +| 다른 회사 | 층 안 골라도 됨 (동일하게 적용) | + +### 기존 사용자에 대한 영향 + +- 층을 안 골라도 **되는** 것이지, 안 골라야 **하는** 것이 아님 +- 기존처럼 층을 선택하면 **완전히 동일하게** 동작함 (하위 호환 보장) +- 즉, 기존 사용 패턴을 유지하는 회사에는 아무런 차이가 없음 + +### 회사별 독립 제어가 필요한 경우 + +만약 특정 회사는 층을 필수로 유지하고, 다른 회사는 선택으로 해야 하는 상황이 발생하면, 방법 A(설정 기능 추가)로 업그레이드가 필요합니다. 이번 방법 B의 변경은 향후 방법 A로 전환할 때 충돌 없이 확장 가능합니다. + +--- + +## 설계 원칙 + +- "창고 코드"와 "구역"의 필수 검증은 기존과 동일하게 유지 +- 층을 선택한 경우의 동작은 기존과 완전히 동일 (하위 호환) +- 층 미선택 시 위치 코드에서 층 부분을 깔끔하게 생략 (폴백값 "1" 사용하지 않음) +- 중복 체크는 가용한 필드 기준으로 수행 (floor 없으면 warehouse_code + zone 기준) +- DB에는 NULL로 저장하여 "미입력"을 정확하게 표현 (프로젝트 표준 패턴) +- 특수 문자열("상관없음" 등) 사용하지 않음 (프로젝트 관행에 맞지 않으므로) diff --git a/docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md b/docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md new file mode 100644 index 00000000..08be3da0 --- /dev/null +++ b/docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md @@ -0,0 +1,92 @@ +# [맥락노트] 렉 구조 등록 - 층(floor) 필수 입력 해제 + +> 관련 문서: [계획서](./RFO[계획]-렉구조-층필수해제.md) | [체크리스트](./RFO[체크]-렉구조-층필수해제.md) + +--- + +## 왜 이 작업을 하는가 + +- 탑씰 회사에서 창고 렉 구조 등록 시 "층"을 선택하지 않아도 되게 해달라는 요청 +- 현재 코드에 창고 코드 / 층 / 구역 3개가 필수로 하드코딩되어 있어, 층 미선택 시 미리보기 생성과 저장이 모두 차단됨 +- 층 필수 검증이 6곳에 분산되어 있어 한 곳만 고치면 다른 곳에서 오류 발생 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 방법 B(하드코딩 제거) 채택, 방법 A(설정 기능) 미채택 + +- **결정**: 코드에서 floor 필수 조건을 직접 제거 +- **근거**: 이 프로젝트의 다른 모달/컴포넌트들은 모두 코드에서 직접 "필수/선택"을 정해놓는 방식을 사용. 설정으로 필수 여부를 바꿀 수 있게 만든 패턴은 기존에 없음 +- **대안 검토**: + - 방법 A(ConfigPanel에 requiredFields 설정 추가): 유연하지만 4파일 수정 + 프로젝트에 없던 새 패턴 도입 → 기각 + - "상관없음" 값 추가 후 null 변환: 프로젝트 어디에서도 magic value → null 변환 패턴을 쓰지 않음 → 기각 + - "상관없음" 값만 추가 (코드 무변경): DB에 "상관없음" 텍스트가 저장되어 데이터가 지저분함 → 기각 +- **향후**: 회사별 독립 제어가 필요해지면 방법 A로 확장 가능 (충돌 없음) + +### 2. 전역 적용 (회사별 독립 설정 아님) + +- **결정**: 렉 구조 컴포넌트를 사용하는 모든 회사에 동일 적용 +- **근거**: 방법 B는 코드 직접 수정이므로 회사별 분기 불가. 단, 기존처럼 층을 선택하면 완전히 동일하게 동작하므로 다른 회사에 실질적 영향 없음 (선택 안 해도 "되는" 것이지, 안 해야 "하는" 것이 아님) + +### 3. floor 미선택 시 NULL 저장 (특수값 아님) + +- **결정**: floor를 선택하지 않으면 DB에 `NULL` 저장 +- **근거**: 프로젝트 표준 패턴. `UserFormModal`의 `email: formData.email || null`, `EnhancedFormService`의 빈 문자열 → null 자동 변환 등과 동일한 방식 +- **대안 검토**: "상관없음" 저장 후 null 변환 → 프로젝트에서 미사용 패턴이므로 기각 + +### 4. 위치 코드에서 층 부분 생략 (폴백값 "1" 사용 안 함) + +- **결정**: floor 없을 때 위치 코드에서 층 부분을 아예 빼버림 +- **근거**: 기존 코드는 `context?.floor || "1"`로 폴백하여 1층을 선택한 것처럼 위장됨. 이는 잘못된 데이터를 만들 수 있음 +- **결과**: + - 층 있을 때: `WH001-1층A구역-01-1` (기존과 동일) + - 층 없을 때: `WH001-A구역-01-1` (층 부분 없이 깔끔) + +### 5. 중복 체크는 가용 필드 기준으로 수행 + +- **결정**: floor 없으면 `warehouse_code + zone`으로 중복 체크, floor 있으면 `warehouse_code + floor + zone`으로 중복 체크 +- **근거**: 기존 코드는 floor 없으면 중복 체크 전체를 건너뜀 → 중복 데이터 발생 위험. 가용 필드 기준으로 체크하면 floor 유무와 관계없이 안전 + +### 6. 렉 구조 화면 감지에서 floor 조건 제거 + +- **결정**: `buttonActions.ts`의 `isRackStructureScreen` 조건에서 `context.formData?.floor` 제거 +- **근거**: floor 없으면 렉 구조 화면으로 인식되지 않아 일반 단건 저장으로 빠짐 → 예기치 않은 동작. zone만으로 감지해야 floor 미선택 시에도 렉 구조 일괄 저장이 정상 동작 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 수정 대상 | `frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx` | 필수 검증, 위치 코드 생성, 기존 데이터 조회 | +| 수정 대상 | `frontend/lib/utils/buttonActions.ts` | 화면 감지, 중복 체크 | +| 타입 정의 | `frontend/lib/registry/components/v2-rack-structure/types.ts` | RackStructureContext, FieldMapping 등 | +| 설정 패널 | `frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx` | 필드 매핑 설정 (이번에 수정 안 함) | +| 저장 모달 | `frontend/components/screen/SaveModal.tsx` | 필수 검증 (DB NOT NULL 기반, 별도 확인 필요) | +| 사전 확인 | DB `warehouse_location.floor` 컬럼 | NULL 허용 여부 확인, NOT NULL이면 ALTER TABLE 필요 | + +--- + +## 기술 참고 + +### 수정 포인트 6곳 요약 + +| # | 파일 | 행 | 내용 | 수정 방향 | +|---|------|-----|------|----------| +| 1 | RackStructureComponent.tsx | 291~298 | missingFields에서 floor 체크 | floor 체크 제거 | +| 2 | RackStructureComponent.tsx | 517~521 | 미리보기 생성 차단 | 1번 수정으로 자동 해결 | +| 3 | RackStructureComponent.tsx | 497~513 | 위치 코드 생성 `floor \|\| "1"` | 폴백값 제거, 없으면 생략 | +| 4 | RackStructureComponent.tsx | 378~432 | 기존 데이터 조회 조건 | floor 없어도 조회 가능하게 | +| 5 | buttonActions.ts | 692~698 | 렉 구조 화면 감지 | floor 조건 제거 | +| 6 | buttonActions.ts | 2085~2131 | 저장 전 중복 체크 | floor 조건부로 포함 | + +### 프로젝트 표준 optional 필드 처리 패턴 + +``` +빈 값 → null 변환: value || null (UserFormModal) +nullable 자동 변환: value === "" && isNullable === "Y" → null (EnhancedFormService) +Select placeholder: "__none__" → "" 또는 undefined (여러 ConfigPanel) +``` + +이번 변경은 위 패턴들과 일관성을 유지합니다. diff --git a/docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md b/docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md new file mode 100644 index 00000000..a80bdacc --- /dev/null +++ b/docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md @@ -0,0 +1,57 @@ +# [체크리스트] 렉 구조 등록 - 층(floor) 필수 입력 해제 + +> 관련 문서: [계획서](./RFO[계획]-렉구조-층필수해제.md) | [맥락노트](./RFO[맥락]-렉구조-층필수해제.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (완료) +- 현재 단계: 전체 완료 + +--- + +## 구현 체크리스트 + +### 0단계: 사전 확인 + +- [x] DB `warehouse_location.floor` 컬럼 nullable 여부 확인 → 이미 NULL 허용 상태, 변경 불필요 + +### 1단계: RackStructureComponent.tsx 수정 + +- [x] `missingFields`에서 `if (!context.floor) missing.push("층")` 제거 (291~298행) +- [x] `generateLocationCode`에서 `context?.floor || "1"` 폴백 제거, floor 없으면 위치 코드에서 생략 (497~513행) +- [x] `loadExistingLocations`에서 floor 없어도 조회 가능하도록 조건 수정 (378~432행) +- [x] `searchParams`에 floor를 조건부로 포함하도록 변경 + +### 2단계: buttonActions.ts 수정 + +- [x] `isRackStructureScreen` 조건에서 `context.formData?.floor` 제거 (692~698행) +- [x] `handleRackStructureBatchSave` 중복 체크에서 floor를 조건부로 포함 (2085~2131행) + +### 3단계: 검증 + +- [x] 층 선택 + 구역 선택: 기존과 동일하게 동작 확인 +- [x] 층 미선택 + 구역 선택: 경고 없이 미리보기 생성 가능 확인 +- [x] 층 미선택 시 위치 코드에 층 부분이 빠져있는지 확인 +- [x] 층 미선택 시 저장 정상 동작 확인 +- [x] 층 미선택 시 기존 데이터 중복 체크 정상 동작 확인 +- [x] 창고 코드 미입력 시 여전히 경고 표시되는지 확인 +- [x] 구역 미입력 시 여전히 경고 표시되는지 확인 + +### 4단계: 정리 + +- [x] 린트 에러 없음 확인 (기존 WARNING 1개만 존재, 이번 변경과 무관) +- [x] 이 체크리스트 완료 표시 업데이트 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-10 | 계획서, 맥락노트, 체크리스트 작성 완료 | +| 2026-03-10 | 1단계 코드 수정 완료 (RackStructureComponent.tsx) | +| 2026-03-10 | 2단계 코드 수정 완료 (buttonActions.ts) | +| 2026-03-10 | 린트 에러 확인 완료 | +| 2026-03-10 | 사용자 검증 완료, 전체 작업 완료 | diff --git a/frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx b/frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx new file mode 100644 index 00000000..5a56364f --- /dev/null +++ b/frontend/lib/registry/components/v2-rack-structure/FormatSegmentEditor.tsx @@ -0,0 +1,203 @@ +"use client"; + +import React, { useMemo } from "react"; +import { GripVertical } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/lib/utils"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, + useSortable, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +import { FormatSegment } from "./types"; +import { SEGMENT_TYPE_LABELS, buildFormattedString, SAMPLE_VALUES } from "./config"; + +// 개별 세그먼트 행 +interface SortableSegmentRowProps { + segment: FormatSegment; + index: number; + onChange: (index: number, updates: Partial) => void; +} + +function SortableSegmentRow({ segment, index, onChange }: SortableSegmentRowProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: `${segment.type}-${index}` }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+
+ +
+ + + {SEGMENT_TYPE_LABELS[segment.type]} + + + + onChange(index, { showLabel: checked === true }) + } + className="h-3.5 w-3.5" + /> + + onChange(index, { label: e.target.value })} + placeholder="" + className={cn( + "h-6 px-1 text-xs", + !segment.showLabel && "text-gray-400 line-through", + )} + /> + + onChange(index, { separatorAfter: e.target.value })} + placeholder="" + className="h-6 px-1 text-center text-xs" + /> + + + onChange(index, { pad: parseInt(e.target.value) || 0 }) + } + disabled={segment.type !== "row" && segment.type !== "level"} + className={cn( + "h-6 px-1 text-center text-xs", + segment.type !== "row" && segment.type !== "level" && "bg-gray-100 opacity-50", + )} + /> +
+ ); +} + +// FormatSegmentEditor 메인 컴포넌트 +interface FormatSegmentEditorProps { + label: string; + segments: FormatSegment[]; + onChange: (segments: FormatSegment[]) => void; + sampleValues?: Record; +} + +export function FormatSegmentEditor({ + label, + segments, + onChange, + sampleValues = SAMPLE_VALUES, +}: FormatSegmentEditorProps) { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + useSensor(KeyboardSensor), + ); + + const preview = useMemo( + () => buildFormattedString(segments, sampleValues), + [segments, sampleValues], + ); + + const sortableIds = useMemo( + () => segments.map((seg, i) => `${seg.type}-${i}`), + [segments], + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const oldIndex = sortableIds.indexOf(active.id as string); + const newIndex = sortableIds.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1) return; + + onChange(arrayMove([...segments], oldIndex, newIndex)); + }; + + const handleSegmentChange = (index: number, updates: Partial) => { + const updated = segments.map((seg, i) => + i === index ? { ...seg, ...updates } : seg, + ); + onChange(updated); + }; + + return ( +
+
{label}
+ +
+ + + + 라벨 + 구분 + 자릿수 +
+ + + +
+ {segments.map((segment, index) => ( + + ))} +
+
+
+ +
+ 미리보기: + + {preview || "(빈 값)"} + +
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx index d670c25c..7309d861 100644 --- a/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx +++ b/frontend/lib/registry/components/v2-rack-structure/RackStructureComponent.tsx @@ -20,6 +20,7 @@ import { GeneratedLocation, RackStructureContext, } from "./types"; +import { defaultFormatConfig, buildFormattedString } from "./config"; // 기존 위치 데이터 타입 interface ExistingLocation { @@ -288,11 +289,10 @@ export const RackStructureComponent: React.FC = ({ return ctx; }, [propContext, formData, fieldMapping, getCategoryLabel]); - // 필수 필드 검증 + // 필수 필드 검증 (층은 선택 입력) const missingFields = useMemo(() => { const missing: string[] = []; if (!context.warehouseCode) missing.push("창고 코드"); - if (!context.floor) missing.push("층"); if (!context.zone) missing.push("구역"); return missing; }, [context]); @@ -377,9 +377,8 @@ export const RackStructureComponent: React.FC = ({ // 기존 데이터 조회 (창고/층/구역이 변경될 때마다) useEffect(() => { const loadExistingLocations = async () => { - // 필수 조건이 충족되지 않으면 기존 데이터 초기화 - // DB에는 라벨 값(예: "1층", "A구역")으로 저장되어 있으므로 라벨 값 사용 - if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) { + // 창고 코드와 구역은 필수, 층은 선택 + if (!warehouseCodeForQuery || !zoneForQuery) { setExistingLocations([]); setDuplicateErrors([]); return; @@ -387,14 +386,13 @@ export const RackStructureComponent: React.FC = ({ setIsCheckingDuplicates(true); try { - // warehouse_location 테이블에서 해당 창고/층/구역의 기존 데이터 조회 - // DB에는 라벨 값으로 저장되어 있으므로 라벨 값으로 필터링 - // equals 연산자를 사용하여 정확한 일치 검색 (ILIKE가 아닌 = 연산자 사용) - const searchParams = { + const searchParams: Record = { warehouse_code: { value: warehouseCodeForQuery, operator: "equals" }, - floor: { value: floorForQuery, operator: "equals" }, zone: { value: zoneForQuery, operator: "equals" }, }; + if (floorForQuery) { + searchParams.floor = { value: floorForQuery, operator: "equals" }; + } // 직접 apiClient 사용하여 정확한 형식으로 요청 // 백엔드는 search를 객체로 받아서 각 필드를 WHERE 조건으로 처리 @@ -493,23 +491,26 @@ export const RackStructureComponent: React.FC = ({ return { totalLocations, totalRows, maxLevel }; }, [conditions]); - // 위치 코드 생성 + // 포맷 설정 (ConfigPanel에서 관리자가 설정한 값, 미설정 시 기본값) + const formatConfig = config.formatConfig || defaultFormatConfig; + + // 위치 코드 생성 (세그먼트 기반 - 순서/구분자/라벨/자릿수 모두 formatConfig에 따름) const generateLocationCode = useCallback( (row: number, level: number): { code: string; name: string } => { - const warehouseCode = context?.warehouseCode || "WH001"; - const floor = context?.floor || "1"; - const zone = context?.zone || "A"; + const values: Record = { + warehouseCode: context?.warehouseCode || "WH001", + floor: context?.floor || "", + zone: context?.zone || "A", + row: row.toString(), + level: level.toString(), + }; - // 코드 생성 (예: WH001-1층D구역-01-1) - const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; - - // 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용 - const zoneName = zone.includes("구역") ? zone : `${zone}구역`; - const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`; + const code = buildFormattedString(formatConfig.codeSegments, values); + const name = buildFormattedString(formatConfig.nameSegments, values); return { code, name }; }, - [context], + [context, formatConfig], ); // 미리보기 생성 @@ -870,7 +871,7 @@ export const RackStructureComponent: React.FC = ({ {idx + 1} {loc.location_code} {loc.location_name} - {loc.floor || context?.floor || "1"} + {loc.floor || context?.floor || "-"} {loc.zone || context?.zone || "A"} {loc.row_num.padStart(2, "0")} {loc.level_num} diff --git a/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx b/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx index 8f0c8177..88335dcc 100644 --- a/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-rack-structure/RackStructureConfigPanel.tsx @@ -11,7 +11,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { RackStructureComponentConfig, FieldMapping } from "./types"; +import { RackStructureComponentConfig, FieldMapping, FormatSegment } from "./types"; +import { defaultFormatConfig, SAMPLE_VALUES } from "./config"; +import { FormatSegmentEditor } from "./FormatSegmentEditor"; interface RackStructureConfigPanelProps { config: RackStructureComponentConfig; @@ -69,6 +71,21 @@ export const RackStructureConfigPanel: React.FC = const fieldMapping = config.fieldMapping || {}; + const formatConfig = config.formatConfig || defaultFormatConfig; + + const handleFormatChange = ( + key: "codeSegments" | "nameSegments", + segments: FormatSegment[], + ) => { + onChange({ + ...config, + formatConfig: { + ...formatConfig, + [key]: segments, + }, + }); + }; + return (
{/* 필드 매핑 섹션 */} @@ -282,6 +299,29 @@ export const RackStructureConfigPanel: React.FC = />
+ + {/* 포맷 설정 */} +
+
포맷 설정
+

+ 위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고, + 구분자/라벨을 편집할 수 있습니다 +

+ + handleFormatChange("codeSegments", segs)} + sampleValues={SAMPLE_VALUES} + /> + + handleFormatChange("nameSegments", segs)} + sampleValues={SAMPLE_VALUES} + /> +
); }; diff --git a/frontend/lib/registry/components/v2-rack-structure/config.ts b/frontend/lib/registry/components/v2-rack-structure/config.ts index 09d9d04b..f5cc56a2 100644 --- a/frontend/lib/registry/components/v2-rack-structure/config.ts +++ b/frontend/lib/registry/components/v2-rack-structure/config.ts @@ -2,26 +2,107 @@ * 렉 구조 컴포넌트 기본 설정 */ -import { RackStructureComponentConfig } from "./types"; +import { + RackStructureComponentConfig, + FormatSegment, + FormatSegmentType, + LocationFormatConfig, +} from "./types"; + +// 세그먼트 타입별 한글 표시명 +export const SEGMENT_TYPE_LABELS: Record = { + warehouseCode: "창고코드", + floor: "층", + zone: "구역", + row: "열", + level: "단", +}; + +// 위치코드 기본 세그먼트 (현재 하드코딩과 동일한 결과) +export const defaultCodeSegments: FormatSegment[] = [ + { type: "warehouseCode", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 0 }, + { type: "floor", enabled: true, showLabel: true, label: "층", separatorAfter: "", pad: 0 }, + { type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 }, + { type: "row", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 2 }, + { type: "level", enabled: true, showLabel: false, label: "", separatorAfter: "", pad: 0 }, +]; + +// 위치명 기본 세그먼트 (현재 하드코딩과 동일한 결과) +export const defaultNameSegments: FormatSegment[] = [ + { type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 }, + { type: "row", enabled: true, showLabel: true, label: "열", separatorAfter: "-", pad: 2 }, + { type: "level", enabled: true, showLabel: true, label: "단", separatorAfter: "", pad: 0 }, +]; + +export const defaultFormatConfig: LocationFormatConfig = { + codeSegments: defaultCodeSegments, + nameSegments: defaultNameSegments, +}; + +// 세그먼트 타입별 기본 한글 접미사 (context 값에 포함되어 있는 한글) +const KNOWN_SUFFIXES: Partial> = { + floor: "층", + zone: "구역", +}; + +// 값에서 알려진 한글 접미사를 제거하여 순수 값만 추출 +function stripKnownSuffix(type: FormatSegmentType, val: string): string { + const suffix = KNOWN_SUFFIXES[type]; + if (suffix && val.endsWith(suffix)) { + return val.slice(0, -suffix.length); + } + return val; +} + +// 세그먼트 배열로 포맷된 문자열 생성 +export function buildFormattedString( + segments: FormatSegment[], + values: Record, +): string { + const activeSegments = segments.filter( + (seg) => seg.enabled && values[seg.type], + ); + + return activeSegments + .map((seg, idx) => { + // 1) 원본 값에서 한글 접미사를 먼저 벗겨냄 ("A구역" → "A", "1층" → "1") + let val = stripKnownSuffix(seg.type, values[seg.type]); + + // 2) showLabel이 켜져 있고 label이 있으면 붙임 + if (seg.showLabel && seg.label) { + val += seg.label; + } + + if (seg.pad > 0 && !isNaN(Number(val))) { + val = val.padStart(seg.pad, "0"); + } + + if (idx < activeSegments.length - 1) { + val += seg.separatorAfter; + } + return val; + }) + .join(""); +} + +// 미리보기용 샘플 값 +export const SAMPLE_VALUES: Record = { + warehouseCode: "WH001", + floor: "1층", + zone: "A구역", + row: "1", + level: "1", +}; export const defaultConfig: RackStructureComponentConfig = { - // 기본 제한 maxConditions: 10, maxRows: 99, maxLevels: 20, - - // 기본 코드 패턴 codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}", namePattern: "{zone}구역-{row:02d}열-{level}단", - - // UI 설정 showTemplates: true, showPreview: true, showStatistics: true, readonly: false, - - // 초기 조건 없음 initialConditions: [], }; - - diff --git a/frontend/lib/registry/components/v2-rack-structure/types.ts b/frontend/lib/registry/components/v2-rack-structure/types.ts index 76214972..8fe714d4 100644 --- a/frontend/lib/registry/components/v2-rack-structure/types.ts +++ b/frontend/lib/registry/components/v2-rack-structure/types.ts @@ -43,6 +43,24 @@ export interface FieldMapping { statusField?: string; // 사용 여부로 사용할 폼 필드명 } +// 포맷 세그먼트 (위치코드/위치명의 각 구성요소) +export type FormatSegmentType = 'warehouseCode' | 'floor' | 'zone' | 'row' | 'level'; + +export interface FormatSegment { + type: FormatSegmentType; + enabled: boolean; // 이 세그먼트를 포함할지 여부 + showLabel: boolean; // 한글 라벨 표시 여부 (false면 값에서 라벨 제거) + label: string; // 한글 라벨 (예: "층", "구역", "열", "단") + separatorAfter: string; // 이 세그먼트 뒤의 구분자 (예: "-", "/", "") + pad: number; // 최소 자릿수 (0 = 그대로, 2 = "01"처럼 2자리 맞춤) +} + +// 위치코드 + 위치명 포맷 설정 +export interface LocationFormatConfig { + codeSegments: FormatSegment[]; + nameSegments: FormatSegment[]; +} + // 컴포넌트 설정 export interface RackStructureComponentConfig { // 기본 설정 @@ -54,8 +72,9 @@ export interface RackStructureComponentConfig { fieldMapping?: FieldMapping; // 위치 코드 생성 규칙 - codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}") - namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단") + codePattern?: string; // 코드 패턴 (하위 호환용 유지) + namePattern?: string; // 이름 패턴 (하위 호환용 유지) + formatConfig?: LocationFormatConfig; // 구조화된 포맷 설정 // UI 설정 showTemplates?: boolean; // 템플릿 기능 표시 @@ -93,5 +112,3 @@ export interface RackStructureComponentProps { isPreview?: boolean; tableName?: string; } - - diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 0c9b1327..cc145262 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -689,11 +689,10 @@ export class ButtonActionExecutor { return false; } - // 🆕 렉 구조 등록 화면 감지 (warehouse_location 테이블 + floor/zone 필드 있음 + 렉 구조 데이터 없음) - // 이 경우 일반 저장을 차단하고 미리보기 생성을 요구 + // 렉 구조 등록 화면 감지 (warehouse_location 테이블 + zone 필드 있음 + 렉 구조 데이터 없음) + // floor는 선택 입력이므로 감지 조건에서 제외 const isRackStructureScreen = context.tableName === "warehouse_location" && - context.formData?.floor && context.formData?.zone && !rackStructureLocations; @@ -2085,15 +2084,18 @@ export class ButtonActionExecutor { const floor = firstLocation.floor; const zone = firstLocation.zone; - if (warehouseCode && floor && zone) { + if (warehouseCode && zone) { try { - // search 파라미터를 사용하여 백엔드에서 필터링 (filters는 백엔드에서 처리 안됨) + const searchParams: Record = { + warehouse_code: { value: warehouseCode, operator: "equals" }, + zone: { value: zone, operator: "equals" }, + }; + if (floor) { + searchParams.floor = { value: floor, operator: "equals" }; + } + const existingResponse = await DynamicFormApi.getTableData(tableName, { - search: { - warehouse_code: { value: warehouseCode, operator: "equals" }, - floor: { value: floor, operator: "equals" }, - zone: { value: zone, operator: "equals" }, - }, + search: searchParams, page: 1, pageSize: 1000, }); From 9c128cc52c37ccde840040c4d28cb678c7f693f7 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Wed, 11 Mar 2026 12:42:25 +0900 Subject: [PATCH 02/14] docs: Add project conventions and guidelines for ERP/PLM project - Introduced a comprehensive document outlining project conventions for the WACE ERP/PLM project. - Included sections on project structure, backend practices, frontend practices, and specific implementation patterns. - Established guidelines for file creation order, controller and service patterns, pagination handling, and caching strategies. - Enhanced documentation to improve consistency and maintainability across the codebase. These additions serve as a reference for developers to follow best practices and ensure uniformity in the project's development process. --- .cursor/rules/project-conventions.mdc | 731 ++++++++++++++++++ docs/ycshin-node/PGN[계획]-페이징-단락이동.md | 389 ++++++++++ docs/ycshin-node/PGN[맥락]-페이징-단락이동.md | 128 +++ docs/ycshin-node/PGN[체크]-페이징-단락이동.md | 90 +++ docs/ycshin-node/탭_시스템_설계.md | 48 +- frontend/components/common/PageGroupNav.tsx | 109 +++ frontend/components/layout/TabContent.tsx | 12 + .../v2-table-list/TableListComponent.tsx | 185 +++-- frontend/lib/tabStateCache.ts | 19 +- frontend/stores/tabStore.ts | 1 + 10 files changed, 1607 insertions(+), 105 deletions(-) create mode 100644 .cursor/rules/project-conventions.mdc create mode 100644 docs/ycshin-node/PGN[계획]-페이징-단락이동.md create mode 100644 docs/ycshin-node/PGN[맥락]-페이징-단락이동.md create mode 100644 docs/ycshin-node/PGN[체크]-페이징-단락이동.md create mode 100644 frontend/components/common/PageGroupNav.tsx diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc new file mode 100644 index 00000000..2f34326f --- /dev/null +++ b/.cursor/rules/project-conventions.mdc @@ -0,0 +1,731 @@ +# WACE ERP/PLM 프로젝트 관행 (Project Conventions) + +이 문서는 AI 에이전트가 새 기능을 구현할 때 기존 코드베이스의 관행을 따르기 위한 참조 문서입니다. +코드를 작성하기 전에 반드시 이 문서를 읽고 동일한 패턴을 사용하세요. + +--- + +## 1. 프로젝트 구조 + +``` +ERP-node/ +├── backend-node/src/ # Express + TypeScript 백엔드 +│ ├── app.ts # 엔트리포인트 (미들웨어, 라우트 등록) +│ ├── controllers/ # API 컨트롤러 (요청 처리, 응답 반환) +│ ├── services/ # 비즈니스 로직 (DB 접근, 트랜잭션) +│ ├── routes/ # Express 라우터 (URL 매핑) +│ ├── middleware/ # 인증, 에러처리, 권한 미들웨어 +│ ├── database/db.ts # PostgreSQL 연결 풀, query/queryOne/transaction +│ ├── config/environment.ts # 환경 변수 설정 +│ ├── types/ # TypeScript 타입 정의 +│ └── utils/logger.ts # winston 로거 +├── frontend/ # Next.js 15 (App Router) 프론트엔드 +│ ├── app/ # 페이지 (Route Groups: (main), (auth), (admin)) +│ ├── components/ # React 컴포넌트 +│ │ ├── ui/ # shadcn/ui 기본 컴포넌트 (33개) +│ │ ├── admin/ # 관리자 화면 컴포넌트 +│ │ └── screen/ # 화면 디자이너/렌더러 컴포넌트 +│ ├── hooks/ # 커스텀 React 훅 +│ ├── lib/api/ # API 클라이언트 모듈 (63개 파일) +│ ├── lib/utils.ts # cn() 등 유틸리티 +│ ├── types/ # 프론트엔드 타입 정의 +│ └── contexts/ # React Context (Auth, Menu 등) +├── db/migrations/ # SQL 마이그레이션 파일 +└── docs/ # 프로젝트 문서 +``` + +--- + +## 2. 백엔드 관행 + +### 2.1 새 기능 추가 시 파일 생성 순서 + +1. `backend-node/src/types/` — 타입 정의 (필요 시) +2. `backend-node/src/services/xxxService.ts` — 비즈니스 로직 +3. `backend-node/src/controllers/xxxController.ts` — 컨트롤러 +4. `backend-node/src/routes/xxxRoutes.ts` — 라우터 +5. `backend-node/src/app.ts` — 라우트 등록 (`app.use("/api/xxx", xxxRoutes)`) + +### 2.2 컨트롤러 패턴 + +```typescript +// backend-node/src/controllers/xxxController.ts +import { Request, Response } from "express"; +import { logger } from "../utils/logger"; +import { AuthenticatedRequest } from "../types/auth"; + +// 패턴 A: named async function (가장 많이 사용) +export async function getXxxList( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId; + + logger.info("XXX 목록 조회 요청", { companyCode, userId }); + + // ... 비즈니스 로직 ... + + res.status(200).json({ + success: true, + message: "XXX 목록 조회 성공", + data: result, + }); + } catch (error) { + logger.error("XXX 목록 조회 중 오류:", error); + res.status(500).json({ + success: false, + message: "XXX 목록 조회 중 오류가 발생했습니다.", + error: { + code: "XXX_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +} +``` + +**핵심 규칙:** +- `AuthenticatedRequest`로 인증된 사용자 정보 접근 +- `req.user?.companyCode`로 회사 코드 추출 +- `try-catch` + `logger.error` + `res.status().json()` 패턴 +- 응답 형식: `{ success, data?, message?, error?: { code, details } }` + +### 2.3 서비스 패턴 + +```typescript +// backend-node/src/services/xxxService.ts +import { logger } from "../utils/logger"; +import { query, queryOne, transaction } from "../database/db"; + +export class XxxService { + // static 메서드 또는 인스턴스 메서드 (둘 다 사용됨) + static async getList(companyCode: string, filters?: any) { + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (filters?.search) { + conditions.push(`name ILIKE $${paramIndex}`); + params.push(`%${filters.search}%`); + paramIndex++; + } + + const whereClause = conditions.length > 0 + ? `WHERE ${conditions.join(" AND ")}` + : ""; + + const rows = await query( + `SELECT * FROM xxx_table ${whereClause} ORDER BY created_date DESC`, + params + ); + return rows; + } +} +``` + +**핵심 규칙:** +- `query(sql, params)` — 다건 조회 (배열 반환) +- `queryOne(sql, params)` — 단건 조회 (객체 | null 반환) +- `transaction(async (client) => { ... })` — 트랜잭션 +- 동적 WHERE: `conditions[]` + `params[]` + `paramIndex` 패턴 +- 파라미터 바인딩: `$1`, `$2`, ... (절대 문자열 삽입 금지) + +### 2.4 DB 쿼리 함수 (database/db.ts) + +```typescript +import { query, queryOne, transaction } from "../database/db"; + +// 다건 조회 +const rows = await query<{ id: string; name: string }>( + "SELECT * FROM xxx WHERE company_code = $1", + [companyCode] +); + +// 단건 조회 +const row = await queryOne<{ id: string }>( + "SELECT * FROM xxx WHERE id = $1 AND company_code = $2", + [id, companyCode] +); + +// 트랜잭션 +const result = await transaction(async (client) => { + await client.query("INSERT INTO xxx (...) VALUES (...)", [params]); + await client.query("UPDATE yyy SET ... WHERE ...", [params]); + return { success: true }; +}); +``` + +### 2.5 라우터 패턴 + +```typescript +// backend-node/src/routes/xxxRoutes.ts +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { getXxxList, createXxx, updateXxx, deleteXxx } from "../controllers/xxxController"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// CRUD 라우트 +router.get("/", getXxxList); // GET /api/xxx +router.get("/:id", getXxxDetail); // GET /api/xxx/:id +router.post("/", createXxx); // POST /api/xxx +router.put("/:id", updateXxx); // PUT /api/xxx/:id +router.delete("/:id", deleteXxx); // DELETE /api/xxx/:id + +export default router; +``` + +**URL 네이밍:** +- 리소스명: 복수형, kebab-case (`/api/flow-definitions`, `/api/admin/users`) +- 하위 리소스: `/api/xxx/:id/yyy` +- 액션: `/api/xxx/:id/toggle`, `/api/xxx/check-duplicate` + +### 2.6 app.ts 라우트 등록 + +```typescript +// backend-node/src/app.ts 에 추가 +import xxxRoutes from "./routes/xxxRoutes"; +// ... +app.use("/api/xxx", xxxRoutes); +``` + +라우트 등록 위치: 기존 라우트들 사이에 알파벳 순서 또는 관련 기능 근처에 배치. + +### 2.7 타입 정의 + +```typescript +// backend-node/src/types/xxx.ts +export interface XxxItem { + id: string; + company_code: string; + name: string; + created_date?: string; + updated_date?: string; + writer?: string; +} +``` + +**공통 타입 (types/common.ts):** +- `ApiResponse` — 표준 API 응답 +- `AuthenticatedRequest` — 인증된 요청 (req.user 포함) +- `PaginationParams` — 페이지네이션 파라미터 + +**인증 타입 (types/auth.ts):** +- `PersonBean` — 세션 사용자 정보 (userId, companyCode, userType 등) +- `AuthenticatedRequest` — Request + PersonBean + +### 2.8 로깅 + +```typescript +import { logger } from "../utils/logger"; + +logger.info("작업 시작", { companyCode, userId }); +logger.error("작업 실패:", error); +logger.warn("경고 상황", { details }); +logger.debug("디버그 정보", { query, params }); +``` + +--- + +## 3. 프론트엔드 관행 + +### 3.1 새 기능 추가 시 파일 생성 순서 + +1. `frontend/lib/api/xxx.ts` — API 클라이언트 함수 +2. `frontend/hooks/useXxx.ts` — 커스텀 훅 (선택) +3. `frontend/components/xxx/XxxComponent.tsx` — 비즈니스 컴포넌트 +4. `frontend/app/(main)/xxx/page.tsx` — 페이지 + +### 3.2 페이지 패턴 + +```tsx +// frontend/app/(main)/xxx/page.tsx +"use client"; + +import { useState } from "react"; +import { useXxx } from "@/hooks/useXxx"; +import { XxxToolbar } from "@/components/xxx/XxxToolbar"; +import { XxxTable } from "@/components/xxx/XxxTable"; + +export default function XxxPage() { + const { data, isLoading, ... } = useXxx(); + + return ( +
+
+ {/* 페이지 헤더 */} +
+

페이지 제목

+

페이지 설명

+
+ + {/* 툴바 + 테이블 + 모달 등 */} + + +
+
+ ); +} +``` + +**핵심 규칙:** +- 모든 페이지: `"use client"` + `export default function` +- 비즈니스 로직은 커스텀 훅으로 분리 +- 페이지는 훅 + UI 컴포넌트 조합에 집중 + +### 3.3 컴포넌트 패턴 + +```tsx +// frontend/components/xxx/XxxToolbar.tsx + +interface XxxToolbarProps { + searchFilter: SearchFilter; + totalCount: number; + onSearchChange: (filter: Partial) => void; + onCreateClick: () => void; +} + +export function XxxToolbar({ + searchFilter, + totalCount, + onSearchChange, + onCreateClick, +}: XxxToolbarProps) { + return ( +
+ {/* ... */} +
+ ); +} +``` + +**핵심 규칙:** +- `export function ComponentName()` (arrow function 아님) +- `interface XxxProps` 정의 후 props 구조 분해 +- 이벤트 핸들러: 내부 `handle` 접두사, props 콜백 `on` 접두사 +- shadcn/ui 컴포넌트 우선 사용 + +### 3.4 커스텀 훅 패턴 + +```typescript +// frontend/hooks/useXxx.ts +import { useState, useCallback, useEffect, useMemo } from "react"; +import { xxxApi } from "@/lib/api/xxx"; +import { toast } from "sonner"; + +export const useXxx = () => { + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const loadData = useCallback(async () => { + setIsLoading(true); + try { + const response = await xxxApi.getList(); + if (response.success) { + setData(response.data); + } + } catch (err) { + setError("데이터 로딩 실패"); + toast.error("데이터를 불러올 수 없습니다."); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + return { + data, + isLoading, + error, + refreshData: loadData, + }; +}; +``` + +### 3.5 API 클라이언트 패턴 + +```typescript +// frontend/lib/api/xxx.ts +import { apiClient } from "./client"; + +interface ApiResponse { + success: boolean; + data?: T; + message?: string; +} + +export async function getXxxList(params?: Record) { + try { + const response = await apiClient.get("/xxx", { params }); + return response.data; + } catch (error) { + console.error("XXX 목록 API 오류:", error); + throw error; + } +} + +export async function createXxx(data: any) { + try { + const response = await apiClient.post("/xxx", data); + return response.data; + } catch (error) { + console.error("XXX 생성 API 오류:", error); + throw error; + } +} + +export async function updateXxx(id: string, data: any) { + const response = await apiClient.put(`/xxx/${id}`, data); + return response.data; +} + +export async function deleteXxx(id: string) { + const response = await apiClient.delete(`/xxx/${id}`); + return response.data; +} + +// 객체로도 export (선택) +export const xxxApi = { + getList: getXxxList, + create: createXxx, + update: updateXxx, + delete: deleteXxx, +}; +``` + +**핵심 규칙:** +- `apiClient` (Axios) 사용 — 절대 `fetch` 직접 사용 금지 +- `apiClient`는 자동으로 Authorization 헤더, 환경별 URL, 토큰 갱신 처리 +- URL에 `/api` 접두사 불필요 (client.ts에서 baseURL에 포함됨) +- 개별 함수 export + 객체 export 둘 다 가능 + +### 3.6 토스트/알림 + +```typescript +import { toast } from "sonner"; + +toast.success("저장되었습니다."); +toast.error("저장에 실패했습니다."); +toast.info("처리 중입니다."); +``` + +- `sonner` 라이브러리 직접 사용 +- 루트 레이아웃에 `` 설정됨 + +### 3.7 모달/다이얼로그 + +```tsx +import { + Dialog, DialogContent, DialogHeader, DialogTitle, + DialogDescription, DialogFooter +} from "@/components/ui/dialog"; + +interface XxxModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + editingItem?: XxxItem | null; +} + +export function XxxModal({ isOpen, onClose, onSuccess, editingItem }: XxxModalProps) { + return ( + + + + 모달 제목 + 설명 + + {/* 컨텐츠 */} + + + + + + + ); +} +``` + +### 3.8 레이아웃 계층 + +``` +app/layout.tsx → QueryProvider, RegistryProvider, Toaster + app/(main)/layout.tsx → AuthProvider, MenuProvider, AppLayout + app/(main)/admin/xxx/page.tsx → 실제 페이지 + app/(auth)/layout.tsx → 로그인 등 인증 페이지 +``` + +--- + +## 4. 데이터베이스 관행 + +### 4.1 테이블 생성 패턴 + +```sql +CREATE TABLE xxx_table ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(500) NOT NULL, + name VARCHAR(500), + description VARCHAR(500), + status VARCHAR(500) DEFAULT 'active', + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500) DEFAULT NULL +); + +CREATE INDEX idx_xxx_table_company_code ON xxx_table(company_code); +``` + +**기본 컬럼 (모든 테이블 필수):** +- `id` — VARCHAR(500), PK, `gen_random_uuid()::text` +- `company_code` — VARCHAR(500), NOT NULL +- `created_date` — TIMESTAMP, DEFAULT NOW() +- `updated_date` — TIMESTAMP, DEFAULT NOW() +- `writer` — VARCHAR(500) + +**컬럼 타입 관행:** +- 문자열: `VARCHAR(500)` (거의 모든 컬럼에 통일) +- 날짜: `TIMESTAMP` +- ID: `VARCHAR(500)` + `gen_random_uuid()::text` + +### 4.2 마이그레이션 파일명 + +``` +db/migrations/NNN_description.sql +예: 034_create_numbering_rules.sql + 078_create_production_plan_tables.sql + 1003_add_source_menu_objid_to_menu_info.sql +``` + +--- + +## 5. 멀티테넌시 (가장 중요) + +### 5.1 모든 쿼리에 company_code 필수 + +```typescript +// SELECT +WHERE company_code = $1 + +// INSERT +INSERT INTO xxx (company_code, ...) VALUES ($1, ...) + +// UPDATE +UPDATE xxx SET ... WHERE id = $1 AND company_code = $2 + +// DELETE +DELETE FROM xxx WHERE id = $1 AND company_code = $2 + +// JOIN +LEFT JOIN yyy ON xxx.yyy_id = yyy.id AND xxx.company_code = yyy.company_code +WHERE xxx.company_code = $1 +``` + +### 5.2 최고 관리자(SUPER_ADMIN) 예외 + +```typescript +const companyCode = req.user?.companyCode; + +if (companyCode === "*") { + // 최고 관리자: 전체 데이터 조회 + query = "SELECT * FROM xxx ORDER BY company_code, created_date DESC"; + params = []; +} else { + // 일반 사용자: 자기 회사만 + query = "SELECT * FROM xxx WHERE company_code = $1 ORDER BY created_date DESC"; + params = [companyCode]; +} +``` + +### 5.3 최고 관리자 가시성 제한 + +사용자 관련 API에서 일반 회사 사용자는 `company_code = "*"` 데이터를 볼 수 없음: + +```typescript +if (req.user && req.user.companyCode !== "*") { + whereConditions.push(`company_code != '*'`); +} +``` + +--- + +## 6. 인증 체계 + +### 6.1 JWT 토큰 기반 + +- 로그인 → JWT 발급 → `localStorage`에 저장 +- 모든 API 요청: `Authorization: Bearer {token}` 헤더 +- 프론트엔드 `apiClient`가 자동으로 토큰 관리 + +### 6.2 사용자 권한 3단계 + +| 역할 | company_code | userType | +|------|-------------|----------| +| 최고 관리자 | `"*"` | `SUPER_ADMIN` | +| 회사 관리자 | `"COMPANY_A"` | `COMPANY_ADMIN` | +| 일반 사용자 | `"COMPANY_A"` | `USER` | + +### 6.3 미들웨어 + +- `authenticateToken` — JWT 검증 (대부분의 라우트에 적용) +- `requireSuperAdmin` — 최고 관리자 전용 +- `requireAdmin` — 관리자(슈퍼+회사) 전용 + +--- + +## 7. 코드 스타일 관행 + +### 7.1 백엔드 + +- TypeScript strict: `false` (느슨한 타입 체크) +- 로거: `winston` (`logger` import) +- 컬럼명: `snake_case` (DB), `camelCase` (TypeScript 변수) +- 에러 코드: `UPPER_SNAKE_CASE` (예: `XXX_LIST_ERROR`) + +### 7.2 프론트엔드 + +- TypeScript strict: `true` +- 스타일: Tailwind CSS v4 + shadcn/ui +- 클래스 병합: `cn()` (clsx + tailwind-merge) +- 색상: CSS 변수 기반 (`bg-primary`, `text-muted-foreground`) +- 아이콘: `lucide-react` +- 상태 관리: `zustand` (전역), `useState`/`useReducer` (로컬) +- 데이터 패칭: `@tanstack/react-query` 또는 직접 `useEffect` + API 호출 +- 폼: `react-hook-form` + `zod` 또는 직접 `useState` +- 테이블: `@tanstack/react-table` 또는 shadcn `Table` +- 차트: `recharts` +- 날짜: `date-fns` + +### 7.3 네이밍 컨벤션 + +| 대상 | 컨벤션 | 예시 | +|------|--------|------| +| 파일명 (백엔드) | camelCase | `xxxController.ts`, `xxxService.ts`, `xxxRoutes.ts` | +| 파일명 (프론트엔드 컴포넌트) | PascalCase | `XxxToolbar.tsx`, `XxxModal.tsx` | +| 파일명 (프론트엔드 훅) | camelCase | `useXxx.ts` | +| 파일명 (프론트엔드 API) | camelCase | `xxx.ts` | +| 파일명 (프론트엔드 페이지) | camelCase 폴더 | `app/(main)/xxxMng/page.tsx` | +| DB 테이블명 | snake_case | `xxx_table`, `user_info` | +| DB 컬럼명 | snake_case | `company_code`, `created_date` | +| 컴포넌트명 | PascalCase | `XxxToolbar`, `XxxModal` | +| 함수명 | camelCase | `getXxxList`, `handleSubmit` | +| 이벤트 핸들러 (내부) | handle 접두사 | `handleCreateUser` | +| 이벤트 콜백 (props) | on 접두사 | `onSearchChange`, `onClose` | +| 상수 | UPPER_SNAKE_CASE | `MAX_PAGE_SIZE`, `DEFAULT_LIMIT` | + +--- + +## 8. 응답 형식 표준 + +### 8.1 성공 응답 + +```json +{ + "success": true, + "message": "조회 성공", + "data": [ ... ], + "pagination": { + "page": 1, + "limit": 20, + "total": 100, + "totalPages": 5 + } +} +``` + +### 8.2 에러 응답 + +```json +{ + "success": false, + "message": "조회 중 오류가 발생했습니다.", + "error": { + "code": "XXX_LIST_ERROR", + "details": "에러 상세 메시지" + } +} +``` + +--- + +## 9. 환경별 URL 매핑 + +| 환경 | 프론트엔드 | 백엔드 API | +|------|-----------|-----------| +| 프로덕션 | `v1.vexplor.com` | `https://api.vexplor.com/api` | +| 개발 (로컬) | `localhost:9771` 또는 `localhost:3000` | `http://localhost:8080/api` | + +- 프론트엔드 `apiClient`가 `window.location.hostname` 기반으로 자동 판별 +- 프론트엔드에서 API URL 하드코딩 금지 + +--- + +## 10. 자주 사용하는 import 경로 + +### 백엔드 + +```typescript +import { Request, Response } from "express"; +import { logger } from "../utils/logger"; +import { AuthenticatedRequest, PersonBean } from "../types/auth"; +import { ApiResponse } from "../types/common"; +import { query, queryOne, transaction } from "../database/db"; +import { authenticateToken } from "../middleware/authMiddleware"; +``` + +### 프론트엔드 + +```typescript +import { apiClient } from "@/lib/api/client"; +import { cn } from "@/lib/utils"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, ... } from "@/components/ui/dialog"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +``` + +--- + +## 11. 체크리스트: 새 기능 구현 시 + +### 백엔드 +- [ ] `company_code` 필터링이 모든 SELECT/INSERT/UPDATE/DELETE에 포함되어 있는가? +- [ ] `req.user?.companyCode`를 사용하는가? (클라이언트 입력 아님) +- [ ] SUPER_ADMIN (`company_code === "*"`) 예외 처리가 되어 있는가? +- [ ] JOIN 쿼리에도 `company_code` 매칭이 있는가? +- [ ] 파라미터 바인딩 (`$1`, `$2`) 사용하는가? (SQL 인젝션 방지) +- [ ] `try-catch` + `logger` + 적절한 HTTP 상태 코드를 반환하는가? +- [ ] `app.ts`에 라우트가 등록되어 있는가? + +### 프론트엔드 +- [ ] `apiClient`를 통해 API를 호출하는가? (fetch 직접 사용 금지) +- [ ] `"use client"` 지시어가 있는가? +- [ ] 비즈니스 로직이 커스텀 훅으로 분리되어 있는가? +- [ ] shadcn/ui 컴포넌트를 사용하는가? +- [ ] 에러 시 `toast.error()`로 사용자에게 피드백하는가? +- [ ] 로딩 상태를 표시하는가? +- [ ] 반응형 디자인 (모바일 우선)을 적용했는가? + +--- + +## 12. 주의사항 + +1. **백엔드 재시작 금지** — nodemon이 파일 변경 감지 시 자동 재시작 +2. **fetch 직접 사용 금지** — 반드시 `apiClient` 사용 +3. **하드코딩 색상 금지** — `bg-blue-500` 대신 `bg-primary` 등 CSS 변수 사용 +4. **company_code 누락 금지** — 모든 비즈니스 테이블/쿼리에 필수 +5. **중첩 박스 금지** — Card 안에 Card, Border 안에 Border 금지 +6. **항상 한글로 답변** diff --git a/docs/ycshin-node/PGN[계획]-페이징-단락이동.md b/docs/ycshin-node/PGN[계획]-페이징-단락이동.md new file mode 100644 index 00000000..4e39f276 --- /dev/null +++ b/docs/ycshin-node/PGN[계획]-페이징-단락이동.md @@ -0,0 +1,389 @@ +# [계획서] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트 + +> 관련 문서: [맥락노트](./PGN[맥락]-페이징-단락이동.md) | [체크리스트](./PGN[체크]-페이징-단락이동.md) + +## 개요 + +페이지네이션의 핵심 컨트롤(`<< < [번호들] > >>`)을 **재사용 가능한 공통 컴포넌트 `PageGroupNav`**로 분리합니다. +현재의 단순 `1 / n` 텍스트 표시를 **10개 단위 페이지 번호 버튼 그룹**으로 교체하고, `< >` 버튼을 **단락(그룹) 이동**으로, `<< >>` 버튼을 **첫/끝 단락 이동**으로 변경합니다. + +### 접근 전략: C안 (핵심 컨트롤 분리 + 단계적 적용) + +- **1단계 (이번 작업)**: `PageGroupNav.tsx` 생성 + v2-table-list에 적용 +- **2단계 (별도 작업)**: 나머지 페이징 사용처에 점진적 적용 + +이 전략을 선택한 이유: +- 레이아웃을 강제하지 않는 순수 컨트롤 컴포넌트 → 어디든 조합 가능 +- v2-table-list에서 먼저 검증 후 확산 → 리스크 최소화 +- 2단계는 `import` 한 줄로 적용 가능 → 미래 작업 비용 최소 + +--- + +## 현재 동작 + +### 페이지네이션 UI + +``` +[<<] [<] 1 / 38 [>] [>>] +``` + +| 버튼 | 현재 동작 | +|------|----------| +| `<<` | 첫 페이지(1)로 이동 | +| `<` | 이전 페이지(`currentPage - 1`)로 이동 | +| 중앙 | `currentPage / totalPages` 텍스트 표시 (클릭 불가) | +| `>` | 다음 페이지(`currentPage + 1`)로 이동 | +| `>>` | 마지막 페이지(`totalPages`)로 이동 | + +### 비활성화 조건 + +- `<<` `<` : `currentPage === 1` +- `>` `>>` : `currentPage >= totalPages` + +### 현재 코드 (TableListComponent.tsx, 5139~5182행) + +```tsx +{/* 중앙 페이지네이션 컨트롤 */} +
+
+``` + +--- + +## 변경 후 동작 + +### 페이지네이션 UI + +``` +[<<] [<] [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [>] [>>] +``` + +| 버튼 | 변경 후 동작 | +|------|-------------| +| `<<` | **첫 번째 단락**으로 이동 (1페이지 선택) | +| `<` | **이전 단락**의 첫 페이지로 이동 | +| 중앙 | 현재 단락의 페이지 번호 버튼 나열 (클릭으로 해당 페이지 이동) | +| `>` | **다음 단락**의 첫 페이지로 이동 | +| `>>` | **마지막 단락**의 첫 페이지로 이동 (마지막 페이지가 아님) | + +### 비활성화 조건 + +- `<<` `<` : **첫 번째 단락**(1~10)을 보고 있을 때 +- `>` `>>` : **마지막 단락**을 보고 있을 때 + +### 단락(그룹) 개념 + +- 10개 단위로 페이지를 묶어 하나의 "단락"으로 취급 +- 단락 1: 1~10, 단락 2: 11~20, 단락 3: 21~30, ... +- 마지막 단락은 10개 미만일 수 있음 (예: 31~38) + +### 고정 슬롯 레이아웃 (핵심 제약) + +**페이지 번호 영역은 항상 10개 슬롯을 고정 렌더링한다.** + +- 각 슬롯은 동일한 고정 너비(`w-9` 등)를 가짐 +- 1자리(`1`)든 2자리(`11`)든 3자리(`100`)든 버튼 너비가 동일 +- 마지막 단락이 10개 미만이면 남은 슬롯은 빈 공간(투명 placeholder)으로 채움 +- 이로써 `< >` 버튼을 연속 클릭해도 **번호 버튼과 화살표 버튼의 위치가 절대 변하지 않음** + +``` +단락 1~10: [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] ← 10개 모두 채움 +단락 11~20: [11][12][13][14][15][16][17][18][19][20] ← 너비 동일 +단락 31~38: [31][32][33][34][35][36][37][38][ ][ ] ← 빈 슬롯 2개로 위치 고정 +``` + +--- + +## 시각적 동작 예시 + +총 38페이지 기준: + +### 단락별 페이지 번호 표시 + +| 현재 페이지 | 표시 번호 | `<<` `<` | `>` `>>` | +|-------------|-----------|----------|----------| +| 1 | **[1]** [2] [3] ... [10] | 비활성 | 활성 | +| 5 | [1] [2] [3] [4] **[5]** [6] [7] [8] [9] [10] | 비활성 | 활성 | +| 10 | [1] [2] [3] [4] [5] [6] [7] [8] [9] **[10]** | 비활성 | 활성 | +| 11 | **[11]** [12] [13] ... [20] | 활성 | 활성 | +| 25 | [21] [22] [23] [24] **[25]** [26] [27] [28] [29] [30] | 활성 | 활성 | +| 31 | **[31]** [32] [33] ... [38] [ ] [ ] | 활성 | 비활성 | +| 38 | [31] [32] [33] [34] [35] [36] [37] **[38]** [ ] [ ] | 활성 | 비활성 | + +### 버튼 클릭 시나리오 + +| 현재 상태 | 클릭 | 결과 | +|----------|------|------| +| 5페이지 (단락 1~10) | `>` | 11페이지 선택, 단락 11~20 표시 | +| 15페이지 (단락 11~20) | `<` | 1페이지 선택, 단락 1~10 표시 | +| 15페이지 (단락 11~20) | `>>` | 31페이지 선택, 단락 31~38 표시 | +| 35페이지 (단락 31~38) | `<<` | 1페이지 선택, 단락 1~10 표시 | +| 5페이지 (단락 1~10) | `[7]` | 7페이지 선택, 단락 1~10 유지 | + +--- + +## 아키텍처 + +### 컴포넌트 구조 (C안) + +```mermaid +flowchart TD + subgraph PageGroupNav ["PageGroupNav.tsx (새 공통 컴포넌트)"] + Props["props: currentPage, totalPages, onPageChange, disabled, groupSize"] + Logic["단락 계산 + 고정 슬롯 + 비활성화"] + UI["<< < [번호들] > >>"] + Props --> Logic --> UI + end + + subgraph Phase1 ["1단계: 이번 작업"] + V2Table["v2-table-list paginationJSX"] + end + + subgraph Phase2 ["2단계: 별도 작업 (미래)"] + TableList["table-list (구형)"] + PaginationTsx["Pagination.tsx (관리자)"] + DrillDown["DrillDown 모달"] + Mail["메일 수신/발송"] + Others["감사로그, 배치, DataTable 등"] + end + + PageGroupNav --> V2Table + PageGroupNav -.-> TableList + PageGroupNav -.-> PaginationTsx + PageGroupNav -.-> DrillDown + PageGroupNav -.-> Mail + PageGroupNav -.-> Others +``` + +### v2-table-list 내부 데이터 흐름 + +```mermaid +flowchart TD + A["currentPage, totalPages (state)"] --> B[PageGroupNav] + B -->|onPageChange| C[handlePageChange] + C --> D[setCurrentPage + onConfigChange] + D --> E[백엔드 API 호출] + E --> F[데이터 갱신] + F --> A +``` + +### v2-table-list 페이징 바 레이아웃 (변경 없음) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ [페이지크기 입력] │ << < [PageGroupNav] > >> │ [내보내기][새로고침] │ +│ 좌측(유지) │ 중앙(교체) │ 우측(유지) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 변경 대상 파일 + +### 1단계 (이번 작업) + +| 구분 | 파일 | 변경 내용 | 변경 규모 | +|------|------|----------|----------| +| 생성 | `frontend/components/common/PageGroupNav.tsx` | 페이지 그룹 네비게이션 공통 컴포넌트 | 약 80줄 신규 | +| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | `paginationJSX` 중앙 영역을 `PageGroupNav`로 교체 (5139~5182행) | 약 40줄 → 5줄 | + +- `handlePageChange` 함수는 기존 것을 그대로 사용 (동작 변경 없음) +- 좌측(페이지크기 입력), 우측(내보내기/새로고침) 영역은 변경하지 않음 +- 백엔드 변경 없음, DB 변경 없음 + +### 1단계 적용 범위 + +v2-table-list를 사용하는 **모든 동적 화면**에 자동 적용: +- 품목정보, 거래처관리, 판매품목정보, 설비정보 등 + +### 2단계 적용 대상 (별도 작업, 미래) + +| 사용처 | 파일 | 현재 페이징 형태 | +|--------|------|----------------| +| table-list (구형) | `lib/registry/components/table-list/TableListComponent.tsx` | `<< < 현재/총 > >>` | +| 공통 Pagination | `components/common/Pagination.tsx` | 번호 ±2 + `...` | +| 피벗 드릴다운 | `lib/registry/components/pivot-grid/components/DrillDownModal.tsx` | `<< < 현재/총 > >>` | +| v2 피벗 드릴다운 | `lib/registry/components/v2-pivot-grid/components/DrillDownModal.tsx` | 동일 | +| 메일 수신함 | `app/(main)/admin/automaticMng/mail/receive/page.tsx` | 번호 5개 클릭 | +| 메일 발송함 | `app/(main)/admin/automaticMng/mail/sent/page.tsx` | 동일 | +| 감사 로그 | `app/(main)/admin/audit-log/page.tsx` | `< 현재/총 >` | +| 배치 관리 | `app/(main)/admin/automaticMng/batchmngList/page.tsx` | 번호 5개 클릭 | +| DataTable | `components/common/DataTable.tsx` | `<< < > >>` + 텍스트 | +| FlowWidget | `components/screen/widgets/FlowWidget.tsx` | shadcn Pagination | + +--- + +## 코드 설계 + +### PageGroupNav.tsx 공통 컴포넌트 + +```tsx +// frontend/components/common/PageGroupNav.tsx +"use client"; + +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +const DEFAULT_GROUP_SIZE = 10; + +interface PageGroupNavProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + disabled?: boolean; + groupSize?: number; +} + +export function PageGroupNav({ + currentPage, + totalPages, + onPageChange, + disabled = false, + groupSize = DEFAULT_GROUP_SIZE, +}: PageGroupNavProps) { + const safeTotal = Math.max(1, totalPages); + const currentGroupIndex = Math.floor((currentPage - 1) / groupSize); + const groupStartPage = currentGroupIndex * groupSize + 1; + + const lastGroupIndex = Math.floor((safeTotal - 1) / groupSize); + const lastGroupStartPage = lastGroupIndex * groupSize + 1; + + const isFirstGroup = currentGroupIndex === 0; + const isLastGroup = currentGroupIndex === lastGroupIndex; + + // 10개 고정 슬롯 배열 + const slots: (number | null)[] = []; + for (let i = 0; i < groupSize; i++) { + const page = groupStartPage + i; + slots.push(page <= safeTotal ? page : null); + } + + return ( +
+ {/* << 첫 단락 */} + + + {/* < 이전 단락 */} + + + {/* 페이지 번호 (고정 슬롯) */} + {slots.map((page, idx) => + page !== null ? ( + + ) : ( +
+ ) + )} + + {/* > 다음 단락 */} + + + {/* >> 마지막 단락 */} + +
+ ); +} +``` + +### v2-table-list 통합 (paginationJSX 중앙 영역 교체) + +기존 5139~5182행의 `
` 블록을 다음으로 교체: + +```tsx +import { PageGroupNav } from "@/components/common/PageGroupNav"; + +// paginationJSX 내부 중앙 영역 + +``` + +좌측(페이지크기 입력), 우측(내보내기/새로고침)은 기존 코드 그대로 유지. + +--- + +## 설계 원칙 + +- **레이아웃 무관 컴포넌트**: PageGroupNav는 순수 컨트롤만 담당. 외부 레이아웃(좌측/우측 부가 기능)을 강제하지 않음 +- **기존 동작 무변경**: `handlePageChange` 함수는 수정하지 않음. 좌측/우측 영역도 변경하지 않음 +- **고정 슬롯 레이아웃**: 페이지 번호 영역은 항상 `groupSize`개(기본 10) 슬롯 고정. 마지막 단락에서 부족하면 빈 div로 채움 +- **고정 너비 버튼**: 모든 번호 버튼은 `w-8 sm:w-9` 고정. 1자리/2자리/3자리에 관계없이 동일 +- **위치 불변**: `< >` `<< >>` 버튼을 연속 클릭해도 모든 버튼의 위치가 절대 변하지 않음 +- **현재 페이지 강조**: `variant="default"`(primary) + `ring-2 ring-primary font-bold`, 나머지 `variant="outline"` +- **엣지 케이스**: totalPages가 0이거나 1일 때도 정상 동작 (`safeTotal = Math.max(1, totalPages)`) +- **빈 슬롯 접근성**: 빈 슬롯에 `cursor-default` 적용 (클릭 가능한 것처럼 보이지 않게) +- **단계적 적용**: 1단계에서 v2-table-list로 검증 후, 2단계에서 나머지 사용처에 점진 적용 + +--- + +## 추가 구현: 표시갯수(pageSize) 캐시 정책 + +### 문제 + +기존 pageSize는 `onConfigChange`로 부모에 전파되어 DB에 저장되거나, `localStorage`에 저장되어 새로고침해도 사용자가 변경한 값이 남아있었음. + +### 해결 + +| 항목 | 정책 | +|------|------| +| 저장소 | sessionStorage (탭 닫으면 자동 소멸) | +| 키 구조 | `pageSize_{tabId}_{tableName}` (탭별 격리) | +| 기본값 | 20 | +| DB 전파 | 안 함 (onConfigChange 제거) | +| F5 새로고침 | 활성 탭 캐시 삭제 → 기본값 20 | +| 탭 바 새로고침 | 활성 탭 캐시 삭제 → 기본값 20 | +| 비활성 탭 전환 | 캐시에서 복원 | +| 입력 UX | onChange는 표시만, onBlur/Enter로 실제 적용 | + +### 테이블 캐시 탭 격리 + +동일한 정책을 테이블 관련 캐시 전체에 적용: + +| 키 | 구조 | +|----|------| +| `tableState_{tabId}_{tableName}` | 컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터 | +| `pageSize_{tabId}_{tableName}` | 표시갯수 | +| `filterSettings_{tabId}_{base}` | 검색 필터 설정 | +| `groupSettings_{tabId}_{base}` | 그룹 설정 | + +사용자 설정(컬럼 가시성/순서/정렬 상태)은 localStorage에 유지 (세션 간 보존). diff --git a/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md b/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md new file mode 100644 index 00000000..024bd7a2 --- /dev/null +++ b/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md @@ -0,0 +1,128 @@ +# [맥락노트] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트 + +> 관련 문서: [계획서](./PGN[계획]-페이징-단락이동.md) | [체크리스트](./PGN[체크]-페이징-단락이동.md) + +--- + +## 왜 이 작업을 하는가 + +- 현재 페이지네이션은 `1 / 38` 텍스트만 표시하고 `< >`로 한 페이지씩 이동 +- 수십 페이지가 있을 때 원하는 페이지로 빠르게 이동할 수 없음 +- 페이지 번호를 직접 클릭할 수 있어야 UX가 개선됨 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 공통 컴포넌트로 분리 (C안) + +- **결정**: `PageGroupNav.tsx`라는 순수 컨트롤 컴포넌트를 별도 파일로 생성 +- **근거**: 프로젝트에 페이징이 15곳 이상 존재. 인라인 수정하면 같은 로직을 복사해야 함 +- **대안 검토 A**: v2-table-list 인라인만 수정 → 기각 (미래 확장 시 복사-붙여넣기 기술 부채) +- **대안 검토 B**: 기존 `Pagination.tsx` 업그레이드 → 기각 (전체 행 레이아웃이 포함되어 v2-table-list와 레이아웃 충돌) +- **대안 검토 D**: 전체 한번에 적용 → 기각 (12파일 동시 수정은 블래스트 반경이 큼) + +### 2. 레이아웃 무관 설계 + +- **결정**: PageGroupNav는 `<< < [번호들] > >>`만 렌더링. 외부 레이아웃(페이지크기, 내보내기 등)을 포함하지 않음 +- **근거**: 사용처마다 레이아웃이 다름. v2-table-list는 좌측(페이지크기)+중앙(컨트롤)+우측(내보내기), Pagination.tsx는 좌측(페이지정보)+우측(크기선택+컨트롤). 레이아웃을 강제하면 props 분기가 증가하여 복잡해짐 + +### 3. 10개 단위 단락(그룹) + +- **결정**: 페이지를 10개씩 묶어 하나의 단락으로 취급 +- **근거**: 사용자에게 익숙한 패턴 (네이버, 구글 등). 5개는 너무 적고, 20개는 너무 많음 +- **확장성**: `groupSize` props로 기본값 10을 변경 가능하게 설계 + +### 4. `< >` = 단락 이동, `<< >>` = 첫/끝 단락 + +- **결정**: `<`는 이전 단락 첫 페이지, `>`는 다음 단락 첫 페이지. `<<`는 1페이지, `>>`는 마지막 단락 첫 페이지 +- **근거**: 사용자 요청. 기존의 "한 페이지씩 이동"은 번호 클릭으로 대체됨 +- **주의**: `>>`는 마지막 **페이지**가 아닌 마지막 **단락의 첫 페이지**로 이동. 예: 총 38페이지일 때 `>>` 클릭 → 31페이지 선택 (38이 아님) + +### 5. 고정 슬롯 + 고정 너비 + +- **결정**: 항상 10개 슬롯을 렌더링하고, 모든 버튼은 동일한 고정 너비(`w-8 sm:w-9`) +- **근거**: `< >` 버튼을 연속 클릭할 때 번호 자릿수(1자리→2자리)나 페이지 수(10개→8개) 변화로 버튼 위치가 흔들리면 안 됨 +- **구현**: 마지막 단락에서 페이지가 10개 미만이면 남은 슬롯은 동일 크기의 빈 `
`로 채움 + +### 6. 단계적 적용 (1단계: v2-table-list만) + +- **결정**: 이번 작업은 v2-table-list에만 적용. 나머지는 별도 작업으로 점진 적용 +- **근거**: 15곳 동시 수정은 리스크가 높음. v2-table-list가 가장 많이 사용되므로 여기서 검증 후 확산 + +### 7. 비활성화 기준은 단락 기준 + +- **결정**: `<< <`는 첫 번째 단락일 때 비활성화 (currentPage === 1이 아님). `> >>`는 마지막 단락일 때 비활성화 +- **근거**: 기존은 currentPage 기준이었지만, 단락 이동이므로 단락 기준으로 변경이 자연스러움. 첫 단락 안에서 5페이지에 있어도 `<`는 비활성화 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 생성 | `frontend/components/common/PageGroupNav.tsx` | 페이지 그룹 네비게이션 공통 컴포넌트 | +| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | paginationJSX 중앙 영역 교체 (5139~5182행) | +| 참고 | `frontend/components/common/Pagination.tsx` | 기존 공통 페이지네이션 (이번에 수정 안 함) | + +--- + +## 기술 참고 + +### 단락 계산 공식 + +``` +groupSize = 10 (기본값) +currentGroupIndex = Math.floor((currentPage - 1) / groupSize) +groupStartPage = currentGroupIndex * groupSize + 1 +groupEndPage = Math.min(groupStartPage + groupSize - 1, totalPages) + +lastGroupIndex = Math.floor((totalPages - 1) / groupSize) +lastGroupStartPage = lastGroupIndex * groupSize + 1 + +isFirstGroup = currentGroupIndex === 0 +isLastGroup = currentGroupIndex === lastGroupIndex +``` + +### 고정 슬롯 배열 생성 + +``` +slots = [groupStart, groupStart+1, ..., groupEnd, null, null, ...] (총 groupSize개) +예: 단락 31~38 → [31, 32, 33, 34, 35, 36, 37, 38, null, null] +``` + +### handlePageChange 호출 흐름 + +``` +PageGroupNav onPageChange(page) + → TableListComponent handlePageChange(newPage) + → setCurrentPage(newPage) + → useEffect 트리거 → 백엔드 API 재호출 (page 파라미터 변경) +``` + +- handlePageChange는 `setCurrentPage`만 호출. `onConfigChange` 전파는 제거됨 (pageSize/currentPage는 세션 전용) +- handlePageChange는 기존 함수 그대로 사용. PageGroupNav가 올바른 page 값을 전달하기만 하면 됨 + +--- + +## 추가 결정: 표시갯수(pageSize) 캐시 정책 + +### 8. pageSize는 세션 전용, DB에 저장 안 함 + +- **결정**: pageSize를 `onConfigChange`로 부모/DB에 전파하지 않음. sessionStorage에만 탭별로 저장 +- **근거**: pageSize는 일시적 탐색 설정이지 영구 화면 설정이 아님. DB에 저장하면 다른 사용자에게도 영향이 가고, 새로고침 시 의도치 않은 값이 남음 +- **F5 정책**: 활성 탭은 캐시 삭제 → 기본값 20으로 fresh start. 비활성 탭은 캐시 유지 + +### 9. 테이블 캐시는 탭별 격리 (탭 ID 스코프) + +- **결정**: `tableState_*`, `pageSize_*`, `filterSettings_*`, `groupSettings_*` 키를 `{prefix}_{tabId}_{tableName}` 구조로 변경 +- **근거**: 같은 테이블이 여러 탭에서 열릴 수 있음. 탭 구분 없으면 "활성 탭 캐시만 삭제" 불가능 +- **구현**: `useTabId()` 훅으로 현재 탭 ID 접근. `clearTabCache(tabId)`에서 해당 탭의 모든 관련 키 일괄 삭제 + +### 10. localStorage vs sessionStorage 분류 + +- **결정**: 탭별 캐시는 sessionStorage, 사용자 설정은 localStorage +- **근거**: 탭별 캐시(컬럼 너비 캐시, 필터, 그룹, pageSize)는 탭 닫으면 무의미. 사용자 설정(컬럼 가시성, 순서, 정렬)은 사용자가 의도적으로 변경한 환경설정이므로 세션 간 보존 +- **분류**: + - sessionStorage: `tableState_*`, `pageSize_*`, `filterSettings_*`, `groupSettings_*` + - localStorage: `table_column_visibility_*`, `table_sort_state_*`, `table_column_order_*` diff --git a/docs/ycshin-node/PGN[체크]-페이징-단락이동.md b/docs/ycshin-node/PGN[체크]-페이징-단락이동.md new file mode 100644 index 00000000..46b94395 --- /dev/null +++ b/docs/ycshin-node/PGN[체크]-페이징-단락이동.md @@ -0,0 +1,90 @@ +# [체크리스트] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트 + +> 관련 문서: [계획서](./PGN[계획]-페이징-단락이동.md) | [맥락노트](./PGN[맥락]-페이징-단락이동.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (완료) +- 현재 단계: 4단계 완료 + +--- + +## 구현 체크리스트 + +### 1단계: PageGroupNav 공통 컴포넌트 생성 + +- [x] `frontend/components/common/PageGroupNav.tsx` 파일 생성 +- [x] `PageGroupNavProps` 인터페이스 정의 (currentPage, totalPages, onPageChange, disabled, groupSize) +- [x] 단락 계산 로직 구현 (currentGroupIndex, groupStartPage, lastGroupIndex 등) +- [x] 10개 고정 슬롯 배열 생성 (빈 슬롯은 null) +- [x] `<<` 첫 단락 버튼 (isFirstGroup일 때 비활성화) +- [x] `<` 이전 단락 버튼 (isFirstGroup일 때 비활성화) +- [x] 페이지 번호 버튼 렌더링 (현재 페이지 variant="default", 나머지 variant="outline") +- [x] 빈 슬롯 렌더링 (동일 크기 빈 div) +- [x] `>` 다음 단락 버튼 (isLastGroup일 때 비활성화) +- [x] `>>` 마지막 단락 버튼 (isLastGroup일 때 비활성화, 마지막 단락 첫 페이지로 이동) +- [x] 고정 너비 스타일 적용 (h-8 w-8 sm:h-9 sm:w-9) +- [x] totalPages가 0 또는 1일 때 엣지 케이스 처리 + +### 2단계: v2-table-list 통합 + +- [x] `TableListComponent.tsx`에 `PageGroupNav` import 추가 +- [x] `paginationJSX`의 중앙 컨트롤 영역(5139~5182행)을 `` 호출로 교체 +- [x] props 연결: currentPage, totalPages, handlePageChange, loading +- [x] 좌측(페이지크기 입력) 영역 변경 없음 확인 +- [x] 우측(내보내기/새로고침) 영역 변경 없음 확인 + +### 3단계: 검증 + +- [x] 품목정보 화면에서 페이지 번호 클릭 동작 확인 +- [x] `< >` 단락 이동 동작 확인 (1~10 → 11~20 → ...) +- [x] `<< >>` 첫/끝 단락 이동 동작 확인 +- [x] `>>` 클릭 시 마지막 단락의 첫 페이지 선택 확인 (마지막 페이지가 아님) +- [x] 첫 단락에서 `<< <` 비활성화 확인 +- [x] 마지막 단락에서 `> >>` 비활성화 확인 +- [x] 고정 슬롯: 단락 이동 시 버튼 위치 변동 없음 확인 +- [x] 고정 너비: 1자리/2자리 숫자에서 버튼 크기 동일 확인 +- [x] 마지막 단락이 10개 미만일 때 빈 슬롯으로 위치 고정 확인 +- [x] totalPages가 1일 때 정상 동작 확인 (단일 페이지) +- [x] 로딩 중 모든 버튼 비활성화 확인 +- [x] 페이지 크기 변경 시 첫 페이지로 리셋 확인 + +### 4단계: 정리 + +- [x] 린트 에러 없음 확인 +- [x] 이 체크리스트 완료 표시 업데이트 + +### 5단계: 표시갯수(pageSize) 캐시 정책 + +- [x] 표시갯수 입력 시 onChange → 표시만 변경, 실제 적용은 onBlur/Enter +- [x] 입력 필드 값 string 타입으로 변경 (백스페이스로 비우기 가능) +- [x] 표시갯수 변경 시 1페이지로 리셋 + 데이터 정상 로드 +- [x] onConfigChange로 DB/부모 전파 제거 (pageSize는 세션 전용) +- [x] localStorage → sessionStorage 전환 (탭 닫으면 자동 소멸) +- [x] 키를 탭 ID 스코프로 변경 (`pageSize_{tabId}_{tableName}`) +- [x] F5 새로고침 시 활성 탭 캐시 삭제 → 기본값 20 초기화 +- [x] 탭 바 새로고침 버튼 시 캐시 삭제 → 기본값 20 초기화 +- [x] 비활성 탭 캐시 유지 (탭 전환 시 복원) + +### 6단계: 테이블 캐시 탭 격리 + +- [x] tableStateKey 탭 ID 스코프 (`tableState_{tabId}_{tableName}`) + sessionStorage +- [x] filterSettingKey 탭 ID 스코프 (`filterSettings_{tabId}_{base}`) + sessionStorage +- [x] groupSettingKey 탭 ID 스코프 (`groupSettings_{tabId}_{base}`) + sessionStorage +- [x] clearTabCache 확장 (tableState_/pageSize_/filterSettings_/groupSettings_ 일괄 삭제) +- [x] TabContent.tsx 모듈 레벨 플래그로 F5 감지 → 활성 탭 캐시만 삭제 +- [x] tabStore.refreshTab에 clearTabCache 추가 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 | +| 2026-03-11 | 1단계(PageGroupNav 생성) + 2단계(v2-table-list 통합) + 4단계(린트) 완료. 3단계(수동 검증)은 브라우저에서 확인 필요 | +| 2026-03-11 | 추가 개선: 선택 페이지 강조(ring + font-bold), 빈 슬롯 cursor-default 적용. 3단계 검증 완료. 전체 완료 | +| 2026-03-11 | 5단계: pageSize 입력 UX 개선 + 캐시 정책 (sessionStorage + 탭 스코프 + F5/탭새로고침 초기화) | +| 2026-03-11 | 6단계: 테이블 전체 캐시를 탭별 격리 (localStorage → sessionStorage + 탭 ID 스코프) | diff --git a/docs/ycshin-node/탭_시스템_설계.md b/docs/ycshin-node/탭_시스템_설계.md index 50ca2468..99ce4a8d 100644 --- a/docs/ycshin-node/탭_시스템_설계.md +++ b/docs/ycshin-node/탭_시스템_설계.md @@ -123,15 +123,49 @@ - [ ] 비활성 탭: 캐시에서 복원 - [ ] 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제 -### 6-3. 캐시 키 관리 (clearTabStateCache) +### 6-3. 캐시 키 관리 (clearTabCache) 탭 닫기/새로고침 시 관련 sessionStorage 키 일괄 제거: -- `tab-cache-{screenId}-{menuObjid}` -- `page-scroll-{screenId}-{menuObjid}` -- `tsp-{screenId}-*`, `table-state-{screenId}-*` -- `split-sel-{screenId}-*`, `catval-sel-{screenId}-*` -- `bom-tree-{screenId}-*` -- URL 탭: `tsp-{urlHash}-*`, `admin-scroll-{url}` +- `tab-cache-{tabId}` (폼/스크롤 캐시) +- `tableState_{tabId}_*` (컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터) +- `pageSize_{tabId}_*` (표시갯수) +- `filterSettings_{tabId}_*` (검색 필터 설정) +- `groupSettings_{tabId}_*` (그룹 설정) + +### 6-4. F5 새로고침 시 캐시 정책 (구현 완료) + +| 탭 상태 | F5 시 동작 | +|---------|-----------| +| **활성 탭** | `clearTabCache(activeTabId)` → 캐시 전체 삭제 → fresh API 호출 | +| **비활성 탭** | 캐시 유지 → 탭 전환 시 복원 | + +**구현 방식**: `TabContent.tsx`에 모듈 레벨 플래그(`hasHandledPageLoad`)를 사용. +전체 페이지 로드 시 모듈이 재실행되어 플래그가 `false`로 리셋. +SPA 내비게이션에서는 모듈이 유지되므로 `true`로 남아 중복 실행 방지. + +### 6-5. 탭 바 새로고침 버튼 (구현 완료) + +`tabStore.refreshTab(tabId)` 호출 시: +1. `clearTabCache(tabId)` → 해당 탭의 모든 sessionStorage 캐시 삭제 +2. `refreshKey` 증가 → 컴포넌트 리마운트 → 기본값으로 초기화 + +### 6-6. 저장소 분류 기준 (구현 완료) + +| 데이터 성격 | 저장소 | 키 구조 | 비고 | +|------------|--------|---------|------| +| 탭별 캐시 | sessionStorage | `{prefix}_{tabId}_{tableName}` | 탭 닫으면 소멸 | +| 사용자 설정 | localStorage | `{prefix}_{tableName}_{userId}` | 세션 간 보존 | + +**탭별 캐시 (sessionStorage)**: +- tableState: 컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터 +- pageSize: 표시갯수 +- filterSettings: 검색 필터 설정 +- groupSettings: 그룹 설정 + +**사용자 설정 (localStorage)**: +- table_column_visibility: 컬럼 표시/숨김 +- table_sort_state: 정렬 상태 +- table_column_order: 컬럼 순서 --- diff --git a/frontend/components/common/PageGroupNav.tsx b/frontend/components/common/PageGroupNav.tsx new file mode 100644 index 00000000..dc59b35e --- /dev/null +++ b/frontend/components/common/PageGroupNav.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const DEFAULT_GROUP_SIZE = 10; + +interface PageGroupNavProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + disabled?: boolean; + groupSize?: number; +} + +export function PageGroupNav({ + currentPage, + totalPages, + onPageChange, + disabled = false, + groupSize = DEFAULT_GROUP_SIZE, +}: PageGroupNavProps) { + const safeTotal = Math.max(1, totalPages); + const currentGroupIndex = Math.floor((currentPage - 1) / groupSize); + const groupStartPage = currentGroupIndex * groupSize + 1; + + const lastGroupIndex = Math.floor((safeTotal - 1) / groupSize); + const lastGroupStartPage = lastGroupIndex * groupSize + 1; + + const isFirstGroup = currentGroupIndex === 0; + const isLastGroup = currentGroupIndex === lastGroupIndex; + + const slots: (number | null)[] = []; + for (let i = 0; i < groupSize; i++) { + const page = groupStartPage + i; + slots.push(page <= safeTotal ? page : null); + } + + return ( +
+ {/* << 첫 단락 */} + + + {/* < 이전 단락 */} + + + {/* 페이지 번호 (고정 슬롯) */} + {slots.map((page, idx) => + page !== null ? ( + + ) : ( +
+ ), + )} + + {/* > 다음 단락 */} + + + {/* >> 마지막 단락 */} + +
+ ); +} diff --git a/frontend/components/layout/TabContent.tsx b/frontend/components/layout/TabContent.tsx index 0c1fabfb..836f3bcd 100644 --- a/frontend/components/layout/TabContent.tsx +++ b/frontend/components/layout/TabContent.tsx @@ -19,6 +19,11 @@ import { clearTabCache, } from "@/lib/tabStateCache"; +// 페이지 로드(F5 새로고침) 감지용 모듈 레벨 플래그. +// 전체 페이지 로드 시 모듈이 재실행되어 false로 리셋된다. +// SPA 내비게이션에서는 모듈이 유지되므로 true로 남는다. +let hasHandledPageLoad = false; + export function TabContent() { const tabs = useTabStore(selectTabs); const activeTabId = useTabStore(selectActiveTabId); @@ -39,6 +44,13 @@ export function TabContent() { // 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함) const pathCacheRef = useRef>(new WeakMap()); + // 페이지 로드(F5) 시 활성 탭 캐시만 삭제 → fresh API 호출 유도 + // 비활성 탭 캐시는 유지하여 탭 전환 시 복원 + if (!hasHandledPageLoad && activeTabId) { + hasHandledPageLoad = true; + clearTabCache(activeTabId); + } + if (activeTabId) { mountedTabIdsRef.current.add(activeTabId); } diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index cc36afd6..0f01370f 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -2,15 +2,15 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { TableListConfig, ColumnConfig } from "./types"; -import { WebType } from "@/types/common"; +import type { WebType } from "@/types/common"; import { tableTypeApi } from "@/lib/api/screen"; import { entityJoinApi } from "@/lib/api/entityJoin"; -import { codeCache } from "@/lib/caching/codeCache"; import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; import { getFullImageUrl } from "@/lib/api/client"; import { getFilePreviewUrl } from "@/lib/api/file"; import { Button } from "@/components/ui/button"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; +import { useTabId } from "@/contexts/TabIdContext"; // 🖼️ 테이블 셀 이미지 썸네일 컴포넌트 // objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용 @@ -155,13 +155,8 @@ declare global { import { ChevronLeft, ChevronRight, - ChevronsLeft, - ChevronsRight, RefreshCw, - ArrowUp, - ArrowDown, TableIcon, - Settings, X, Layers, ChevronDown, @@ -174,14 +169,14 @@ import { Edit, CheckSquare, Trash2, - Lock, } from "lucide-react"; import * as XLSX from "xlsx"; -import { FileText, ChevronRightIcon } from "lucide-react"; +import { FileText } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; +import { PageGroupNav } from "@/components/common/PageGroupNav"; import { tableDisplayStore } from "@/stores/tableDisplayStore"; import { Dialog, @@ -193,7 +188,6 @@ import { } from "@/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; -import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters"; import { SingleTableWithSticky } from "./SingleTableWithSticky"; import { CardModeRenderer } from "./CardModeRenderer"; import { TableOptionsModal } from "@/components/common/TableOptionsModal"; @@ -201,7 +195,7 @@ import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; -import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; +import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer"; // ======================================== @@ -404,6 +398,8 @@ export const TableListComponent: React.FC = ({ // 디버그 로그 제거 (성능 최적화) + const currentTabId = useTabId(); + const buttonColor = component.style?.labelColor || "#212121"; const buttonTextColor = component.config?.buttonTextColor || "#ffffff"; @@ -694,7 +690,38 @@ export const TableListComponent: React.FC = ({ const hasInitializedSort = useRef(false); const [columnLabels, setColumnLabels] = useState>({}); const [tableLabel, setTableLabel] = useState(""); - const [localPageSize, setLocalPageSize] = useState(tableConfig.pagination?.pageSize || 20); + const pageSizeKey = useMemo(() => { + if (!tableConfig.selectedTable) return null; + if (currentTabId) return `pageSize_${currentTabId}_${tableConfig.selectedTable}`; + return `pageSize_${tableConfig.selectedTable}`; + }, [tableConfig.selectedTable, currentTabId]); + + const [localPageSize, setLocalPageSize] = useState(() => { + const key = + currentTabId && tableConfig.selectedTable + ? `pageSize_${currentTabId}_${tableConfig.selectedTable}` + : tableConfig.selectedTable + ? `pageSize_${tableConfig.selectedTable}` + : null; + if (key) { + const val = sessionStorage.getItem(key); + if (val) return Number(val); + } + return 20; + }); + const [pageSizeInputValue, setPageSizeInputValue] = useState(() => { + const key = + currentTabId && tableConfig.selectedTable + ? `pageSize_${currentTabId}_${tableConfig.selectedTable}` + : tableConfig.selectedTable + ? `pageSize_${tableConfig.selectedTable}` + : null; + if (key) { + const val = sessionStorage.getItem(key); + if (val) return val; + } + return "20"; + }); const [displayColumns, setDisplayColumns] = useState([]); const [columnMeta, setColumnMeta] = useState< Record @@ -804,11 +831,12 @@ export const TableListComponent: React.FC = ({ const [dropTargetColumnIndex, setDropTargetColumnIndex] = useState(null); const [isColumnDragEnabled] = useState((tableConfig as any).enableColumnDrag ?? true); - // 🆕 State Persistence: 통합 상태 키 + // 🆕 State Persistence: 통합 상태 키 (탭 ID 스코프 + sessionStorage) const tableStateKey = useMemo(() => { if (!tableConfig.selectedTable) return null; + if (currentTabId) return `tableState_${currentTabId}_${tableConfig.selectedTable}`; return `tableState_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable]); + }, [tableConfig.selectedTable, currentTabId]); // 🆕 Real-Time Updates 관련 상태 const [isRealTimeEnabled] = useState((tableConfig as any).realTimeUpdates ?? false); @@ -1612,7 +1640,7 @@ export const TableListComponent: React.FC = ({ setError(null); try { - const page = tableConfig.pagination?.currentPage || currentPage; + const page = currentPage || tableConfig.pagination?.currentPage || 1; const pageSize = localPageSize; // 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용 const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined; @@ -1910,12 +1938,6 @@ export const TableListComponent: React.FC = ({ const handlePageChange = (newPage: number) => { if (newPage < 1 || newPage > totalPages) return; setCurrentPage(newPage); - if (tableConfig.pagination) { - tableConfig.pagination.currentPage = newPage; - } - if (onConfigChange) { - onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } }); - } }; const handleSort = (column: string) => { @@ -2952,12 +2974,11 @@ export const TableListComponent: React.FC = ({ headerFilters: Object.fromEntries( Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]), ), - pageSize: localPageSize, timestamp: Date.now(), }; try { - localStorage.setItem(tableStateKey, JSON.stringify(state)); + sessionStorage.setItem(tableStateKey, JSON.stringify(state)); } catch (error) { console.error("❌ 테이블 상태 저장 실패:", error); } @@ -2972,7 +2993,6 @@ export const TableListComponent: React.FC = ({ frozenColumnCount, showGridLines, headerFilters, - localPageSize, ]); // 🆕 State Persistence: 통합 상태 복원 @@ -2980,7 +3000,7 @@ export const TableListComponent: React.FC = ({ if (!tableStateKey) return; try { - const saved = localStorage.getItem(tableStateKey); + const saved = sessionStorage.getItem(tableStateKey); if (!saved) return; const state = JSON.parse(saved); @@ -2991,7 +3011,6 @@ export const TableListComponent: React.FC = ({ if (state.sortDirection) setSortDirection(state.sortDirection); if (state.groupByColumns) setGroupByColumns(state.groupByColumns); if (state.frozenColumns) { - // 체크박스 컬럼이 항상 포함되도록 보장 const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null; const restoredFrozenColumns = checkboxColumn && !state.frozenColumns.includes(checkboxColumn) @@ -2999,7 +3018,7 @@ export const TableListComponent: React.FC = ({ : state.frozenColumns; setFrozenColumns(restoredFrozenColumns); } - if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원 + if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines); if (state.headerFilters) { const filters: Record> = {}; @@ -3018,7 +3037,7 @@ export const TableListComponent: React.FC = ({ if (!tableStateKey) return; try { - localStorage.removeItem(tableStateKey); + sessionStorage.removeItem(tableStateKey); setColumnWidths({}); setColumnOrder([]); setSortColumn(null); @@ -3027,6 +3046,8 @@ export const TableListComponent: React.FC = ({ setFrozenColumns([]); setShowGridLines(true); setHeaderFilters({}); + setLocalPageSize(20); + setPageSizeInputValue("20"); toast.success("테이블 설정이 초기화되었습니다."); } catch (error) { console.error("❌ 테이블 상태 초기화 실패:", error); @@ -4442,33 +4463,36 @@ export const TableListComponent: React.FC = ({ // useEffect 훅 // ======================================== - // 필터 설정 localStorage 키 생성 (화면별로 독립적) + // 필터 설정 sessionStorage 키 생성 (탭 ID 스코프) const filterSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; - return screenId - ? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}` - : `tableList_filterSettings_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable, screenId]); + const base = screenId + ? `${tableConfig.selectedTable}_screen_${screenId}` + : tableConfig.selectedTable; + if (currentTabId) return `filterSettings_${currentTabId}_${base}`; + return `filterSettings_${base}`; + }, [tableConfig.selectedTable, screenId, currentTabId]); - // 그룹 설정 localStorage 키 생성 (화면별로 독립적) + // 그룹 설정 sessionStorage 키 생성 (탭 ID 스코프) const groupSettingKey = useMemo(() => { if (!tableConfig.selectedTable) return null; - return screenId - ? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}` - : `tableList_groupSettings_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable, screenId]); + const base = screenId + ? `${tableConfig.selectedTable}_screen_${screenId}` + : tableConfig.selectedTable; + if (currentTabId) return `groupSettings_${currentTabId}_${base}`; + return `groupSettings_${base}`; + }, [tableConfig.selectedTable, screenId, currentTabId]); // 저장된 필터 설정 불러오기 useEffect(() => { if (!filterSettingKey || visibleColumns.length === 0) return; try { - const saved = localStorage.getItem(filterSettingKey); + const saved = sessionStorage.getItem(filterSettingKey); if (saved) { const savedFilters = JSON.parse(saved); setVisibleFilterColumns(new Set(savedFilters)); } else { - // 초기값: 빈 Set (아무것도 선택 안 함) setVisibleFilterColumns(new Set()); } } catch (error) { @@ -4482,7 +4506,7 @@ export const TableListComponent: React.FC = ({ if (!filterSettingKey) return; try { - localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns))); + sessionStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns))); setIsFilterSettingOpen(false); toast.success("검색 필터 설정이 저장되었습니다"); @@ -4537,7 +4561,7 @@ export const TableListComponent: React.FC = ({ if (!groupSettingKey) return; try { - localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); + sessionStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); } catch (error) { console.error("그룹 설정 저장 실패:", error); } @@ -4617,7 +4641,7 @@ export const TableListComponent: React.FC = ({ setGroupByColumns([]); setCollapsedGroups(new Set()); if (groupSettingKey) { - localStorage.removeItem(groupSettingKey); + sessionStorage.removeItem(groupSettingKey); } toast.success("그룹이 해제되었습니다"); }, [groupSettingKey]); @@ -4801,7 +4825,7 @@ export const TableListComponent: React.FC = ({ if (!groupSettingKey || visibleColumns.length === 0) return; try { - const saved = localStorage.getItem(groupSettingKey); + const saved = sessionStorage.getItem(groupSettingKey); if (saved) { const savedGroups = JSON.parse(saved); setGroupByColumns(savedGroups); @@ -5100,13 +5124,11 @@ export const TableListComponent: React.FC = ({ // 페이지 크기 변경 핸들러 const handlePageSizeChange = (newSize: number) => { + setPageSizeInputValue(String(newSize)); setLocalPageSize(newSize); - setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로 이동 - if (onConfigChange) { - onConfigChange({ - ...tableConfig, - pagination: { ...tableConfig.pagination, pageSize: newSize, currentPage: 1 }, - }); + setCurrentPage(1); + if (pageSizeKey) { + sessionStorage.setItem(pageSizeKey, String(newSize)); } }; @@ -5121,65 +5143,33 @@ export const TableListComponent: React.FC = ({ type="number" min={1} max={10000} - value={localPageSize} + value={pageSizeInputValue} onChange={(e) => { - const value = Math.min(10000, Math.max(1, Number(e.target.value) || 1)); - handlePageSizeChange(value); + setPageSizeInputValue(e.target.value); }} onBlur={(e) => { - // 포커스 잃을 때 유효 범위로 조정 const value = Math.min(10000, Math.max(1, Number(e.target.value) || 10)); handlePageSizeChange(value); }} + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = Math.min(10000, Math.max(1, Number((e.target as HTMLInputElement).value) || 10)); + handlePageSizeChange(value); + (e.target as HTMLInputElement).blur(); + } + }} className="border-input bg-background focus:ring-ring h-7 w-14 rounded-md border px-2 text-center text-xs focus:ring-1 focus:outline-none sm:h-8 sm:w-16" />
{/* 중앙 페이지네이션 컨트롤 */} -
- - - - - {currentPage} / {totalPages || 1} - - - - -
+ {/* 우측 버튼 그룹 */}
@@ -5254,6 +5244,7 @@ export const TableListComponent: React.FC = ({ exportToExcel, exportToPdf, localPageSize, + pageSizeInputValue, onConfigChange, tableConfig, ]); diff --git a/frontend/lib/tabStateCache.ts b/frontend/lib/tabStateCache.ts index bb33de3d..af3e8c1a 100644 --- a/frontend/lib/tabStateCache.ts +++ b/frontend/lib/tabStateCache.ts @@ -78,13 +78,30 @@ export function loadTabCache(tabId: string): TabCacheData | null { } /** - * 특정 탭의 캐시 삭제 + * 특정 탭의 캐시 삭제. + * tab-cache-{tabId} 외에도 테이블 관련 키(tableState_, pageSize_, filterSettings_, groupSettings_)를 일괄 제거한다. */ export function clearTabCache(tabId: string): void { if (typeof window === "undefined") return; try { sessionStorage.removeItem(CACHE_PREFIX + tabId); + + const suffix = `_${tabId}_`; + const keysToRemove: string[] = []; + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if ( + key && + (key.startsWith("tableState" + suffix) || + key.startsWith("pageSize" + suffix) || + key.startsWith("filterSettings" + suffix) || + key.startsWith("groupSettings" + suffix)) + ) { + keysToRemove.push(key); + } + } + keysToRemove.forEach((k) => sessionStorage.removeItem(k)); } catch { // ignore } diff --git a/frontend/stores/tabStore.ts b/frontend/stores/tabStore.ts index ea0b8c5b..4414a3da 100644 --- a/frontend/stores/tabStore.ts +++ b/frontend/stores/tabStore.ts @@ -149,6 +149,7 @@ export const useTabStore = create()( }, refreshTab: (tabId) => { + clearTabCache(tabId); set((state) => ({ refreshKeys: { ...state.refreshKeys, [tabId]: (state.refreshKeys[tabId] || 0) + 1 }, })); From d9611f234e1157f68e9e6fb1c8312a86c076490c Mon Sep 17 00:00:00 2001 From: syc0123 Date: Wed, 11 Mar 2026 14:05:38 +0900 Subject: [PATCH 03/14] docs: Update pagination navigation documentation and remove obsolete components - Deleted the outdated `PageGroupNav` component and its related documentation. - Introduced a new document for the direct input navigation feature in pagination, detailing the rationale for the change and the new user experience. - Updated the checklist to reflect the completion of the new pagination input feature and its implementation steps. These changes enhance the clarity and usability of the pagination system in the project. --- docs/ycshin-node/PGN[계획]-페이징-단락이동.md | 389 ------------------ docs/ycshin-node/PGN[계획]-페이징-직접입력.md | 128 ++++++ docs/ycshin-node/PGN[맥락]-페이징-단락이동.md | 128 ------ docs/ycshin-node/PGN[맥락]-페이징-직접입력.md | 115 ++++++ docs/ycshin-node/PGN[체크]-페이징-단락이동.md | 90 ---- docs/ycshin-node/PGN[체크]-페이징-직접입력.md | 73 ++++ frontend/components/common/PageGroupNav.tsx | 109 ----- .../v2-table-list/TableListComponent.tsx | 208 ++++++---- 8 files changed, 452 insertions(+), 788 deletions(-) delete mode 100644 docs/ycshin-node/PGN[계획]-페이징-단락이동.md create mode 100644 docs/ycshin-node/PGN[계획]-페이징-직접입력.md delete mode 100644 docs/ycshin-node/PGN[맥락]-페이징-단락이동.md create mode 100644 docs/ycshin-node/PGN[맥락]-페이징-직접입력.md delete mode 100644 docs/ycshin-node/PGN[체크]-페이징-단락이동.md create mode 100644 docs/ycshin-node/PGN[체크]-페이징-직접입력.md delete mode 100644 frontend/components/common/PageGroupNav.tsx diff --git a/docs/ycshin-node/PGN[계획]-페이징-단락이동.md b/docs/ycshin-node/PGN[계획]-페이징-단락이동.md deleted file mode 100644 index 4e39f276..00000000 --- a/docs/ycshin-node/PGN[계획]-페이징-단락이동.md +++ /dev/null @@ -1,389 +0,0 @@ -# [계획서] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트 - -> 관련 문서: [맥락노트](./PGN[맥락]-페이징-단락이동.md) | [체크리스트](./PGN[체크]-페이징-단락이동.md) - -## 개요 - -페이지네이션의 핵심 컨트롤(`<< < [번호들] > >>`)을 **재사용 가능한 공통 컴포넌트 `PageGroupNav`**로 분리합니다. -현재의 단순 `1 / n` 텍스트 표시를 **10개 단위 페이지 번호 버튼 그룹**으로 교체하고, `< >` 버튼을 **단락(그룹) 이동**으로, `<< >>` 버튼을 **첫/끝 단락 이동**으로 변경합니다. - -### 접근 전략: C안 (핵심 컨트롤 분리 + 단계적 적용) - -- **1단계 (이번 작업)**: `PageGroupNav.tsx` 생성 + v2-table-list에 적용 -- **2단계 (별도 작업)**: 나머지 페이징 사용처에 점진적 적용 - -이 전략을 선택한 이유: -- 레이아웃을 강제하지 않는 순수 컨트롤 컴포넌트 → 어디든 조합 가능 -- v2-table-list에서 먼저 검증 후 확산 → 리스크 최소화 -- 2단계는 `import` 한 줄로 적용 가능 → 미래 작업 비용 최소 - ---- - -## 현재 동작 - -### 페이지네이션 UI - -``` -[<<] [<] 1 / 38 [>] [>>] -``` - -| 버튼 | 현재 동작 | -|------|----------| -| `<<` | 첫 페이지(1)로 이동 | -| `<` | 이전 페이지(`currentPage - 1`)로 이동 | -| 중앙 | `currentPage / totalPages` 텍스트 표시 (클릭 불가) | -| `>` | 다음 페이지(`currentPage + 1`)로 이동 | -| `>>` | 마지막 페이지(`totalPages`)로 이동 | - -### 비활성화 조건 - -- `<<` `<` : `currentPage === 1` -- `>` `>>` : `currentPage >= totalPages` - -### 현재 코드 (TableListComponent.tsx, 5139~5182행) - -```tsx -{/* 중앙 페이지네이션 컨트롤 */} -
-
-``` - ---- - -## 변경 후 동작 - -### 페이지네이션 UI - -``` -[<<] [<] [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [>] [>>] -``` - -| 버튼 | 변경 후 동작 | -|------|-------------| -| `<<` | **첫 번째 단락**으로 이동 (1페이지 선택) | -| `<` | **이전 단락**의 첫 페이지로 이동 | -| 중앙 | 현재 단락의 페이지 번호 버튼 나열 (클릭으로 해당 페이지 이동) | -| `>` | **다음 단락**의 첫 페이지로 이동 | -| `>>` | **마지막 단락**의 첫 페이지로 이동 (마지막 페이지가 아님) | - -### 비활성화 조건 - -- `<<` `<` : **첫 번째 단락**(1~10)을 보고 있을 때 -- `>` `>>` : **마지막 단락**을 보고 있을 때 - -### 단락(그룹) 개념 - -- 10개 단위로 페이지를 묶어 하나의 "단락"으로 취급 -- 단락 1: 1~10, 단락 2: 11~20, 단락 3: 21~30, ... -- 마지막 단락은 10개 미만일 수 있음 (예: 31~38) - -### 고정 슬롯 레이아웃 (핵심 제약) - -**페이지 번호 영역은 항상 10개 슬롯을 고정 렌더링한다.** - -- 각 슬롯은 동일한 고정 너비(`w-9` 등)를 가짐 -- 1자리(`1`)든 2자리(`11`)든 3자리(`100`)든 버튼 너비가 동일 -- 마지막 단락이 10개 미만이면 남은 슬롯은 빈 공간(투명 placeholder)으로 채움 -- 이로써 `< >` 버튼을 연속 클릭해도 **번호 버튼과 화살표 버튼의 위치가 절대 변하지 않음** - -``` -단락 1~10: [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] ← 10개 모두 채움 -단락 11~20: [11][12][13][14][15][16][17][18][19][20] ← 너비 동일 -단락 31~38: [31][32][33][34][35][36][37][38][ ][ ] ← 빈 슬롯 2개로 위치 고정 -``` - ---- - -## 시각적 동작 예시 - -총 38페이지 기준: - -### 단락별 페이지 번호 표시 - -| 현재 페이지 | 표시 번호 | `<<` `<` | `>` `>>` | -|-------------|-----------|----------|----------| -| 1 | **[1]** [2] [3] ... [10] | 비활성 | 활성 | -| 5 | [1] [2] [3] [4] **[5]** [6] [7] [8] [9] [10] | 비활성 | 활성 | -| 10 | [1] [2] [3] [4] [5] [6] [7] [8] [9] **[10]** | 비활성 | 활성 | -| 11 | **[11]** [12] [13] ... [20] | 활성 | 활성 | -| 25 | [21] [22] [23] [24] **[25]** [26] [27] [28] [29] [30] | 활성 | 활성 | -| 31 | **[31]** [32] [33] ... [38] [ ] [ ] | 활성 | 비활성 | -| 38 | [31] [32] [33] [34] [35] [36] [37] **[38]** [ ] [ ] | 활성 | 비활성 | - -### 버튼 클릭 시나리오 - -| 현재 상태 | 클릭 | 결과 | -|----------|------|------| -| 5페이지 (단락 1~10) | `>` | 11페이지 선택, 단락 11~20 표시 | -| 15페이지 (단락 11~20) | `<` | 1페이지 선택, 단락 1~10 표시 | -| 15페이지 (단락 11~20) | `>>` | 31페이지 선택, 단락 31~38 표시 | -| 35페이지 (단락 31~38) | `<<` | 1페이지 선택, 단락 1~10 표시 | -| 5페이지 (단락 1~10) | `[7]` | 7페이지 선택, 단락 1~10 유지 | - ---- - -## 아키텍처 - -### 컴포넌트 구조 (C안) - -```mermaid -flowchart TD - subgraph PageGroupNav ["PageGroupNav.tsx (새 공통 컴포넌트)"] - Props["props: currentPage, totalPages, onPageChange, disabled, groupSize"] - Logic["단락 계산 + 고정 슬롯 + 비활성화"] - UI["<< < [번호들] > >>"] - Props --> Logic --> UI - end - - subgraph Phase1 ["1단계: 이번 작업"] - V2Table["v2-table-list paginationJSX"] - end - - subgraph Phase2 ["2단계: 별도 작업 (미래)"] - TableList["table-list (구형)"] - PaginationTsx["Pagination.tsx (관리자)"] - DrillDown["DrillDown 모달"] - Mail["메일 수신/발송"] - Others["감사로그, 배치, DataTable 등"] - end - - PageGroupNav --> V2Table - PageGroupNav -.-> TableList - PageGroupNav -.-> PaginationTsx - PageGroupNav -.-> DrillDown - PageGroupNav -.-> Mail - PageGroupNav -.-> Others -``` - -### v2-table-list 내부 데이터 흐름 - -```mermaid -flowchart TD - A["currentPage, totalPages (state)"] --> B[PageGroupNav] - B -->|onPageChange| C[handlePageChange] - C --> D[setCurrentPage + onConfigChange] - D --> E[백엔드 API 호출] - E --> F[데이터 갱신] - F --> A -``` - -### v2-table-list 페이징 바 레이아웃 (변경 없음) - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ [페이지크기 입력] │ << < [PageGroupNav] > >> │ [내보내기][새로고침] │ -│ 좌측(유지) │ 중앙(교체) │ 우측(유지) │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 변경 대상 파일 - -### 1단계 (이번 작업) - -| 구분 | 파일 | 변경 내용 | 변경 규모 | -|------|------|----------|----------| -| 생성 | `frontend/components/common/PageGroupNav.tsx` | 페이지 그룹 네비게이션 공통 컴포넌트 | 약 80줄 신규 | -| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | `paginationJSX` 중앙 영역을 `PageGroupNav`로 교체 (5139~5182행) | 약 40줄 → 5줄 | - -- `handlePageChange` 함수는 기존 것을 그대로 사용 (동작 변경 없음) -- 좌측(페이지크기 입력), 우측(내보내기/새로고침) 영역은 변경하지 않음 -- 백엔드 변경 없음, DB 변경 없음 - -### 1단계 적용 범위 - -v2-table-list를 사용하는 **모든 동적 화면**에 자동 적용: -- 품목정보, 거래처관리, 판매품목정보, 설비정보 등 - -### 2단계 적용 대상 (별도 작업, 미래) - -| 사용처 | 파일 | 현재 페이징 형태 | -|--------|------|----------------| -| table-list (구형) | `lib/registry/components/table-list/TableListComponent.tsx` | `<< < 현재/총 > >>` | -| 공통 Pagination | `components/common/Pagination.tsx` | 번호 ±2 + `...` | -| 피벗 드릴다운 | `lib/registry/components/pivot-grid/components/DrillDownModal.tsx` | `<< < 현재/총 > >>` | -| v2 피벗 드릴다운 | `lib/registry/components/v2-pivot-grid/components/DrillDownModal.tsx` | 동일 | -| 메일 수신함 | `app/(main)/admin/automaticMng/mail/receive/page.tsx` | 번호 5개 클릭 | -| 메일 발송함 | `app/(main)/admin/automaticMng/mail/sent/page.tsx` | 동일 | -| 감사 로그 | `app/(main)/admin/audit-log/page.tsx` | `< 현재/총 >` | -| 배치 관리 | `app/(main)/admin/automaticMng/batchmngList/page.tsx` | 번호 5개 클릭 | -| DataTable | `components/common/DataTable.tsx` | `<< < > >>` + 텍스트 | -| FlowWidget | `components/screen/widgets/FlowWidget.tsx` | shadcn Pagination | - ---- - -## 코드 설계 - -### PageGroupNav.tsx 공통 컴포넌트 - -```tsx -// frontend/components/common/PageGroupNav.tsx -"use client"; - -import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -const DEFAULT_GROUP_SIZE = 10; - -interface PageGroupNavProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; - disabled?: boolean; - groupSize?: number; -} - -export function PageGroupNav({ - currentPage, - totalPages, - onPageChange, - disabled = false, - groupSize = DEFAULT_GROUP_SIZE, -}: PageGroupNavProps) { - const safeTotal = Math.max(1, totalPages); - const currentGroupIndex = Math.floor((currentPage - 1) / groupSize); - const groupStartPage = currentGroupIndex * groupSize + 1; - - const lastGroupIndex = Math.floor((safeTotal - 1) / groupSize); - const lastGroupStartPage = lastGroupIndex * groupSize + 1; - - const isFirstGroup = currentGroupIndex === 0; - const isLastGroup = currentGroupIndex === lastGroupIndex; - - // 10개 고정 슬롯 배열 - const slots: (number | null)[] = []; - for (let i = 0; i < groupSize; i++) { - const page = groupStartPage + i; - slots.push(page <= safeTotal ? page : null); - } - - return ( -
- {/* << 첫 단락 */} - - - {/* < 이전 단락 */} - - - {/* 페이지 번호 (고정 슬롯) */} - {slots.map((page, idx) => - page !== null ? ( - - ) : ( -
- ) - )} - - {/* > 다음 단락 */} - - - {/* >> 마지막 단락 */} - -
- ); -} -``` - -### v2-table-list 통합 (paginationJSX 중앙 영역 교체) - -기존 5139~5182행의 `
` 블록을 다음으로 교체: - -```tsx -import { PageGroupNav } from "@/components/common/PageGroupNav"; - -// paginationJSX 내부 중앙 영역 - -``` - -좌측(페이지크기 입력), 우측(내보내기/새로고침)은 기존 코드 그대로 유지. - ---- - -## 설계 원칙 - -- **레이아웃 무관 컴포넌트**: PageGroupNav는 순수 컨트롤만 담당. 외부 레이아웃(좌측/우측 부가 기능)을 강제하지 않음 -- **기존 동작 무변경**: `handlePageChange` 함수는 수정하지 않음. 좌측/우측 영역도 변경하지 않음 -- **고정 슬롯 레이아웃**: 페이지 번호 영역은 항상 `groupSize`개(기본 10) 슬롯 고정. 마지막 단락에서 부족하면 빈 div로 채움 -- **고정 너비 버튼**: 모든 번호 버튼은 `w-8 sm:w-9` 고정. 1자리/2자리/3자리에 관계없이 동일 -- **위치 불변**: `< >` `<< >>` 버튼을 연속 클릭해도 모든 버튼의 위치가 절대 변하지 않음 -- **현재 페이지 강조**: `variant="default"`(primary) + `ring-2 ring-primary font-bold`, 나머지 `variant="outline"` -- **엣지 케이스**: totalPages가 0이거나 1일 때도 정상 동작 (`safeTotal = Math.max(1, totalPages)`) -- **빈 슬롯 접근성**: 빈 슬롯에 `cursor-default` 적용 (클릭 가능한 것처럼 보이지 않게) -- **단계적 적용**: 1단계에서 v2-table-list로 검증 후, 2단계에서 나머지 사용처에 점진 적용 - ---- - -## 추가 구현: 표시갯수(pageSize) 캐시 정책 - -### 문제 - -기존 pageSize는 `onConfigChange`로 부모에 전파되어 DB에 저장되거나, `localStorage`에 저장되어 새로고침해도 사용자가 변경한 값이 남아있었음. - -### 해결 - -| 항목 | 정책 | -|------|------| -| 저장소 | sessionStorage (탭 닫으면 자동 소멸) | -| 키 구조 | `pageSize_{tabId}_{tableName}` (탭별 격리) | -| 기본값 | 20 | -| DB 전파 | 안 함 (onConfigChange 제거) | -| F5 새로고침 | 활성 탭 캐시 삭제 → 기본값 20 | -| 탭 바 새로고침 | 활성 탭 캐시 삭제 → 기본값 20 | -| 비활성 탭 전환 | 캐시에서 복원 | -| 입력 UX | onChange는 표시만, onBlur/Enter로 실제 적용 | - -### 테이블 캐시 탭 격리 - -동일한 정책을 테이블 관련 캐시 전체에 적용: - -| 키 | 구조 | -|----|------| -| `tableState_{tabId}_{tableName}` | 컬럼 너비, 정렬, 틀고정, 그리드선, 헤더필터 | -| `pageSize_{tabId}_{tableName}` | 표시갯수 | -| `filterSettings_{tabId}_{base}` | 검색 필터 설정 | -| `groupSettings_{tabId}_{base}` | 그룹 설정 | - -사용자 설정(컬럼 가시성/순서/정렬 상태)은 localStorage에 유지 (세션 간 보존). diff --git a/docs/ycshin-node/PGN[계획]-페이징-직접입력.md b/docs/ycshin-node/PGN[계획]-페이징-직접입력.md new file mode 100644 index 00000000..635041b5 --- /dev/null +++ b/docs/ycshin-node/PGN[계획]-페이징-직접입력.md @@ -0,0 +1,128 @@ +# [계획서] 페이징 - 페이지 번호 직접 입력 네비게이션 + +> 관련 문서: [맥락노트](./PGN[맥락]-페이징-직접입력.md) | [체크리스트](./PGN[체크]-페이징-직접입력.md) + +## 개요 + +v2-table-list 컴포넌트의 하단 페이지네이션 중앙 영역에서, 현재 페이지 번호를 **읽기 전용 텍스트**에서 **입력 가능한 필드**로 변경합니다. +사용자가 원하는 페이지 번호를 키보드로 직접 입력하여 빠르게 이동할 수 있게 합니다. + +### 이전 설계(10개 번호 버튼 그룹) 폐기 사유 + +- 10개 버튼은 공간을 많이 차지하고, 모바일에서 렌더링이 어려움 +- 고정 슬롯/고정 너비 등 복잡한 레이아웃 제약이 발생 +- 입력 필드 방식이 더 직관적이고 공간 효율적 + +--- + +## 변경 전 → 변경 후 + +### 페이지네이션 UI + +``` +변경 전: [<<] [<] 1 / 38 [>] [>>] ← 읽기 전용 텍스트 +변경 후: [<<] [<] [ 15 ] / 49 [>] [>>] ← 입력 가능 필드 +``` + +| 버튼 | 동작 (변경 없음) | +|------|-----------------| +| `<<` | 첫 페이지(1)로 이동 | +| `<` | 이전 페이지(`currentPage - 1`)로 이동 | +| 중앙 | **입력 필드** `/` **총 페이지** — 사용자가 원하는 페이지 번호를 직접 입력 | +| `>` | 다음 페이지(`currentPage + 1`)로 이동 | +| `>>` | 마지막 페이지(`totalPages`)로 이동 | + +### 입력 필드 동작 규칙 + +| 동작 | 설명 | +|------|------| +| 클릭 | 입력 필드에 포커스, 기존 숫자 전체 선택(select all) | +| 숫자 입력 | 자유롭게 타이핑 가능 (입력 중에는 페이지 이동 안 함) | +| Enter | 입력한 페이지로 이동 + 포커스 해제 | +| 포커스 아웃 (blur) | 입력한 페이지로 이동 | +| 유효 범위 보정 | 1 미만 → 1, totalPages 초과 → totalPages, 빈 값/비숫자 → 현재 페이지 유지 | +| `< >` 클릭 | 기존대로 한 페이지씩 이동 (입력 필드 값도 갱신) | +| `<< >>` 클릭 | 기존대로 첫/끝 페이지 이동 (입력 필드 값도 갱신) | + +### 비활성화 조건 (기존과 동일) + +- `<<` `<` : `currentPage === 1` +- `>` `>>` : `currentPage >= totalPages` + +--- + +## 시각적 동작 예시 + +총 49페이지 기준: + +| 사용자 동작 | 입력 필드 표시 | 결과 | +|------------|---------------|------| +| 초기 상태 | `1 / 49` | 1페이지 표시 | +| 입력 필드 클릭 | `[1]` 전체 선택됨 | 타이핑 대기 | +| `28` 입력 후 Enter | `28 / 49` | 28페이지로 이동 | +| `0` 입력 후 Enter | `1 / 49` | 1로 보정 | +| `999` 입력 후 Enter | `49 / 49` | 49로 보정 | +| 빈 값으로 blur | `28 / 49` | 이전 페이지(28) 유지 | +| `abc` 입력 후 Enter | `28 / 49` | 이전 페이지(28) 유지 | +| `>` 클릭 | `29 / 49` | 29페이지로 이동 | + +--- + +## 아키텍처 + +### 데이터 흐름 + +```mermaid +flowchart TD + A["currentPage (state, 단일 소스)"] --> B["입력 필드 표시값 (pageInputValue)"] + B -->|"사용자 타이핑"| C["pageInputValue 갱신 (표시만)"] + C -->|"Enter 또는 blur"| D["유효 범위 보정 (1~totalPages)"] + D -->|"보정된 값"| E[handlePageChange] + E --> F["setCurrentPage → useEffect → fetchTableDataDebounced"] + F --> G[백엔드 API 호출] + G --> H[데이터 갱신] + H --> A + + I["<< < > >> 클릭"] --> E + J["페이지크기 변경"] --> K["setCurrentPage(1) + setLocalPageSize + onConfigChange"] + K --> F +``` + +### 페이징 바 레이아웃 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ [페이지크기 입력] │ << < [__입력__] / n > >> │ [내보내기][새로고침] │ +│ 좌측(유지) │ 중앙(입력필드 교체) │ 우측(유지) │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## 변경 대상 파일 + +| 구분 | 파일 | 변경 내용 | +|------|------|----------| +| 수정 | `TableListComponent.tsx` | (1) `pageInputValue` 상태 + `useEffect` 동기화 + `commitPageInput` 핸들러 추가 | +| | | (2) paginationJSX 중앙 `` → `` + `/` + `` 교체 | +| | | (3) `handlePageSizeChange`에 `onConfigChange` 호출 추가 | +| | | (4) `fetchTableDataInternal`에서 `currentPage`를 단일 소스로 사용 | +| | | (5) `useMemo` 의존성에 `pageInputValue` 추가 | +| 삭제 | `PageGroupNav.tsx` | 이전 설계 산출물 삭제 (이미 삭제됨) | + +- 신규 파일 생성 없음 +- 백엔드 변경 없음, DB 변경 없음 +- v2-table-list를 사용하는 **모든 동적 화면**에 자동 적용 + +--- + +## 설계 원칙 + +- **최소 변경**: `` 1개를 `` + 유효성 검증으로 교체. 나머지 전부 유지 +- **기존 버튼 동작 무변경**: `<< < > >>` 4개 버튼의 onClick/disabled 로직은 그대로 +- **`handlePageChange` 재사용**: 기존 함수를 그대로 호출 +- **입력 중 페이지 이동 안 함**: onChange는 표시만 변경, Enter/blur로 실제 적용 +- **유효 범위 자동 보정**: 1 미만 → 1, totalPages 초과 → totalPages, 비숫자 → 현재 값 유지 +- **포커스 시 전체 선택**: 클릭하면 바로 타이핑 가능 +- **`currentPage`가 단일 소스**: fetch 시 `tableConfig.pagination?.currentPage` 대신 로컬 `currentPage`만 사용 (비동기 전파 문제 방지) +- **페이지크기 변경 시 1페이지로 리셋**: `handlePageSizeChange`가 `onConfigChange`를 호출하여 부모/백엔드 동기화 diff --git a/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md b/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md deleted file mode 100644 index 024bd7a2..00000000 --- a/docs/ycshin-node/PGN[맥락]-페이징-단락이동.md +++ /dev/null @@ -1,128 +0,0 @@ -# [맥락노트] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트 - -> 관련 문서: [계획서](./PGN[계획]-페이징-단락이동.md) | [체크리스트](./PGN[체크]-페이징-단락이동.md) - ---- - -## 왜 이 작업을 하는가 - -- 현재 페이지네이션은 `1 / 38` 텍스트만 표시하고 `< >`로 한 페이지씩 이동 -- 수십 페이지가 있을 때 원하는 페이지로 빠르게 이동할 수 없음 -- 페이지 번호를 직접 클릭할 수 있어야 UX가 개선됨 - ---- - -## 핵심 결정 사항과 근거 - -### 1. 공통 컴포넌트로 분리 (C안) - -- **결정**: `PageGroupNav.tsx`라는 순수 컨트롤 컴포넌트를 별도 파일로 생성 -- **근거**: 프로젝트에 페이징이 15곳 이상 존재. 인라인 수정하면 같은 로직을 복사해야 함 -- **대안 검토 A**: v2-table-list 인라인만 수정 → 기각 (미래 확장 시 복사-붙여넣기 기술 부채) -- **대안 검토 B**: 기존 `Pagination.tsx` 업그레이드 → 기각 (전체 행 레이아웃이 포함되어 v2-table-list와 레이아웃 충돌) -- **대안 검토 D**: 전체 한번에 적용 → 기각 (12파일 동시 수정은 블래스트 반경이 큼) - -### 2. 레이아웃 무관 설계 - -- **결정**: PageGroupNav는 `<< < [번호들] > >>`만 렌더링. 외부 레이아웃(페이지크기, 내보내기 등)을 포함하지 않음 -- **근거**: 사용처마다 레이아웃이 다름. v2-table-list는 좌측(페이지크기)+중앙(컨트롤)+우측(내보내기), Pagination.tsx는 좌측(페이지정보)+우측(크기선택+컨트롤). 레이아웃을 강제하면 props 분기가 증가하여 복잡해짐 - -### 3. 10개 단위 단락(그룹) - -- **결정**: 페이지를 10개씩 묶어 하나의 단락으로 취급 -- **근거**: 사용자에게 익숙한 패턴 (네이버, 구글 등). 5개는 너무 적고, 20개는 너무 많음 -- **확장성**: `groupSize` props로 기본값 10을 변경 가능하게 설계 - -### 4. `< >` = 단락 이동, `<< >>` = 첫/끝 단락 - -- **결정**: `<`는 이전 단락 첫 페이지, `>`는 다음 단락 첫 페이지. `<<`는 1페이지, `>>`는 마지막 단락 첫 페이지 -- **근거**: 사용자 요청. 기존의 "한 페이지씩 이동"은 번호 클릭으로 대체됨 -- **주의**: `>>`는 마지막 **페이지**가 아닌 마지막 **단락의 첫 페이지**로 이동. 예: 총 38페이지일 때 `>>` 클릭 → 31페이지 선택 (38이 아님) - -### 5. 고정 슬롯 + 고정 너비 - -- **결정**: 항상 10개 슬롯을 렌더링하고, 모든 버튼은 동일한 고정 너비(`w-8 sm:w-9`) -- **근거**: `< >` 버튼을 연속 클릭할 때 번호 자릿수(1자리→2자리)나 페이지 수(10개→8개) 변화로 버튼 위치가 흔들리면 안 됨 -- **구현**: 마지막 단락에서 페이지가 10개 미만이면 남은 슬롯은 동일 크기의 빈 `
`로 채움 - -### 6. 단계적 적용 (1단계: v2-table-list만) - -- **결정**: 이번 작업은 v2-table-list에만 적용. 나머지는 별도 작업으로 점진 적용 -- **근거**: 15곳 동시 수정은 리스크가 높음. v2-table-list가 가장 많이 사용되므로 여기서 검증 후 확산 - -### 7. 비활성화 기준은 단락 기준 - -- **결정**: `<< <`는 첫 번째 단락일 때 비활성화 (currentPage === 1이 아님). `> >>`는 마지막 단락일 때 비활성화 -- **근거**: 기존은 currentPage 기준이었지만, 단락 이동이므로 단락 기준으로 변경이 자연스러움. 첫 단락 안에서 5페이지에 있어도 `<`는 비활성화 - ---- - -## 관련 파일 위치 - -| 구분 | 파일 경로 | 설명 | -|------|----------|------| -| 생성 | `frontend/components/common/PageGroupNav.tsx` | 페이지 그룹 네비게이션 공통 컴포넌트 | -| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | paginationJSX 중앙 영역 교체 (5139~5182행) | -| 참고 | `frontend/components/common/Pagination.tsx` | 기존 공통 페이지네이션 (이번에 수정 안 함) | - ---- - -## 기술 참고 - -### 단락 계산 공식 - -``` -groupSize = 10 (기본값) -currentGroupIndex = Math.floor((currentPage - 1) / groupSize) -groupStartPage = currentGroupIndex * groupSize + 1 -groupEndPage = Math.min(groupStartPage + groupSize - 1, totalPages) - -lastGroupIndex = Math.floor((totalPages - 1) / groupSize) -lastGroupStartPage = lastGroupIndex * groupSize + 1 - -isFirstGroup = currentGroupIndex === 0 -isLastGroup = currentGroupIndex === lastGroupIndex -``` - -### 고정 슬롯 배열 생성 - -``` -slots = [groupStart, groupStart+1, ..., groupEnd, null, null, ...] (총 groupSize개) -예: 단락 31~38 → [31, 32, 33, 34, 35, 36, 37, 38, null, null] -``` - -### handlePageChange 호출 흐름 - -``` -PageGroupNav onPageChange(page) - → TableListComponent handlePageChange(newPage) - → setCurrentPage(newPage) - → useEffect 트리거 → 백엔드 API 재호출 (page 파라미터 변경) -``` - -- handlePageChange는 `setCurrentPage`만 호출. `onConfigChange` 전파는 제거됨 (pageSize/currentPage는 세션 전용) -- handlePageChange는 기존 함수 그대로 사용. PageGroupNav가 올바른 page 값을 전달하기만 하면 됨 - ---- - -## 추가 결정: 표시갯수(pageSize) 캐시 정책 - -### 8. pageSize는 세션 전용, DB에 저장 안 함 - -- **결정**: pageSize를 `onConfigChange`로 부모/DB에 전파하지 않음. sessionStorage에만 탭별로 저장 -- **근거**: pageSize는 일시적 탐색 설정이지 영구 화면 설정이 아님. DB에 저장하면 다른 사용자에게도 영향이 가고, 새로고침 시 의도치 않은 값이 남음 -- **F5 정책**: 활성 탭은 캐시 삭제 → 기본값 20으로 fresh start. 비활성 탭은 캐시 유지 - -### 9. 테이블 캐시는 탭별 격리 (탭 ID 스코프) - -- **결정**: `tableState_*`, `pageSize_*`, `filterSettings_*`, `groupSettings_*` 키를 `{prefix}_{tabId}_{tableName}` 구조로 변경 -- **근거**: 같은 테이블이 여러 탭에서 열릴 수 있음. 탭 구분 없으면 "활성 탭 캐시만 삭제" 불가능 -- **구현**: `useTabId()` 훅으로 현재 탭 ID 접근. `clearTabCache(tabId)`에서 해당 탭의 모든 관련 키 일괄 삭제 - -### 10. localStorage vs sessionStorage 분류 - -- **결정**: 탭별 캐시는 sessionStorage, 사용자 설정은 localStorage -- **근거**: 탭별 캐시(컬럼 너비 캐시, 필터, 그룹, pageSize)는 탭 닫으면 무의미. 사용자 설정(컬럼 가시성, 순서, 정렬)은 사용자가 의도적으로 변경한 환경설정이므로 세션 간 보존 -- **분류**: - - sessionStorage: `tableState_*`, `pageSize_*`, `filterSettings_*`, `groupSettings_*` - - localStorage: `table_column_visibility_*`, `table_sort_state_*`, `table_column_order_*` diff --git a/docs/ycshin-node/PGN[맥락]-페이징-직접입력.md b/docs/ycshin-node/PGN[맥락]-페이징-직접입력.md new file mode 100644 index 00000000..c036a089 --- /dev/null +++ b/docs/ycshin-node/PGN[맥락]-페이징-직접입력.md @@ -0,0 +1,115 @@ +# [맥락노트] 페이징 - 페이지 번호 직접 입력 네비게이션 + +> 관련 문서: [계획서](./PGN[계획]-페이징-직접입력.md) | [체크리스트](./PGN[체크]-페이징-직접입력.md) + +--- + +## 왜 이 작업을 하는가 + +- 현재 페이지네이션은 `1 / 38` 읽기 전용 텍스트만 표시 +- 수십 페이지가 있을 때 원하는 페이지로 빠르게 이동할 수 없음 (`>` 연타 필요) +- 페이지 번호를 직접 입력하여 즉시 이동할 수 있어야 UX가 개선됨 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 10개 번호 버튼 그룹 → 입력 필드로 설계 변경 + +- **결정**: 이전 설계(10개 페이지 번호 버튼 나열)를 폐기하고, 기존 `현재/총` 텍스트에서 현재 부분을 입력 필드로 교체 +- **근거**: 10개 버튼은 공간을 많이 차지하고 고정 슬롯/고정 너비 등 복잡한 레이아웃 제약이 발생. 입력 필드 방식이 더 직관적이고 공간 효율적 +- **이전 산출물**: `PageGroupNav.tsx` → 삭제 완료 + +### 2. `<< < > >>` 버튼 동작 유지 + +- **결정**: 4개 화살표 버튼의 동작은 기존과 완전히 동일하게 유지 +- **근거**: 입력 필드가 "원하는 페이지로 점프" 역할을 하므로, 버튼은 기존의 순차 이동(+1/-1, 첫/끝) 그대로 유지하는 것이 자연스러움 + +### 3. 입력 중에는 페이지 이동 안 함 + +- **결정**: onChange는 입력 필드 표시만 변경. Enter 또는 blur로 실제 페이지 이동 +- **근거**: `28`을 입력하려면 `2`를 먼저 치는데, `2`에서 바로 이동하면 안 됨 + +### 4. 포커스 시 전체 선택 (select all) + +- **결정**: 입력 필드 클릭 시 기존 숫자를 전체 선택 +- **근거**: 사용자가 "15페이지로 가고 싶다" → 클릭 → 바로 `15` 타이핑. 기존 값을 지우는 추가 동작 불필요 + +### 5. 유효 범위 자동 보정 + +- **결정**: 1 미만 → 1, totalPages 초과 → totalPages, 빈 값/비숫자 → 현재 페이지 유지 +- **근거**: 에러 메시지보다 자동 보정이 UX에 유리 +- **대안 검토**: 입력 자체를 숫자만 허용 → 기각 (백스페이스로 비울 때 불편) + +### 6. `inputMode="numeric"` 사용 + +- **결정**: `type="text"` + `inputMode="numeric"` +- **근거**: `type="number"`는 브라우저별 스피너 UI가 추가되고, 빈 값 처리가 어려움. `inputMode="numeric"`은 모바일에서 숫자 키보드를 띄우면서 text 입력의 유연성 유지 + +### 7. 신규 컴포넌트 분리 안 함 + +- **결정**: v2-table-list의 paginationJSX 내부에 인라인으로 구현 +- **근거**: 변경이 `` → `` + 핸들러 약 30줄 수준으로 매우 작음 + +### 8. `currentPage`를 fetch의 단일 소스로 사용 + +- **결정**: `fetchTableDataInternal`에서 `tableConfig.pagination?.currentPage || currentPage` 대신 `currentPage`만 사용 +- **근거**: `handlePageSizeChange`에서 `setCurrentPage(1)` + `onConfigChange(...)` 호출 시, `onConfigChange`를 통한 부모의 `tableConfig` 갱신은 다음 렌더 사이클에서 전파됨. fetch가 실행되는 시점에 `tableConfig.pagination?.currentPage`가 아직 이전 값(예: 4)이고 truthy이므로 로컬 `currentPage`(1) 대신 4를 사용하게 되는 문제 발생. 로컬 `currentPage`는 `setCurrentPage`로 즉시 갱신되므로 이 문제가 없음 +- **발견 과정**: 페이지 크기를 20→40으로 변경하면 1페이지로 설정되지만 리스트가 빈 상태로 표시되는 버그로 발견 + +### 9. `handlePageSizeChange`에서 `onConfigChange` 호출 필수 + +- **결정**: 페이지 크기 변경 시 `onConfigChange`로 `{ pageSize, currentPage: 1 }`을 부모에게 전달 +- **근거**: 기존 코드는 `setLocalPageSize` + `setCurrentPage(1)`만 호출하고 `onConfigChange`를 호출하지 않았음. 이로 인해 부모 컴포넌트의 `tableConfig.pagination`이 갱신되지 않아 후속 동작에서 stale 값 참조 가능 +- **발견 과정**: 위 8번과 같은 맥락에서 발견 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 수정 | `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | paginationJSX 중앙 입력 필드 + fetch 소스 수정 | +| 삭제 | `frontend/components/common/PageGroupNav.tsx` | 이전 설계 산출물 (삭제 완료) | + +--- + +## 기술 참고 + +### 로컬 입력 상태와 실제 페이지 상태 분리 + +``` +pageInputValue (string) — 입력 필드에 표시되는 값 (사용자가 타이핑 중일 수 있음) +currentPage (number) — 실제 현재 페이지 (API 호출의 단일 소스) + +동기화: +- currentPage 변경 시 → useEffect → setPageInputValue(String(currentPage)) +- Enter/blur 시 → commitPageInput → parseInt + clamp → handlePageChange(보정된 값) +``` + +### handlePageChange 호출 흐름 + +``` +입력 필드 Enter/blur + → commitPageInput() + → parseInt + clamp(1, totalPages) + → handlePageChange(clampedPage) + → setCurrentPage(clampedPage) + onConfigChange + → useEffect 트리거 → fetchTableDataDebounced + → fetchTableDataInternal(page = currentPage) + → 백엔드 API 호출 +``` + +### handlePageSizeChange 호출 흐름 + +``` +좌측 페이지크기 입력 onChange/onBlur + → handlePageSizeChange(newSize) + → setLocalPageSize(newSize) + → setCurrentPage(1) + → sessionStorage 저장 + → onConfigChange({ pageSize: newSize, currentPage: 1 }) + → useEffect 트리거 → fetchTableDataDebounced + → fetchTableDataInternal(page = 1, pageSize = newSize) + → 백엔드 API 호출 +``` diff --git a/docs/ycshin-node/PGN[체크]-페이징-단락이동.md b/docs/ycshin-node/PGN[체크]-페이징-단락이동.md deleted file mode 100644 index 46b94395..00000000 --- a/docs/ycshin-node/PGN[체크]-페이징-단락이동.md +++ /dev/null @@ -1,90 +0,0 @@ -# [체크리스트] 페이징 단락(그룹) 번호 네비게이션 - PageGroupNav 공통 컴포넌트 - -> 관련 문서: [계획서](./PGN[계획]-페이징-단락이동.md) | [맥락노트](./PGN[맥락]-페이징-단락이동.md) - ---- - -## 공정 상태 - -- 전체 진행률: **100%** (완료) -- 현재 단계: 4단계 완료 - ---- - -## 구현 체크리스트 - -### 1단계: PageGroupNav 공통 컴포넌트 생성 - -- [x] `frontend/components/common/PageGroupNav.tsx` 파일 생성 -- [x] `PageGroupNavProps` 인터페이스 정의 (currentPage, totalPages, onPageChange, disabled, groupSize) -- [x] 단락 계산 로직 구현 (currentGroupIndex, groupStartPage, lastGroupIndex 등) -- [x] 10개 고정 슬롯 배열 생성 (빈 슬롯은 null) -- [x] `<<` 첫 단락 버튼 (isFirstGroup일 때 비활성화) -- [x] `<` 이전 단락 버튼 (isFirstGroup일 때 비활성화) -- [x] 페이지 번호 버튼 렌더링 (현재 페이지 variant="default", 나머지 variant="outline") -- [x] 빈 슬롯 렌더링 (동일 크기 빈 div) -- [x] `>` 다음 단락 버튼 (isLastGroup일 때 비활성화) -- [x] `>>` 마지막 단락 버튼 (isLastGroup일 때 비활성화, 마지막 단락 첫 페이지로 이동) -- [x] 고정 너비 스타일 적용 (h-8 w-8 sm:h-9 sm:w-9) -- [x] totalPages가 0 또는 1일 때 엣지 케이스 처리 - -### 2단계: v2-table-list 통합 - -- [x] `TableListComponent.tsx`에 `PageGroupNav` import 추가 -- [x] `paginationJSX`의 중앙 컨트롤 영역(5139~5182행)을 `` 호출로 교체 -- [x] props 연결: currentPage, totalPages, handlePageChange, loading -- [x] 좌측(페이지크기 입력) 영역 변경 없음 확인 -- [x] 우측(내보내기/새로고침) 영역 변경 없음 확인 - -### 3단계: 검증 - -- [x] 품목정보 화면에서 페이지 번호 클릭 동작 확인 -- [x] `< >` 단락 이동 동작 확인 (1~10 → 11~20 → ...) -- [x] `<< >>` 첫/끝 단락 이동 동작 확인 -- [x] `>>` 클릭 시 마지막 단락의 첫 페이지 선택 확인 (마지막 페이지가 아님) -- [x] 첫 단락에서 `<< <` 비활성화 확인 -- [x] 마지막 단락에서 `> >>` 비활성화 확인 -- [x] 고정 슬롯: 단락 이동 시 버튼 위치 변동 없음 확인 -- [x] 고정 너비: 1자리/2자리 숫자에서 버튼 크기 동일 확인 -- [x] 마지막 단락이 10개 미만일 때 빈 슬롯으로 위치 고정 확인 -- [x] totalPages가 1일 때 정상 동작 확인 (단일 페이지) -- [x] 로딩 중 모든 버튼 비활성화 확인 -- [x] 페이지 크기 변경 시 첫 페이지로 리셋 확인 - -### 4단계: 정리 - -- [x] 린트 에러 없음 확인 -- [x] 이 체크리스트 완료 표시 업데이트 - -### 5단계: 표시갯수(pageSize) 캐시 정책 - -- [x] 표시갯수 입력 시 onChange → 표시만 변경, 실제 적용은 onBlur/Enter -- [x] 입력 필드 값 string 타입으로 변경 (백스페이스로 비우기 가능) -- [x] 표시갯수 변경 시 1페이지로 리셋 + 데이터 정상 로드 -- [x] onConfigChange로 DB/부모 전파 제거 (pageSize는 세션 전용) -- [x] localStorage → sessionStorage 전환 (탭 닫으면 자동 소멸) -- [x] 키를 탭 ID 스코프로 변경 (`pageSize_{tabId}_{tableName}`) -- [x] F5 새로고침 시 활성 탭 캐시 삭제 → 기본값 20 초기화 -- [x] 탭 바 새로고침 버튼 시 캐시 삭제 → 기본값 20 초기화 -- [x] 비활성 탭 캐시 유지 (탭 전환 시 복원) - -### 6단계: 테이블 캐시 탭 격리 - -- [x] tableStateKey 탭 ID 스코프 (`tableState_{tabId}_{tableName}`) + sessionStorage -- [x] filterSettingKey 탭 ID 스코프 (`filterSettings_{tabId}_{base}`) + sessionStorage -- [x] groupSettingKey 탭 ID 스코프 (`groupSettings_{tabId}_{base}`) + sessionStorage -- [x] clearTabCache 확장 (tableState_/pageSize_/filterSettings_/groupSettings_ 일괄 삭제) -- [x] TabContent.tsx 모듈 레벨 플래그로 F5 감지 → 활성 탭 캐시만 삭제 -- [x] tabStore.refreshTab에 clearTabCache 추가 - ---- - -## 변경 이력 - -| 날짜 | 내용 | -|------|------| -| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 | -| 2026-03-11 | 1단계(PageGroupNav 생성) + 2단계(v2-table-list 통합) + 4단계(린트) 완료. 3단계(수동 검증)은 브라우저에서 확인 필요 | -| 2026-03-11 | 추가 개선: 선택 페이지 강조(ring + font-bold), 빈 슬롯 cursor-default 적용. 3단계 검증 완료. 전체 완료 | -| 2026-03-11 | 5단계: pageSize 입력 UX 개선 + 캐시 정책 (sessionStorage + 탭 스코프 + F5/탭새로고침 초기화) | -| 2026-03-11 | 6단계: 테이블 전체 캐시를 탭별 격리 (localStorage → sessionStorage + 탭 ID 스코프) | diff --git a/docs/ycshin-node/PGN[체크]-페이징-직접입력.md b/docs/ycshin-node/PGN[체크]-페이징-직접입력.md new file mode 100644 index 00000000..50f8fe8d --- /dev/null +++ b/docs/ycshin-node/PGN[체크]-페이징-직접입력.md @@ -0,0 +1,73 @@ +# [체크리스트] 페이징 - 페이지 번호 직접 입력 네비게이션 + +> 관련 문서: [계획서](./PGN[계획]-페이징-직접입력.md) | [맥락노트](./PGN[맥락]-페이징-직접입력.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (완료) +- 현재 단계: 완료 + +--- + +## 구현 체크리스트 + +### 1단계: 이전 설계 산출물 정리 + +- [x] `frontend/components/common/PageGroupNav.tsx` 삭제 +- [x] `TableListComponent.tsx`에서 `PageGroupNav` import 제거 (있으면) — 이미 없음 + +### 2단계: 입력 필드 구현 + +- [x] `pageInputValue` 로컬 상태 추가 (`useState`) +- [x] `currentPage` 변경 시 `pageInputValue` 동기화 (`useEffect`) +- [x] `commitPageInput` 핸들러 구현 (parseInt + clamp + handlePageChange) +- [x] paginationJSX 중앙의 `` → `` + `/` + `` 교체 +- [x] `inputMode="numeric"` 적용 +- [x] `onFocus`에 전체 선택 (`e.target.select()`) +- [x] `onChange`에 `setPageInputValue` (표시만 변경) +- [x] `onKeyDown` Enter에 `commitPageInput` + `blur()` +- [x] `onBlur`에 `commitPageInput` +- [x] `disabled={loading}` 적용 +- [x] 기존 좌측 페이지크기 입력과 일관된 스타일 적용 + +### 3단계: 버그 수정 + +- [x] `handlePageSizeChange`에 `onConfigChange` 호출 추가 (`pageSize` + `currentPage: 1` 전달) +- [x] `fetchTableDataInternal`에서 `currentPage`를 단일 소스로 변경 (stale `tableConfig.pagination?.currentPage` 문제 해결) +- [x] `useCallback` 의존성에서 `tableConfig.pagination?.currentPage` 제거 +- [x] `useMemo` 의존성에 `pageInputValue` 추가 + +### 4단계: 검증 + +- [x] 입력 필드에 숫자 입력 후 Enter → 해당 페이지로 이동 +- [x] 입력 필드에 숫자 입력 후 포커스 아웃 → 해당 페이지로 이동 +- [x] 0 입력 → 1로 보정 +- [x] totalPages 초과 입력 → totalPages로 보정 +- [x] 빈 값으로 blur → 현재 페이지 유지 +- [x] 비숫자(abc) 입력 후 Enter → 현재 페이지 유지 +- [x] 입력 필드 클릭 시 기존 숫자 전체 선택 확인 +- [x] `< >` 버튼 클릭 시 입력 필드 값도 갱신 확인 +- [x] `<< >>` 버튼 클릭 시 입력 필드 값도 갱신 확인 +- [x] 로딩 중 입력 필드 비활성화 확인 +- [x] 좌측 페이지크기 입력과 스타일 일관성 확인 +- [x] 기존 `<< < > >>` 버튼 동작 변화 없음 확인 +- [x] 페이지크기 변경 시 1페이지로 리셋 + 데이터 정상 로딩 확인 + +### 5단계: 정리 + +- [x] 린트 에러 없음 확인 (기존 에러만 존재, 신규 없음) +- [x] 문서(계획서/맥락노트/체크리스트) 최신화 완료 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-11 | 최초 설계: 10개 번호 버튼 그룹 (PageGroupNav) | +| 2026-03-11 | 설계 변경: 입력 필드 방식으로 전면 재작성 | +| 2026-03-11 | 구현 완료: 입력 필드 + 유효성 검증 | +| 2026-03-11 | 버그 수정: 페이지크기 변경 시 빈 데이터 문제 (onConfigChange 누락 + stale currentPage) | +| 2026-03-11 | 문서 최신화: 버그 수정 내역 반영, 코드 설계 섹션 제거 (구현 완료) | diff --git a/frontend/components/common/PageGroupNav.tsx b/frontend/components/common/PageGroupNav.tsx deleted file mode 100644 index dc59b35e..00000000 --- a/frontend/components/common/PageGroupNav.tsx +++ /dev/null @@ -1,109 +0,0 @@ -"use client"; - -import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; - -const DEFAULT_GROUP_SIZE = 10; - -interface PageGroupNavProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; - disabled?: boolean; - groupSize?: number; -} - -export function PageGroupNav({ - currentPage, - totalPages, - onPageChange, - disabled = false, - groupSize = DEFAULT_GROUP_SIZE, -}: PageGroupNavProps) { - const safeTotal = Math.max(1, totalPages); - const currentGroupIndex = Math.floor((currentPage - 1) / groupSize); - const groupStartPage = currentGroupIndex * groupSize + 1; - - const lastGroupIndex = Math.floor((safeTotal - 1) / groupSize); - const lastGroupStartPage = lastGroupIndex * groupSize + 1; - - const isFirstGroup = currentGroupIndex === 0; - const isLastGroup = currentGroupIndex === lastGroupIndex; - - const slots: (number | null)[] = []; - for (let i = 0; i < groupSize; i++) { - const page = groupStartPage + i; - slots.push(page <= safeTotal ? page : null); - } - - return ( -
- {/* << 첫 단락 */} - - - {/* < 이전 단락 */} - - - {/* 페이지 번호 (고정 슬롯) */} - {slots.map((page, idx) => - page !== null ? ( - - ) : ( -
- ), - )} - - {/* > 다음 단락 */} - - - {/* >> 마지막 단락 */} - -
- ); -} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 593ab529..63cdc3f2 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -2,16 +2,16 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { TableListConfig, ColumnConfig } from "./types"; -import type { WebType } from "@/types/common"; +import { WebType } from "@/types/common"; import { tableTypeApi } from "@/lib/api/screen"; import { entityJoinApi } from "@/lib/api/entityJoin"; +import { codeCache } from "@/lib/caching/codeCache"; import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; import { getFullImageUrl } from "@/lib/api/client"; import { getFilePreviewUrl } from "@/lib/api/file"; import { Button } from "@/components/ui/button"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; import { useTabId } from "@/contexts/TabIdContext"; -import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor"; // 🖼️ 테이블 셀 이미지 썸네일 컴포넌트 // objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용 @@ -156,8 +156,13 @@ declare global { import { ChevronLeft, ChevronRight, + ChevronsLeft, + ChevronsRight, RefreshCw, + ArrowUp, + ArrowDown, TableIcon, + Settings, X, Layers, ChevronDown, @@ -170,14 +175,14 @@ import { Edit, CheckSquare, Trash2, + Lock, } from "lucide-react"; import * as XLSX from "xlsx"; -import { FileText } from "lucide-react"; +import { FileText, ChevronRightIcon } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; -import { PageGroupNav } from "@/components/common/PageGroupNav"; import { tableDisplayStore } from "@/stores/tableDisplayStore"; import { Dialog, @@ -189,6 +194,7 @@ import { } from "@/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; +import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters"; import { SingleTableWithSticky } from "./SingleTableWithSticky"; import { CardModeRenderer } from "./CardModeRenderer"; import { TableOptionsModal } from "@/components/common/TableOptionsModal"; @@ -196,7 +202,7 @@ import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; -import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; +import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer"; // ======================================== @@ -401,7 +407,7 @@ export const TableListComponent: React.FC = ({ const currentTabId = useTabId(); - const buttonColor = getAdaptiveLabelColor(component.style?.labelColor); + const buttonColor = component.style?.labelColor || "#212121"; const buttonTextColor = component.config?.buttonTextColor || "#ffffff"; const gridColumns = component.gridColumns || 1; @@ -429,13 +435,7 @@ export const TableListComponent: React.FC = ({ width: "100%", height: "100%", minHeight: isDesignMode ? "300px" : "100%", - ...style, - // 런타임에서는 DB의 고정 px 크기를 무시하고 부모에 맞춤 - ...(!isDesignMode && { - width: "100%", - height: "100%", - minWidth: 0, - }), + ...style, // style prop이 위의 기본값들을 덮어씀 }; // ======================================== @@ -691,6 +691,7 @@ export const TableListComponent: React.FC = ({ const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); const [totalItems, setTotalItems] = useState(0); + const [pageInputValue, setPageInputValue] = useState("1"); const [searchTerm, setSearchTerm] = useState(""); const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); @@ -714,20 +715,7 @@ export const TableListComponent: React.FC = ({ const val = sessionStorage.getItem(key); if (val) return Number(val); } - return 20; - }); - const [pageSizeInputValue, setPageSizeInputValue] = useState(() => { - const key = - currentTabId && tableConfig.selectedTable - ? `pageSize_${currentTabId}_${tableConfig.selectedTable}` - : tableConfig.selectedTable - ? `pageSize_${tableConfig.selectedTable}` - : null; - if (key) { - const val = sessionStorage.getItem(key); - if (val) return val; - } - return "20"; + return tableConfig.pagination?.pageSize || 20; }); const [displayColumns, setDisplayColumns] = useState([]); const [columnMeta, setColumnMeta] = useState< @@ -843,7 +831,7 @@ export const TableListComponent: React.FC = ({ if (!tableConfig.selectedTable) return null; if (currentTabId) return `tableState_${currentTabId}_${tableConfig.selectedTable}`; return `tableState_${tableConfig.selectedTable}`; - }, [tableConfig.selectedTable, currentTabId]); + }, [tableConfig.selectedTable]); // 🆕 Real-Time Updates 관련 상태 const [isRealTimeEnabled] = useState((tableConfig as any).realTimeUpdates ?? false); @@ -1647,7 +1635,7 @@ export const TableListComponent: React.FC = ({ setError(null); try { - const page = currentPage || tableConfig.pagination?.currentPage || 1; + const page = currentPage; const pageSize = localPageSize; // 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용 const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined; @@ -1910,7 +1898,6 @@ export const TableListComponent: React.FC = ({ } }, [ tableConfig.selectedTable, - tableConfig.pagination?.currentPage, tableConfig.columns, currentPage, localPageSize, @@ -1945,6 +1932,29 @@ export const TableListComponent: React.FC = ({ const handlePageChange = (newPage: number) => { if (newPage < 1 || newPage > totalPages) return; setCurrentPage(newPage); + if (tableConfig.pagination) { + tableConfig.pagination.currentPage = newPage; + } + if (onConfigChange) { + onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } }); + } + }; + + useEffect(() => { + setPageInputValue(String(currentPage)); + }, [currentPage]); + + const commitPageInput = () => { + const parsed = parseInt(pageInputValue, 10); + if (isNaN(parsed) || pageInputValue.trim() === "") { + setPageInputValue(String(currentPage)); + return; + } + const clamped = Math.max(1, Math.min(parsed, totalPages || 1)); + if (clamped !== currentPage) { + handlePageChange(clamped); + } + setPageInputValue(String(clamped)); }; const handleSort = (column: string) => { @@ -2981,6 +2991,7 @@ export const TableListComponent: React.FC = ({ headerFilters: Object.fromEntries( Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]), ), + pageSize: localPageSize, timestamp: Date.now(), }; @@ -3000,6 +3011,7 @@ export const TableListComponent: React.FC = ({ frozenColumnCount, showGridLines, headerFilters, + localPageSize, ]); // 🆕 State Persistence: 통합 상태 복원 @@ -3018,6 +3030,7 @@ export const TableListComponent: React.FC = ({ if (state.sortDirection) setSortDirection(state.sortDirection); if (state.groupByColumns) setGroupByColumns(state.groupByColumns); if (state.frozenColumns) { + // 체크박스 컬럼이 항상 포함되도록 보장 const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null; const restoredFrozenColumns = checkboxColumn && !state.frozenColumns.includes(checkboxColumn) @@ -3025,7 +3038,7 @@ export const TableListComponent: React.FC = ({ : state.frozenColumns; setFrozenColumns(restoredFrozenColumns); } - if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); + if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원 if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines); if (state.headerFilters) { const filters: Record> = {}; @@ -3053,8 +3066,6 @@ export const TableListComponent: React.FC = ({ setFrozenColumns([]); setShowGridLines(true); setHeaderFilters({}); - setLocalPageSize(20); - setPageSizeInputValue("20"); toast.success("테이블 설정이 초기화되었습니다."); } catch (error) { console.error("❌ 테이블 상태 초기화 실패:", error); @@ -4280,8 +4291,8 @@ export const TableListComponent: React.FC = ({ return (
- - + + {fileNames} {files.length > 1 && ({files.length})} @@ -4500,6 +4511,7 @@ export const TableListComponent: React.FC = ({ const savedFilters = JSON.parse(saved); setVisibleFilterColumns(new Set(savedFilters)); } else { + // 초기값: 빈 Set (아무것도 선택 안 함) setVisibleFilterColumns(new Set()); } } catch (error) { @@ -5131,16 +5143,19 @@ export const TableListComponent: React.FC = ({ // 페이지 크기 변경 핸들러 const handlePageSizeChange = (newSize: number) => { - setPageSizeInputValue(String(newSize)); setLocalPageSize(newSize); setCurrentPage(1); if (pageSizeKey) { sessionStorage.setItem(pageSizeKey, String(newSize)); } + if (onConfigChange) { + onConfigChange({ + ...tableConfig, + pagination: { ...tableConfig.pagination, pageSize: newSize, currentPage: 1 }, + }); + } }; - const pageSizeOptions = tableConfig.pagination?.pageSizeOptions || [5, 10, 20, 50, 100]; - return (
{/* 좌측: 페이지 크기 입력 */} @@ -5150,20 +5165,15 @@ export const TableListComponent: React.FC = ({ type="number" min={1} max={10000} - value={pageSizeInputValue} + value={localPageSize} onChange={(e) => { - setPageSizeInputValue(e.target.value); - }} - onBlur={(e) => { - const value = Math.min(10000, Math.max(1, Number(e.target.value) || 10)); + const value = Math.min(10000, Math.max(1, Number(e.target.value) || 1)); handlePageSizeChange(value); }} - onKeyDown={(e) => { - if (e.key === "Enter") { - const value = Math.min(10000, Math.max(1, Number((e.target as HTMLInputElement).value) || 10)); - handlePageSizeChange(value); - (e.target as HTMLInputElement).blur(); - } + onBlur={(e) => { + // 포커스 잃을 때 유효 범위로 조정 + const value = Math.min(10000, Math.max(1, Number(e.target.value) || 10)); + handlePageSizeChange(value); }} className="border-input bg-background focus:ring-ring h-7 w-14 rounded-md border px-2 text-center text-xs focus:ring-1 focus:outline-none sm:h-8 sm:w-16" /> @@ -5171,12 +5181,68 @@ export const TableListComponent: React.FC = ({
{/* 중앙 페이지네이션 컨트롤 */} - +
+ + + +
+ setPageInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + commitPageInput(); + (e.target as HTMLInputElement).blur(); + } + }} + onBlur={commitPageInput} + onFocus={(e) => e.target.select()} + disabled={loading} + className="border-input bg-background focus:ring-ring h-7 w-10 rounded-md border px-1 text-center text-xs font-medium focus:ring-1 focus:outline-none sm:h-8 sm:w-12 sm:text-sm" + /> + / + + {totalPages || 1} + +
+ + + +
{/* 우측 버튼 그룹 */}
@@ -5191,7 +5257,7 @@ export const TableListComponent: React.FC = ({
Excel
PDF/인쇄
@@ -5251,9 +5317,9 @@ export const TableListComponent: React.FC = ({ exportToExcel, exportToPdf, localPageSize, - pageSizeInputValue, onConfigChange, tableConfig, + pageInputValue, ]); // ======================================== @@ -5265,7 +5331,7 @@ export const TableListComponent: React.FC = ({ onDragStart: isDesignMode ? onDragStart : undefined, onDragEnd: isDesignMode ? onDragEnd : undefined, draggable: isDesignMode, - className: cn("w-full h-full overflow-hidden", className, isDesignMode && "cursor-move"), + className: cn("w-full h-full", className, isDesignMode && "cursor-move"), // customer-item-mapping과 동일 style: componentStyle, }; @@ -5335,7 +5401,7 @@ export const TableListComponent: React.FC = ({
)} -
+
= ({ className="h-7 text-xs" title="Excel 내보내기" > - + Excel )} @@ -5413,7 +5479,7 @@ export const TableListComponent: React.FC = ({ className="h-7 text-xs" title="PDF 내보내기" > - + PDF )} @@ -5645,7 +5711,6 @@ export const TableListComponent: React.FC = ({ width: "100%", height: "100%", overflow: "auto", - WebkitOverflowScrolling: "touch", }} onScroll={handleVirtualScroll} > @@ -5656,7 +5721,6 @@ export const TableListComponent: React.FC = ({ borderCollapse: "collapse", width: "100%", tableLayout: "fixed", - minWidth: "400px", }} > {/* 헤더 (sticky) */} @@ -5874,7 +5938,7 @@ export const TableListComponent: React.FC = ({ {/* 리사이즈 핸들 (체크박스 제외) */} {columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
e.stopPropagation()} // 정렬 클릭 방지 onMouseDown={(e) => { @@ -6227,11 +6291,11 @@ export const TableListComponent: React.FC = ({ // 🆕 배치 편집: 수정된 셀 스타일 (노란 배경) isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40", // 🆕 유효성 에러: 빨간 테두리 및 배경 - cellValidationError && "bg-destructive/10 ring-2 ring-destructive ring-inset dark:bg-destructive/15", + cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40", // 🆕 검색 하이라이트 스타일 (노란 배경) isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50", // 🆕 편집 불가 컬럼 스타일 (연한 회색 배경) - column.editable === false && "bg-muted dark:bg-foreground/30", + column.editable === false && "bg-gray-50 dark:bg-gray-900/30", )} // 🆕 유효성 에러 툴팁 title={cellValidationError || undefined} @@ -6624,7 +6688,7 @@ export const TableListComponent: React.FC = ({ {/* 행 삭제 */} @@ -6751,7 +6815,7 @@ export const TableListComponent: React.FC = ({ variant="ghost" size="sm" onClick={() => removeFilterCondition(group.id, condition.id)} - className="h-6 w-6 p-0 text-destructive hover:text-destructive" + className="h-6 w-6 p-0 text-red-500 hover:text-red-700" disabled={group.conditions.length === 1} > From 634f0cae18083624cd3cd634091acbbe7e4e0471 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Wed, 11 Mar 2026 14:44:34 +0900 Subject: [PATCH 04/14] docs: Add documentation for category tree modal updates with continuous registration mode - Introduced new documents detailing the modifications made to the category tree modal for continuous registration mode. - Updated the functionality to allow the modal to close after saving or remain open based on user preference via a checkbox. - Enhanced the user experience by aligning the modal behavior with existing patterns in the project. - Included a checklist to track implementation progress and ensure thorough testing. These changes aim to improve the usability and consistency of the category management feature in the application. --- .../CCA[계획]-카테고리-연속등록모드.md | 199 ++++++++++++++++++ .../CCA[맥락]-카테고리-연속등록모드.md | 84 ++++++++ .../CCA[체크]-카테고리-연속등록모드.md | 52 +++++ .../CategoryValueManagerTree.tsx | 39 +++- 4 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md create mode 100644 docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md create mode 100644 docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md diff --git a/docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md b/docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md new file mode 100644 index 00000000..964c389f --- /dev/null +++ b/docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md @@ -0,0 +1,199 @@ +# [계획서] 카테고리 트리 대분류 추가 모달 - 연속 등록 모드 수정 + +> 관련 문서: [맥락노트](./CCA[맥락]-카테고리-연속등록모드.md) | [체크리스트](./CCA[체크]-카테고리-연속등록모드.md) + +## 개요 + +기준정보 - 옵션설정 화면에서 트리 구조 카테고리(예: 품목정보 > 재고단위)의 "대분류 추가" 모달이 저장 후 닫히지 않는 버그를 수정합니다. +평면 목록용 추가 모달(`CategoryValueAddDialog.tsx`)과 동일한 연속 입력 패턴을 적용합니다. + +--- + +## 현재 동작 + +- 대분류 추가 모달에서 값 입력 후 "추가" 클릭 시 **값은 정상 저장됨** +- 저장 후 **모달이 닫히지 않고** 폼만 초기화됨 (항상 연속 입력 상태) +- "연속 입력" 체크박스 UI가 **없음** → 사용자가 모드를 끌 수 없음 +- 모달을 닫으려면 "닫기" 버튼 또는 외부 클릭을 해야 함 + +### 현재 코드 (CategoryValueManagerTree.tsx - handleAdd, 512~530행) + +```tsx +if (response.success) { + toast.success("카테고리가 추가되었습니다"); + // 폼 초기화 (모달은 닫지 않고 연속 입력) + setFormData((prev) => ({ + ...prev, + valueCode: "", + valueLabel: "", + description: "", + color: "", + })); + setTimeout(() => addNameRef.current?.focus(), 50); + await loadTree(true); + if (parentValue) { + setExpandedNodes((prev) => new Set([...prev, parentValue.valueId])); + } +} +``` + +### 현재 DialogFooter (809~821행) + +```tsx + + + + +``` + +--- + +## 변경 후 동작 + +### 1. 기본 동작: 저장 후 모달 닫힘 + +- "추가" 클릭 → 저장 성공 → 모달 닫힘 + 트리 새로고침 +- `CategoryValueAddDialog.tsx`(평면 목록 추가 모달)와 동일한 기본 동작 + +### 2. 연속 입력 체크박스 추가 + +- DialogFooter 좌측에 "연속 입력" 체크박스 표시 +- 기본값: 체크 해제 (OFF) +- 체크 시: 저장 후 폼만 초기화, 모달 유지, 이름 필드에 포커스 +- 체크 해제 시: 저장 후 모달 닫힘 + +--- + +## 시각적 예시 + +| 상태 | 연속 입력 체크 | 추가 버튼 클릭 후 | +|------|---------------|-----------------| +| 기본 (체크 해제) | [ ] 연속 입력 | 저장 → 모달 닫힘 → 트리 갱신 | +| 연속 모드 (체크) | [x] 연속 입력 | 저장 → 폼 초기화 → 모달 유지 → 이름 필드 포커스 | + +### 모달 하단 레이아웃 (ScreenModal.tsx 패턴) + +``` +┌─────────────────────────────────────────┐ +│ [닫기] [추가] │ ← DialogFooter (버튼만) +├─────────────────────────────────────────┤ +│ [x] 저장 후 계속 입력 (연속 등록 모드) │ ← border-t 구분선 아래 별도 영역 +└─────────────────────────────────────────┘ +``` + +--- + +## 아키텍처 + +```mermaid +flowchart TD + A["사용자: '추가' 클릭"] --> B["handleAdd()"] + B --> C{"API 호출 성공?"} + C -- 실패 --> D["toast.error → 모달 유지"] + C -- 성공 --> E["toast.success + loadTree"] + E --> F{"continuousAdd?"} + F -- true --> G["폼 초기화 + 이름 필드 포커스\n모달 유지"] + F -- false --> H["폼 초기화 + 모달 닫힘"] +``` + +--- + +## 변경 대상 파일 + +| 파일 | 역할 | 변경 내용 | +|------|------|----------| +| `frontend/components/table-category/CategoryValueManagerTree.tsx` | 트리형 카테고리 값 관리 | 상태 추가, handleAdd 분기, DialogFooter UI | + +- **변경 규모**: 약 20줄 내외 소규모 변경 +- **참고 파일**: `frontend/components/table-category/CategoryValueAddDialog.tsx` (동일 패턴) + +--- + +## 코드 설계 + +### 1. 상태 추가 (286행 근처, 모달 상태 선언부) + +```tsx +const [continuousAdd, setContinuousAdd] = useState(false); +``` + +### 2. handleAdd 성공 분기 수정 (512~530행 대체) + +```tsx +if (response.success) { + toast.success("카테고리가 추가되었습니다"); + await loadTree(true); + if (parentValue) { + setExpandedNodes((prev) => new Set([...prev, parentValue.valueId])); + } + + if (continuousAdd) { + setFormData((prev) => ({ + ...prev, + valueCode: "", + valueLabel: "", + description: "", + color: "", + })); + setTimeout(() => addNameRef.current?.focus(), 50); + } else { + setFormData({ valueCode: "", valueLabel: "", description: "", color: "", isActive: true }); + setIsAddModalOpen(false); + } +} +``` + +### 3. DialogFooter + 연속 등록 체크박스 수정 (809~821행 대체) + +DialogFooter는 버튼만 유지하고, 그 아래에 `border-t` 구분선과 체크박스를 별도 영역으로 배치합니다. +`ScreenModal.tsx` (1287~1303행) 패턴 그대로입니다. + +```tsx + + + + + +{/* 연속 등록 모드 체크박스 - ScreenModal.tsx 패턴 */} +
+
+ setContinuousAdd(checked as boolean)} + /> + +
+
+``` + +--- + +## 예상 문제 및 대응 + +`CategoryValueAddDialog.tsx`와 동일한 패턴이므로 별도 예상 문제 없음. + +--- + +## 설계 원칙 + +- `CategoryValueAddDialog.tsx`(같은 폴더, 같은 목적)의 패턴을 그대로 따름 +- 기존 수정/삭제 모달 동작은 변경하지 않음 +- 하위 추가(중분류/소분류) 모달도 동일한 `handleAdd`를 사용하므로 자동 적용 +- `Checkbox` import는 이미 존재 (24행)하므로 추가 import 불필요 +- `Label` import는 이미 존재 (53행)하므로 추가 import 불필요 +- 체크박스 위치/라벨/className 모두 `ScreenModal.tsx` (1287~1303행)과 동일 diff --git a/docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md b/docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md new file mode 100644 index 00000000..1b5cb92e --- /dev/null +++ b/docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md @@ -0,0 +1,84 @@ +# [맥락노트] 카테고리 트리 대분류 추가 모달 - 연속 등록 모드 수정 + +> 관련 문서: [계획서](./CCA[계획]-카테고리-연속등록모드.md) | [체크리스트](./CCA[체크]-카테고리-연속등록모드.md) + +--- + +## 왜 이 작업을 하는가 + +- 기준정보 - 옵션설정에서 트리 구조 카테고리(품목정보 > 재고단위 등)의 "대분류 추가" 모달이 저장 후 닫히지 않음 +- 연속 등록 모드가 하드코딩되어 항상 ON 상태이고, 끌 수 있는 UI가 없음 +- 같은 폴더의 평면 목록 모달(`CategoryValueAddDialog.tsx`)은 이미 올바르게 구현되어 있음 +- 동일 패턴을 적용하여 일관성 확보 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 기본값: 연속 등록 OFF (모달 닫힘) + +- **결정**: `continuousAdd` 초기값을 `false`로 설정 +- **근거**: 대부분의 사용자는 한 건 추가 후 결과를 확인하려 함. 연속 입력은 선택적 기능 + +### 2. 체크박스 위치: DialogFooter 아래, border-t 구분선 별도 영역 + +- **결정**: `ScreenModal.tsx` (1287~1303행) 패턴 그대로 적용 +- **근거**: "기준정보 - 부서관리" 추가 모달과 동일한 디자인. 프로젝트 관행 준수 +- **대안 검토**: `CategoryValueAddDialog.tsx`는 DialogFooter 안에 체크박스 배치 → 부서 모달과 다른 디자인이므로 기각 + +### 3. 라벨: "저장 후 계속 입력 (연속 등록 모드)" + +- **결정**: `ScreenModal.tsx`와 동일한 라벨 텍스트 사용 +- **근거**: 부서 추가 모달과 동일한 문구로 사용자 혼란 방지 + +### 4. localStorage 미사용 + +- **결정**: 컴포넌트 state만 사용, localStorage 영속화 안 함 +- **근거**: `CategoryValueAddDialog.tsx`(같은 폴더 형제 컴포넌트)가 localStorage를 쓰지 않음. `ScreenModal.tsx`는 사용하지만 동적 화면 모달 전용 기능이므로 범위가 다름 + +### 5. 수정 대상: handleAdd 함수만 + +- **결정**: 저장 성공 분기에서만 `continuousAdd` 체크 +- **근거**: 실패 시에는 원래대로 모달 유지 + 에러 표시. 분기가 필요한 건 성공 시뿐 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 수정 대상 | `frontend/components/table-category/CategoryValueManagerTree.tsx` | 트리형 카테고리 값 관리 (대분류/중분류/소분류) | +| 참고 패턴 (로직) | `frontend/components/table-category/CategoryValueAddDialog.tsx` | 평면 목록 추가 모달 - continuousAdd 분기 로직 | +| 참고 패턴 (UI) | `frontend/components/common/ScreenModal.tsx` | 동적 화면 모달 - 체크박스 위치/라벨/스타일 | + +--- + +## 기술 참고 + +### 현재 handleAdd 흐름 + +``` +handleAdd() → API 호출 → 성공 시: + 1. toast.success + 2. 폼 초기화 (모달 유지 - 하드코딩) + 3. addNameRef 포커스 + 4. loadTree(true) - 펼침 상태 유지 + 5. parentValue 있으면 해당 노드 펼침 +``` + +### 변경 후 handleAdd 흐름 + +``` +handleAdd() → API 호출 → 성공 시: + 1. toast.success + 2. loadTree(true) + parentValue 펼침 + 3. continuousAdd 체크: + - true: 폼 초기화 + addNameRef 포커스 (모달 유지) + - false: 폼 초기화 + setIsAddModalOpen(false) (모달 닫힘) +``` + +### import 현황 + +- `Checkbox`: 24행에서 이미 import (`@/components/ui/checkbox`) +- `Label`: 53행에서 이미 import (`@/components/ui/label`) +- 추가 import 불필요 diff --git a/docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md b/docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md new file mode 100644 index 00000000..f794e0ff --- /dev/null +++ b/docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md @@ -0,0 +1,52 @@ +# [체크리스트] 카테고리 트리 대분류 추가 모달 - 연속 등록 모드 수정 + +> 관련 문서: [계획서](./CCA[계획]-카테고리-연속등록모드.md) | [맥락노트](./CCA[맥락]-카테고리-연속등록모드.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (구현 완료) +- 현재 단계: 완료 + +--- + +## 구현 체크리스트 + +### 1단계: 상태 추가 + +- [x] `CategoryValueManagerTree.tsx` 모달 상태 선언부(286행 근처)에 `continuousAdd` 상태 추가 + +### 2단계: handleAdd 분기 수정 + +- [x] `handleAdd` 성공 분기(512~530행)에서 `continuousAdd` 체크 분기 추가 +- [x] `continuousAdd === true`: 폼 초기화 + addNameRef 포커스 (모달 유지) +- [x] `continuousAdd === false`: 폼 초기화 + `setIsAddModalOpen(false)` (모달 닫힘) + +### 3단계: DialogFooter UI 수정 + +- [x] DialogFooter(809~821행)는 버튼만 유지 +- [x] DialogFooter 아래에 `border-t px-4 py-3` 영역 추가 +- [x] "저장 후 계속 입력 (연속 등록 모드)" 체크박스 배치 +- [x] ScreenModal.tsx (1287~1303행) 패턴과 동일한 className/라벨 사용 + +### 4단계: 검증 + +- [ ] 대분류 추가: 체크 해제 상태에서 추가 → 모달 닫힘 확인 +- [ ] 대분류 추가: 체크 상태에서 추가 → 모달 유지 + 폼 초기화 + 포커스 확인 +- [ ] 하위 추가(중분류/소분류): 동일하게 동작하는지 확인 +- [ ] 수정/삭제 모달: 기존 동작 변화 없음 확인 + +### 5단계: 정리 + +- [x] 린트 에러 없음 확인 +- [x] 이 체크리스트 완료 표시 업데이트 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 | +| 2026-03-11 | 구현 완료 (1~3단계, 5단계 정리). 4단계 검증은 수동 테스트 필요 | diff --git a/frontend/components/table-category/CategoryValueManagerTree.tsx b/frontend/components/table-category/CategoryValueManagerTree.tsx index 88ecfb49..f6f7ff8a 100644 --- a/frontend/components/table-category/CategoryValueManagerTree.tsx +++ b/frontend/components/table-category/CategoryValueManagerTree.tsx @@ -288,6 +288,7 @@ export const CategoryValueManagerTree: React.FC = const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); const [parentValue, setParentValue] = useState(null); + const [continuousAdd, setContinuousAdd] = useState(false); const [editingValue, setEditingValue] = useState(null); const [deletingValue, setDeletingValue] = useState(null); @@ -512,21 +513,24 @@ export const CategoryValueManagerTree: React.FC = const response = await createCategoryValue(input); if (response.success) { toast.success("카테고리가 추가되었습니다"); - // 폼 초기화 (모달은 닫지 않고 연속 입력) - setFormData((prev) => ({ - ...prev, - valueCode: "", - valueLabel: "", - description: "", - color: "", - })); - setTimeout(() => addNameRef.current?.focus(), 50); - // 기존 펼침 상태 유지하면서 데이터 새로고침 await loadTree(true); - // 부모 노드만 펼치기 (하위 추가 시) if (parentValue) { setExpandedNodes((prev) => new Set([...prev, parentValue.valueId])); } + + if (continuousAdd) { + setFormData((prev) => ({ + ...prev, + valueCode: "", + valueLabel: "", + description: "", + color: "", + })); + setTimeout(() => addNameRef.current?.focus(), 50); + } else { + setFormData({ valueCode: "", valueLabel: "", description: "", color: "", isActive: true }); + setIsAddModalOpen(false); + } } else { toast.error(response.error || "추가 실패"); } @@ -818,6 +822,19 @@ export const CategoryValueManagerTree: React.FC = 추가 + +
+
+ setContinuousAdd(checked as boolean)} + /> + +
+
From 65026f14e4c870c6657e9751dc92c4988a7d26f6 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Wed, 11 Mar 2026 15:53:01 +0900 Subject: [PATCH 05/14] docs: Add documentation for category dropdown depth separation - Introduced new documents detailing the implementation of visual separation for three-level category dropdowns. - Updated the `flattenTree` function in both `V2Select.tsx` and `UnifiedSelect.tsx` to use Non-Breaking Space (`\u00A0`) for indentation, ensuring proper visual hierarchy. - Included a checklist to track the implementation progress and verification of the changes. - Documented the rationale behind the changes, including the issues with HTML whitespace collapsing and the decisions made to enhance user experience. These updates aim to improve the clarity and usability of the category selection interface in the application. --- .../CTI[계획]-카테고리-깊이구분.md | 122 ++++++++++++++++++ .../CTI[맥락]-카테고리-깊이구분.md | 105 +++++++++++++++ .../CTI[체크]-카테고리-깊이구분.md | 53 ++++++++ frontend/app/globals.css | 8 ++ frontend/components/unified/UnifiedSelect.tsx | 2 +- frontend/components/v2/V2Input.tsx | 10 +- frontend/components/v2/V2Select.tsx | 2 +- frontend/lib/hooks/useDialogAutoValidation.ts | 30 ++++- 8 files changed, 318 insertions(+), 14 deletions(-) create mode 100644 docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md create mode 100644 docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md create mode 100644 docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md diff --git a/docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md b/docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md new file mode 100644 index 00000000..7b524b82 --- /dev/null +++ b/docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md @@ -0,0 +1,122 @@ +# [계획서] 카테고리 드롭다운 - 3단계 깊이 구분 표시 + +> 관련 문서: [맥락노트](./CTI[맥락]-카테고리-깊이구분.md) | [체크리스트](./CTI[체크]-카테고리-깊이구분.md) +> +> 상태: **완료** (2026-03-11) + +## 개요 + +카테고리 타입(`source="category"`) 드롭다운에서 3단계 계층(대분류 > 중분류 > 소분류)의 들여쓰기가 시각적으로 구분되지 않는 문제를 수정합니다. + +--- + +## 변경 전 동작 + +- `category_values` 테이블은 `parent_value_id`, `depth` 컬럼으로 3단계 계층 구조를 지원 +- 백엔드 `buildHierarchy()`가 트리 구조를 정상적으로 반환 +- 프론트엔드 `flattenTree()`가 트리를 평탄화하면서 **일반 ASCII 공백(`" "`)** 으로 들여쓰기 생성 +- HTML이 연속 공백을 하나로 축소(collapse)하여 depth 1과 depth 2가 동일하게 렌더링됨 + +### 변경 전 코드 (flattenTree) + +```tsx +const prefix = depth > 0 ? " ".repeat(depth) + "└ " : ""; +``` + +### 변경 전 렌더링 결과 + +``` +신예철 +└ 신2 +└ 신22 ← depth 2인데 depth 1과 구분 불가 +└ 신3 +└ 신4 +``` + +--- + +## 변경 후 동작 + +### 일반 공백을 Non-Breaking Space(`\u00A0`)로 교체 + +- `\u00A0`는 HTML에서 축소되지 않으므로 depth별 들여쓰기가 정확히 유지됨 +- depth당 3칸(`\u00A0\u00A0\u00A0`)으로 시각적 계층 구분을 명확히 함 +- 백엔드 변경 없음 (트리 구조는 이미 정상) + +### 변경 후 코드 (flattenTree) + +```tsx +const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : ""; +``` + +--- + +## 시각적 예시 + +| depth | prefix | 드롭다운 표시 | +|-------|--------|-------------| +| 0 (대분류) | `""` | `신예철` | +| 1 (중분류) | `"\u00A0\u00A0\u00A0└ "` | `···└ 신2` | +| 2 (소분류) | `"\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0└ "` | `······└ 신22` | + +### 변경 전후 비교 + +``` +변경 전: 변경 후: +신예철 신예철 +└ 신2 └ 신2 +└ 신22 ← 구분 불가 └ 신22 ← 명확히 구분 +└ 신3 └ 신3 +└ 신4 └ 신4 +``` + +--- + +## 아키텍처 + +```mermaid +flowchart TD + A[category_values 테이블] -->|parent_value_id, depth| B[백엔드 buildHierarchy] + B -->|트리 JSON 응답| C[프론트엔드 API 호출] + C --> D[flattenTree 함수] + D -->|"depth별 \u00A0 prefix 생성"| E[SelectOption 배열] + E --> F{렌더링 모드} + F -->|비검색| G[SelectItem - label 표시] + F -->|검색| H[CommandItem - displayLabel 표시] + + style D fill:#f96,stroke:#333,color:#000 +``` + +**변경 지점**: `flattenTree` 함수 내 prefix 생성 로직 (주황색 표시) + +--- + +## 변경 대상 파일 + +| 파일 경로 | 변경 내용 | 변경 규모 | +|-----------|----------|----------| +| `frontend/components/v2/V2Select.tsx` (904행) | `flattenTree` prefix를 `\u00A0` 기반으로 변경 | 1줄 | +| `frontend/components/unified/UnifiedSelect.tsx` (632행) | 동일한 `flattenTree` prefix 변경 | 1줄 | + +--- + +## 영향받는 기존 로직 + +V2Select.tsx의 `resolvedValue`(979행)에서 prefix를 제거하는 정규식: + +```tsx +const cleanLabel = o.label.replace(/^[\s└]+/, "").trim(); +``` + +- JavaScript `\s`는 `\u00A0`를 포함하므로 기존 정규식이 정상 동작함 +- 추가 수정 불필요 + +--- + +## 설계 원칙 + +- 백엔드 변경 없이 프론트엔드 표시 로직만 수정 +- `flattenTree` 공통 함수 수정이므로 카테고리 타입 드롭다운 전체에 자동 적용 +- DB 저장값(`valueCode`)에는 영향 없음 — `label`만 변경 +- 기존 prefix strip 정규식(`/^[\s└]+/`)과 호환 유지 +- `V2Select`와 `UnifiedSelect` 두 곳의 동일 패턴을 일관되게 수정 diff --git a/docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md b/docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md new file mode 100644 index 00000000..0cb61da0 --- /dev/null +++ b/docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md @@ -0,0 +1,105 @@ +# [맥락노트] 카테고리 드롭다운 - 3단계 깊이 구분 표시 + +> 관련 문서: [계획서](./CTI[계획]-카테고리-깊이구분.md) | [체크리스트](./CTI[체크]-카테고리-깊이구분.md) + +--- + +## 왜 이 작업을 하는가 + +- 품목정보 등록 모달의 "재고단위" 등 카테고리 드롭다운에서 3단계 계층이 시각적으로 구분되지 않음 +- 예: "신22"가 "신2"의 하위인데, "신3", "신4"와 같은 레벨로 보임 +- 사용자가 대분류/중분류/소분류 관계를 파악할 수 없어 잘못된 항목을 선택할 위험 + +--- + +## 핵심 결정 사항과 근거 + +### 1. 원인: HTML 공백 축소(collapse) + +- **현상**: `flattenTree`에서 `" ".repeat(depth)`로 들여쓰기를 만들지만, HTML이 연속 공백을 하나로 합침 +- **결과**: depth 1(`" └ "`)과 depth 2(`" └ "`)가 동일하게 렌더링됨 +- **확인**: `SelectItem`, `CommandItem` 모두 `white-space: pre` 미적용 상태 + +### 2. 해결: Non-Breaking Space(`\u00A0`) 사용 + +- **결정**: 일반 공백 `" "`를 `"\u00A0"`로 교체 +- **근거**: `\u00A0`는 HTML에서 축소되지 않아 depth별 들여쓰기가 정확히 유지됨 +- **대안 검토**: + - `white-space: pre` CSS 적용 → 기각 (SelectItem, CommandItem 양쪽 모두 수정 필요, shadcn 기본 스타일 오버라이드 부담) + - CSS `padding-left` 사용 → 기각 (label 문자열 기반 옵션 구조에서 개별 아이템에 스타일 전달 어려움) + - 트리 문자(`│`, `├`, `└`) 조합 → 기각 (과도한 시각 정보, 단순 들여쓰기면 충분) + +### 3. depth당 3칸 `\u00A0` + +- **결정**: `"\u00A0\u00A0\u00A0".repeat(depth)` (depth당 3칸) +- **근거**: 기존 2칸은 `\u00A0`로 바꿔도 depth간 차이가 작음. 3칸이 시각적 구분에 적절 + +### 4. 두 파일 동시 수정 + +- **결정**: `V2Select.tsx`와 `UnifiedSelect.tsx` 모두 수정 +- **근거**: 동일한 `flattenTree` 패턴이 두 컴포넌트에 존재. 하나만 수정하면 불일치 발생 + +### 5. 기존 prefix strip 정규식 호환 + +- **확인**: V2Select.tsx 979행의 `o.label.replace(/^[\s└]+/, "").trim()` +- **근거**: JavaScript `\s`는 `\u00A0`를 포함하므로 추가 수정 불필요 + +--- + +## 구현 중 발견한 사항 + +### CAT_ vs CATEGORY_ 접두사 불일치 + +테스트 과정에서 최고 관리자 계정으로 리스트 조회 시 `CAT_MMLL6U02_QH2V` 같은 코드가 그대로 표시되는 현상 발견. + +- **원인**: 카테고리 값 생성 함수가 두 곳에 존재하며 접두사가 다름 + - `CategoryValueAddDialog.tsx`: `CATEGORY_` 접두사 + - `CategoryValueManagerTree.tsx`: `CAT_` 접두사 +- **영향**: 리스트 해석 로직(`V2Repeater`, `InteractiveDataTable`, `UnifiedRepeater`)이 `CATEGORY_` 접두사만 인식하여 `CAT_` 코드는 라벨 변환 실패 +- **판단**: 일반 회사 계정에서는 정상 동작 확인. 본 작업(들여쓰기 표시) 범위 외로 별도 이슈로 분리 + +--- + +## 관련 파일 위치 + +| 구분 | 파일 경로 | 설명 | +|------|----------|------| +| 수정 완료 | `frontend/components/v2/V2Select.tsx` | flattenTree 함수 (904행) | +| 수정 완료 | `frontend/components/unified/UnifiedSelect.tsx` | flattenTree 함수 (632행) | +| 백엔드 (변경 없음) | `backend-node/src/services/tableCategoryValueService.ts` | buildHierarchy 메서드 | +| UI 컴포넌트 (변경 없음) | `frontend/components/ui/select.tsx` | SelectItem 렌더링 | +| UI 컴포넌트 (변경 없음) | `frontend/components/ui/command.tsx` | CommandItem 렌더링 | + +--- + +## 기술 참고 + +### flattenTree 동작 흐름 + +``` +백엔드 API 응답 (트리 구조): +{ + valueCode: "CAT_001", valueLabel: "신예철", children: [ + { valueCode: "CAT_002", valueLabel: "신2", children: [ + { valueCode: "CAT_003", valueLabel: "신22", children: [] } + ]}, + { valueCode: "CAT_004", valueLabel: "신3", children: [] }, + { valueCode: "CAT_005", valueLabel: "신4", children: [] } + ] +} + +→ flattenTree 변환 후 (SelectOption 배열): +[ + { value: "CAT_001", label: "신예철" }, + { value: "CAT_002", label: "\u00A0\u00A0\u00A0└ 신2" }, + { value: "CAT_003", label: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0└ 신22" }, + { value: "CAT_004", label: "\u00A0\u00A0\u00A0└ 신3" }, + { value: "CAT_005", label: "\u00A0\u00A0\u00A0└ 신4" } +] +``` + +### value vs label 분리 + +- `value` (저장값): `valueCode` — DB에 저장되는 값, 들여쓰기 없음 +- `label` (표시값): prefix + `valueLabel` — 화면에만 보이는 값, 들여쓰기 포함 +- 데이터 무결성에 영향 없음 diff --git a/docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md b/docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md new file mode 100644 index 00000000..8a1cc237 --- /dev/null +++ b/docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md @@ -0,0 +1,53 @@ +# [체크리스트] 카테고리 드롭다운 - 3단계 깊이 구분 표시 + +> 관련 문서: [계획서](./CTI[계획]-카테고리-깊이구분.md) | [맥락노트](./CTI[맥락]-카테고리-깊이구분.md) + +--- + +## 공정 상태 + +- 전체 진행률: **100%** (완료) +- 현재 단계: 전체 완료 + +--- + +## 구현 체크리스트 + +### 1단계: 코드 수정 + +- [x] `V2Select.tsx` 904행 — `flattenTree` prefix를 `\u00A0` 기반으로 변경 +- [x] `UnifiedSelect.tsx` 632행 — 동일한 `flattenTree` prefix 변경 + +### 2단계: 검증 + +- [x] depth 1 항목: 3칸 들여쓰기 + `└` 표시 확인 +- [x] depth 2 항목: 6칸 들여쓰기 + `└` 표시, depth 1과 명확히 구분됨 확인 +- [x] depth 0 항목: 들여쓰기 없이 원래대로 표시 확인 +- [x] 항목 선택 후 값이 정상 저장되는지 확인 (valueCode 기준) +- [x] 기존 prefix strip 로직 정상 동작 확인 — JS `\s`가 `\u00A0` 포함하므로 호환 +- [x] 검색 가능 모드(Combobox): 정상 동작 확인 +- [x] 비검색 모드(Select): 렌더링 정상 확인 + +### 3단계: 정리 + +- [x] 린트 에러 없음 확인 (기존 에러 제외) +- [x] 계맥체 문서 최신화 + +--- + +## 참고: 최고 관리자 계정 표시 이슈 + +- 최고 관리자(`company_code = "*"`)로 리스트 조회 시 `CAT_MMLL6U02_QH2V` 같은 코드값이 그대로 노출되는 현상 발견 +- 원인: `CategoryValueManagerTree.tsx`의 `generateCode()`가 `CAT_` 접두사를 사용하나, 리스트 해석 로직은 `CATEGORY_` 접두사만 인식 +- 일반 회사 계정에서는 정상 표시됨을 확인 +- 본 작업 범위 외로 판단하여 별도 이슈로 분리 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 | +| 2026-03-11 | 1단계 코드 수정 완료 (V2Select.tsx, UnifiedSelect.tsx) | +| 2026-03-11 | 2단계 검증 완료, 3단계 문서 정리 완료 | diff --git a/frontend/app/globals.css b/frontend/app/globals.css index abcbd2f8..b3dbab89 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -458,6 +458,14 @@ select { border-color: hsl(var(--destructive)) !important; } + +/* 채번 세그먼트 포커스 스타일 (shadcn Input과 동일한 3단 구조) */ +.numbering-segment:focus-within { + box-shadow: 0 0 0 3px hsl(var(--ring) / 0.5); + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; +} + /* 필수 입력 경고 문구 (입력 필드 아래, 레이아웃 영향 없음) */ .validation-error-msg-wrapper { height: 0; diff --git a/frontend/components/unified/UnifiedSelect.tsx b/frontend/components/unified/UnifiedSelect.tsx index d307fbc1..1045ba8c 100644 --- a/frontend/components/unified/UnifiedSelect.tsx +++ b/frontend/components/unified/UnifiedSelect.tsx @@ -629,7 +629,7 @@ export const UnifiedSelect = forwardRef((pro ): SelectOption[] => { const result: SelectOption[] = []; for (const item of items) { - const prefix = depth > 0 ? " ".repeat(depth) + "└ " : ""; + const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : ""; result.push({ value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치) label: prefix + item.valueLabel, diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index e61ba143..2d7c3246 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -909,10 +909,10 @@ export const V2Input = forwardRef((props, ref) => const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : ""; return ( -
+
{/* 고정 접두어 */} {templatePrefix && ( - + {templatePrefix} )} @@ -945,13 +945,13 @@ export const V2Input = forwardRef((props, ref) => } }} placeholder="입력" - className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none" + className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm ring-0" disabled={disabled || isGeneratingNumbering} - style={inputTextStyle} + style={{ ...inputTextStyle, outline: 'none' }} /> {/* 고정 접미어 */} {templateSuffix && ( - + {templateSuffix} )} diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 959abe05..9062e7bc 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -901,7 +901,7 @@ export const V2Select = forwardRef((props, ref) = ): SelectOption[] => { const result: SelectOption[] = []; for (const item of items) { - const prefix = depth > 0 ? " ".repeat(depth) + "└ " : ""; + const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : ""; result.push({ value: item.valueCode, // 🔧 valueCode를 value로 사용 label: prefix + item.valueLabel, diff --git a/frontend/lib/hooks/useDialogAutoValidation.ts b/frontend/lib/hooks/useDialogAutoValidation.ts index eefa9342..66e4f20b 100644 --- a/frontend/lib/hooks/useDialogAutoValidation.ts +++ b/frontend/lib/hooks/useDialogAutoValidation.ts @@ -98,6 +98,16 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) { return el instanceof HTMLSelectElement && el.hasAttribute("aria-hidden"); } + // 복합 입력 필드(채번 세그먼트 등)의 시각적 테두리 컨테이너 탐지 + // input 자체에 border가 없고 부모가 border를 가진 경우 부모를 반환 + function findBorderContainer(input: TargetEl): HTMLElement { + const parent = input.parentElement; + if (parent && parent.classList.contains("border")) { + return parent; + } + return input; + } + function isEmpty(input: TargetEl): boolean { if (input instanceof HTMLButtonElement) { // Radix Select: data-placeholder 속성이 자식 span에 있으면 미선택 상태 @@ -120,20 +130,24 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) { } function markError(input: TargetEl) { - input.setAttribute(ERROR_ATTR, "true"); + const container = findBorderContainer(input); + container.setAttribute(ERROR_ATTR, "true"); errorFields.add(input); showErrorMsg(input); } function clearError(input: TargetEl) { - input.removeAttribute(ERROR_ATTR); + const container = findBorderContainer(input); + container.removeAttribute(ERROR_ATTR); errorFields.delete(input); removeErrorMsg(input); } // 빈 필수 필드 아래에 경고 문구 삽입 (레이아웃 영향 없는 zero-height wrapper) + // 복합 입력(채번 세그먼트 등)은 border 컨테이너 바깥에 삽입 function showErrorMsg(input: TargetEl) { - if (input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return; + const container = findBorderContainer(input); + if (container.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return; const wrapper = document.createElement("div"); wrapper.className = MSG_WRAPPER_CLASS; @@ -142,17 +156,19 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) { msg.textContent = "필수 입력 항목입니다"; wrapper.appendChild(msg); - input.insertAdjacentElement("afterend", wrapper); + container.insertAdjacentElement("afterend", wrapper); } function removeErrorMsg(input: TargetEl) { - const wrapper = input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`); + const container = findBorderContainer(input); + const wrapper = container.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`); if (wrapper) wrapper.remove(); } function highlightField(input: TargetEl) { - input.setAttribute(HIGHLIGHT_ATTR, "true"); - input.addEventListener("animationend", () => input.removeAttribute(HIGHLIGHT_ATTR), { once: true }); + const container = findBorderContainer(input); + container.setAttribute(HIGHLIGHT_ATTR, "true"); + container.addEventListener("animationend", () => container.removeAttribute(HIGHLIGHT_ATTR), { once: true }); if (input instanceof HTMLButtonElement) { input.click(); From 4f603bd41edb1441a6c6e93b043cc8e39a920893 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Mar 2026 22:06:22 +0900 Subject: [PATCH 06/14] docs: update pipeline rules for user menu implementation - Added critical guidelines prohibiting the direct creation of user menu screens in React (.tsx) files, emphasizing that user menus must be implemented through database registration methods (screen_definitions, screen_layouts_v2, menu_info). - Clarified that backend agents should not request or suggest the creation of frontend pages for user menus. - Reinforced the importance of adhering to the established rendering system to prevent hardcoding UI components. Made-with: Cursor --- .cursor/agents/pipeline-backend.md | 8 + .cursor/agents/pipeline-common-rules.md | 113 ++++- .cursor/agents/pipeline-frontend.md | 83 +++- .cursor/agents/pipeline-ui.md | 46 ++- .cursor/agents/pipeline-verifier.md | 32 +- .../src/services/screenManagementService.ts | 13 +- .../components/layout/AdminPageRenderer.tsx | 390 +++++++++++++++--- frontend/components/layout/AppLayout.tsx | 6 +- 8 files changed, 617 insertions(+), 74 deletions(-) diff --git a/.cursor/agents/pipeline-backend.md b/.cursor/agents/pipeline-backend.md index 6b4ff99c..9f7ef180 100644 --- a/.cursor/agents/pipeline-backend.md +++ b/.cursor/agents/pipeline-backend.md @@ -51,6 +51,14 @@ export const getList = async (req: Request, res: Response) => { - backend-node/src/routes/index.ts에 import 추가 필수 - authenticateToken 미들웨어 적용 필수 +# CRITICAL: 사용자 메뉴 화면은 프론트엔드 페이지로 만들지 않는다! + +백엔드 에이전트는 프론트엔드 page.tsx를 직접 생성하지 않지만, +다른 에이전트에게 "프론트엔드 페이지를 만들어달라"고 요청하거나 제안해서도 안 된다. + +사용자 메뉴 화면은 DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로만 구현한다. +백엔드 에이전트가 할 일은 API 엔드포인트(controller/routes)와 DB 마이그레이션까지다. + # Your Domain - backend-node/src/controllers/ - backend-node/src/services/ diff --git a/.cursor/agents/pipeline-common-rules.md b/.cursor/agents/pipeline-common-rules.md index 57049ce6..575f355f 100644 --- a/.cursor/agents/pipeline-common-rules.md +++ b/.cursor/agents/pipeline-common-rules.md @@ -1,5 +1,79 @@ # WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수) +--- + +# !!!! STOP - 작업 시작 전 필수 게이트 (이것을 건너뛰면 모든 작업이 REJECT 된다) !!!! + +## PRE-CHECK GATE: 파일 생성/수정 전 반드시 확인 + +**어떤 에이전트든 파일을 생성하거나 수정하기 전에 반드시 이 게이트를 통과해야 한다.** +**이 게이트를 건너뛰거나 무시한 작업은 전부 REJECT + ROLLBACK 대상이다.** + +### GATE 1: 이 파일을 만들어도 되는가? + +아래 경로에 `.tsx` 페이지 파일을 **절대 생성하지 마라**: +``` +frontend/app/(main)/production/** ← 금지! 사용자 메뉴! +frontend/app/(main)/warehouse/** ← 금지! 사용자 메뉴! +frontend/app/(main)/quality/** ← 금지! 사용자 메뉴! +frontend/app/(main)/logistics/** ← 금지! 사용자 메뉴! +frontend/app/(main)/inventory/** ← 금지! 사용자 메뉴! +frontend/app/(main)/purchase/** ← 금지! 사용자 메뉴! +frontend/app/(main)/sales/** ← 금지! 사용자 메뉴! +frontend/app/(main)/bom/** ← 금지! 사용자 메뉴! +frontend/app/(main)/mold/** ← 금지! 사용자 메뉴! +frontend/app/(main)/packaging/** ← 금지! 사용자 메뉴! +frontend/app/(main)/document/** ← 금지! 사용자 메뉴! +frontend/app/(main)/work/** ← 금지! 사용자 메뉴! +frontend/app/(main)/order/** ← 금지! 사용자 메뉴! +frontend/app/(main)/material/** ← 금지! 사용자 메뉴! +frontend/app/(main)/equipment/** ← 금지! 사용자 메뉴! +frontend/app/(main)/inspection/** ← 금지! 사용자 메뉴! +``` + +**유일하게 React 페이지(.tsx)를 만들 수 있는 경로:** +``` +frontend/app/(main)/admin/** ← 허용! 관리자 메뉴만! +``` + +**판단 로직 (의사코드):** +``` +IF 생성하려는 파일 경로가 "frontend/app/(main)/admin/" 하위가 아니다 + AND 파일이 page.tsx 또는 layout.tsx 또는 React 컴포넌트다 +THEN + !!!! 즉시 중단 !!!! + → 이것은 사용자 메뉴다 + → React 페이지를 만들면 안 된다 + → DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로 전환하라 + → pipeline-common-rules.md의 "사용자 메뉴 구현 방법" 섹션을 따르라 +END IF +``` + +### GATE 2: 사용자 메뉴인데 코드로 만들려고 하는가? + +아래 키워드가 요구사항에 포함되어 있으면 **사용자 메뉴**일 가능성이 높다: +- 포장, 적재, 금형, BOM, 입출고, 품질, 검사, 재고, 자재, 문서, 생산, 작업, 설비 +- "목록 + 상세" 구조, "좌측 테이블 + 우측 폼" 구조 +- 일반 업무 화면, CRUD 화면 + +**사용자 메뉴라면:** +- .tsx 페이지 파일 생성 → 금지 +- screen_definitions + screen_layouts_v2 + menu_info INSERT → 올바른 방법 +- 백엔드 API(controller/routes)는 필요하면 코드로 작성 가능 +- 프론트엔드 API 클라이언트(lib/api/)도 필요하면 코드로 작성 가능 +- 하지만 **프론트엔드 화면 UI 자체**는 절대 코드로 만들지 않는다! + +### GATE 3: 관리자 메뉴가 맞는가? + +관리자 메뉴는 다음 조건을 **전부** 만족해야 한다: +- 시스템 관리자만 사용하는 기능 (사용자 관리, 권한 관리, 시스템 설정 등) +- URL이 `/admin/*` 패턴 +- `frontend/app/(main)/admin/` 하위에만 page.tsx 생성 + +**이 3가지 게이트를 모두 통과한 후에만 작업을 시작하라.** + +--- + ## 1. 화면 유형 구분 (절대 규칙!) 이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다. @@ -7,7 +81,7 @@ ### 관리자 메뉴 (Admin) - **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일) -- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` +- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` **이 경로만 허용!** - **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!) - **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등 - **특징**: 하드코딩된 UI, 관리자만 접근 @@ -20,6 +94,7 @@ - **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등 - **특징**: 코드 수정 없이 화면 구성 변경 가능 - **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것! +- **위반 시**: 해당 작업 전체 REJECT + 파일 삭제 + 처음부터 DB 등록 방식으로 재작업 ### 판단 기준 @@ -166,7 +241,7 @@ VALUES ( - [ ] BE: 필요한 경우 전용 API 작성 (범용 table-management API로 커버 안 되는 경우만) - [ ] FE: .tsx 페이지 파일 만들지 않음 (이미 `/screens/[screenId]/page.tsx`가 렌더링) -## 6. 절대 하지 말 것 +## 6. 절대 하지 말 것 (위반 시 전체 작업 REJECT) 1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!) 2. fetch() 직접 사용 (lib/api/ 클라이언트 필수) @@ -174,9 +249,39 @@ VALUES ( 4. 하드코딩 색상/URL/사용자ID 사용 5. Card 안에 Card 중첩 (중첩 박스 금지) 6. 백엔드 재실행하기 (nodemon이 자동 재시작) -7. **사용자 메뉴를 React 하드코딩(.tsx)으로 만들기 (절대 금지!!!)** - - `frontend/app/(main)/production/`, `frontend/app/(main)/warehouse/` 등에 page.tsx 파일을 만들어서 직접 UI를 코딩하는 것은 절대 금지 +7. **[최우선 금지] 사용자 메뉴를 React 하드코딩(.tsx)으로 만들기** + - `frontend/app/(main)/` 하위에서 `/admin/` 이외의 경로에 page.tsx를 만드는 것은 절대 금지 + - 구체적 금지 경로: production/, warehouse/, quality/, logistics/, inventory/, purchase/, sales/, bom/, mold/, packaging/, document/, work/, order/, material/, equipment/, inspection/ 및 기타 모든 비-admin 경로 - 사용자 메뉴는 반드시 `screen_definitions` + `screen_layouts_v2` + `menu_info` DB 등록 방식으로 구현 - 이미 `/screen/[screenCode]` → `/screens/[screenId]` 렌더링 시스템이 존재함 - 백엔드 API(controller/routes)와 프론트엔드 API 클라이언트(lib/api/)는 필요하면 코드로 작성 가능 - 하지만 프론트엔드 화면 UI 자체는 DB의 V2 레이아웃 JSON으로만 구성 + - **위반 발견 시: 해당 라운드 전체 FAIL 처리, 생성된 파일 즉시 삭제, DB 등록 방식으로 처음부터 재작업** + +## 7. 위반 사례 및 올바른 대응 + +### 위반 사례 (실제 발생한 문제) +``` +# 이런 파일을 만들면 절대 안 된다! +frontend/app/(main)/production/packaging/page.tsx ← REJECT! +frontend/app/(main)/warehouse/inventory/page.tsx ← REJECT! +frontend/app/(main)/quality/inspection/page.tsx ← REJECT! +frontend/app/(main)/mold/management/page.tsx ← REJECT! +``` + +### 올바른 대응 +```sql +-- 1. screen_definitions에 등록 +INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, is_active) +VALUES ('포장관리', 'COMPANY_7_PKG', 'pkg_unit', 'COMPANY_7', 'Y'); + +-- 2. screen_layouts_v2에 V2 레이아웃 JSON 등록 +INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data) +VALUES ({screen_id}, 'COMPANY_7', 1, '기본 레이어', '{...V2 JSON...}'::jsonb); + +-- 3. menu_info에 메뉴 등록 +INSERT INTO menu_info (..., menu_url, screen_code, ...) +VALUES (..., '/screen/COMPANY_7_PKG', 'COMPANY_7_PKG', ...); +``` + +**React 페이지(.tsx) 파일은 단 한 줄도 만들지 않는다. DB INSERT만으로 화면이 완성된다.** diff --git a/.cursor/agents/pipeline-frontend.md b/.cursor/agents/pipeline-frontend.md index 223b5b38..7c8f5a31 100644 --- a/.cursor/agents/pipeline-frontend.md +++ b/.cursor/agents/pipeline-frontend.md @@ -8,6 +8,63 @@ model: inherit You are a Frontend specialist for ERP-node project. Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui. +--- + +# !!!! STOP - 파일 생성 전 필수 게이트 (반드시 읽고 확인하라) !!!! + +## 파일을 생성하거나 수정하기 전에 반드시 이 체크를 수행하라: + +### CHECK 1: page.tsx를 만들려고 하는가? + +``` +IF 파일 경로가 "frontend/app/(main)/" 하위이다 + AND 파일명이 page.tsx 또는 layout.tsx이다 + AND 경로에 "/admin/"이 포함되어 있지 않다 +THEN + !!!! 즉시 중단 !!!! 이것은 사용자 메뉴다! + → React 페이지를 만들면 안 된다 + → DB 등록 방식으로 전환하라 (screen_definitions + screen_layouts_v2 + menu_info) + → 이 파일의 "올바른 패턴" 섹션을 참조하라 +END IF +``` + +### 금지 경로 목록 (이 경로에 page.tsx 생성 시 즉시 REJECT): +``` +frontend/app/(main)/production/** ← 금지! +frontend/app/(main)/warehouse/** ← 금지! +frontend/app/(main)/quality/** ← 금지! +frontend/app/(main)/logistics/** ← 금지! +frontend/app/(main)/inventory/** ← 금지! +frontend/app/(main)/purchase/** ← 금지! +frontend/app/(main)/sales/** ← 금지! +frontend/app/(main)/bom/** ← 금지! +frontend/app/(main)/mold/** ← 금지! +frontend/app/(main)/packaging/** ← 금지! +frontend/app/(main)/document/** ← 금지! +frontend/app/(main)/work/** ← 금지! +frontend/app/(main)/order/** ← 금지! +frontend/app/(main)/material/** ← 금지! +frontend/app/(main)/equipment/** ← 금지! +frontend/app/(main)/inspection/** ← 금지! +(위 목록에 없더라도 /admin/ 이외의 모든 경로가 금지 대상이다!) +``` + +### 유일하게 허용되는 page.tsx 생성 경로: +``` +frontend/app/(main)/admin/** ← 유일하게 허용! +``` + +### CHECK 2: 사용자 메뉴 키워드 감지 + +요구사항에 아래 키워드가 포함되면 사용자 메뉴일 가능성이 높다: +> 포장, 적재, 금형, BOM, 입출고, 품질, 검사, 재고, 자재, 문서, 생산, 작업, 설비, 목록+상세, 좌측 테이블+우측 폼, CRUD 화면 + +사용자 메뉴라면 **page.tsx 생성을 절대 하지 말고** DB 등록으로 전환하라. + +**이 게이트를 통과하지 않은 파일 생성은 전부 REJECT 된다.** + +--- + # CRITICAL PROJECT RULES ## 1. API Client (ABSOLUTE RULE!) @@ -49,18 +106,23 @@ export async function getYourData(id: number) { } ``` -# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! +--- + +# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT) **이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.** 사용자 업무 화면(포장관리, 금형관리, BOM, 문서관리 등)은 절대 React 페이지(.tsx)로 직접 UI를 하드코딩하지 않는다! -## 금지 패턴 (절대 하지 말 것) +## 금지 패턴 (이 파일을 만드는 순간 작업 전체 REJECT) ``` -frontend/app/(main)/production/packaging/page.tsx ← 이런 파일 만들지 마라! -frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 마라! +frontend/app/(main)/production/packaging/page.tsx ← REJECT! 삭제 대상! +frontend/app/(main)/warehouse/something/page.tsx ← REJECT! 삭제 대상! +frontend/app/(main)/quality/inspection/page.tsx ← REJECT! 삭제 대상! +frontend/app/(main)/mold/management/page.tsx ← REJECT! 삭제 대상! +frontend/app/(main)/{admin이_아닌_모든_경로}/page.tsx ← 전부 REJECT! ``` -## 올바른 패턴 +## 올바른 패턴 (사용자 메뉴는 DB 등록만으로 완성된다) 사용자 화면은 DB에 등록만 하면 자동으로 렌더링된다: 1. `screen_definitions` 테이블에 화면 등록 (screen_code, table_name 등) 2. `screen_layouts_v2` 테이블에 V2 레이아웃 JSON 등록 (v2-split-panel-layout, v2-table-list 등) @@ -70,17 +132,20 @@ frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 - `/screen/[screenCode]/page.tsx` → screenCode를 screenId로 변환 - `/screens/[screenId]/page.tsx` → screen_layouts_v2에서 V2 레이아웃 로드 → DynamicComponentRenderer로 렌더링 +**React 페이지 파일은 단 한 줄도 만들지 않는다. DB INSERT만으로 화면이 완성된다.** + ## 프론트엔드 에이전트가 할 수 있는 것 - `frontend/lib/api/` 하위에 API 클라이언트 함수 작성 (백엔드와 통신) - V2 컴포넌트 자체를 수정/신규 개발 (`frontend/lib/registry/components/v2-*/`) -- 관리자 메뉴(`/admin/*`)는 React 페이지 코딩 가능 +- 관리자 메뉴(`/admin/*`)만 React 페이지 코딩 가능 -## 프론트엔드 에이전트가 할 수 없는 것 -- 사용자 메뉴 화면을 React 페이지로 직접 코딩하는 것 +## 프론트엔드 에이전트가 할 수 없는 것 (위반 시 REJECT) +- `/admin/` 이외 경로에 page.tsx 생성 +- 사용자 메뉴 화면을 React 페이지로 직접 코딩 # Your Domain - frontend/components/ -- frontend/app/ +- frontend/app/ (admin/ 하위만 page.tsx 생성 가능!) - frontend/lib/ - frontend/hooks/ diff --git a/.cursor/agents/pipeline-ui.md b/.cursor/agents/pipeline-ui.md index 05d3359e..3717b455 100644 --- a/.cursor/agents/pipeline-ui.md +++ b/.cursor/agents/pipeline-ui.md @@ -8,6 +8,43 @@ model: inherit You are a UI/UX Design specialist for the ERP-node project. Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons. +--- + +# !!!! STOP - 파일 생성/수정 전 필수 게이트 !!!! + +## 파일을 만들거나 수정하기 전에 반드시 확인하라: + +**page.tsx를 생성하려는 경로가 `frontend/app/(main)/admin/` 하위인가?** +- YES → 진행 가능 +- NO → **즉시 중단!** 사용자 메뉴는 React 페이지로 만들지 않는다! + +**금지 경로 (이 경로에 page.tsx 생성 시 즉시 REJECT):** +``` +frontend/app/(main)/production/** ← 금지! +frontend/app/(main)/warehouse/** ← 금지! +frontend/app/(main)/quality/** ← 금지! +frontend/app/(main)/logistics/** ← 금지! +frontend/app/(main)/inventory/** ← 금지! +frontend/app/(main)/purchase/** ← 금지! +frontend/app/(main)/sales/** ← 금지! +frontend/app/(main)/bom/** ← 금지! +frontend/app/(main)/mold/** ← 금지! +frontend/app/(main)/packaging/** ← 금지! +frontend/app/(main)/document/** ← 금지! +frontend/app/(main)/work/** ← 금지! +frontend/app/(main)/order/** ← 금지! +frontend/app/(main)/material/** ← 금지! +frontend/app/(main)/equipment/** ← 금지! +frontend/app/(main)/inspection/** ← 금지! +(위 목록에 없더라도 /admin/ 이외의 모든 경로가 금지!) +``` + +**사용자 메뉴 화면은 DB 등록(screen_definitions + screen_layouts_v2 + menu_info)으로만 구현한다.** + +**이 게이트를 무시하면 작업 전체 REJECT + 파일 삭제 + 재작업 대상이다.** + +--- + # Design Philosophy - Apple-level polish with enterprise functionality - Consistent spacing, typography, color usage @@ -39,22 +76,23 @@ FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black - Use cn() for conditional classes - Use lucide-react for ALL icons -# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! +# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT) 사용자 업무 화면(포장관리, 금형관리, BOM 등)의 UI는 DB의 `screen_layouts_v2` 테이블에 V2 레이아웃 JSON으로 정의된다. React 페이지(.tsx)로 직접 UI를 하드코딩하는 것은 절대 금지! -UI 에이전트가 할 수 있는 것: +## UI 에이전트가 할 수 있는 것 - V2 컴포넌트 자체의 스타일/UX 개선 (`frontend/lib/registry/components/v2-*/`) - 관리자 메뉴(`/admin/*`) 페이지의 UI 개선 - 공통 UI 컴포넌트(`frontend/components/ui/`) 스타일 개선 -UI 에이전트가 할 수 없는 것: +## UI 에이전트가 할 수 없는 것 (위반 시 REJECT) +- `/admin/` 이외 경로에 page.tsx 생성 또는 수정 - 사용자 메뉴 화면을 React 페이지로 직접 코딩 # Your Domain - frontend/components/ (UI components) -- frontend/app/ (pages - 관리자 메뉴만) +- frontend/app/ (pages - **admin/ 하위만 page.tsx 생성/수정 가능!**) - frontend/lib/registry/components/v2-*/ (V2 컴포넌트) # Output Rules diff --git a/.cursor/agents/pipeline-verifier.md b/.cursor/agents/pipeline-verifier.md index a4f4186d..4030eb93 100644 --- a/.cursor/agents/pipeline-verifier.md +++ b/.cursor/agents/pipeline-verifier.md @@ -1,6 +1,6 @@ --- name: pipeline-verifier -description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증. +description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증, 하드코딩 페이지 탐지. model: fast readonly: true --- @@ -11,6 +11,29 @@ Your job is to verify that work claimed as complete actually works. # Verification Checklist +## 0. 하드코딩 페이지 탐지 (최최우선! 이것부터 먼저 확인!) + +**이 프로젝트에서 가장 심각한 위반은 사용자 메뉴를 React 페이지(.tsx)로 하드코딩하는 것이다.** +검증 시 반드시 아래를 제일 먼저 확인하라: + +- [ ] `frontend/app/(main)/` 하위에 `/admin/` 이외의 경로에 새로운 page.tsx가 생성되지 않았는가? +- [ ] 구체적 금지 경로 확인: production/, warehouse/, quality/, logistics/, inventory/, purchase/, sales/, bom/, mold/, packaging/, document/, work/, order/, material/, equipment/, inspection/ +- [ ] 위 경로뿐 아니라 `/admin/` 이외의 **모든** 경로에 page.tsx가 새로 생성되었는지 확인 +- [ ] 사용자 메뉴 화면이 DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로 구현되었는가? + +**검증 방법:** +```bash +# 이 라운드에서 새로 생성된 파일 중 금지 경로의 page.tsx가 있는지 확인 +git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep -v "/admin/" +# 결과가 있으면 → 즉시 FAIL! +``` + +**위반 발견 시:** +- 검증 결과: **CRITICAL FAIL** +- 해당 파일 삭제 필수 +- DB 등록 방식으로 재작업 지시 +- 이 위반이 있으면 다른 항목 전부 PASS여도 최종 결과는 FAIL + ## 1. Multi-tenancy (최우선) - [ ] 모든 SQL에 company_code 필터 존재 - [ ] req.user!.companyCode 사용 (클라이언트 입력 아님) @@ -28,6 +51,7 @@ Your job is to verify that work claimed as complete actually works. - [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용) - [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음) - [ ] Frontend: V2 컴포넌트 규격 준수 +- [ ] Frontend: `/admin/` 이외 경로에 page.tsx 생성 안 함 (0번 항목 재확인!) - [ ] Backend: logger 사용 - [ ] Backend: try/catch 에러 처리 @@ -39,7 +63,10 @@ Your job is to verify that work claimed as complete actually works. # Reporting Format ``` -## 검증 결과: [PASS/FAIL] +## 검증 결과: [PASS/FAIL/CRITICAL FAIL] + +### [CRITICAL] 하드코딩 페이지 탐지 +- 금지 경로에 생성된 page.tsx: (있으면 파일 경로 나열, 없으면 "없음 (PASS)") ### 통과 항목 - item 1 @@ -55,3 +82,4 @@ Your job is to verify that work claimed as complete actually works. ``` Do not accept claims at face value. Check the actual code. +하드코딩 페이지 탐지는 다른 모든 검증보다 우선한다. 이것이 FAIL이면 전체 FAIL이다. diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 4c5bdc57..64b1dff0 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2346,19 +2346,24 @@ export class ScreenManagementService { } /** - * 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료) + * 메뉴별 화면 목록 조회 + * company_code 매칭: 본인 회사 할당 + SUPER_ADMIN 글로벌 할당('*') 모두 조회 + * 본인 회사 할당이 우선, 없으면 글로벌 할당 사용 */ async getScreensByMenu( menuObjid: number, companyCode: string, ): Promise { const screens = await query( - `SELECT sd.* FROM screen_menu_assignments sma + `SELECT sd.*, sma.company_code AS assign_company_code + FROM screen_menu_assignments sma INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id WHERE sma.menu_objid = $1 - AND sma.company_code = $2 + AND (sma.company_code = $2 OR sma.company_code = '*') AND sma.is_active = 'Y' - ORDER BY sma.display_order ASC`, + ORDER BY + CASE WHEN sma.company_code = $2 THEN 0 ELSE 1 END, + sma.display_order ASC`, [menuObjid, companyCode], ); diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 20175b5e..62280f9d 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -1,8 +1,10 @@ "use client"; -import React, { useMemo } from "react"; +import React, { useMemo, useState, useEffect } from "react"; import dynamic from "next/dynamic"; import { Loader2 } from "lucide-react"; +import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page"; +import { apiClient } from "@/lib/api/client"; const LoadingFallback = () => (
@@ -10,70 +12,320 @@ const LoadingFallback = () => (
); +const d = (loader: () => Promise) => + dynamic(loader, { ssr: false, loading: LoadingFallback }); + +/** + * /dashboard/[dashboardId] URL을 탭 내에서 직접 렌더링 + * Next.js params Promise 없이 dashboardId를 직접 전달 + */ +const LazyDashboardViewer = d(() => import("@/components/dashboard/DashboardViewer").then((mod) => ({ + default: mod.DashboardViewer, +}))); + +function DashboardTabRenderer({ dashboardId }: { dashboardId: string }) { + const [dashboard, setDashboard] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const load = async () => { + setIsLoading(true); + try { + const { dashboardApi } = await import("@/lib/api/dashboard"); + const data = await dashboardApi.getDashboard(dashboardId); + setDashboard({ ...data, elements: data.elements || [] }); + } catch { + const saved = JSON.parse(localStorage.getItem("savedDashboards") || "[]"); + const found = saved.find((d: any) => d.id === dashboardId); + if (found) { + setDashboard(found); + } else { + setError("대시보드를 찾을 수 없습니다"); + } + } finally { + setIsLoading(false); + } + }; + load(); + }, [dashboardId]); + + if (isLoading) return ; + + if (error || !dashboard) { + return ( +
+
+

{error || "대시보드를 찾을 수 없습니다"}

+

대시보드 ID: {dashboardId}

+
+
+ ); + } + + return ( +
+ +
+ ); +} + +/** + * /screen/[screenCode] URL을 screenId로 변환해서 ScreenViewPageWrapper를 렌더링 + */ +function ScreenCodeResolver({ screenCode }: { screenCode: string }) { + const [screenId, setScreenId] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + const numericId = parseInt(screenCode); + if (!isNaN(numericId)) { + setScreenId(numericId); + return; + } + + const resolve = async () => { + try { + const res = await apiClient.get("/screen-management/screens", { + params: { searchTerm: screenCode, size: 50 }, + }); + const items = res.data?.data?.data || res.data?.data || []; + const arr = Array.isArray(items) ? items : []; + const exact = arr.find((s: any) => s.screenCode === screenCode || s.screen_code === screenCode); + const target = exact || arr[0]; + if (target) { + setScreenId(target.screenId || target.screen_id); + } else { + setError(true); + } + } catch { + setError(true); + } + }; + resolve(); + }, [screenCode]); + + if (error) { + return ( +
+
+

화면을 찾을 수 없습니다

+

+ 화면 코드: {screenCode} +

+
+
+ ); + } + + if (screenId === null) { + return ; + } + + return ; +} + /** * 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리. - * 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다. - * 매핑되지 않은 URL은 catch-all fallback으로 처리된다. + * 레지스트리에 없는 URL은 자동으로 파일 경로 기반 동적 임포트를 시도한다. */ const ADMIN_PAGE_REGISTRY: Record> = { // 관리자 메인 - "/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }), + "/admin": d(() => import("@/app/(main)/admin/page")), // 메뉴 관리 - "/admin/menu": dynamic(() => import("@/app/(main)/admin/menu/page"), { ssr: false, loading: LoadingFallback }), + "/admin/menu": d(() => import("@/app/(main)/admin/menu/page")), // 사용자 관리 - "/admin/userMng/userMngList": dynamic(() => import("@/app/(main)/admin/userMng/userMngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/userMng/userMngList": d(() => import("@/app/(main)/admin/userMng/userMngList/page")), + "/admin/userMng/rolesList": d(() => import("@/app/(main)/admin/userMng/rolesList/page")), + "/admin/userMng/userAuthList": d(() => import("@/app/(main)/admin/userMng/userAuthList/page")), + "/admin/userMng/companyList": d(() => import("@/app/(main)/admin/userMng/companyList/page")), // 화면 관리 - "/admin/screenMng/screenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/screenMngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/screenMng/popScreenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/screenMng/dashboardList": dynamic(() => import("@/app/(main)/admin/screenMng/dashboardList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/screenMng/reportList": dynamic(() => import("@/app/(main)/admin/screenMng/reportList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/screenMng/screenMngList": d(() => import("@/app/(main)/admin/screenMng/screenMngList/page")), + "/admin/screenMng/popScreenMngList": d(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page")), + "/admin/screenMng/dashboardList": d(() => import("@/app/(main)/admin/screenMng/dashboardList/page")), + "/admin/screenMng/reportList": d(() => import("@/app/(main)/admin/screenMng/reportList/page")), + "/admin/screenMng/barcodeList": d(() => import("@/app/(main)/admin/screenMng/barcodeList/page")), // 시스템 관리 - "/admin/systemMng/commonCodeList": dynamic(() => import("@/app/(main)/admin/systemMng/commonCodeList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/commonCodeList": d(() => import("@/app/(main)/admin/systemMng/commonCodeList/page")), + "/admin/systemMng/tableMngList": d(() => import("@/app/(main)/admin/systemMng/tableMngList/page")), + "/admin/systemMng/i18nList": d(() => import("@/app/(main)/admin/systemMng/i18nList/page")), + "/admin/systemMng/collection-managementList": d(() => import("@/app/(main)/admin/systemMng/collection-managementList/page")), + "/admin/systemMng/dataflow": d(() => import("@/app/(main)/admin/systemMng/dataflow/page")), + "/admin/systemMng/dataflow/node-editorList": d(() => import("@/app/(main)/admin/systemMng/dataflow/node-editorList/page")), // 자동화 관리 - "/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/flowMgmtList": d(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page")), + "/admin/automaticMng/batchmngList": d(() => import("@/app/(main)/admin/automaticMng/batchmngList/page")), + "/admin/automaticMng/exconList": d(() => import("@/app/(main)/admin/automaticMng/exconList/page")), + "/admin/automaticMng/exCallConfList": d(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page")), // 메일 - "/admin/automaticMng/mail/send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/send/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/receive": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/receive/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/sent": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/sent/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/drafts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/trash": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/trash/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/accounts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }), - "/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/send": d(() => import("@/app/(main)/admin/automaticMng/mail/send/page")), + "/admin/automaticMng/mail/receive": d(() => import("@/app/(main)/admin/automaticMng/mail/receive/page")), + "/admin/automaticMng/mail/sent": d(() => import("@/app/(main)/admin/automaticMng/mail/sent/page")), + "/admin/automaticMng/mail/drafts": d(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page")), + "/admin/automaticMng/mail/trash": d(() => import("@/app/(main)/admin/automaticMng/mail/trash/page")), + "/admin/automaticMng/mail/accounts": d(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page")), + "/admin/automaticMng/mail/templates": d(() => import("@/app/(main)/admin/automaticMng/mail/templates/page")), + "/admin/automaticMng/mail/dashboardList": d(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page")), + "/admin/automaticMng/mail/bulk-send": d(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page")), // 배치 관리 - "/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }), - "/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }), + "/admin/batch-management": d(() => import("@/app/(main)/admin/batch-management/page")), + "/admin/batch-management-new": d(() => import("@/app/(main)/admin/batch-management-new/page")), - // 기타 - "/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), - "/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }), - "/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }), - "/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }), - "/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }), - "/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }), - "/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }), - "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }), + // 결재 관리 + "/admin/approvalTemplate": d(() => import("@/app/(main)/admin/approvalTemplate/page")), + "/admin/approvalMng": d(() => import("@/app/(main)/admin/approvalMng/page")), + "/admin/approvalBox": d(() => import("@/app/(main)/admin/approvalBox/page")), + + // AI 어시스턴트 + "/admin/aiAssistant": d(() => import("@/app/(main)/admin/aiAssistant/page")), + "/admin/aiAssistant/usage": d(() => import("@/app/(main)/admin/aiAssistant/usage/page")), + "/admin/aiAssistant/history": d(() => import("@/app/(main)/admin/aiAssistant/history/page")), + "/admin/aiAssistant/api-keys": d(() => import("@/app/(main)/admin/aiAssistant/api-keys/page")), + "/admin/aiAssistant/dashboard": d(() => import("@/app/(main)/admin/aiAssistant/dashboard/page")), + "/admin/aiAssistant/chat": d(() => import("@/app/(main)/admin/aiAssistant/chat/page")), + "/admin/aiAssistant/api-test": d(() => import("@/app/(main)/admin/aiAssistant/api-test/page")), + + // 기타 관리 + "/admin/cascading-management": d(() => import("@/app/(main)/admin/cascading-management/page")), + "/admin/cascading-relations": d(() => import("@/app/(main)/admin/cascading-relations/page")), + "/admin/layouts": d(() => import("@/app/(main)/admin/layouts/page")), + "/admin/templates": d(() => import("@/app/(main)/admin/templates/page")), + "/admin/monitoring": d(() => import("@/app/(main)/admin/monitoring/page")), + "/admin/standards": d(() => import("@/app/(main)/admin/standards/page")), + "/admin/flow-external-db": d(() => import("@/app/(main)/admin/flow-external-db/page")), + "/admin/auto-fill": d(() => import("@/app/(main)/admin/auto-fill/page")), + "/admin/system-notices": d(() => import("@/app/(main)/admin/system-notices/page")), + "/admin/audit-log": d(() => import("@/app/(main)/admin/audit-log/page")), + + // 개발/테스트 + "/admin/debug": d(() => import("@/app/(main)/admin/debug/page")), + "/admin/debug-simple": d(() => import("@/app/(main)/admin/debug-simple/page")), + "/admin/debug-layout": d(() => import("@/app/(main)/admin/debug-layout/page")), + "/admin/test": d(() => import("@/app/(main)/admin/test/page")), + "/admin/ui-components-demo": d(() => import("@/app/(main)/admin/ui-components-demo/page")), + "/admin/validation-demo": d(() => import("@/app/(main)/admin/validation-demo/page")), + "/admin/token-test": d(() => import("@/app/(main)/admin/token-test/page")), + + // === 사용자 화면 (admin이 아닌 URL 기반 메뉴) === + "/approval": d(() => import("@/app/(main)/approval/page")), + "/dashboard": d(() => import("@/app/(main)/dashboard/page")), + "/multilang": d(() => import("@/app/(main)/multilang/page")), + "/test-flow": d(() => import("@/app/(main)/test-flow/page")), + "/main": d(() => import("@/app/(main)/main/page")), }; -// 매핑되지 않은 URL용 Fallback +/** + * 동적 라우트 패턴 매칭 (URL 경로에 동적 세그먼트가 포함된 경우) + * /admin/screenMng/dashboardList/123 → dashboardList/[id] 페이지에 매핑 + * + * extractParams: URL에서 동적 파라미터를 추출 (use(params)를 쓰는 페이지용) + * 추출된 값은 params={Promise.resolve(...)}로 전달되어 + * Next.js 라우팅 컨텍스트 없이도 use(params)가 정상 동작함 + */ +interface DynamicRouteEntry { + pattern: RegExp; + loader: () => Promise; + extractParams?: (url: string) => Record; +} + +const DYNAMIC_ROUTE_PATTERNS: DynamicRouteEntry[] = [ + { + pattern: /^\/admin\/userMng\/rolesList\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/userMng/rolesList/[id]/page"), + extractParams: (url) => ({ id: url.split("/").pop()! }), + }, + { + pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/, + loader: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"), + extractParams: (url) => ({ companyCode: url.split("/")[4] }), + }, + { + pattern: /^\/admin\/automaticMng\/batchmngList\/create$/, + loader: () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"), + }, + { + pattern: /^\/admin\/automaticMng\/batchmngList\/edit\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page"), + extractParams: (url) => ({ id: url.split("/").pop()! }), + }, + { + pattern: /^\/admin\/automaticMng\/flowMgmtList\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/automaticMng/flowMgmtList/[id]/page"), + extractParams: (url) => ({ id: url.split("/").pop()! }), + }, + { + pattern: /^\/admin\/standards\/new$/, + loader: () => import("@/app/(main)/admin/standards/new/page"), + }, + { + pattern: /^\/admin\/standards\/([^/]+)\/edit$/, + loader: () => import("@/app/(main)/admin/standards/[webType]/edit/page"), + extractParams: (url) => ({ webType: url.split("/")[3] }), + }, + { + pattern: /^\/admin\/standards\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/standards/[webType]/page"), + extractParams: (url) => ({ webType: url.split("/").pop()! }), + }, + { + pattern: /^\/admin\/systemMng\/dataflow\/edit\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"), + extractParams: (url) => ({ diagramId: url.split("/").pop()! }), + }, + { + pattern: /^\/admin\/screenMng\/barcodeList\/designer\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page"), + extractParams: (url) => ({ labelId: url.split("/").pop()! }), + }, + { + pattern: /^\/admin\/screenMng\/reportList\/designer\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/screenMng/reportList/designer/[reportId]/page"), + extractParams: (url) => ({ reportId: url.split("/").pop()! }), + }, + { + pattern: /^\/admin\/screenMng\/dashboardList\/([^/]+)$/, + loader: () => import("@/app/(main)/admin/screenMng/dashboardList/[id]/page"), + extractParams: (url) => ({ id: url.split("/").pop()! }), + }, +]; + +interface DynamicRouteResult { + component: React.ComponentType; + params?: Record; +} + +const dynamicRouteCache = new Map(); + +function resolveDynamicRoute(cleanUrl: string): DynamicRouteResult | null { + if (dynamicRouteCache.has(cleanUrl)) { + return dynamicRouteCache.get(cleanUrl)!; + } + + for (const entry of DYNAMIC_ROUTE_PATTERNS) { + if (entry.pattern.test(cleanUrl)) { + const comp = d(entry.loader); + const params = entry.extractParams?.(cleanUrl); + const result: DynamicRouteResult = { component: comp, params }; + dynamicRouteCache.set(cleanUrl, result); + return result; + } + } + return null; +} + function AdminPageFallback({ url }: { url: string }) { return (
@@ -95,15 +347,55 @@ interface AdminPageRendererProps { } export function AdminPageRenderer({ url }: AdminPageRendererProps) { - const PageComponent = useMemo(() => { - // URL에서 쿼리스트링/해시 제거 후 매칭 - const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); - return ADMIN_PAGE_REGISTRY[cleanUrl] || null; - }, [url]); + const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); - if (!PageComponent) { + // 0) /screens/[id] → ScreenViewPageWrapper 직접 렌더링 + // 탭 시스템에서는 useParams()가 동작하지 않으므로 screenId를 직접 추출해서 전달 + const screenIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/); + if (screenIdMatch) { + const screenId = parseInt(screenIdMatch[1]); + return ; + } + + // 0-1) /screen/[code] → screenCode를 screenId로 변환 후 렌더링 + const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/); + if (screenCodeMatch) { + return ; + } + + // 0-2) /dashboard/[id] → DashboardTabRenderer 직접 렌더링 + // Next.js의 params Promise를 우회하여 dashboardId를 직접 전달 + const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/); + if (dashboardMatch) { + return ; + } + + const resolved = useMemo(() => { + // 1) 정적 레지스트리 매칭 + if (ADMIN_PAGE_REGISTRY[cleanUrl]) { + return { component: ADMIN_PAGE_REGISTRY[cleanUrl] } as DynamicRouteResult; + } + + // 2) 동적 라우트 패턴 매칭 (/admin/xxx/[id] 등) + const dynamicMatch = resolveDynamicRoute(cleanUrl); + if (dynamicMatch) { + return dynamicMatch; + } + + return null; + }, [cleanUrl]); + + if (!resolved) { return ; } + const { component: PageComponent, params } = resolved; + + // 동적 라우트에서 추출한 파라미터가 있으면 params={Promise.resolve(...)}로 전달 + // Next.js page 컴포넌트의 use(params)가 동기적으로 resolve됨 + if (params) { + return ; + } + return ; } diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index ad9a6aaf..2fe934a4 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -362,8 +362,10 @@ function AppLayoutInner({ children }: AppLayoutProps) { if (isMobile) setSidebarOpen(false); return; } - } catch { - console.warn("할당된 화면 조회 실패"); + } catch (err) { + console.error("할당된 화면 조회 실패:", err); + toast.error("화면 정보를 불러오지 못했습니다. 다시 시도해주세요."); + return; } if (menu.url && menu.url !== "#") { From 1b2d42ffc504982650d290a08b491086216c7d29 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Mar 2026 22:14:40 +0900 Subject: [PATCH 07/14] [agent-pipeline] pipe-20260311130636-hzyn round-2 --- .../src/controllers/packagingController.ts | 995 ++++++++++++++++++ backend-node/src/routes/packagingRoutes.ts | 42 +- 2 files changed, 1036 insertions(+), 1 deletion(-) create mode 100644 backend-node/src/controllers/packagingController.ts diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts new file mode 100644 index 00000000..681d9ebc --- /dev/null +++ b/backend-node/src/controllers/packagingController.ts @@ -0,0 +1,995 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { query, transaction } from "../database/db"; +import { logger } from "../utils/logger"; + +// ============================================================ +// 포장단위(pkg_unit) CRUD +// ============================================================ + +/** + * 포장단위 목록 조회 + * GET /api/packaging/pkg-units + */ +export const getPkgUnits = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { search, pkg_type, status } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 필터 + if (companyCode === "*") { + // 최고 관리자: 전체 조회 + } else { + conditions.push(`pu.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + if (search && typeof search === "string" && search.trim()) { + conditions.push( + `(pu.pkg_code ILIKE $${paramIndex} OR pu.pkg_name ILIKE $${paramIndex})` + ); + params.push(`%${search.trim()}%`); + paramIndex++; + } + + if (pkg_type && typeof pkg_type === "string" && pkg_type.trim()) { + conditions.push(`pu.pkg_type = $${paramIndex}`); + params.push(pkg_type.trim()); + paramIndex++; + } + + if (status && typeof status === "string" && status.trim()) { + conditions.push(`pu.status = $${paramIndex}`); + params.push(status.trim()); + paramIndex++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const sql = ` + SELECT + pu.*, + (SELECT COUNT(*) FROM pkg_unit_item pui + WHERE pui.pkg_code = pu.pkg_code AND pui.company_code = pu.company_code + ) AS item_count + FROM pkg_unit pu + ${whereClause} + ORDER BY pu.created_date DESC + `; + + const rows = await query(sql, params); + + logger.info("포장단위 목록 조회 성공", { + companyCode, + count: rows.length, + }); + + res.json({ success: true, data: rows }); + } catch (error: any) { + logger.error("포장단위 목록 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 포장단위 상세 조회 + * GET /api/packaging/pkg-units/:id + */ +export const getPkgUnitById = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `SELECT * FROM pkg_unit WHERE id = $1`; + params = [id]; + } else { + sql = `SELECT * FROM pkg_unit WHERE id = $1 AND company_code = $2`; + params = [id, companyCode]; + } + + const rows = await query(sql, params); + + if (rows.length === 0) { + res.status(404).json({ + success: false, + message: "포장단위를 찾을 수 없습니다.", + }); + return; + } + + res.json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("포장단위 상세 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 포장단위 등록 + * POST /api/packaging/pkg-units + */ +export const createPkgUnit = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + pkg_code, + pkg_name, + pkg_type, + status, + width_mm, + length_mm, + height_mm, + self_weight_kg, + max_load_kg, + volume_l, + remarks, + } = req.body; + + if (!pkg_code || !pkg_name) { + res.status(400).json({ + success: false, + message: "포장코드(pkg_code)와 포장명(pkg_name)은 필수입니다.", + }); + return; + } + + // 중복 체크 + const dupCheck = await query( + `SELECT id FROM pkg_unit WHERE pkg_code = $1 AND company_code = $2`, + [pkg_code, companyCode] + ); + if (dupCheck.length > 0) { + res.status(409).json({ + success: false, + message: `포장코드 '${pkg_code}'가 이미 존재합니다.`, + }); + return; + } + + const sql = ` + INSERT INTO pkg_unit + (company_code, pkg_code, pkg_name, pkg_type, status, + width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, writer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING * + `; + + const rows = await query(sql, [ + companyCode, + pkg_code, + pkg_name, + pkg_type || null, + status || "ACTIVE", + width_mm || null, + length_mm || null, + height_mm || null, + self_weight_kg || null, + max_load_kg || null, + volume_l || null, + remarks || null, + userId, + ]); + + logger.info("포장단위 등록 성공", { companyCode, pkg_code }); + res.status(201).json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("포장단위 등록 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 포장단위 수정 + * PUT /api/packaging/pkg-units/:id + */ +export const updatePkgUnit = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + const { + pkg_name, + pkg_type, + status, + width_mm, + length_mm, + height_mm, + self_weight_kg, + max_load_kg, + volume_l, + remarks, + } = req.body; + + const setClauses: string[] = ["updated_date = NOW()", "writer = $1"]; + const params: any[] = [userId]; + let paramIndex = 2; + + const fieldMap: Record = { + pkg_name, + pkg_type, + status, + width_mm, + length_mm, + height_mm, + self_weight_kg, + max_load_kg, + volume_l, + remarks, + }; + + for (const [col, val] of Object.entries(fieldMap)) { + if (val !== undefined) { + setClauses.push(`${col} = $${paramIndex}`); + params.push(val); + paramIndex++; + } + } + + // WHERE: id + company_code + params.push(id); + const idIdx = paramIndex; + paramIndex++; + + let whereClause: string; + if (companyCode === "*") { + whereClause = `WHERE id = $${idIdx}`; + } else { + params.push(companyCode); + whereClause = `WHERE id = $${idIdx} AND company_code = $${paramIndex}`; + } + + const sql = ` + UPDATE pkg_unit + SET ${setClauses.join(", ")} + ${whereClause} + RETURNING * + `; + + const rows = await query(sql, params); + + if (rows.length === 0) { + res.status(404).json({ + success: false, + message: "포장단위를 찾을 수 없거나 권한이 없습니다.", + }); + return; + } + + logger.info("포장단위 수정 성공", { companyCode, id }); + res.json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("포장단위 수정 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 포장단위 삭제 + * DELETE /api/packaging/pkg-units/:id + */ +export const deletePkgUnit = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + // 트랜잭션으로 관련 데이터 함께 삭제 + const result = await transaction(async (client) => { + // 삭제 대상의 pkg_code 조회 (관계 데이터 삭제용) + let findSql: string; + let findParams: any[]; + if (companyCode === "*") { + findSql = `SELECT pkg_code, company_code FROM pkg_unit WHERE id = $1`; + findParams = [id]; + } else { + findSql = `SELECT pkg_code, company_code FROM pkg_unit WHERE id = $1 AND company_code = $2`; + findParams = [id, companyCode]; + } + + const found = await client.query(findSql, findParams); + if (found.rowCount === 0) return null; + + const { pkg_code, company_code: targetCompany } = found.rows[0]; + + // 매칭품목 먼저 삭제 + await client.query( + `DELETE FROM pkg_unit_item WHERE pkg_code = $1 AND company_code = $2`, + [pkg_code, targetCompany] + ); + + // 적재함 포장구성에서 참조 삭제 + await client.query( + `DELETE FROM loading_unit_pkg WHERE pkg_code = $1 AND company_code = $2`, + [pkg_code, targetCompany] + ); + + // 포장단위 삭제 + const del = await client.query( + `DELETE FROM pkg_unit WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, targetCompany] + ); + + return del.rows[0]; + }); + + if (!result) { + res.status(404).json({ + success: false, + message: "포장단위를 찾을 수 없거나 권한이 없습니다.", + }); + return; + } + + logger.info("포장단위 삭제 성공", { companyCode, id }); + res.json({ success: true, message: "삭제 완료" }); + } catch (error: any) { + logger.error("포장단위 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ============================================================ +// 포장단위 매칭품목(pkg_unit_item) N:M +// ============================================================ + +/** + * 매칭품목 목록 조회 (포장단위 기준) + * GET /api/packaging/pkg-unit-items?pkg_code=XXX + */ +export const getPkgUnitItems = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { pkg_code } = req.query; + + if (!pkg_code) { + res.status(400).json({ + success: false, + message: "pkg_code 파라미터가 필요합니다.", + }); + return; + } + + const conditions: string[] = [`pui.pkg_code = $1`]; + const params: any[] = [pkg_code]; + let paramIndex = 2; + + if (companyCode !== "*") { + conditions.push(`pui.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + const sql = ` + SELECT pui.* + FROM pkg_unit_item pui + WHERE ${conditions.join(" AND ")} + ORDER BY pui.created_date DESC + `; + + const rows = await query(sql, params); + + logger.info("매칭품목 조회 성공", { companyCode, pkg_code, count: rows.length }); + res.json({ success: true, data: rows }); + } catch (error: any) { + logger.error("매칭품목 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 매칭품목 추가 + * POST /api/packaging/pkg-unit-items + */ +export const createPkgUnitItem = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { pkg_code, item_number, pkg_qty } = req.body; + + if (!pkg_code || !item_number) { + res.status(400).json({ + success: false, + message: "pkg_code와 item_number는 필수입니다.", + }); + return; + } + + // 중복 체크 + const dup = await query( + `SELECT id FROM pkg_unit_item WHERE pkg_code = $1 AND item_number = $2 AND company_code = $3`, + [pkg_code, item_number, companyCode] + ); + if (dup.length > 0) { + res.status(409).json({ + success: false, + message: `이미 매칭된 품목입니다: ${item_number}`, + }); + return; + } + + const sql = ` + INSERT INTO pkg_unit_item (company_code, pkg_code, item_number, pkg_qty, writer) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `; + + const rows = await query(sql, [ + companyCode, + pkg_code, + item_number, + pkg_qty || null, + userId, + ]); + + logger.info("매칭품목 추가 성공", { companyCode, pkg_code, item_number }); + res.status(201).json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("매칭품목 추가 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 매칭품목 삭제 + * DELETE /api/packaging/pkg-unit-items/:id + */ +export const deletePkgUnitItem = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `DELETE FROM pkg_unit_item WHERE id = $1 RETURNING id`; + params = [id]; + } else { + sql = `DELETE FROM pkg_unit_item WHERE id = $1 AND company_code = $2 RETURNING id`; + params = [id, companyCode]; + } + + const rows = await query(sql, params); + + if (rows.length === 0) { + res.status(404).json({ + success: false, + message: "매칭품목을 찾을 수 없거나 권한이 없습니다.", + }); + return; + } + + logger.info("매칭품목 삭제 성공", { companyCode, id }); + res.json({ success: true, message: "삭제 완료" }); + } catch (error: any) { + logger.error("매칭품목 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ============================================================ +// 적재함(loading_unit) CRUD +// ============================================================ + +/** + * 적재함 목록 조회 + * GET /api/packaging/loading-units + */ +export const getLoadingUnits = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { search, loading_type, status } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode === "*") { + // 최고 관리자: 전체 조회 + } else { + conditions.push(`lu.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + if (search && typeof search === "string" && search.trim()) { + conditions.push( + `(lu.loading_code ILIKE $${paramIndex} OR lu.loading_name ILIKE $${paramIndex})` + ); + params.push(`%${search.trim()}%`); + paramIndex++; + } + + if (loading_type && typeof loading_type === "string" && loading_type.trim()) { + conditions.push(`lu.loading_type = $${paramIndex}`); + params.push(loading_type.trim()); + paramIndex++; + } + + if (status && typeof status === "string" && status.trim()) { + conditions.push(`lu.status = $${paramIndex}`); + params.push(status.trim()); + paramIndex++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const sql = ` + SELECT + lu.*, + (SELECT COUNT(*) FROM loading_unit_pkg lup + WHERE lup.loading_code = lu.loading_code AND lup.company_code = lu.company_code + ) AS pkg_count + FROM loading_unit lu + ${whereClause} + ORDER BY lu.created_date DESC + `; + + const rows = await query(sql, params); + + logger.info("적재함 목록 조회 성공", { companyCode, count: rows.length }); + res.json({ success: true, data: rows }); + } catch (error: any) { + logger.error("적재함 목록 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 적재함 상세 조회 + * GET /api/packaging/loading-units/:id + */ +export const getLoadingUnitById = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `SELECT * FROM loading_unit WHERE id = $1`; + params = [id]; + } else { + sql = `SELECT * FROM loading_unit WHERE id = $1 AND company_code = $2`; + params = [id, companyCode]; + } + + const rows = await query(sql, params); + + if (rows.length === 0) { + res.status(404).json({ + success: false, + message: "적재함을 찾을 수 없습니다.", + }); + return; + } + + res.json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("적재함 상세 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 적재함 등록 + * POST /api/packaging/loading-units + */ +export const createLoadingUnit = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + loading_code, + loading_name, + loading_type, + status, + width_mm, + length_mm, + height_mm, + self_weight_kg, + max_load_kg, + max_stack, + remarks, + } = req.body; + + if (!loading_code || !loading_name) { + res.status(400).json({ + success: false, + message: "적재코드(loading_code)와 적재명(loading_name)은 필수입니다.", + }); + return; + } + + // 중복 체크 + const dupCheck = await query( + `SELECT id FROM loading_unit WHERE loading_code = $1 AND company_code = $2`, + [loading_code, companyCode] + ); + if (dupCheck.length > 0) { + res.status(409).json({ + success: false, + message: `적재코드 '${loading_code}'가 이미 존재합니다.`, + }); + return; + } + + const sql = ` + INSERT INTO loading_unit + (company_code, loading_code, loading_name, loading_type, status, + width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, writer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING * + `; + + const rows = await query(sql, [ + companyCode, + loading_code, + loading_name, + loading_type || null, + status || "ACTIVE", + width_mm || null, + length_mm || null, + height_mm || null, + self_weight_kg || null, + max_load_kg || null, + max_stack || null, + remarks || null, + userId, + ]); + + logger.info("적재함 등록 성공", { companyCode, loading_code }); + res.status(201).json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("적재함 등록 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 적재함 수정 + * PUT /api/packaging/loading-units/:id + */ +export const updateLoadingUnit = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + const { + loading_name, + loading_type, + status, + width_mm, + length_mm, + height_mm, + self_weight_kg, + max_load_kg, + max_stack, + remarks, + } = req.body; + + const setClauses: string[] = ["updated_date = NOW()", "writer = $1"]; + const params: any[] = [userId]; + let paramIndex = 2; + + const fieldMap: Record = { + loading_name, + loading_type, + status, + width_mm, + length_mm, + height_mm, + self_weight_kg, + max_load_kg, + max_stack, + remarks, + }; + + for (const [col, val] of Object.entries(fieldMap)) { + if (val !== undefined) { + setClauses.push(`${col} = $${paramIndex}`); + params.push(val); + paramIndex++; + } + } + + params.push(id); + const idIdx = paramIndex; + paramIndex++; + + let whereClause: string; + if (companyCode === "*") { + whereClause = `WHERE id = $${idIdx}`; + } else { + params.push(companyCode); + whereClause = `WHERE id = $${idIdx} AND company_code = $${paramIndex}`; + } + + const sql = ` + UPDATE loading_unit + SET ${setClauses.join(", ")} + ${whereClause} + RETURNING * + `; + + const rows = await query(sql, params); + + if (rows.length === 0) { + res.status(404).json({ + success: false, + message: "적재함을 찾을 수 없거나 권한이 없습니다.", + }); + return; + } + + logger.info("적재함 수정 성공", { companyCode, id }); + res.json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("적재함 수정 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 적재함 삭제 + * DELETE /api/packaging/loading-units/:id + */ +export const deleteLoadingUnit = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + const result = await transaction(async (client) => { + let findSql: string; + let findParams: any[]; + if (companyCode === "*") { + findSql = `SELECT loading_code, company_code FROM loading_unit WHERE id = $1`; + findParams = [id]; + } else { + findSql = `SELECT loading_code, company_code FROM loading_unit WHERE id = $1 AND company_code = $2`; + findParams = [id, companyCode]; + } + + const found = await client.query(findSql, findParams); + if (found.rowCount === 0) return null; + + const { loading_code, company_code: targetCompany } = found.rows[0]; + + // 포장구성 먼저 삭제 + await client.query( + `DELETE FROM loading_unit_pkg WHERE loading_code = $1 AND company_code = $2`, + [loading_code, targetCompany] + ); + + // 적재함 삭제 + const del = await client.query( + `DELETE FROM loading_unit WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, targetCompany] + ); + + return del.rows[0]; + }); + + if (!result) { + res.status(404).json({ + success: false, + message: "적재함을 찾을 수 없거나 권한이 없습니다.", + }); + return; + } + + logger.info("적재함 삭제 성공", { companyCode, id }); + res.json({ success: true, message: "삭제 완료" }); + } catch (error: any) { + logger.error("적재함 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ============================================================ +// 적재함 포장구성(loading_unit_pkg) N:M +// ============================================================ + +/** + * 포장구성 목록 조회 (적재함 기준) + * GET /api/packaging/loading-unit-pkgs?loading_code=XXX + */ +export const getLoadingUnitPkgs = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { loading_code } = req.query; + + if (!loading_code) { + res.status(400).json({ + success: false, + message: "loading_code 파라미터가 필요합니다.", + }); + return; + } + + const conditions: string[] = [`lup.loading_code = $1`]; + const params: any[] = [loading_code]; + let paramIndex = 2; + + if (companyCode !== "*") { + conditions.push(`lup.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + const sql = ` + SELECT + lup.*, + pu.pkg_name + FROM loading_unit_pkg lup + LEFT JOIN pkg_unit pu + ON lup.pkg_code = pu.pkg_code AND lup.company_code = pu.company_code + WHERE ${conditions.join(" AND ")} + ORDER BY lup.created_date DESC + `; + + const rows = await query(sql, params); + + logger.info("포장구성 조회 성공", { + companyCode, + loading_code, + count: rows.length, + }); + res.json({ success: true, data: rows }); + } catch (error: any) { + logger.error("포장구성 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 포장구성 추가 + * POST /api/packaging/loading-unit-pkgs + */ +export const createLoadingUnitPkg = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { loading_code, pkg_code, max_load_qty, load_method } = req.body; + + if (!loading_code || !pkg_code) { + res.status(400).json({ + success: false, + message: "loading_code와 pkg_code는 필수입니다.", + }); + return; + } + + // 중복 체크 + const dup = await query( + `SELECT id FROM loading_unit_pkg WHERE loading_code = $1 AND pkg_code = $2 AND company_code = $3`, + [loading_code, pkg_code, companyCode] + ); + if (dup.length > 0) { + res.status(409).json({ + success: false, + message: `이미 등록된 포장구성입니다: ${pkg_code}`, + }); + return; + } + + const sql = ` + INSERT INTO loading_unit_pkg (company_code, loading_code, pkg_code, max_load_qty, load_method, writer) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `; + + const rows = await query(sql, [ + companyCode, + loading_code, + pkg_code, + max_load_qty || null, + load_method || null, + userId, + ]); + + logger.info("포장구성 추가 성공", { companyCode, loading_code, pkg_code }); + res.status(201).json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("포장구성 추가 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 포장구성 삭제 + * DELETE /api/packaging/loading-unit-pkgs/:id + */ +export const deleteLoadingUnitPkg = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `DELETE FROM loading_unit_pkg WHERE id = $1 RETURNING id`; + params = [id]; + } else { + sql = `DELETE FROM loading_unit_pkg WHERE id = $1 AND company_code = $2 RETURNING id`; + params = [id, companyCode]; + } + + const rows = await query(sql, params); + + if (rows.length === 0) { + res.status(404).json({ + success: false, + message: "포장구성을 찾을 수 없거나 권한이 없습니다.", + }); + return; + } + + logger.info("포장구성 삭제 성공", { companyCode, id }); + res.json({ success: true, message: "삭제 완료" }); + } catch (error: any) { + logger.error("포장구성 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/backend-node/src/routes/packagingRoutes.ts b/backend-node/src/routes/packagingRoutes.ts index f501269e..ffbf5d14 100644 --- a/backend-node/src/routes/packagingRoutes.ts +++ b/backend-node/src/routes/packagingRoutes.ts @@ -1,10 +1,50 @@ import { Router } from "express"; import { authenticateToken } from "../middleware/authMiddleware"; +import { + getPkgUnits, + getPkgUnitById, + createPkgUnit, + updatePkgUnit, + deletePkgUnit, + getPkgUnitItems, + createPkgUnitItem, + deletePkgUnitItem, + getLoadingUnits, + getLoadingUnitById, + createLoadingUnit, + updateLoadingUnit, + deleteLoadingUnit, + getLoadingUnitPkgs, + createLoadingUnitPkg, + deleteLoadingUnitPkg, +} from "../controllers/packagingController"; const router = Router(); router.use(authenticateToken); -// TODO: 포장/적재정보 관리 API 구현 예정 +// 포장단위 CRUD +router.get("/pkg-units", getPkgUnits); +router.get("/pkg-units/:id", getPkgUnitById); +router.post("/pkg-units", createPkgUnit); +router.put("/pkg-units/:id", updatePkgUnit); +router.delete("/pkg-units/:id", deletePkgUnit); + +// 포장단위 매칭품목 (N:M) +router.get("/pkg-unit-items", getPkgUnitItems); +router.post("/pkg-unit-items", createPkgUnitItem); +router.delete("/pkg-unit-items/:id", deletePkgUnitItem); + +// 적재함 CRUD +router.get("/loading-units", getLoadingUnits); +router.get("/loading-units/:id", getLoadingUnitById); +router.post("/loading-units", createLoadingUnit); +router.put("/loading-units/:id", updateLoadingUnit); +router.delete("/loading-units/:id", deleteLoadingUnit); + +// 적재함 포장구성 (N:M) +router.get("/loading-unit-pkgs", getLoadingUnitPkgs); +router.post("/loading-unit-pkgs", createLoadingUnitPkg); +router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg); export default router; From 7269867d9120429170c7594c838ed606e2706a28 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Mar 2026 23:11:07 +0900 Subject: [PATCH 08/14] =?UTF-8?q?revert:=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=BB=A4=EB=B0=8B=20=EB=A1=A4=EB=B0=B1=20?= =?UTF-8?q?(=EC=A7=81=EC=A0=91=20=EA=B5=AC=ED=98=84=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 1b2d42ff: packagingController.ts, packagingRoutes.ts 롤백 - 4f603bd4: pipeline rules 강화, AdminPageRenderer 롤백 Made-with: Cursor --- .cursor/agents/pipeline-backend.md | 8 - .cursor/agents/pipeline-common-rules.md | 113 +- .cursor/agents/pipeline-frontend.md | 83 +- .cursor/agents/pipeline-ui.md | 46 +- .cursor/agents/pipeline-verifier.md | 32 +- .../src/controllers/packagingController.ts | 995 ------------------ backend-node/src/routes/packagingRoutes.ts | 42 +- .../src/services/screenManagementService.ts | 13 +- .../components/layout/AdminPageRenderer.tsx | 390 +------ frontend/components/layout/AppLayout.tsx | 6 +- 10 files changed, 75 insertions(+), 1653 deletions(-) delete mode 100644 backend-node/src/controllers/packagingController.ts diff --git a/.cursor/agents/pipeline-backend.md b/.cursor/agents/pipeline-backend.md index 9f7ef180..6b4ff99c 100644 --- a/.cursor/agents/pipeline-backend.md +++ b/.cursor/agents/pipeline-backend.md @@ -51,14 +51,6 @@ export const getList = async (req: Request, res: Response) => { - backend-node/src/routes/index.ts에 import 추가 필수 - authenticateToken 미들웨어 적용 필수 -# CRITICAL: 사용자 메뉴 화면은 프론트엔드 페이지로 만들지 않는다! - -백엔드 에이전트는 프론트엔드 page.tsx를 직접 생성하지 않지만, -다른 에이전트에게 "프론트엔드 페이지를 만들어달라"고 요청하거나 제안해서도 안 된다. - -사용자 메뉴 화면은 DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로만 구현한다. -백엔드 에이전트가 할 일은 API 엔드포인트(controller/routes)와 DB 마이그레이션까지다. - # Your Domain - backend-node/src/controllers/ - backend-node/src/services/ diff --git a/.cursor/agents/pipeline-common-rules.md b/.cursor/agents/pipeline-common-rules.md index 575f355f..57049ce6 100644 --- a/.cursor/agents/pipeline-common-rules.md +++ b/.cursor/agents/pipeline-common-rules.md @@ -1,79 +1,5 @@ # WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수) ---- - -# !!!! STOP - 작업 시작 전 필수 게이트 (이것을 건너뛰면 모든 작업이 REJECT 된다) !!!! - -## PRE-CHECK GATE: 파일 생성/수정 전 반드시 확인 - -**어떤 에이전트든 파일을 생성하거나 수정하기 전에 반드시 이 게이트를 통과해야 한다.** -**이 게이트를 건너뛰거나 무시한 작업은 전부 REJECT + ROLLBACK 대상이다.** - -### GATE 1: 이 파일을 만들어도 되는가? - -아래 경로에 `.tsx` 페이지 파일을 **절대 생성하지 마라**: -``` -frontend/app/(main)/production/** ← 금지! 사용자 메뉴! -frontend/app/(main)/warehouse/** ← 금지! 사용자 메뉴! -frontend/app/(main)/quality/** ← 금지! 사용자 메뉴! -frontend/app/(main)/logistics/** ← 금지! 사용자 메뉴! -frontend/app/(main)/inventory/** ← 금지! 사용자 메뉴! -frontend/app/(main)/purchase/** ← 금지! 사용자 메뉴! -frontend/app/(main)/sales/** ← 금지! 사용자 메뉴! -frontend/app/(main)/bom/** ← 금지! 사용자 메뉴! -frontend/app/(main)/mold/** ← 금지! 사용자 메뉴! -frontend/app/(main)/packaging/** ← 금지! 사용자 메뉴! -frontend/app/(main)/document/** ← 금지! 사용자 메뉴! -frontend/app/(main)/work/** ← 금지! 사용자 메뉴! -frontend/app/(main)/order/** ← 금지! 사용자 메뉴! -frontend/app/(main)/material/** ← 금지! 사용자 메뉴! -frontend/app/(main)/equipment/** ← 금지! 사용자 메뉴! -frontend/app/(main)/inspection/** ← 금지! 사용자 메뉴! -``` - -**유일하게 React 페이지(.tsx)를 만들 수 있는 경로:** -``` -frontend/app/(main)/admin/** ← 허용! 관리자 메뉴만! -``` - -**판단 로직 (의사코드):** -``` -IF 생성하려는 파일 경로가 "frontend/app/(main)/admin/" 하위가 아니다 - AND 파일이 page.tsx 또는 layout.tsx 또는 React 컴포넌트다 -THEN - !!!! 즉시 중단 !!!! - → 이것은 사용자 메뉴다 - → React 페이지를 만들면 안 된다 - → DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로 전환하라 - → pipeline-common-rules.md의 "사용자 메뉴 구현 방법" 섹션을 따르라 -END IF -``` - -### GATE 2: 사용자 메뉴인데 코드로 만들려고 하는가? - -아래 키워드가 요구사항에 포함되어 있으면 **사용자 메뉴**일 가능성이 높다: -- 포장, 적재, 금형, BOM, 입출고, 품질, 검사, 재고, 자재, 문서, 생산, 작업, 설비 -- "목록 + 상세" 구조, "좌측 테이블 + 우측 폼" 구조 -- 일반 업무 화면, CRUD 화면 - -**사용자 메뉴라면:** -- .tsx 페이지 파일 생성 → 금지 -- screen_definitions + screen_layouts_v2 + menu_info INSERT → 올바른 방법 -- 백엔드 API(controller/routes)는 필요하면 코드로 작성 가능 -- 프론트엔드 API 클라이언트(lib/api/)도 필요하면 코드로 작성 가능 -- 하지만 **프론트엔드 화면 UI 자체**는 절대 코드로 만들지 않는다! - -### GATE 3: 관리자 메뉴가 맞는가? - -관리자 메뉴는 다음 조건을 **전부** 만족해야 한다: -- 시스템 관리자만 사용하는 기능 (사용자 관리, 권한 관리, 시스템 설정 등) -- URL이 `/admin/*` 패턴 -- `frontend/app/(main)/admin/` 하위에만 page.tsx 생성 - -**이 3가지 게이트를 모두 통과한 후에만 작업을 시작하라.** - ---- - ## 1. 화면 유형 구분 (절대 규칙!) 이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다. @@ -81,7 +7,7 @@ END IF ### 관리자 메뉴 (Admin) - **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일) -- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` **이 경로만 허용!** +- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` - **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!) - **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등 - **특징**: 하드코딩된 UI, 관리자만 접근 @@ -94,7 +20,6 @@ END IF - **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등 - **특징**: 코드 수정 없이 화면 구성 변경 가능 - **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것! -- **위반 시**: 해당 작업 전체 REJECT + 파일 삭제 + 처음부터 DB 등록 방식으로 재작업 ### 판단 기준 @@ -241,7 +166,7 @@ VALUES ( - [ ] BE: 필요한 경우 전용 API 작성 (범용 table-management API로 커버 안 되는 경우만) - [ ] FE: .tsx 페이지 파일 만들지 않음 (이미 `/screens/[screenId]/page.tsx`가 렌더링) -## 6. 절대 하지 말 것 (위반 시 전체 작업 REJECT) +## 6. 절대 하지 말 것 1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!) 2. fetch() 직접 사용 (lib/api/ 클라이언트 필수) @@ -249,39 +174,9 @@ VALUES ( 4. 하드코딩 색상/URL/사용자ID 사용 5. Card 안에 Card 중첩 (중첩 박스 금지) 6. 백엔드 재실행하기 (nodemon이 자동 재시작) -7. **[최우선 금지] 사용자 메뉴를 React 하드코딩(.tsx)으로 만들기** - - `frontend/app/(main)/` 하위에서 `/admin/` 이외의 경로에 page.tsx를 만드는 것은 절대 금지 - - 구체적 금지 경로: production/, warehouse/, quality/, logistics/, inventory/, purchase/, sales/, bom/, mold/, packaging/, document/, work/, order/, material/, equipment/, inspection/ 및 기타 모든 비-admin 경로 +7. **사용자 메뉴를 React 하드코딩(.tsx)으로 만들기 (절대 금지!!!)** + - `frontend/app/(main)/production/`, `frontend/app/(main)/warehouse/` 등에 page.tsx 파일을 만들어서 직접 UI를 코딩하는 것은 절대 금지 - 사용자 메뉴는 반드시 `screen_definitions` + `screen_layouts_v2` + `menu_info` DB 등록 방식으로 구현 - 이미 `/screen/[screenCode]` → `/screens/[screenId]` 렌더링 시스템이 존재함 - 백엔드 API(controller/routes)와 프론트엔드 API 클라이언트(lib/api/)는 필요하면 코드로 작성 가능 - 하지만 프론트엔드 화면 UI 자체는 DB의 V2 레이아웃 JSON으로만 구성 - - **위반 발견 시: 해당 라운드 전체 FAIL 처리, 생성된 파일 즉시 삭제, DB 등록 방식으로 처음부터 재작업** - -## 7. 위반 사례 및 올바른 대응 - -### 위반 사례 (실제 발생한 문제) -``` -# 이런 파일을 만들면 절대 안 된다! -frontend/app/(main)/production/packaging/page.tsx ← REJECT! -frontend/app/(main)/warehouse/inventory/page.tsx ← REJECT! -frontend/app/(main)/quality/inspection/page.tsx ← REJECT! -frontend/app/(main)/mold/management/page.tsx ← REJECT! -``` - -### 올바른 대응 -```sql --- 1. screen_definitions에 등록 -INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, is_active) -VALUES ('포장관리', 'COMPANY_7_PKG', 'pkg_unit', 'COMPANY_7', 'Y'); - --- 2. screen_layouts_v2에 V2 레이아웃 JSON 등록 -INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data) -VALUES ({screen_id}, 'COMPANY_7', 1, '기본 레이어', '{...V2 JSON...}'::jsonb); - --- 3. menu_info에 메뉴 등록 -INSERT INTO menu_info (..., menu_url, screen_code, ...) -VALUES (..., '/screen/COMPANY_7_PKG', 'COMPANY_7_PKG', ...); -``` - -**React 페이지(.tsx) 파일은 단 한 줄도 만들지 않는다. DB INSERT만으로 화면이 완성된다.** diff --git a/.cursor/agents/pipeline-frontend.md b/.cursor/agents/pipeline-frontend.md index 7c8f5a31..223b5b38 100644 --- a/.cursor/agents/pipeline-frontend.md +++ b/.cursor/agents/pipeline-frontend.md @@ -8,63 +8,6 @@ model: inherit You are a Frontend specialist for ERP-node project. Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui. ---- - -# !!!! STOP - 파일 생성 전 필수 게이트 (반드시 읽고 확인하라) !!!! - -## 파일을 생성하거나 수정하기 전에 반드시 이 체크를 수행하라: - -### CHECK 1: page.tsx를 만들려고 하는가? - -``` -IF 파일 경로가 "frontend/app/(main)/" 하위이다 - AND 파일명이 page.tsx 또는 layout.tsx이다 - AND 경로에 "/admin/"이 포함되어 있지 않다 -THEN - !!!! 즉시 중단 !!!! 이것은 사용자 메뉴다! - → React 페이지를 만들면 안 된다 - → DB 등록 방식으로 전환하라 (screen_definitions + screen_layouts_v2 + menu_info) - → 이 파일의 "올바른 패턴" 섹션을 참조하라 -END IF -``` - -### 금지 경로 목록 (이 경로에 page.tsx 생성 시 즉시 REJECT): -``` -frontend/app/(main)/production/** ← 금지! -frontend/app/(main)/warehouse/** ← 금지! -frontend/app/(main)/quality/** ← 금지! -frontend/app/(main)/logistics/** ← 금지! -frontend/app/(main)/inventory/** ← 금지! -frontend/app/(main)/purchase/** ← 금지! -frontend/app/(main)/sales/** ← 금지! -frontend/app/(main)/bom/** ← 금지! -frontend/app/(main)/mold/** ← 금지! -frontend/app/(main)/packaging/** ← 금지! -frontend/app/(main)/document/** ← 금지! -frontend/app/(main)/work/** ← 금지! -frontend/app/(main)/order/** ← 금지! -frontend/app/(main)/material/** ← 금지! -frontend/app/(main)/equipment/** ← 금지! -frontend/app/(main)/inspection/** ← 금지! -(위 목록에 없더라도 /admin/ 이외의 모든 경로가 금지 대상이다!) -``` - -### 유일하게 허용되는 page.tsx 생성 경로: -``` -frontend/app/(main)/admin/** ← 유일하게 허용! -``` - -### CHECK 2: 사용자 메뉴 키워드 감지 - -요구사항에 아래 키워드가 포함되면 사용자 메뉴일 가능성이 높다: -> 포장, 적재, 금형, BOM, 입출고, 품질, 검사, 재고, 자재, 문서, 생산, 작업, 설비, 목록+상세, 좌측 테이블+우측 폼, CRUD 화면 - -사용자 메뉴라면 **page.tsx 생성을 절대 하지 말고** DB 등록으로 전환하라. - -**이 게이트를 통과하지 않은 파일 생성은 전부 REJECT 된다.** - ---- - # CRITICAL PROJECT RULES ## 1. API Client (ABSOLUTE RULE!) @@ -106,23 +49,18 @@ export async function getYourData(id: number) { } ``` ---- - -# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT) +# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! **이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.** 사용자 업무 화면(포장관리, 금형관리, BOM, 문서관리 등)은 절대 React 페이지(.tsx)로 직접 UI를 하드코딩하지 않는다! -## 금지 패턴 (이 파일을 만드는 순간 작업 전체 REJECT) +## 금지 패턴 (절대 하지 말 것) ``` -frontend/app/(main)/production/packaging/page.tsx ← REJECT! 삭제 대상! -frontend/app/(main)/warehouse/something/page.tsx ← REJECT! 삭제 대상! -frontend/app/(main)/quality/inspection/page.tsx ← REJECT! 삭제 대상! -frontend/app/(main)/mold/management/page.tsx ← REJECT! 삭제 대상! -frontend/app/(main)/{admin이_아닌_모든_경로}/page.tsx ← 전부 REJECT! +frontend/app/(main)/production/packaging/page.tsx ← 이런 파일 만들지 마라! +frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 마라! ``` -## 올바른 패턴 (사용자 메뉴는 DB 등록만으로 완성된다) +## 올바른 패턴 사용자 화면은 DB에 등록만 하면 자동으로 렌더링된다: 1. `screen_definitions` 테이블에 화면 등록 (screen_code, table_name 등) 2. `screen_layouts_v2` 테이블에 V2 레이아웃 JSON 등록 (v2-split-panel-layout, v2-table-list 등) @@ -132,20 +70,17 @@ frontend/app/(main)/{admin이_아닌_모든_경로}/page.tsx ← 전부 REJECT! - `/screen/[screenCode]/page.tsx` → screenCode를 screenId로 변환 - `/screens/[screenId]/page.tsx` → screen_layouts_v2에서 V2 레이아웃 로드 → DynamicComponentRenderer로 렌더링 -**React 페이지 파일은 단 한 줄도 만들지 않는다. DB INSERT만으로 화면이 완성된다.** - ## 프론트엔드 에이전트가 할 수 있는 것 - `frontend/lib/api/` 하위에 API 클라이언트 함수 작성 (백엔드와 통신) - V2 컴포넌트 자체를 수정/신규 개발 (`frontend/lib/registry/components/v2-*/`) -- 관리자 메뉴(`/admin/*`)만 React 페이지 코딩 가능 +- 관리자 메뉴(`/admin/*`)는 React 페이지 코딩 가능 -## 프론트엔드 에이전트가 할 수 없는 것 (위반 시 REJECT) -- `/admin/` 이외 경로에 page.tsx 생성 -- 사용자 메뉴 화면을 React 페이지로 직접 코딩 +## 프론트엔드 에이전트가 할 수 없는 것 +- 사용자 메뉴 화면을 React 페이지로 직접 코딩하는 것 # Your Domain - frontend/components/ -- frontend/app/ (admin/ 하위만 page.tsx 생성 가능!) +- frontend/app/ - frontend/lib/ - frontend/hooks/ diff --git a/.cursor/agents/pipeline-ui.md b/.cursor/agents/pipeline-ui.md index 3717b455..05d3359e 100644 --- a/.cursor/agents/pipeline-ui.md +++ b/.cursor/agents/pipeline-ui.md @@ -8,43 +8,6 @@ model: inherit You are a UI/UX Design specialist for the ERP-node project. Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons. ---- - -# !!!! STOP - 파일 생성/수정 전 필수 게이트 !!!! - -## 파일을 만들거나 수정하기 전에 반드시 확인하라: - -**page.tsx를 생성하려는 경로가 `frontend/app/(main)/admin/` 하위인가?** -- YES → 진행 가능 -- NO → **즉시 중단!** 사용자 메뉴는 React 페이지로 만들지 않는다! - -**금지 경로 (이 경로에 page.tsx 생성 시 즉시 REJECT):** -``` -frontend/app/(main)/production/** ← 금지! -frontend/app/(main)/warehouse/** ← 금지! -frontend/app/(main)/quality/** ← 금지! -frontend/app/(main)/logistics/** ← 금지! -frontend/app/(main)/inventory/** ← 금지! -frontend/app/(main)/purchase/** ← 금지! -frontend/app/(main)/sales/** ← 금지! -frontend/app/(main)/bom/** ← 금지! -frontend/app/(main)/mold/** ← 금지! -frontend/app/(main)/packaging/** ← 금지! -frontend/app/(main)/document/** ← 금지! -frontend/app/(main)/work/** ← 금지! -frontend/app/(main)/order/** ← 금지! -frontend/app/(main)/material/** ← 금지! -frontend/app/(main)/equipment/** ← 금지! -frontend/app/(main)/inspection/** ← 금지! -(위 목록에 없더라도 /admin/ 이외의 모든 경로가 금지!) -``` - -**사용자 메뉴 화면은 DB 등록(screen_definitions + screen_layouts_v2 + menu_info)으로만 구현한다.** - -**이 게이트를 무시하면 작업 전체 REJECT + 파일 삭제 + 재작업 대상이다.** - ---- - # Design Philosophy - Apple-level polish with enterprise functionality - Consistent spacing, typography, color usage @@ -76,23 +39,22 @@ FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black - Use cn() for conditional classes - Use lucide-react for ALL icons -# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT) +# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! 사용자 업무 화면(포장관리, 금형관리, BOM 등)의 UI는 DB의 `screen_layouts_v2` 테이블에 V2 레이아웃 JSON으로 정의된다. React 페이지(.tsx)로 직접 UI를 하드코딩하는 것은 절대 금지! -## UI 에이전트가 할 수 있는 것 +UI 에이전트가 할 수 있는 것: - V2 컴포넌트 자체의 스타일/UX 개선 (`frontend/lib/registry/components/v2-*/`) - 관리자 메뉴(`/admin/*`) 페이지의 UI 개선 - 공통 UI 컴포넌트(`frontend/components/ui/`) 스타일 개선 -## UI 에이전트가 할 수 없는 것 (위반 시 REJECT) -- `/admin/` 이외 경로에 page.tsx 생성 또는 수정 +UI 에이전트가 할 수 없는 것: - 사용자 메뉴 화면을 React 페이지로 직접 코딩 # Your Domain - frontend/components/ (UI components) -- frontend/app/ (pages - **admin/ 하위만 page.tsx 생성/수정 가능!**) +- frontend/app/ (pages - 관리자 메뉴만) - frontend/lib/registry/components/v2-*/ (V2 컴포넌트) # Output Rules diff --git a/.cursor/agents/pipeline-verifier.md b/.cursor/agents/pipeline-verifier.md index 4030eb93..a4f4186d 100644 --- a/.cursor/agents/pipeline-verifier.md +++ b/.cursor/agents/pipeline-verifier.md @@ -1,6 +1,6 @@ --- name: pipeline-verifier -description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증, 하드코딩 페이지 탐지. +description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증. model: fast readonly: true --- @@ -11,29 +11,6 @@ Your job is to verify that work claimed as complete actually works. # Verification Checklist -## 0. 하드코딩 페이지 탐지 (최최우선! 이것부터 먼저 확인!) - -**이 프로젝트에서 가장 심각한 위반은 사용자 메뉴를 React 페이지(.tsx)로 하드코딩하는 것이다.** -검증 시 반드시 아래를 제일 먼저 확인하라: - -- [ ] `frontend/app/(main)/` 하위에 `/admin/` 이외의 경로에 새로운 page.tsx가 생성되지 않았는가? -- [ ] 구체적 금지 경로 확인: production/, warehouse/, quality/, logistics/, inventory/, purchase/, sales/, bom/, mold/, packaging/, document/, work/, order/, material/, equipment/, inspection/ -- [ ] 위 경로뿐 아니라 `/admin/` 이외의 **모든** 경로에 page.tsx가 새로 생성되었는지 확인 -- [ ] 사용자 메뉴 화면이 DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로 구현되었는가? - -**검증 방법:** -```bash -# 이 라운드에서 새로 생성된 파일 중 금지 경로의 page.tsx가 있는지 확인 -git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep -v "/admin/" -# 결과가 있으면 → 즉시 FAIL! -``` - -**위반 발견 시:** -- 검증 결과: **CRITICAL FAIL** -- 해당 파일 삭제 필수 -- DB 등록 방식으로 재작업 지시 -- 이 위반이 있으면 다른 항목 전부 PASS여도 최종 결과는 FAIL - ## 1. Multi-tenancy (최우선) - [ ] 모든 SQL에 company_code 필터 존재 - [ ] req.user!.companyCode 사용 (클라이언트 입력 아님) @@ -51,7 +28,6 @@ git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep - [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용) - [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음) - [ ] Frontend: V2 컴포넌트 규격 준수 -- [ ] Frontend: `/admin/` 이외 경로에 page.tsx 생성 안 함 (0번 항목 재확인!) - [ ] Backend: logger 사용 - [ ] Backend: try/catch 에러 처리 @@ -63,10 +39,7 @@ git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep # Reporting Format ``` -## 검증 결과: [PASS/FAIL/CRITICAL FAIL] - -### [CRITICAL] 하드코딩 페이지 탐지 -- 금지 경로에 생성된 page.tsx: (있으면 파일 경로 나열, 없으면 "없음 (PASS)") +## 검증 결과: [PASS/FAIL] ### 통과 항목 - item 1 @@ -82,4 +55,3 @@ git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep ``` Do not accept claims at face value. Check the actual code. -하드코딩 페이지 탐지는 다른 모든 검증보다 우선한다. 이것이 FAIL이면 전체 FAIL이다. diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts deleted file mode 100644 index 681d9ebc..00000000 --- a/backend-node/src/controllers/packagingController.ts +++ /dev/null @@ -1,995 +0,0 @@ -import { Response } from "express"; -import { AuthenticatedRequest } from "../types/auth"; -import { query, transaction } from "../database/db"; -import { logger } from "../utils/logger"; - -// ============================================================ -// 포장단위(pkg_unit) CRUD -// ============================================================ - -/** - * 포장단위 목록 조회 - * GET /api/packaging/pkg-units - */ -export const getPkgUnits = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { search, pkg_type, status } = req.query; - - const conditions: string[] = []; - const params: any[] = []; - let paramIndex = 1; - - // 멀티테넌시 필터 - if (companyCode === "*") { - // 최고 관리자: 전체 조회 - } else { - conditions.push(`pu.company_code = $${paramIndex}`); - params.push(companyCode); - paramIndex++; - } - - if (search && typeof search === "string" && search.trim()) { - conditions.push( - `(pu.pkg_code ILIKE $${paramIndex} OR pu.pkg_name ILIKE $${paramIndex})` - ); - params.push(`%${search.trim()}%`); - paramIndex++; - } - - if (pkg_type && typeof pkg_type === "string" && pkg_type.trim()) { - conditions.push(`pu.pkg_type = $${paramIndex}`); - params.push(pkg_type.trim()); - paramIndex++; - } - - if (status && typeof status === "string" && status.trim()) { - conditions.push(`pu.status = $${paramIndex}`); - params.push(status.trim()); - paramIndex++; - } - - const whereClause = - conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - - const sql = ` - SELECT - pu.*, - (SELECT COUNT(*) FROM pkg_unit_item pui - WHERE pui.pkg_code = pu.pkg_code AND pui.company_code = pu.company_code - ) AS item_count - FROM pkg_unit pu - ${whereClause} - ORDER BY pu.created_date DESC - `; - - const rows = await query(sql, params); - - logger.info("포장단위 목록 조회 성공", { - companyCode, - count: rows.length, - }); - - res.json({ success: true, data: rows }); - } catch (error: any) { - logger.error("포장단위 목록 조회 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 포장단위 상세 조회 - * GET /api/packaging/pkg-units/:id - */ -export const getPkgUnitById = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { id } = req.params; - - let sql: string; - let params: any[]; - - if (companyCode === "*") { - sql = `SELECT * FROM pkg_unit WHERE id = $1`; - params = [id]; - } else { - sql = `SELECT * FROM pkg_unit WHERE id = $1 AND company_code = $2`; - params = [id, companyCode]; - } - - const rows = await query(sql, params); - - if (rows.length === 0) { - res.status(404).json({ - success: false, - message: "포장단위를 찾을 수 없습니다.", - }); - return; - } - - res.json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("포장단위 상세 조회 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 포장단위 등록 - * POST /api/packaging/pkg-units - */ -export const createPkgUnit = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { - pkg_code, - pkg_name, - pkg_type, - status, - width_mm, - length_mm, - height_mm, - self_weight_kg, - max_load_kg, - volume_l, - remarks, - } = req.body; - - if (!pkg_code || !pkg_name) { - res.status(400).json({ - success: false, - message: "포장코드(pkg_code)와 포장명(pkg_name)은 필수입니다.", - }); - return; - } - - // 중복 체크 - const dupCheck = await query( - `SELECT id FROM pkg_unit WHERE pkg_code = $1 AND company_code = $2`, - [pkg_code, companyCode] - ); - if (dupCheck.length > 0) { - res.status(409).json({ - success: false, - message: `포장코드 '${pkg_code}'가 이미 존재합니다.`, - }); - return; - } - - const sql = ` - INSERT INTO pkg_unit - (company_code, pkg_code, pkg_name, pkg_type, status, - width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, writer) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) - RETURNING * - `; - - const rows = await query(sql, [ - companyCode, - pkg_code, - pkg_name, - pkg_type || null, - status || "ACTIVE", - width_mm || null, - length_mm || null, - height_mm || null, - self_weight_kg || null, - max_load_kg || null, - volume_l || null, - remarks || null, - userId, - ]); - - logger.info("포장단위 등록 성공", { companyCode, pkg_code }); - res.status(201).json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("포장단위 등록 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 포장단위 수정 - * PUT /api/packaging/pkg-units/:id - */ -export const updatePkgUnit = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { id } = req.params; - const { - pkg_name, - pkg_type, - status, - width_mm, - length_mm, - height_mm, - self_weight_kg, - max_load_kg, - volume_l, - remarks, - } = req.body; - - const setClauses: string[] = ["updated_date = NOW()", "writer = $1"]; - const params: any[] = [userId]; - let paramIndex = 2; - - const fieldMap: Record = { - pkg_name, - pkg_type, - status, - width_mm, - length_mm, - height_mm, - self_weight_kg, - max_load_kg, - volume_l, - remarks, - }; - - for (const [col, val] of Object.entries(fieldMap)) { - if (val !== undefined) { - setClauses.push(`${col} = $${paramIndex}`); - params.push(val); - paramIndex++; - } - } - - // WHERE: id + company_code - params.push(id); - const idIdx = paramIndex; - paramIndex++; - - let whereClause: string; - if (companyCode === "*") { - whereClause = `WHERE id = $${idIdx}`; - } else { - params.push(companyCode); - whereClause = `WHERE id = $${idIdx} AND company_code = $${paramIndex}`; - } - - const sql = ` - UPDATE pkg_unit - SET ${setClauses.join(", ")} - ${whereClause} - RETURNING * - `; - - const rows = await query(sql, params); - - if (rows.length === 0) { - res.status(404).json({ - success: false, - message: "포장단위를 찾을 수 없거나 권한이 없습니다.", - }); - return; - } - - logger.info("포장단위 수정 성공", { companyCode, id }); - res.json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("포장단위 수정 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 포장단위 삭제 - * DELETE /api/packaging/pkg-units/:id - */ -export const deletePkgUnit = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { id } = req.params; - - // 트랜잭션으로 관련 데이터 함께 삭제 - const result = await transaction(async (client) => { - // 삭제 대상의 pkg_code 조회 (관계 데이터 삭제용) - let findSql: string; - let findParams: any[]; - if (companyCode === "*") { - findSql = `SELECT pkg_code, company_code FROM pkg_unit WHERE id = $1`; - findParams = [id]; - } else { - findSql = `SELECT pkg_code, company_code FROM pkg_unit WHERE id = $1 AND company_code = $2`; - findParams = [id, companyCode]; - } - - const found = await client.query(findSql, findParams); - if (found.rowCount === 0) return null; - - const { pkg_code, company_code: targetCompany } = found.rows[0]; - - // 매칭품목 먼저 삭제 - await client.query( - `DELETE FROM pkg_unit_item WHERE pkg_code = $1 AND company_code = $2`, - [pkg_code, targetCompany] - ); - - // 적재함 포장구성에서 참조 삭제 - await client.query( - `DELETE FROM loading_unit_pkg WHERE pkg_code = $1 AND company_code = $2`, - [pkg_code, targetCompany] - ); - - // 포장단위 삭제 - const del = await client.query( - `DELETE FROM pkg_unit WHERE id = $1 AND company_code = $2 RETURNING id`, - [id, targetCompany] - ); - - return del.rows[0]; - }); - - if (!result) { - res.status(404).json({ - success: false, - message: "포장단위를 찾을 수 없거나 권한이 없습니다.", - }); - return; - } - - logger.info("포장단위 삭제 성공", { companyCode, id }); - res.json({ success: true, message: "삭제 완료" }); - } catch (error: any) { - logger.error("포장단위 삭제 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -// ============================================================ -// 포장단위 매칭품목(pkg_unit_item) N:M -// ============================================================ - -/** - * 매칭품목 목록 조회 (포장단위 기준) - * GET /api/packaging/pkg-unit-items?pkg_code=XXX - */ -export const getPkgUnitItems = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { pkg_code } = req.query; - - if (!pkg_code) { - res.status(400).json({ - success: false, - message: "pkg_code 파라미터가 필요합니다.", - }); - return; - } - - const conditions: string[] = [`pui.pkg_code = $1`]; - const params: any[] = [pkg_code]; - let paramIndex = 2; - - if (companyCode !== "*") { - conditions.push(`pui.company_code = $${paramIndex}`); - params.push(companyCode); - paramIndex++; - } - - const sql = ` - SELECT pui.* - FROM pkg_unit_item pui - WHERE ${conditions.join(" AND ")} - ORDER BY pui.created_date DESC - `; - - const rows = await query(sql, params); - - logger.info("매칭품목 조회 성공", { companyCode, pkg_code, count: rows.length }); - res.json({ success: true, data: rows }); - } catch (error: any) { - logger.error("매칭품목 조회 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 매칭품목 추가 - * POST /api/packaging/pkg-unit-items - */ -export const createPkgUnitItem = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { pkg_code, item_number, pkg_qty } = req.body; - - if (!pkg_code || !item_number) { - res.status(400).json({ - success: false, - message: "pkg_code와 item_number는 필수입니다.", - }); - return; - } - - // 중복 체크 - const dup = await query( - `SELECT id FROM pkg_unit_item WHERE pkg_code = $1 AND item_number = $2 AND company_code = $3`, - [pkg_code, item_number, companyCode] - ); - if (dup.length > 0) { - res.status(409).json({ - success: false, - message: `이미 매칭된 품목입니다: ${item_number}`, - }); - return; - } - - const sql = ` - INSERT INTO pkg_unit_item (company_code, pkg_code, item_number, pkg_qty, writer) - VALUES ($1, $2, $3, $4, $5) - RETURNING * - `; - - const rows = await query(sql, [ - companyCode, - pkg_code, - item_number, - pkg_qty || null, - userId, - ]); - - logger.info("매칭품목 추가 성공", { companyCode, pkg_code, item_number }); - res.status(201).json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("매칭품목 추가 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 매칭품목 삭제 - * DELETE /api/packaging/pkg-unit-items/:id - */ -export const deletePkgUnitItem = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { id } = req.params; - - let sql: string; - let params: any[]; - - if (companyCode === "*") { - sql = `DELETE FROM pkg_unit_item WHERE id = $1 RETURNING id`; - params = [id]; - } else { - sql = `DELETE FROM pkg_unit_item WHERE id = $1 AND company_code = $2 RETURNING id`; - params = [id, companyCode]; - } - - const rows = await query(sql, params); - - if (rows.length === 0) { - res.status(404).json({ - success: false, - message: "매칭품목을 찾을 수 없거나 권한이 없습니다.", - }); - return; - } - - logger.info("매칭품목 삭제 성공", { companyCode, id }); - res.json({ success: true, message: "삭제 완료" }); - } catch (error: any) { - logger.error("매칭품목 삭제 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -// ============================================================ -// 적재함(loading_unit) CRUD -// ============================================================ - -/** - * 적재함 목록 조회 - * GET /api/packaging/loading-units - */ -export const getLoadingUnits = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { search, loading_type, status } = req.query; - - const conditions: string[] = []; - const params: any[] = []; - let paramIndex = 1; - - if (companyCode === "*") { - // 최고 관리자: 전체 조회 - } else { - conditions.push(`lu.company_code = $${paramIndex}`); - params.push(companyCode); - paramIndex++; - } - - if (search && typeof search === "string" && search.trim()) { - conditions.push( - `(lu.loading_code ILIKE $${paramIndex} OR lu.loading_name ILIKE $${paramIndex})` - ); - params.push(`%${search.trim()}%`); - paramIndex++; - } - - if (loading_type && typeof loading_type === "string" && loading_type.trim()) { - conditions.push(`lu.loading_type = $${paramIndex}`); - params.push(loading_type.trim()); - paramIndex++; - } - - if (status && typeof status === "string" && status.trim()) { - conditions.push(`lu.status = $${paramIndex}`); - params.push(status.trim()); - paramIndex++; - } - - const whereClause = - conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - - const sql = ` - SELECT - lu.*, - (SELECT COUNT(*) FROM loading_unit_pkg lup - WHERE lup.loading_code = lu.loading_code AND lup.company_code = lu.company_code - ) AS pkg_count - FROM loading_unit lu - ${whereClause} - ORDER BY lu.created_date DESC - `; - - const rows = await query(sql, params); - - logger.info("적재함 목록 조회 성공", { companyCode, count: rows.length }); - res.json({ success: true, data: rows }); - } catch (error: any) { - logger.error("적재함 목록 조회 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 적재함 상세 조회 - * GET /api/packaging/loading-units/:id - */ -export const getLoadingUnitById = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { id } = req.params; - - let sql: string; - let params: any[]; - - if (companyCode === "*") { - sql = `SELECT * FROM loading_unit WHERE id = $1`; - params = [id]; - } else { - sql = `SELECT * FROM loading_unit WHERE id = $1 AND company_code = $2`; - params = [id, companyCode]; - } - - const rows = await query(sql, params); - - if (rows.length === 0) { - res.status(404).json({ - success: false, - message: "적재함을 찾을 수 없습니다.", - }); - return; - } - - res.json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("적재함 상세 조회 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 적재함 등록 - * POST /api/packaging/loading-units - */ -export const createLoadingUnit = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { - loading_code, - loading_name, - loading_type, - status, - width_mm, - length_mm, - height_mm, - self_weight_kg, - max_load_kg, - max_stack, - remarks, - } = req.body; - - if (!loading_code || !loading_name) { - res.status(400).json({ - success: false, - message: "적재코드(loading_code)와 적재명(loading_name)은 필수입니다.", - }); - return; - } - - // 중복 체크 - const dupCheck = await query( - `SELECT id FROM loading_unit WHERE loading_code = $1 AND company_code = $2`, - [loading_code, companyCode] - ); - if (dupCheck.length > 0) { - res.status(409).json({ - success: false, - message: `적재코드 '${loading_code}'가 이미 존재합니다.`, - }); - return; - } - - const sql = ` - INSERT INTO loading_unit - (company_code, loading_code, loading_name, loading_type, status, - width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, writer) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) - RETURNING * - `; - - const rows = await query(sql, [ - companyCode, - loading_code, - loading_name, - loading_type || null, - status || "ACTIVE", - width_mm || null, - length_mm || null, - height_mm || null, - self_weight_kg || null, - max_load_kg || null, - max_stack || null, - remarks || null, - userId, - ]); - - logger.info("적재함 등록 성공", { companyCode, loading_code }); - res.status(201).json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("적재함 등록 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 적재함 수정 - * PUT /api/packaging/loading-units/:id - */ -export const updateLoadingUnit = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { id } = req.params; - const { - loading_name, - loading_type, - status, - width_mm, - length_mm, - height_mm, - self_weight_kg, - max_load_kg, - max_stack, - remarks, - } = req.body; - - const setClauses: string[] = ["updated_date = NOW()", "writer = $1"]; - const params: any[] = [userId]; - let paramIndex = 2; - - const fieldMap: Record = { - loading_name, - loading_type, - status, - width_mm, - length_mm, - height_mm, - self_weight_kg, - max_load_kg, - max_stack, - remarks, - }; - - for (const [col, val] of Object.entries(fieldMap)) { - if (val !== undefined) { - setClauses.push(`${col} = $${paramIndex}`); - params.push(val); - paramIndex++; - } - } - - params.push(id); - const idIdx = paramIndex; - paramIndex++; - - let whereClause: string; - if (companyCode === "*") { - whereClause = `WHERE id = $${idIdx}`; - } else { - params.push(companyCode); - whereClause = `WHERE id = $${idIdx} AND company_code = $${paramIndex}`; - } - - const sql = ` - UPDATE loading_unit - SET ${setClauses.join(", ")} - ${whereClause} - RETURNING * - `; - - const rows = await query(sql, params); - - if (rows.length === 0) { - res.status(404).json({ - success: false, - message: "적재함을 찾을 수 없거나 권한이 없습니다.", - }); - return; - } - - logger.info("적재함 수정 성공", { companyCode, id }); - res.json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("적재함 수정 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 적재함 삭제 - * DELETE /api/packaging/loading-units/:id - */ -export const deleteLoadingUnit = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { id } = req.params; - - const result = await transaction(async (client) => { - let findSql: string; - let findParams: any[]; - if (companyCode === "*") { - findSql = `SELECT loading_code, company_code FROM loading_unit WHERE id = $1`; - findParams = [id]; - } else { - findSql = `SELECT loading_code, company_code FROM loading_unit WHERE id = $1 AND company_code = $2`; - findParams = [id, companyCode]; - } - - const found = await client.query(findSql, findParams); - if (found.rowCount === 0) return null; - - const { loading_code, company_code: targetCompany } = found.rows[0]; - - // 포장구성 먼저 삭제 - await client.query( - `DELETE FROM loading_unit_pkg WHERE loading_code = $1 AND company_code = $2`, - [loading_code, targetCompany] - ); - - // 적재함 삭제 - const del = await client.query( - `DELETE FROM loading_unit WHERE id = $1 AND company_code = $2 RETURNING id`, - [id, targetCompany] - ); - - return del.rows[0]; - }); - - if (!result) { - res.status(404).json({ - success: false, - message: "적재함을 찾을 수 없거나 권한이 없습니다.", - }); - return; - } - - logger.info("적재함 삭제 성공", { companyCode, id }); - res.json({ success: true, message: "삭제 완료" }); - } catch (error: any) { - logger.error("적재함 삭제 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -// ============================================================ -// 적재함 포장구성(loading_unit_pkg) N:M -// ============================================================ - -/** - * 포장구성 목록 조회 (적재함 기준) - * GET /api/packaging/loading-unit-pkgs?loading_code=XXX - */ -export const getLoadingUnitPkgs = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { loading_code } = req.query; - - if (!loading_code) { - res.status(400).json({ - success: false, - message: "loading_code 파라미터가 필요합니다.", - }); - return; - } - - const conditions: string[] = [`lup.loading_code = $1`]; - const params: any[] = [loading_code]; - let paramIndex = 2; - - if (companyCode !== "*") { - conditions.push(`lup.company_code = $${paramIndex}`); - params.push(companyCode); - paramIndex++; - } - - const sql = ` - SELECT - lup.*, - pu.pkg_name - FROM loading_unit_pkg lup - LEFT JOIN pkg_unit pu - ON lup.pkg_code = pu.pkg_code AND lup.company_code = pu.company_code - WHERE ${conditions.join(" AND ")} - ORDER BY lup.created_date DESC - `; - - const rows = await query(sql, params); - - logger.info("포장구성 조회 성공", { - companyCode, - loading_code, - count: rows.length, - }); - res.json({ success: true, data: rows }); - } catch (error: any) { - logger.error("포장구성 조회 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 포장구성 추가 - * POST /api/packaging/loading-unit-pkgs - */ -export const createLoadingUnitPkg = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { loading_code, pkg_code, max_load_qty, load_method } = req.body; - - if (!loading_code || !pkg_code) { - res.status(400).json({ - success: false, - message: "loading_code와 pkg_code는 필수입니다.", - }); - return; - } - - // 중복 체크 - const dup = await query( - `SELECT id FROM loading_unit_pkg WHERE loading_code = $1 AND pkg_code = $2 AND company_code = $3`, - [loading_code, pkg_code, companyCode] - ); - if (dup.length > 0) { - res.status(409).json({ - success: false, - message: `이미 등록된 포장구성입니다: ${pkg_code}`, - }); - return; - } - - const sql = ` - INSERT INTO loading_unit_pkg (company_code, loading_code, pkg_code, max_load_qty, load_method, writer) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING * - `; - - const rows = await query(sql, [ - companyCode, - loading_code, - pkg_code, - max_load_qty || null, - load_method || null, - userId, - ]); - - logger.info("포장구성 추가 성공", { companyCode, loading_code, pkg_code }); - res.status(201).json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("포장구성 추가 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 포장구성 삭제 - * DELETE /api/packaging/loading-unit-pkgs/:id - */ -export const deleteLoadingUnitPkg = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { id } = req.params; - - let sql: string; - let params: any[]; - - if (companyCode === "*") { - sql = `DELETE FROM loading_unit_pkg WHERE id = $1 RETURNING id`; - params = [id]; - } else { - sql = `DELETE FROM loading_unit_pkg WHERE id = $1 AND company_code = $2 RETURNING id`; - params = [id, companyCode]; - } - - const rows = await query(sql, params); - - if (rows.length === 0) { - res.status(404).json({ - success: false, - message: "포장구성을 찾을 수 없거나 권한이 없습니다.", - }); - return; - } - - logger.info("포장구성 삭제 성공", { companyCode, id }); - res.json({ success: true, message: "삭제 완료" }); - } catch (error: any) { - logger.error("포장구성 삭제 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; diff --git a/backend-node/src/routes/packagingRoutes.ts b/backend-node/src/routes/packagingRoutes.ts index ffbf5d14..f501269e 100644 --- a/backend-node/src/routes/packagingRoutes.ts +++ b/backend-node/src/routes/packagingRoutes.ts @@ -1,50 +1,10 @@ import { Router } from "express"; import { authenticateToken } from "../middleware/authMiddleware"; -import { - getPkgUnits, - getPkgUnitById, - createPkgUnit, - updatePkgUnit, - deletePkgUnit, - getPkgUnitItems, - createPkgUnitItem, - deletePkgUnitItem, - getLoadingUnits, - getLoadingUnitById, - createLoadingUnit, - updateLoadingUnit, - deleteLoadingUnit, - getLoadingUnitPkgs, - createLoadingUnitPkg, - deleteLoadingUnitPkg, -} from "../controllers/packagingController"; const router = Router(); router.use(authenticateToken); -// 포장단위 CRUD -router.get("/pkg-units", getPkgUnits); -router.get("/pkg-units/:id", getPkgUnitById); -router.post("/pkg-units", createPkgUnit); -router.put("/pkg-units/:id", updatePkgUnit); -router.delete("/pkg-units/:id", deletePkgUnit); - -// 포장단위 매칭품목 (N:M) -router.get("/pkg-unit-items", getPkgUnitItems); -router.post("/pkg-unit-items", createPkgUnitItem); -router.delete("/pkg-unit-items/:id", deletePkgUnitItem); - -// 적재함 CRUD -router.get("/loading-units", getLoadingUnits); -router.get("/loading-units/:id", getLoadingUnitById); -router.post("/loading-units", createLoadingUnit); -router.put("/loading-units/:id", updateLoadingUnit); -router.delete("/loading-units/:id", deleteLoadingUnit); - -// 적재함 포장구성 (N:M) -router.get("/loading-unit-pkgs", getLoadingUnitPkgs); -router.post("/loading-unit-pkgs", createLoadingUnitPkg); -router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg); +// TODO: 포장/적재정보 관리 API 구현 예정 export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 64b1dff0..4c5bdc57 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2346,24 +2346,19 @@ export class ScreenManagementService { } /** - * 메뉴별 화면 목록 조회 - * company_code 매칭: 본인 회사 할당 + SUPER_ADMIN 글로벌 할당('*') 모두 조회 - * 본인 회사 할당이 우선, 없으면 글로벌 할당 사용 + * 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료) */ async getScreensByMenu( menuObjid: number, companyCode: string, ): Promise { const screens = await query( - `SELECT sd.*, sma.company_code AS assign_company_code - FROM screen_menu_assignments sma + `SELECT sd.* FROM screen_menu_assignments sma INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id WHERE sma.menu_objid = $1 - AND (sma.company_code = $2 OR sma.company_code = '*') + AND sma.company_code = $2 AND sma.is_active = 'Y' - ORDER BY - CASE WHEN sma.company_code = $2 THEN 0 ELSE 1 END, - sma.display_order ASC`, + ORDER BY sma.display_order ASC`, [menuObjid, companyCode], ); diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 62280f9d..20175b5e 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -1,10 +1,8 @@ "use client"; -import React, { useMemo, useState, useEffect } from "react"; +import React, { useMemo } from "react"; import dynamic from "next/dynamic"; import { Loader2 } from "lucide-react"; -import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page"; -import { apiClient } from "@/lib/api/client"; const LoadingFallback = () => (
@@ -12,320 +10,70 @@ const LoadingFallback = () => (
); -const d = (loader: () => Promise) => - dynamic(loader, { ssr: false, loading: LoadingFallback }); - -/** - * /dashboard/[dashboardId] URL을 탭 내에서 직접 렌더링 - * Next.js params Promise 없이 dashboardId를 직접 전달 - */ -const LazyDashboardViewer = d(() => import("@/components/dashboard/DashboardViewer").then((mod) => ({ - default: mod.DashboardViewer, -}))); - -function DashboardTabRenderer({ dashboardId }: { dashboardId: string }) { - const [dashboard, setDashboard] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const load = async () => { - setIsLoading(true); - try { - const { dashboardApi } = await import("@/lib/api/dashboard"); - const data = await dashboardApi.getDashboard(dashboardId); - setDashboard({ ...data, elements: data.elements || [] }); - } catch { - const saved = JSON.parse(localStorage.getItem("savedDashboards") || "[]"); - const found = saved.find((d: any) => d.id === dashboardId); - if (found) { - setDashboard(found); - } else { - setError("대시보드를 찾을 수 없습니다"); - } - } finally { - setIsLoading(false); - } - }; - load(); - }, [dashboardId]); - - if (isLoading) return ; - - if (error || !dashboard) { - return ( -
-
-

{error || "대시보드를 찾을 수 없습니다"}

-

대시보드 ID: {dashboardId}

-
-
- ); - } - - return ( -
- -
- ); -} - -/** - * /screen/[screenCode] URL을 screenId로 변환해서 ScreenViewPageWrapper를 렌더링 - */ -function ScreenCodeResolver({ screenCode }: { screenCode: string }) { - const [screenId, setScreenId] = useState(null); - const [error, setError] = useState(false); - - useEffect(() => { - const numericId = parseInt(screenCode); - if (!isNaN(numericId)) { - setScreenId(numericId); - return; - } - - const resolve = async () => { - try { - const res = await apiClient.get("/screen-management/screens", { - params: { searchTerm: screenCode, size: 50 }, - }); - const items = res.data?.data?.data || res.data?.data || []; - const arr = Array.isArray(items) ? items : []; - const exact = arr.find((s: any) => s.screenCode === screenCode || s.screen_code === screenCode); - const target = exact || arr[0]; - if (target) { - setScreenId(target.screenId || target.screen_id); - } else { - setError(true); - } - } catch { - setError(true); - } - }; - resolve(); - }, [screenCode]); - - if (error) { - return ( -
-
-

화면을 찾을 수 없습니다

-

- 화면 코드: {screenCode} -

-
-
- ); - } - - if (screenId === null) { - return ; - } - - return ; -} - /** * 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리. - * 레지스트리에 없는 URL은 자동으로 파일 경로 기반 동적 임포트를 시도한다. + * 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다. + * 매핑되지 않은 URL은 catch-all fallback으로 처리된다. */ const ADMIN_PAGE_REGISTRY: Record> = { // 관리자 메인 - "/admin": d(() => import("@/app/(main)/admin/page")), + "/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }), // 메뉴 관리 - "/admin/menu": d(() => import("@/app/(main)/admin/menu/page")), + "/admin/menu": dynamic(() => import("@/app/(main)/admin/menu/page"), { ssr: false, loading: LoadingFallback }), // 사용자 관리 - "/admin/userMng/userMngList": d(() => import("@/app/(main)/admin/userMng/userMngList/page")), - "/admin/userMng/rolesList": d(() => import("@/app/(main)/admin/userMng/rolesList/page")), - "/admin/userMng/userAuthList": d(() => import("@/app/(main)/admin/userMng/userAuthList/page")), - "/admin/userMng/companyList": d(() => import("@/app/(main)/admin/userMng/companyList/page")), + "/admin/userMng/userMngList": dynamic(() => import("@/app/(main)/admin/userMng/userMngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }), // 화면 관리 - "/admin/screenMng/screenMngList": d(() => import("@/app/(main)/admin/screenMng/screenMngList/page")), - "/admin/screenMng/popScreenMngList": d(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page")), - "/admin/screenMng/dashboardList": d(() => import("@/app/(main)/admin/screenMng/dashboardList/page")), - "/admin/screenMng/reportList": d(() => import("@/app/(main)/admin/screenMng/reportList/page")), - "/admin/screenMng/barcodeList": d(() => import("@/app/(main)/admin/screenMng/barcodeList/page")), + "/admin/screenMng/screenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/screenMngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/screenMng/popScreenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/screenMng/dashboardList": dynamic(() => import("@/app/(main)/admin/screenMng/dashboardList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/screenMng/reportList": dynamic(() => import("@/app/(main)/admin/screenMng/reportList/page"), { ssr: false, loading: LoadingFallback }), // 시스템 관리 - "/admin/systemMng/commonCodeList": d(() => import("@/app/(main)/admin/systemMng/commonCodeList/page")), - "/admin/systemMng/tableMngList": d(() => import("@/app/(main)/admin/systemMng/tableMngList/page")), - "/admin/systemMng/i18nList": d(() => import("@/app/(main)/admin/systemMng/i18nList/page")), - "/admin/systemMng/collection-managementList": d(() => import("@/app/(main)/admin/systemMng/collection-managementList/page")), - "/admin/systemMng/dataflow": d(() => import("@/app/(main)/admin/systemMng/dataflow/page")), - "/admin/systemMng/dataflow/node-editorList": d(() => import("@/app/(main)/admin/systemMng/dataflow/node-editorList/page")), + "/admin/systemMng/commonCodeList": dynamic(() => import("@/app/(main)/admin/systemMng/commonCodeList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }), // 자동화 관리 - "/admin/automaticMng/flowMgmtList": d(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page")), - "/admin/automaticMng/batchmngList": d(() => import("@/app/(main)/admin/automaticMng/batchmngList/page")), - "/admin/automaticMng/exconList": d(() => import("@/app/(main)/admin/automaticMng/exconList/page")), - "/admin/automaticMng/exCallConfList": d(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page")), + "/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }), // 메일 - "/admin/automaticMng/mail/send": d(() => import("@/app/(main)/admin/automaticMng/mail/send/page")), - "/admin/automaticMng/mail/receive": d(() => import("@/app/(main)/admin/automaticMng/mail/receive/page")), - "/admin/automaticMng/mail/sent": d(() => import("@/app/(main)/admin/automaticMng/mail/sent/page")), - "/admin/automaticMng/mail/drafts": d(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page")), - "/admin/automaticMng/mail/trash": d(() => import("@/app/(main)/admin/automaticMng/mail/trash/page")), - "/admin/automaticMng/mail/accounts": d(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page")), - "/admin/automaticMng/mail/templates": d(() => import("@/app/(main)/admin/automaticMng/mail/templates/page")), - "/admin/automaticMng/mail/dashboardList": d(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page")), - "/admin/automaticMng/mail/bulk-send": d(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page")), + "/admin/automaticMng/mail/send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/send/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/receive": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/receive/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/sent": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/sent/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/drafts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/trash": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/trash/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/accounts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }), // 배치 관리 - "/admin/batch-management": d(() => import("@/app/(main)/admin/batch-management/page")), - "/admin/batch-management-new": d(() => import("@/app/(main)/admin/batch-management-new/page")), + "/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }), + "/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }), - // 결재 관리 - "/admin/approvalTemplate": d(() => import("@/app/(main)/admin/approvalTemplate/page")), - "/admin/approvalMng": d(() => import("@/app/(main)/admin/approvalMng/page")), - "/admin/approvalBox": d(() => import("@/app/(main)/admin/approvalBox/page")), - - // AI 어시스턴트 - "/admin/aiAssistant": d(() => import("@/app/(main)/admin/aiAssistant/page")), - "/admin/aiAssistant/usage": d(() => import("@/app/(main)/admin/aiAssistant/usage/page")), - "/admin/aiAssistant/history": d(() => import("@/app/(main)/admin/aiAssistant/history/page")), - "/admin/aiAssistant/api-keys": d(() => import("@/app/(main)/admin/aiAssistant/api-keys/page")), - "/admin/aiAssistant/dashboard": d(() => import("@/app/(main)/admin/aiAssistant/dashboard/page")), - "/admin/aiAssistant/chat": d(() => import("@/app/(main)/admin/aiAssistant/chat/page")), - "/admin/aiAssistant/api-test": d(() => import("@/app/(main)/admin/aiAssistant/api-test/page")), - - // 기타 관리 - "/admin/cascading-management": d(() => import("@/app/(main)/admin/cascading-management/page")), - "/admin/cascading-relations": d(() => import("@/app/(main)/admin/cascading-relations/page")), - "/admin/layouts": d(() => import("@/app/(main)/admin/layouts/page")), - "/admin/templates": d(() => import("@/app/(main)/admin/templates/page")), - "/admin/monitoring": d(() => import("@/app/(main)/admin/monitoring/page")), - "/admin/standards": d(() => import("@/app/(main)/admin/standards/page")), - "/admin/flow-external-db": d(() => import("@/app/(main)/admin/flow-external-db/page")), - "/admin/auto-fill": d(() => import("@/app/(main)/admin/auto-fill/page")), - "/admin/system-notices": d(() => import("@/app/(main)/admin/system-notices/page")), - "/admin/audit-log": d(() => import("@/app/(main)/admin/audit-log/page")), - - // 개발/테스트 - "/admin/debug": d(() => import("@/app/(main)/admin/debug/page")), - "/admin/debug-simple": d(() => import("@/app/(main)/admin/debug-simple/page")), - "/admin/debug-layout": d(() => import("@/app/(main)/admin/debug-layout/page")), - "/admin/test": d(() => import("@/app/(main)/admin/test/page")), - "/admin/ui-components-demo": d(() => import("@/app/(main)/admin/ui-components-demo/page")), - "/admin/validation-demo": d(() => import("@/app/(main)/admin/validation-demo/page")), - "/admin/token-test": d(() => import("@/app/(main)/admin/token-test/page")), - - // === 사용자 화면 (admin이 아닌 URL 기반 메뉴) === - "/approval": d(() => import("@/app/(main)/approval/page")), - "/dashboard": d(() => import("@/app/(main)/dashboard/page")), - "/multilang": d(() => import("@/app/(main)/multilang/page")), - "/test-flow": d(() => import("@/app/(main)/test-flow/page")), - "/main": d(() => import("@/app/(main)/main/page")), + // 기타 + "/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), + "/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }), + "/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }), + "/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }), + "/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }), + "/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }), + "/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }), + "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }), }; -/** - * 동적 라우트 패턴 매칭 (URL 경로에 동적 세그먼트가 포함된 경우) - * /admin/screenMng/dashboardList/123 → dashboardList/[id] 페이지에 매핑 - * - * extractParams: URL에서 동적 파라미터를 추출 (use(params)를 쓰는 페이지용) - * 추출된 값은 params={Promise.resolve(...)}로 전달되어 - * Next.js 라우팅 컨텍스트 없이도 use(params)가 정상 동작함 - */ -interface DynamicRouteEntry { - pattern: RegExp; - loader: () => Promise; - extractParams?: (url: string) => Record; -} - -const DYNAMIC_ROUTE_PATTERNS: DynamicRouteEntry[] = [ - { - pattern: /^\/admin\/userMng\/rolesList\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/userMng/rolesList/[id]/page"), - extractParams: (url) => ({ id: url.split("/").pop()! }), - }, - { - pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/, - loader: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"), - extractParams: (url) => ({ companyCode: url.split("/")[4] }), - }, - { - pattern: /^\/admin\/automaticMng\/batchmngList\/create$/, - loader: () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"), - }, - { - pattern: /^\/admin\/automaticMng\/batchmngList\/edit\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page"), - extractParams: (url) => ({ id: url.split("/").pop()! }), - }, - { - pattern: /^\/admin\/automaticMng\/flowMgmtList\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/automaticMng/flowMgmtList/[id]/page"), - extractParams: (url) => ({ id: url.split("/").pop()! }), - }, - { - pattern: /^\/admin\/standards\/new$/, - loader: () => import("@/app/(main)/admin/standards/new/page"), - }, - { - pattern: /^\/admin\/standards\/([^/]+)\/edit$/, - loader: () => import("@/app/(main)/admin/standards/[webType]/edit/page"), - extractParams: (url) => ({ webType: url.split("/")[3] }), - }, - { - pattern: /^\/admin\/standards\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/standards/[webType]/page"), - extractParams: (url) => ({ webType: url.split("/").pop()! }), - }, - { - pattern: /^\/admin\/systemMng\/dataflow\/edit\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"), - extractParams: (url) => ({ diagramId: url.split("/").pop()! }), - }, - { - pattern: /^\/admin\/screenMng\/barcodeList\/designer\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page"), - extractParams: (url) => ({ labelId: url.split("/").pop()! }), - }, - { - pattern: /^\/admin\/screenMng\/reportList\/designer\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/screenMng/reportList/designer/[reportId]/page"), - extractParams: (url) => ({ reportId: url.split("/").pop()! }), - }, - { - pattern: /^\/admin\/screenMng\/dashboardList\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/screenMng/dashboardList/[id]/page"), - extractParams: (url) => ({ id: url.split("/").pop()! }), - }, -]; - -interface DynamicRouteResult { - component: React.ComponentType; - params?: Record; -} - -const dynamicRouteCache = new Map(); - -function resolveDynamicRoute(cleanUrl: string): DynamicRouteResult | null { - if (dynamicRouteCache.has(cleanUrl)) { - return dynamicRouteCache.get(cleanUrl)!; - } - - for (const entry of DYNAMIC_ROUTE_PATTERNS) { - if (entry.pattern.test(cleanUrl)) { - const comp = d(entry.loader); - const params = entry.extractParams?.(cleanUrl); - const result: DynamicRouteResult = { component: comp, params }; - dynamicRouteCache.set(cleanUrl, result); - return result; - } - } - return null; -} - +// 매핑되지 않은 URL용 Fallback function AdminPageFallback({ url }: { url: string }) { return (
@@ -347,55 +95,15 @@ interface AdminPageRendererProps { } export function AdminPageRenderer({ url }: AdminPageRendererProps) { - const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); + const PageComponent = useMemo(() => { + // URL에서 쿼리스트링/해시 제거 후 매칭 + const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); + return ADMIN_PAGE_REGISTRY[cleanUrl] || null; + }, [url]); - // 0) /screens/[id] → ScreenViewPageWrapper 직접 렌더링 - // 탭 시스템에서는 useParams()가 동작하지 않으므로 screenId를 직접 추출해서 전달 - const screenIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/); - if (screenIdMatch) { - const screenId = parseInt(screenIdMatch[1]); - return ; - } - - // 0-1) /screen/[code] → screenCode를 screenId로 변환 후 렌더링 - const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/); - if (screenCodeMatch) { - return ; - } - - // 0-2) /dashboard/[id] → DashboardTabRenderer 직접 렌더링 - // Next.js의 params Promise를 우회하여 dashboardId를 직접 전달 - const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/); - if (dashboardMatch) { - return ; - } - - const resolved = useMemo(() => { - // 1) 정적 레지스트리 매칭 - if (ADMIN_PAGE_REGISTRY[cleanUrl]) { - return { component: ADMIN_PAGE_REGISTRY[cleanUrl] } as DynamicRouteResult; - } - - // 2) 동적 라우트 패턴 매칭 (/admin/xxx/[id] 등) - const dynamicMatch = resolveDynamicRoute(cleanUrl); - if (dynamicMatch) { - return dynamicMatch; - } - - return null; - }, [cleanUrl]); - - if (!resolved) { + if (!PageComponent) { return ; } - const { component: PageComponent, params } = resolved; - - // 동적 라우트에서 추출한 파라미터가 있으면 params={Promise.resolve(...)}로 전달 - // Next.js page 컴포넌트의 use(params)가 동기적으로 resolve됨 - if (params) { - return ; - } - return ; } diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 2fe934a4..ad9a6aaf 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -362,10 +362,8 @@ function AppLayoutInner({ children }: AppLayoutProps) { if (isMobile) setSidebarOpen(false); return; } - } catch (err) { - console.error("할당된 화면 조회 실패:", err); - toast.error("화면 정보를 불러오지 못했습니다. 다시 시도해주세요."); - return; + } catch { + console.warn("할당된 화면 조회 실패"); } if (menu.url && menu.url !== "#") { From 238a7d1db4402f22a571eed5941b42d6522ceb77 Mon Sep 17 00:00:00 2001 From: kmh Date: Wed, 11 Mar 2026 23:38:42 +0900 Subject: [PATCH 09/14] feat: Enhance V2RepeaterConfigPanel with entity join column management - Updated the toggleEntityJoinColumn function to include an optional columnType parameter for better flexibility in handling join columns. - Improved the logic for managing entity joins and columns, ensuring that columns are correctly added or removed based on user interactions. - Introduced a new section in the UI to display entity join columns in a read-only format, providing users with clear visibility of the join configurations. - Added loading states and messages to enhance user experience during data retrieval for entity joins. These changes aim to improve the functionality and usability of the V2RepeaterConfigPanel in managing entity relationships. --- .../config-panels/V2RepeaterConfigPanel.tsx | 278 +++++++++--------- 1 file changed, 134 insertions(+), 144 deletions(-) diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index e3f5f6cc..92efdcd8 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -375,12 +375,15 @@ export const V2RepeaterConfigPanel: React.FC = ({ // Entity 조인 컬럼 토글 (추가/제거) const toggleEntityJoinColumn = useCallback( - (joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => { + (joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string, columnType?: string) => { const currentJoins = config.entityJoins || []; const existingJoinIdx = currentJoins.findIndex( (j) => j.sourceColumn === sourceColumn && j.referenceTable === joinTableName, ); + let newEntityJoins = [...currentJoins]; + let newColumns = [...config.columns]; + if (existingJoinIdx >= 0) { const existingJoin = currentJoins[existingJoinIdx]; const existingColIdx = existingJoin.columns.findIndex((c) => c.referenceField === refColumnName); @@ -388,34 +391,49 @@ export const V2RepeaterConfigPanel: React.FC = ({ if (existingColIdx >= 0) { const updatedColumns = existingJoin.columns.filter((_, i) => i !== existingColIdx); if (updatedColumns.length === 0) { - updateConfig({ entityJoins: currentJoins.filter((_, i) => i !== existingJoinIdx) }); + newEntityJoins = newEntityJoins.filter((_, i) => i !== existingJoinIdx); } else { - const updated = [...currentJoins]; - updated[existingJoinIdx] = { ...existingJoin, columns: updatedColumns }; - updateConfig({ entityJoins: updated }); + newEntityJoins[existingJoinIdx] = { ...existingJoin, columns: updatedColumns }; } + // config.columns에서도 제거 + newColumns = newColumns.filter(c => !(c.key === displayField && c.isJoinColumn)); } else { - const updated = [...currentJoins]; - updated[existingJoinIdx] = { + newEntityJoins[existingJoinIdx] = { ...existingJoin, columns: [...existingJoin.columns, { referenceField: refColumnName, displayField }], }; - updateConfig({ entityJoins: updated }); + // config.columns에 추가 + newColumns.push({ + key: displayField, + title: refColumnLabel, + width: "auto", + visible: true, + editable: false, + isJoinColumn: true, + inputType: columnType || "text", + }); } } else { - updateConfig({ - entityJoins: [ - ...currentJoins, - { - sourceColumn, - referenceTable: joinTableName, - columns: [{ referenceField: refColumnName, displayField }], - }, - ], + newEntityJoins.push({ + sourceColumn, + referenceTable: joinTableName, + columns: [{ referenceField: refColumnName, displayField }], + }); + // config.columns에 추가 + newColumns.push({ + key: displayField, + title: refColumnLabel, + width: "auto", + visible: true, + editable: false, + isJoinColumn: true, + inputType: columnType || "text", }); } + + updateConfig({ entityJoins: newEntityJoins, columns: newColumns }); }, - [config.entityJoins, updateConfig], + [config.entityJoins, config.columns, updateConfig], ); // Entity 조인에 특정 컬럼이 설정되어 있는지 확인 @@ -604,9 +622,9 @@ export const V2RepeaterConfigPanel: React.FC = ({ // 컬럼 토글 (현재 테이블 컬럼 - 입력용) const toggleInputColumn = (column: ColumnOption) => { - const existingIndex = config.columns.findIndex((c) => c.key === column.columnName); + const existingIndex = config.columns.findIndex((c) => c.key === column.columnName && !c.isJoinColumn && !c.isSourceDisplay); if (existingIndex >= 0) { - const newColumns = config.columns.filter((c) => c.key !== column.columnName); + const newColumns = config.columns.filter((_, i) => i !== existingIndex); updateConfig({ columns: newColumns }); } else { // 컬럼의 inputType과 detailSettings 정보 포함 @@ -651,7 +669,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ }; const isColumnAdded = (columnName: string) => { - return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay); + return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay && !c.isJoinColumn); }; const isSourceColumnSelected = (columnName: string) => { @@ -761,10 +779,9 @@ export const V2RepeaterConfigPanel: React.FC = ({ return (
- + 기본 컬럼 - Entity 조인 {/* 기본 설정 탭 */} @@ -1365,6 +1382,84 @@ export const V2RepeaterConfigPanel: React.FC = ({ )}
+ {/* ===== 🆕 Entity 조인 컬럼 (표시용) ===== */} +
+
+ + +
+

+ FK 컬럼을 기반으로 참조 테이블의 데이터를 자동으로 조회하여 표시합니다. +

+ + {loadingEntityJoins ? ( +

로딩 중...

+ ) : entityJoinData.joinTables.length === 0 ? ( +

+ {entityJoinTargetTable + ? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다` + : "저장 테이블을 먼저 설정해주세요"} +

+ ) : ( +
+ {entityJoinData.joinTables.map((joinTable, tableIndex) => { + const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || ""; + + return ( +
+
+ + {joinTable.tableName} + ({sourceColumn}) +
+
+ {joinTable.availableColumns.map((column, colIndex) => { + const isActive = isEntityJoinColumnActive( + joinTable.tableName, + sourceColumn, + column.columnName, + ); + const matchingCol = config.columns.find((c) => c.key === column.columnName && c.isJoinColumn); + const displayField = matchingCol?.key || column.columnName; + + return ( +
+ toggleEntityJoinColumn( + joinTable.tableName, + sourceColumn, + column.columnName, + column.columnLabel, + displayField, + column.inputType || column.dataType + ) + } + > + + + {column.columnLabel} + + {column.inputType || column.dataType} + +
+ ); + })} +
+
+ ); + })} +
+ )} +
+ {/* 선택된 컬럼 상세 설정 - 🆕 모든 컬럼 통합, 순서 변경 가능 */} {config.columns.length > 0 && ( <> @@ -1381,7 +1476,7 @@ export const V2RepeaterConfigPanel: React.FC = ({
= ({ {/* 확장/축소 버튼 (입력 컬럼만) */} - {!col.isSourceDisplay && ( + {(!col.isSourceDisplay && !col.isJoinColumn) && (
{/* 확장된 상세 설정 (입력 컬럼만) */} - {!col.isSourceDisplay && expandedColumn === col.key && ( + {(!col.isSourceDisplay && !col.isJoinColumn) && expandedColumn === col.key && (
{/* 자동 입력 설정 */}
@@ -1812,120 +1916,6 @@ export const V2RepeaterConfigPanel: React.FC = ({ )} - {/* Entity 조인 설정 탭 */} - -
-
-

Entity 조인 연결

-

- FK 컬럼을 기반으로 참조 테이블의 데이터를 자동으로 조회하여 표시합니다 -

-
-
- - {loadingEntityJoins ? ( -

로딩 중...

- ) : entityJoinData.joinTables.length === 0 ? ( -
-

- {entityJoinTargetTable - ? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다` - : "저장 테이블을 먼저 설정해주세요"} -

-
- ) : ( -
- {entityJoinData.joinTables.map((joinTable, tableIndex) => { - const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || ""; - - return ( -
-
- - {joinTable.tableName} - ({sourceColumn}) -
-
- {joinTable.availableColumns.map((column, colIndex) => { - const isActive = isEntityJoinColumnActive( - joinTable.tableName, - sourceColumn, - column.columnName, - ); - const matchingCol = config.columns.find((c) => c.key === column.columnName); - const displayField = matchingCol?.key || column.columnName; - - return ( -
- toggleEntityJoinColumn( - joinTable.tableName, - sourceColumn, - column.columnName, - column.columnLabel, - displayField, - ) - } - > - - - {column.columnLabel} - - {column.inputType || column.dataType} - -
- ); - })} -
-
- ); - })} -
- )} - - {/* 현재 설정된 Entity 조인 목록 */} - {config.entityJoins && config.entityJoins.length > 0 && ( -
-

설정된 조인

-
- {config.entityJoins.map((join, idx) => ( -
- - {join.sourceColumn} - - {join.referenceTable} - - ({join.columns.map((c) => c.referenceField).join(", ")}) - - -
- ))} -
-
- )} -
-
-
); From 09c3fa4708047ba23f96e88ff6524096a5351f2d Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Mar 2026 01:18:09 +0900 Subject: [PATCH 10/14] feat: implement packaging unit and item management APIs - Added CRUD operations for packaging units and their associated items in the new `packagingController.ts`. - Implemented routes for managing packaging units and items in `packagingRoutes.ts`. - Enhanced error handling and logging for better traceability. - Ensured company code filtering for data access based on user roles. Made-with: Cursor --- .../src/controllers/packagingController.ts | 478 ++++++++++++++++++ backend-node/src/routes/packagingRoutes.ts | 28 +- .../src/services/screenManagementService.ts | 13 +- .../components/layout/AdminPageRenderer.tsx | 235 ++++++++- frontend/components/layout/AppLayout.tsx | 102 +++- frontend/components/layout/TabContent.tsx | 9 + .../v2/config-panels/V2InputConfigPanel.tsx | 66 +++ frontend/lib/api/menu.ts | 2 + .../components/v2-input/V2InputRenderer.tsx | 122 ++++- .../SplitPanelLayoutComponent.tsx | 1 + .../components/v2-split-panel-layout/types.ts | 20 +- .../v2-table-list/TableListComponent.tsx | 17 +- 12 files changed, 1005 insertions(+), 88 deletions(-) create mode 100644 backend-node/src/controllers/packagingController.ts diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts new file mode 100644 index 00000000..c804963f --- /dev/null +++ b/backend-node/src/controllers/packagingController.ts @@ -0,0 +1,478 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { logger } from "../utils/logger"; +import { getPool } from "../database/db"; + +// ────────────────────────────────────────────── +// 포장단위 (pkg_unit) CRUD +// ────────────────────────────────────────────── + +export async function getPkgUnits( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `SELECT * FROM pkg_unit ORDER BY company_code, created_date DESC`; + params = []; + } else { + sql = `SELECT * FROM pkg_unit WHERE company_code = $1 ORDER BY created_date DESC`; + params = [companyCode]; + } + + const result = await pool.query(sql, params); + logger.info("포장단위 목록 조회", { companyCode, count: result.rowCount }); + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("포장단위 목록 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createPkgUnit( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + const { + pkg_code, pkg_name, pkg_type, status, + width_mm, length_mm, height_mm, + self_weight_kg, max_load_kg, volume_l, remarks, + } = req.body; + + if (!pkg_code || !pkg_name) { + res.status(400).json({ success: false, message: "포장코드와 포장명은 필수입니다." }); + return; + } + + const dup = await pool.query( + `SELECT id FROM pkg_unit WHERE pkg_code = $1 AND company_code = $2`, + [pkg_code, companyCode] + ); + if (dup.rowCount && dup.rowCount > 0) { + res.status(409).json({ success: false, message: "이미 존재하는 포장코드입니다." }); + return; + } + + const result = await pool.query( + `INSERT INTO pkg_unit + (company_code, pkg_code, pkg_name, pkg_type, status, + width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, writer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING *`, + [companyCode, pkg_code, pkg_name, pkg_type, status || "ACTIVE", + width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, + req.user!.userId] + ); + + logger.info("포장단위 등록", { companyCode, pkg_code }); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("포장단위 등록 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updatePkgUnit( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const pool = getPool(); + const { + pkg_name, pkg_type, status, + width_mm, length_mm, height_mm, + self_weight_kg, max_load_kg, volume_l, remarks, + } = req.body; + + const result = await pool.query( + `UPDATE pkg_unit SET + pkg_name=$1, pkg_type=$2, status=$3, + width_mm=$4, length_mm=$5, height_mm=$6, + self_weight_kg=$7, max_load_kg=$8, volume_l=$9, remarks=$10, + updated_date=NOW(), writer=$11 + WHERE id=$12 AND company_code=$13 + RETURNING *`, + [pkg_name, pkg_type, status, + width_mm, length_mm, height_mm, + self_weight_kg, max_load_kg, volume_l, remarks, + req.user!.userId, id, companyCode] + ); + + if (result.rowCount === 0) { + res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return; + } + + logger.info("포장단위 수정", { companyCode, id }); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("포장단위 수정 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deletePkgUnit( + req: AuthenticatedRequest, + res: Response +): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + await client.query("BEGIN"); + await client.query( + `DELETE FROM pkg_unit_item WHERE pkg_code = (SELECT pkg_code FROM pkg_unit WHERE id=$1 AND company_code=$2) AND company_code=$2`, + [id, companyCode] + ); + const result = await client.query( + `DELETE FROM pkg_unit WHERE id=$1 AND company_code=$2 RETURNING id`, + [id, companyCode] + ); + await client.query("COMMIT"); + + if (result.rowCount === 0) { + res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return; + } + + logger.info("포장단위 삭제", { companyCode, id }); + res.json({ success: true }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("포장단위 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +// ────────────────────────────────────────────── +// 포장단위 매칭품목 (pkg_unit_item) CRUD +// ────────────────────────────────────────────── + +export async function getPkgUnitItems( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { pkgCode } = req.params; + const pool = getPool(); + + const result = await pool.query( + `SELECT * FROM pkg_unit_item WHERE pkg_code=$1 AND company_code=$2 ORDER BY created_date DESC`, + [pkgCode, companyCode] + ); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("매칭품목 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createPkgUnitItem( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + const { pkg_code, item_number, pkg_qty } = req.body; + + if (!pkg_code || !item_number) { + res.status(400).json({ success: false, message: "포장코드와 품번은 필수입니다." }); + return; + } + + const result = await pool.query( + `INSERT INTO pkg_unit_item (company_code, pkg_code, item_number, pkg_qty, writer) + VALUES ($1,$2,$3,$4,$5) + RETURNING *`, + [companyCode, pkg_code, item_number, pkg_qty, req.user!.userId] + ); + + logger.info("매칭품목 추가", { companyCode, pkg_code, item_number }); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("매칭품목 추가 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deletePkgUnitItem( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const pool = getPool(); + + const result = await pool.query( + `DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`, + [id, companyCode] + ); + + if (result.rowCount === 0) { + res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return; + } + + logger.info("매칭품목 삭제", { companyCode, id }); + res.json({ success: true }); + } catch (error: any) { + logger.error("매칭품목 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ────────────────────────────────────────────── +// 적재함 (loading_unit) CRUD +// ────────────────────────────────────────────── + +export async function getLoadingUnits( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `SELECT * FROM loading_unit ORDER BY company_code, created_date DESC`; + params = []; + } else { + sql = `SELECT * FROM loading_unit WHERE company_code = $1 ORDER BY created_date DESC`; + params = [companyCode]; + } + + const result = await pool.query(sql, params); + logger.info("적재함 목록 조회", { companyCode, count: result.rowCount }); + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("적재함 목록 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createLoadingUnit( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + const { + loading_code, loading_name, loading_type, status, + width_mm, length_mm, height_mm, + self_weight_kg, max_load_kg, max_stack, remarks, + } = req.body; + + if (!loading_code || !loading_name) { + res.status(400).json({ success: false, message: "적재함코드와 적재함명은 필수입니다." }); + return; + } + + const dup = await pool.query( + `SELECT id FROM loading_unit WHERE loading_code=$1 AND company_code=$2`, + [loading_code, companyCode] + ); + if (dup.rowCount && dup.rowCount > 0) { + res.status(409).json({ success: false, message: "이미 존재하는 적재함코드입니다." }); + return; + } + + const result = await pool.query( + `INSERT INTO loading_unit + (company_code, loading_code, loading_name, loading_type, status, + width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, writer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING *`, + [companyCode, loading_code, loading_name, loading_type, status || "ACTIVE", + width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, + req.user!.userId] + ); + + logger.info("적재함 등록", { companyCode, loading_code }); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("적재함 등록 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updateLoadingUnit( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const pool = getPool(); + const { + loading_name, loading_type, status, + width_mm, length_mm, height_mm, + self_weight_kg, max_load_kg, max_stack, remarks, + } = req.body; + + const result = await pool.query( + `UPDATE loading_unit SET + loading_name=$1, loading_type=$2, status=$3, + width_mm=$4, length_mm=$5, height_mm=$6, + self_weight_kg=$7, max_load_kg=$8, max_stack=$9, remarks=$10, + updated_date=NOW(), writer=$11 + WHERE id=$12 AND company_code=$13 + RETURNING *`, + [loading_name, loading_type, status, + width_mm, length_mm, height_mm, + self_weight_kg, max_load_kg, max_stack, remarks, + req.user!.userId, id, companyCode] + ); + + if (result.rowCount === 0) { + res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return; + } + + logger.info("적재함 수정", { companyCode, id }); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("적재함 수정 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteLoadingUnit( + req: AuthenticatedRequest, + res: Response +): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + await client.query("BEGIN"); + await client.query( + `DELETE FROM loading_unit_pkg WHERE loading_code = (SELECT loading_code FROM loading_unit WHERE id=$1 AND company_code=$2) AND company_code=$2`, + [id, companyCode] + ); + const result = await client.query( + `DELETE FROM loading_unit WHERE id=$1 AND company_code=$2 RETURNING id`, + [id, companyCode] + ); + await client.query("COMMIT"); + + if (result.rowCount === 0) { + res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return; + } + + logger.info("적재함 삭제", { companyCode, id }); + res.json({ success: true }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("적재함 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +// ────────────────────────────────────────────── +// 적재함 포장구성 (loading_unit_pkg) CRUD +// ────────────────────────────────────────────── + +export async function getLoadingUnitPkgs( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { loadingCode } = req.params; + const pool = getPool(); + + const result = await pool.query( + `SELECT * FROM loading_unit_pkg WHERE loading_code=$1 AND company_code=$2 ORDER BY created_date DESC`, + [loadingCode, companyCode] + ); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("적재구성 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createLoadingUnitPkg( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + const { loading_code, pkg_code, max_load_qty, load_method } = req.body; + + if (!loading_code || !pkg_code) { + res.status(400).json({ success: false, message: "적재함코드와 포장코드는 필수입니다." }); + return; + } + + const result = await pool.query( + `INSERT INTO loading_unit_pkg (company_code, loading_code, pkg_code, max_load_qty, load_method, writer) + VALUES ($1,$2,$3,$4,$5,$6) + RETURNING *`, + [companyCode, loading_code, pkg_code, max_load_qty, load_method, req.user!.userId] + ); + + logger.info("적재구성 추가", { companyCode, loading_code, pkg_code }); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("적재구성 추가 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteLoadingUnitPkg( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const pool = getPool(); + + const result = await pool.query( + `DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`, + [id, companyCode] + ); + + if (result.rowCount === 0) { + res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return; + } + + logger.info("적재구성 삭제", { companyCode, id }); + res.json({ success: true }); + } catch (error: any) { + logger.error("적재구성 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/packagingRoutes.ts b/backend-node/src/routes/packagingRoutes.ts index f501269e..db921caa 100644 --- a/backend-node/src/routes/packagingRoutes.ts +++ b/backend-node/src/routes/packagingRoutes.ts @@ -1,10 +1,36 @@ import { Router } from "express"; import { authenticateToken } from "../middleware/authMiddleware"; +import { + getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit, + getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem, + getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit, + getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg, +} from "../controllers/packagingController"; const router = Router(); router.use(authenticateToken); -// TODO: 포장/적재정보 관리 API 구현 예정 +// 포장단위 +router.get("/pkg-units", getPkgUnits); +router.post("/pkg-units", createPkgUnit); +router.put("/pkg-units/:id", updatePkgUnit); +router.delete("/pkg-units/:id", deletePkgUnit); + +// 포장단위 매칭품목 +router.get("/pkg-unit-items/:pkgCode", getPkgUnitItems); +router.post("/pkg-unit-items", createPkgUnitItem); +router.delete("/pkg-unit-items/:id", deletePkgUnitItem); + +// 적재함 +router.get("/loading-units", getLoadingUnits); +router.post("/loading-units", createLoadingUnit); +router.put("/loading-units/:id", updateLoadingUnit); +router.delete("/loading-units/:id", deleteLoadingUnit); + +// 적재함 포장구성 +router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs); +router.post("/loading-unit-pkgs", createLoadingUnitPkg); +router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg); export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 4c5bdc57..9d5d56a5 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2346,19 +2346,24 @@ export class ScreenManagementService { } /** - * 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료) + * 메뉴별 화면 목록 조회 + * company_code 매칭: 본인 회사 할당 + SUPER_ADMIN 글로벌 할당('*') 모두 조회 + * 본인 회사 할당이 우선, 없으면 글로벌 할당 사용 */ async getScreensByMenu( menuObjid: number, companyCode: string, ): Promise { const screens = await query( - `SELECT sd.* FROM screen_menu_assignments sma + `SELECT sd.* + FROM screen_menu_assignments sma INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id WHERE sma.menu_objid = $1 - AND sma.company_code = $2 + AND (sma.company_code = $2 OR sma.company_code = '*') AND sma.is_active = 'Y' - ORDER BY sma.display_order ASC`, + ORDER BY + CASE WHEN sma.company_code = $2 THEN 0 ELSE 1 END, + sma.display_order ASC`, [menuObjid, companyCode], ); diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 20175b5e..6f7ba4a4 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -1,8 +1,10 @@ "use client"; -import React, { useMemo } from "react"; +import React, { useMemo, useState, useEffect } from "react"; import dynamic from "next/dynamic"; import { Loader2 } from "lucide-react"; +import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page"; +import { apiClient } from "@/lib/api/client"; const LoadingFallback = () => (
@@ -10,11 +12,52 @@ const LoadingFallback = () => (
); -/** - * 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리. - * 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다. - * 매핑되지 않은 URL은 catch-all fallback으로 처리된다. - */ +function ScreenCodeResolver({ screenCode }: { screenCode: string }) { + const [screenId, setScreenId] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const numericId = parseInt(screenCode); + if (!isNaN(numericId)) { + setScreenId(numericId); + setLoading(false); + return; + } + const resolve = async () => { + try { + const res = await apiClient.get("/screen-management/screens", { + params: { searchTerm: screenCode, size: 50 }, + }); + const items = res.data?.data?.data || res.data?.data || []; + const arr = Array.isArray(items) ? items : []; + const exact = arr.find((s: any) => s.screenCode === screenCode); + const target = exact || arr[0]; + if (target) setScreenId(target.screenId || target.screen_id); + } catch { + console.error("스크린 코드 변환 실패:", screenCode); + } finally { + setLoading(false); + } + }; + resolve(); + }, [screenCode]); + + if (loading) return ; + if (!screenId) { + return ( +
+

화면을 찾을 수 없습니다 (코드: {screenCode})

+
+ ); + } + return ; +} + +const DashboardViewPage = dynamic( + () => import("@/app/(main)/dashboard/[dashboardId]/page"), + { ssr: false, loading: LoadingFallback }, +); + const ADMIN_PAGE_REGISTRY: Record> = { // 관리자 메인 "/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }), @@ -62,6 +105,16 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }), "/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }), + // 결재 관리 + "/admin/approvalTemplate": dynamic(() => import("@/app/(main)/admin/approvalTemplate/page"), { ssr: false, loading: LoadingFallback }), + "/admin/approvalBox": dynamic(() => import("@/app/(main)/admin/approvalBox/page"), { ssr: false, loading: LoadingFallback }), + "/admin/approvalMng": dynamic(() => import("@/app/(main)/admin/approvalMng/page"), { ssr: false, loading: LoadingFallback }), + + // 시스템 + "/admin/audit-log": dynamic(() => import("@/app/(main)/admin/audit-log/page"), { ssr: false, loading: LoadingFallback }), + "/admin/system-notices": dynamic(() => import("@/app/(main)/admin/system-notices/page"), { ssr: false, loading: LoadingFallback }), + "/admin/aiAssistant": dynamic(() => import("@/app/(main)/admin/aiAssistant/page"), { ssr: false, loading: LoadingFallback }), + // 기타 "/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), "/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }), @@ -73,18 +126,115 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }), }; -// 매핑되지 않은 URL용 Fallback +const DYNAMIC_ADMIN_IMPORTS: Record Promise> = { + "/admin/aiAssistant/dashboard": () => import("@/app/(main)/admin/aiAssistant/dashboard/page"), + "/admin/aiAssistant/history": () => import("@/app/(main)/admin/aiAssistant/history/page"), + "/admin/aiAssistant/api-keys": () => import("@/app/(main)/admin/aiAssistant/api-keys/page"), + "/admin/aiAssistant/api-test": () => import("@/app/(main)/admin/aiAssistant/api-test/page"), + "/admin/aiAssistant/usage": () => import("@/app/(main)/admin/aiAssistant/usage/page"), + "/admin/aiAssistant/chat": () => import("@/app/(main)/admin/aiAssistant/chat/page"), + "/admin/screenMng/barcodeList": () => import("@/app/(main)/admin/screenMng/barcodeList/page"), + "/admin/automaticMng/batchmngList/create": () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"), + "/admin/systemMng/dataflow/node-editorList": () => import("@/app/(main)/admin/systemMng/dataflow/node-editorList/page"), + "/admin/standards/new": () => import("@/app/(main)/admin/standards/new/page"), +}; + +const DYNAMIC_ADMIN_PATTERNS: Array<{ + pattern: RegExp; + getImport: (match: RegExpMatchArray) => Promise; + extractParams: (match: RegExpMatchArray) => Record; +}> = [ + { + pattern: /^\/admin\/userMng\/rolesList\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/userMng/rolesList/[id]/page"), + extractParams: (m) => ({ id: m[1] }), + }, + { + pattern: /^\/admin\/screenMng\/dashboardList\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/screenMng/dashboardList/[id]/page"), + extractParams: (m) => ({ id: m[1] }), + }, + { + pattern: /^\/admin\/automaticMng\/flowMgmtList\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/automaticMng/flowMgmtList/[id]/page"), + extractParams: (m) => ({ id: m[1] }), + }, + { + pattern: /^\/admin\/automaticMng\/batchmngList\/edit\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page"), + extractParams: (m) => ({ id: m[1] }), + }, + { + pattern: /^\/admin\/screenMng\/barcodeList\/designer\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page"), + extractParams: (m) => ({ labelId: m[1] }), + }, + { + pattern: /^\/admin\/screenMng\/reportList\/designer\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/screenMng/reportList/designer/[reportId]/page"), + extractParams: (m) => ({ reportId: m[1] }), + }, + { + pattern: /^\/admin\/systemMng\/dataflow\/edit\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"), + extractParams: (m) => ({ diagramId: m[1] }), + }, + { + pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/, + getImport: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"), + extractParams: (m) => ({ companyCode: m[1] }), + }, + { + pattern: /^\/admin\/standards\/([^/]+)\/edit$/, + getImport: () => import("@/app/(main)/admin/standards/[webType]/edit/page"), + extractParams: (m) => ({ webType: m[1] }), + }, + { + pattern: /^\/admin\/standards\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/standards/[webType]/page"), + extractParams: (m) => ({ webType: m[1] }), + }, +]; + +function DynamicAdminLoader({ url, params }: { url: string; params?: Record }) { + const [Component, setComponent] = useState | null>(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + const staticImport = DYNAMIC_ADMIN_IMPORTS[url]; + if (staticImport) { + staticImport() + .then((mod) => setComponent(() => mod.default)) + .catch(() => setFailed(true)); + return; + } + + for (const { pattern, getImport, extractParams } of DYNAMIC_ADMIN_PATTERNS) { + const match = url.match(pattern); + if (match) { + getImport() + .then((mod) => setComponent(() => mod.default)) + .catch(() => setFailed(true)); + return; + } + } + + setFailed(true); + }, [url]); + + if (failed) return ; + if (!Component) return ; + if (params) return ; + return ; +} + function AdminPageFallback({ url }: { url: string }) { return (

페이지 로딩 불가

-

- 경로: {url} -

-

- AdminPageRenderer 레지스트리에 이 URL을 추가해주세요. -

+

경로: {url}

+

해당 페이지가 존재하지 않습니다.

); @@ -95,15 +245,58 @@ interface AdminPageRendererProps { } export function AdminPageRenderer({ url }: AdminPageRendererProps) { - const PageComponent = useMemo(() => { - // URL에서 쿼리스트링/해시 제거 후 매칭 - const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); - return ADMIN_PAGE_REGISTRY[cleanUrl] || null; - }, [url]); + const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); - if (!PageComponent) { - return ; + console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl }); + + // 화면 할당: /screens/[id] + const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/); + if (screensIdMatch) { + console.log("[AdminPageRenderer] → /screens/[id] 매칭:", screensIdMatch[1]); + return ; } - return ; + // 화면 할당: /screen/[code] (구 형식) + const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/); + if (screenCodeMatch) { + console.log("[AdminPageRenderer] → /screen/[code] 매칭:", screenCodeMatch[1]); + return ; + } + + // 대시보드 할당: /dashboard/[id] + const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/); + if (dashboardMatch) { + console.log("[AdminPageRenderer] → /dashboard/[id] 매칭:", dashboardMatch[1]); + return ; + } + + // URL 직접 입력: 레지스트리 매칭 + const PageComponent = useMemo(() => { + return ADMIN_PAGE_REGISTRY[cleanUrl] || null; + }, [cleanUrl]); + + if (PageComponent) { + console.log("[AdminPageRenderer] → 레지스트리 매칭:", cleanUrl); + return ; + } + + // 레지스트리에 없으면 동적 import 시도 + // 동적 라우트 패턴 매칭 (params 추출) + for (const { pattern, extractParams } of DYNAMIC_ADMIN_PATTERNS) { + const match = cleanUrl.match(pattern); + if (match) { + const params = extractParams(match); + console.log("[AdminPageRenderer] → 동적 라우트 매칭:", cleanUrl, params); + return ; + } + } + + // 정적 동적 import 목록에 있으면 + if (DYNAMIC_ADMIN_IMPORTS[cleanUrl]) { + console.log("[AdminPageRenderer] → 동적 import:", cleanUrl); + return ; + } + + console.error("[AdminPageRenderer] 미등록 URL:", cleanUrl); + return ; } diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index ad9a6aaf..095552d5 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -202,12 +202,26 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle); + const menuUrl = menu.menu_url || menu.MENU_URL || "#"; + const screenCode = menu.screen_code || menu.SCREEN_CODE || null; + const menuType = String(menu.menu_type ?? menu.MENU_TYPE ?? ""); + + let screenId: number | null = null; + const screensMatch = menuUrl.match(/^\/screens\/(\d+)/); + if (screensMatch) { + screenId = parseInt(screensMatch[1]); + } + return { id: menuId, + objid: menuId, name: displayName, tabTitle, icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON), - url: menu.menu_url || menu.MENU_URL || "#", + url: menuUrl, + screenCode, + screenId, + menuType, children: children.length > 0 ? children : undefined, hasChildren: children.length > 0, }; @@ -341,42 +355,76 @@ function AppLayoutInner({ children }: AppLayoutProps) { const handleMenuClick = async (menu: any) => { if (menu.hasChildren) { toggleMenu(menu.id); - } else { - const menuName = menu.tabTitle || menu.label || menu.name || "메뉴"; - if (typeof window !== "undefined") { - localStorage.setItem("currentMenuName", menuName); + return; + } + + const menuName = menu.tabTitle || menu.label || menu.name || "메뉴"; + if (typeof window !== "undefined") { + localStorage.setItem("currentMenuName", menuName); + } + + const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0"); + const isAdminMenu = menu.menuType === "0"; + + console.log("[handleMenuClick] 메뉴 클릭:", { + menuName, + menuObjid, + menuType: menu.menuType, + isAdminMenu, + screenId: menu.screenId, + screenCode: menu.screenCode, + url: menu.url, + fullMenu: menu, + }); + + // 관리자 메뉴 (menu_type = 0): URL 직접 입력 → admin 탭 + if (isAdminMenu) { + if (menu.url && menu.url !== "#") { + console.log("[handleMenuClick] → admin 탭:", menu.url); + openTab({ type: "admin", title: menuName, adminUrl: menu.url }); + if (isMobile) setSidebarOpen(false); + } else { + toast.warning("이 메뉴에는 연결된 페이지가 없습니다."); } + return; + } + // 사용자 메뉴 (menu_type = 1, 2): 화면/대시보드 할당 + // 1) screenId가 메뉴 URL에서 추출된 경우 바로 screen 탭 + if (menu.screenId) { + console.log("[handleMenuClick] → screen 탭 (URL에서 screenId 추출):", menu.screenId); + openTab({ type: "screen", title: menuName, screenId: menu.screenId, menuObjid }); + if (isMobile) setSidebarOpen(false); + return; + } + + // 2) screen_menu_assignments 테이블 조회 + if (menuObjid) { try { - const menuObjid = menu.objid || menu.id; + console.log("[handleMenuClick] → screen_menu_assignments 조회 시도, menuObjid:", menuObjid); const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid); - + console.log("[handleMenuClick] → 조회 결과:", assignedScreens); if (assignedScreens.length > 0) { - const firstScreen = assignedScreens[0]; - openTab({ - type: "screen", - title: menuName, - screenId: firstScreen.screenId, - menuObjid: parseInt(menuObjid), - }); + console.log("[handleMenuClick] → screen 탭 (assignments):", assignedScreens[0].screenId); + openTab({ type: "screen", title: menuName, screenId: assignedScreens[0].screenId, menuObjid }); if (isMobile) setSidebarOpen(false); return; } - } catch { - console.warn("할당된 화면 조회 실패"); - } - - if (menu.url && menu.url !== "#") { - openTab({ - type: "admin", - title: menuName, - adminUrl: menu.url, - }); - if (isMobile) setSidebarOpen(false); - } else { - toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다."); + } catch (err) { + console.error("[handleMenuClick] 할당된 화면 조회 실패:", err); } } + + // 3) 대시보드 할당 (/dashboard/xxx) → admin 탭으로 렌더링 (AdminPageRenderer가 처리) + if (menu.url && menu.url.startsWith("/dashboard/")) { + console.log("[handleMenuClick] → 대시보드 탭:", menu.url); + openTab({ type: "admin", title: menuName, adminUrl: menu.url }); + if (isMobile) setSidebarOpen(false); + return; + } + + console.warn("[handleMenuClick] 어떤 조건에도 매칭 안 됨:", { menuName, menuType: menu.menuType, url: menu.url, screenId: menu.screenId }); + toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요."); }; const handleModeSwitch = () => { diff --git a/frontend/components/layout/TabContent.tsx b/frontend/components/layout/TabContent.tsx index 0c1fabfb..d6e36817 100644 --- a/frontend/components/layout/TabContent.tsx +++ b/frontend/components/layout/TabContent.tsx @@ -226,6 +226,14 @@ function TabPageRenderer({ tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string }; refreshKey: number; }) { + console.log("[TabPageRenderer] 탭 렌더링:", { + tabId: tab.id, + type: tab.type, + screenId: tab.screenId, + adminUrl: tab.adminUrl, + menuObjid: tab.menuObjid, + }); + if (tab.type === "screen" && tab.screenId != null) { return ( = ({ config,
)} + + {/* 데이터 바인딩 설정 */} + +
+
+ { + if (checked) { + updateConfig("dataBinding", { + sourceComponentId: config.dataBinding?.sourceComponentId || "", + sourceColumn: config.dataBinding?.sourceColumn || "", + }); + } else { + updateConfig("dataBinding", undefined); + } + }} + /> + +
+ + {config.dataBinding && ( +
+

+ v2-table-list에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다 +

+
+ + { + updateConfig("dataBinding", { + ...config.dataBinding, + sourceComponentId: e.target.value, + }); + }} + placeholder="예: tbl_items" + className="h-7 text-xs" + /> +

+ 같은 화면 내 v2-table-list 컴포넌트의 ID +

+
+
+ + { + updateConfig("dataBinding", { + ...config.dataBinding, + sourceColumn: e.target.value, + }); + }} + placeholder="예: item_number" + className="h-7 text-xs" + /> +

+ 선택된 행에서 가져올 컬럼명 +

+
+
+ )} +
); }; diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index 8611aeda..adbd53a0 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -42,6 +42,8 @@ export interface MenuItem { TRANSLATED_DESC?: string; menu_icon?: string; MENU_ICON?: string; + screen_code?: string; + SCREEN_CODE?: string; } export interface MenuFormData { diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx index 90c4f801..b6f929be 100644 --- a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -1,10 +1,78 @@ "use client"; -import React from "react"; +import React, { useEffect, useRef } from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { V2InputDefinition } from "./index"; import { V2Input } from "@/components/v2/V2Input"; import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer"; +import { v2EventBus, V2_EVENTS } from "@/lib/v2-core"; + +/** + * dataBinding이 설정된 v2-input을 위한 wrapper + * v2-table-list의 TABLE_DATA_CHANGE 이벤트를 구독하여 + * 선택된 행의 특정 컬럼 값을 자동으로 formData에 반영 + */ +function DataBindingWrapper({ + dataBinding, + columnName, + onFormDataChange, + isInteractive, + children, +}: { + dataBinding: { sourceComponentId: string; sourceColumn: string }; + columnName: string; + onFormDataChange?: (field: string, value: any) => void; + isInteractive?: boolean; + children: React.ReactNode; +}) { + const lastBoundValueRef = useRef(null); + + useEffect(() => { + if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return; + + console.log("[DataBinding] 구독 시작:", { + sourceComponentId: dataBinding.sourceComponentId, + sourceColumn: dataBinding.sourceColumn, + targetColumn: columnName, + isInteractive, + hasOnFormDataChange: !!onFormDataChange, + }); + + const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_DATA_CHANGE, (payload: any) => { + console.log("[DataBinding] TABLE_DATA_CHANGE 수신:", { + payloadSource: payload.source, + expectedSource: dataBinding.sourceComponentId, + dataLength: payload.data?.length, + match: payload.source === dataBinding.sourceComponentId, + }); + + if (payload.source !== dataBinding.sourceComponentId) return; + + const selectedData = payload.data; + if (selectedData && selectedData.length > 0) { + const value = selectedData[0][dataBinding.sourceColumn]; + console.log("[DataBinding] 바인딩 값:", { column: dataBinding.sourceColumn, value, columnName }); + if (value !== lastBoundValueRef.current) { + lastBoundValueRef.current = value; + if (onFormDataChange && columnName) { + onFormDataChange(columnName, value ?? ""); + } + } + } else { + if (lastBoundValueRef.current !== null) { + lastBoundValueRef.current = null; + if (onFormDataChange && columnName) { + onFormDataChange(columnName, ""); + } + } + } + }); + + return () => unsubscribe(); + }, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName, onFormDataChange, isInteractive]); + + return <>{children}; +} /** * V2Input 렌더러 @@ -16,41 +84,37 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer { render(): React.ReactElement { const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; - // 컴포넌트 설정 추출 const config = component.componentConfig || component.config || {}; const columnName = component.columnName; const tableName = component.tableName || this.props.tableName; - // formData에서 현재 값 가져오기 const currentValue = formData?.[columnName] ?? component.value ?? ""; - // 값 변경 핸들러 const handleChange = (value: any) => { - console.log("🔄 [V2InputRenderer] handleChange 호출:", { - columnName, - value, - isInteractive, - hasOnFormDataChange: !!onFormDataChange, - }); if (isInteractive && onFormDataChange && columnName) { onFormDataChange(columnName, value); - } else { - console.warn("⚠️ [V2InputRenderer] onFormDataChange 호출 스킵:", { - isInteractive, - hasOnFormDataChange: !!onFormDataChange, - columnName, - }); } }; - // 라벨: style.labelText 우선, 없으면 component.label 사용 - // 🔧 style.labelDisplay를 먼저 체크 (속성 패널에서 style 객체로 업데이트하므로) const style = component.style || {}; const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay; - // labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김) const effectiveLabel = labelDisplay === true ? style.labelText || component.label : undefined; - return ( + const dataBinding = config.dataBinding || (component as any).dataBinding || config.componentConfig?.dataBinding; + + if (dataBinding || (config as any).dataBinding || (component as any).dataBinding) { + console.log("[V2InputRenderer] dataBinding 탐색:", { + componentId: component.id, + columnName, + configKeys: Object.keys(config), + configDataBinding: config.dataBinding, + componentDataBinding: (component as any).dataBinding, + nestedDataBinding: config.componentConfig?.dataBinding, + finalDataBinding: dataBinding, + }); + } + + const inputElement = ( ); + + // dataBinding이 있으면 wrapper로 감싸서 이벤트 구독 + if (dataBinding?.sourceComponentId && dataBinding?.sourceColumn) { + return ( + + {inputElement} + + ); + } + + return inputElement; } } diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 94ab366a..9d987d5e 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -5103,6 +5103,7 @@ export const SplitPanelLayoutComponent: React.FC }} /> )} +
); }; diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index b738d317..ed41f578 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -118,9 +118,9 @@ export interface AdditionalTabConfig { // 추가 버튼 설정 (모달 화면 연결 지원) addButton?: { enabled: boolean; - mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 - modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) - buttonLabel?: string; // 버튼 라벨 (기본: "추가") + mode: "auto" | "modal"; + modalScreenId?: number; + buttonLabel?: string; }; deleteButton?: { @@ -161,9 +161,9 @@ export interface SplitPanelLayoutConfig { // 추가 버튼 설정 (모달 화면 연결 지원) addButton?: { enabled: boolean; - mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 - modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) - buttonLabel?: string; // 버튼 라벨 (기본: "추가") + mode: "auto" | "modal"; + modalScreenId?: number; + buttonLabel?: string; }; columns?: Array<{ @@ -334,10 +334,10 @@ export interface SplitPanelLayoutConfig { // 🆕 추가 버튼 설정 (모달 화면 연결 지원) addButton?: { - enabled: boolean; // 추가 버튼 표시 여부 (기본: true) - mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 - modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) - buttonLabel?: string; // 버튼 라벨 (기본: "추가") + enabled: boolean; + mode: "auto" | "modal"; + modalScreenId?: number; + buttonLabel?: string; }; // 🆕 삭제 버튼 설정 diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 2accfe1e..b7524f41 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -2080,11 +2080,19 @@ export const TableListComponent: React.FC = ({ }; const handleRowSelection = (rowKey: string, checked: boolean) => { - const newSelectedRows = new Set(selectedRows); - if (checked) { - newSelectedRows.add(rowKey); + const isMultiSelect = tableConfig.checkbox?.multiple !== false; + let newSelectedRows: Set; + + if (isMultiSelect) { + newSelectedRows = new Set(selectedRows); + if (checked) { + newSelectedRows.add(rowKey); + } else { + newSelectedRows.delete(rowKey); + } } else { - newSelectedRows.delete(rowKey); + // 단일 선택: 기존 선택 해제 후 새 항목만 선택 + newSelectedRows = checked ? new Set([rowKey]) : new Set(); } setSelectedRows(newSelectedRows); @@ -4154,6 +4162,7 @@ export const TableListComponent: React.FC = ({ const renderCheckboxHeader = () => { if (!tableConfig.checkbox?.selectAll) return null; + if (tableConfig.checkbox?.multiple === false) return null; return ; }; From 5c6469c75ca8ce2b4e44d94f5c859cc4af419585 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Mar 2026 02:02:45 +0900 Subject: [PATCH 11/14] refactor: simplify node editor page and update admin page registry - Removed the redirect logic from the NodeEditorPage, now directly rendering the DataFlowPage component. - Updated the AdminPageRenderer to correctly register the new node editor page and adjust the cascading management imports for consistency. - Enhanced the dynamic import logic to streamline page loading and improve performance. Made-with: Cursor --- .../dataflow/node-editorList/page.tsx | 21 +---- .../components/layout/AdminPageRenderer.tsx | 76 ++++++++++++------- 2 files changed, 52 insertions(+), 45 deletions(-) diff --git a/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx b/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx index 5930ecb9..f05b8164 100644 --- a/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/dataflow/node-editorList/page.tsx @@ -1,24 +1,7 @@ "use client"; -/** - * 제어 시스템 페이지 (리다이렉트) - * 이 페이지는 /admin/dataflow로 리다이렉트됩니다. - */ - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import DataFlowPage from "../page"; export default function NodeEditorPage() { - const router = useRouter(); - - useEffect(() => { - // /admin/dataflow 메인 페이지로 리다이렉트 - router.replace("/admin/systemMng/dataflow"); - }, [router]); - - return ( -
-
제어 관리 페이지로 이동중...
-
- ); + return ; } diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 6f7ba4a4..a9e01016 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -82,7 +82,9 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }), "/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }), "/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/cascading-managementList": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), "/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/dataflow/node-editorList": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }), // 자동화 관리 "/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }), @@ -117,13 +119,13 @@ const ADMIN_PAGE_REGISTRY: Record> = { // 기타 "/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), - "/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }), + "/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), "/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }), "/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }), "/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }), "/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }), "/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }), - "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }), + "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), }; const DYNAMIC_ADMIN_IMPORTS: Record Promise> = { @@ -135,7 +137,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record Promise> = { "/admin/aiAssistant/chat": () => import("@/app/(main)/admin/aiAssistant/chat/page"), "/admin/screenMng/barcodeList": () => import("@/app/(main)/admin/screenMng/barcodeList/page"), "/admin/automaticMng/batchmngList/create": () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"), - "/admin/systemMng/dataflow/node-editorList": () => import("@/app/(main)/admin/systemMng/dataflow/node-editorList/page"), + "/admin/systemMng/dataflow/node-editorList": () => import("@/app/(main)/admin/systemMng/dataflow/page"), "/admin/standards/new": () => import("@/app/(main)/admin/standards/new/page"), }; @@ -201,25 +203,52 @@ function DynamicAdminLoader({ url, params }: { url: string; params?: Record { - const staticImport = DYNAMIC_ADMIN_IMPORTS[url]; - if (staticImport) { - staticImport() - .then((mod) => setComponent(() => mod.default)) - .catch(() => setFailed(true)); - return; - } + let cancelled = false; - for (const { pattern, getImport, extractParams } of DYNAMIC_ADMIN_PATTERNS) { - const match = url.match(pattern); - if (match) { - getImport() - .then((mod) => setComponent(() => mod.default)) - .catch(() => setFailed(true)); + const tryLoad = async () => { + // 1) 정적 import 목록 + const staticImport = DYNAMIC_ADMIN_IMPORTS[url]; + if (staticImport) { + try { + const mod = await staticImport(); + if (!cancelled) setComponent(() => mod.default); + } catch { + if (!cancelled) setFailed(true); + } return; } - } - setFailed(true); + // 2) 동적 라우트 패턴 매칭 + for (const { pattern, getImport } of DYNAMIC_ADMIN_PATTERNS) { + const match = url.match(pattern); + if (match) { + try { + const mod = await getImport(); + if (!cancelled) setComponent(() => mod.default); + } catch { + if (!cancelled) setFailed(true); + } + return; + } + } + + // 3) URL 경로 기반 자동 import 시도 + const pagePath = url.replace(/^\//, ""); + try { + const mod = await import( + /* webpackMode: "lazy" */ + /* webpackInclude: /\/page\.tsx$/ */ + `@/app/(main)/${pagePath}/page` + ); + if (!cancelled) setComponent(() => mod.default); + } catch { + console.warn("[DynamicAdminLoader] 자동 import 실패:", url); + if (!cancelled) setFailed(true); + } + }; + + tryLoad(); + return () => { cancelled = true; }; }, [url]); if (failed) return ; @@ -291,12 +320,7 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) { } } - // 정적 동적 import 목록에 있으면 - if (DYNAMIC_ADMIN_IMPORTS[cleanUrl]) { - console.log("[AdminPageRenderer] → 동적 import:", cleanUrl); - return ; - } - - console.error("[AdminPageRenderer] 미등록 URL:", cleanUrl); - return ; + // 레지스트리/패턴에 없으면 DynamicAdminLoader가 자동 import 시도 + console.log("[AdminPageRenderer] → 자동 import 시도:", cleanUrl); + return ; } From fd90e3d761977e494b5bec21cc033c142f593874 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Mar 2026 02:13:15 +0900 Subject: [PATCH 12/14] feat: add audit logging for node flow operations - Integrated audit logging for create, update, and delete actions in the node flows API. - Enhanced the logging service to capture relevant details such as user information, action type, resource details, and IP address. - Updated the audit log service to include NODE_FLOW as a resource type. - Improved the overall traceability of node flow changes within the system. Made-with: Cursor --- .../src/routes/dataflow/node-flows.ts | 68 ++++++++++++++++++- backend-node/src/services/auditLogService.ts | 3 +- frontend/app/(main)/admin/audit-log/page.tsx | 1 + 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index 177b4304..30fffd7b 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -8,6 +8,7 @@ import { logger } from "../../utils/logger"; import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService"; import { AuthenticatedRequest } from "../../types/auth"; import { authenticateToken } from "../../middleware/authMiddleware"; +import { auditLogService, getClientIp } from "../../services/auditLogService"; const router = Router(); @@ -124,6 +125,21 @@ router.post("/", async (req: AuthenticatedRequest, res: Response) => { `플로우 저장 성공: ${result.flowId} (회사: ${userCompanyCode})` ); + auditLogService.log({ + companyCode: userCompanyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "CREATE", + resourceType: "NODE_FLOW", + resourceId: String(result.flowId), + resourceName: flowName, + tableName: "node_flows", + summary: `노드 플로우 "${flowName}" 생성`, + changes: { after: { flowName, flowDescription } }, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "플로우가 저장되었습니다.", @@ -143,7 +159,7 @@ router.post("/", async (req: AuthenticatedRequest, res: Response) => { /** * 플로우 수정 */ -router.put("/", async (req: Request, res: Response) => { +router.put("/", async (req: AuthenticatedRequest, res: Response) => { try { const { flowId, flowName, flowDescription, flowData } = req.body; @@ -154,6 +170,11 @@ router.put("/", async (req: Request, res: Response) => { }); } + const oldFlow = await queryOne( + `SELECT flow_name, flow_description FROM node_flows WHERE flow_id = $1`, + [flowId] + ); + await query( ` UPDATE node_flows @@ -168,6 +189,25 @@ router.put("/", async (req: Request, res: Response) => { logger.info(`플로우 수정 성공: ${flowId}`); + const userCompanyCode = req.user?.companyCode || "*"; + auditLogService.log({ + companyCode: userCompanyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "UPDATE", + resourceType: "NODE_FLOW", + resourceId: String(flowId), + resourceName: flowName, + tableName: "node_flows", + summary: `노드 플로우 "${flowName}" 수정`, + changes: { + before: oldFlow ? { flowName: (oldFlow as any).flow_name, flowDescription: (oldFlow as any).flow_description } : undefined, + after: { flowName, flowDescription }, + }, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "플로우가 수정되었습니다.", @@ -187,10 +227,15 @@ router.put("/", async (req: Request, res: Response) => { /** * 플로우 삭제 */ -router.delete("/:flowId", async (req: Request, res: Response) => { +router.delete("/:flowId", async (req: AuthenticatedRequest, res: Response) => { try { const { flowId } = req.params; + const oldFlow = await queryOne( + `SELECT flow_name, flow_description, company_code FROM node_flows WHERE flow_id = $1`, + [flowId] + ); + await query( ` DELETE FROM node_flows @@ -201,6 +246,25 @@ router.delete("/:flowId", async (req: Request, res: Response) => { logger.info(`플로우 삭제 성공: ${flowId}`); + const userCompanyCode = req.user?.companyCode || "*"; + const flowName = (oldFlow as any)?.flow_name || `ID:${flowId}`; + auditLogService.log({ + companyCode: userCompanyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "DELETE", + resourceType: "NODE_FLOW", + resourceId: String(flowId), + resourceName: flowName, + tableName: "node_flows", + summary: `노드 플로우 "${flowName}" 삭제`, + changes: { + before: oldFlow ? { flowName: (oldFlow as any).flow_name, flowDescription: (oldFlow as any).flow_description } : undefined, + }, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "플로우가 삭제되었습니다.", diff --git a/backend-node/src/services/auditLogService.ts b/backend-node/src/services/auditLogService.ts index bc77be49..9ac3e35e 100644 --- a/backend-node/src/services/auditLogService.ts +++ b/backend-node/src/services/auditLogService.ts @@ -41,7 +41,8 @@ export type AuditResourceType = | "DATA" | "TABLE" | "NUMBERING_RULE" - | "BATCH"; + | "BATCH" + | "NODE_FLOW"; export interface AuditLogParams { companyCode: string; diff --git a/frontend/app/(main)/admin/audit-log/page.tsx b/frontend/app/(main)/admin/audit-log/page.tsx index 74cb550b..8fbe5e95 100644 --- a/frontend/app/(main)/admin/audit-log/page.tsx +++ b/frontend/app/(main)/admin/audit-log/page.tsx @@ -74,6 +74,7 @@ const RESOURCE_TYPE_CONFIG: Record< SCREEN_LAYOUT: { label: "레이아웃", icon: Monitor, color: "bg-purple-100 text-purple-700" }, FLOW: { label: "플로우", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" }, FLOW_STEP: { label: "플로우 스텝", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" }, + NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" }, USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" }, ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" }, PERMISSION: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" }, From 20c85569b0a23b0d3613f25387e3c1aadb108230 Mon Sep 17 00:00:00 2001 From: kmh Date: Thu, 12 Mar 2026 07:02:22 +0900 Subject: [PATCH 13/14] fix: update filter handling in data filtering logic - Refactored the handling of "in" and "not_in" operators to ensure proper array handling and prevent errors when values are not provided. - Enhanced the InteractiveDataTable component to re-fetch data when filters are applied, improving user experience. - Updated DataFilterConfigPanel to correctly manage filter values based on selected operators. - Adjusted SplitPanelLayoutComponent to apply client-side data filtering based on defined conditions. These changes aim to improve the robustness and usability of the data filtering features across the application. --- ai-assistant/package-lock.json | 2 + .../src/services/tableManagementService.ts | 16 +++-- backend-node/src/utils/dataFilterUtil.ts | 24 ++++---- .../screen/InteractiveDataTable.tsx | 17 ++++++ .../config-panels/DataFilterConfigPanel.tsx | 27 ++++++++- .../table-options/TableOptionsToolbar.tsx | 5 +- .../SplitPanelLayoutComponent.tsx | 58 ++++++++++++++++++- .../SplitPanelLayoutConfigPanel.tsx | 8 +-- 8 files changed, 129 insertions(+), 28 deletions(-) diff --git a/ai-assistant/package-lock.json b/ai-assistant/package-lock.json index 30eef7bc..5cc0f755 100644 --- a/ai-assistant/package-lock.json +++ b/ai-assistant/package-lock.json @@ -947,6 +947,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -2184,6 +2185,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index d727a96e..0273b1fc 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3367,22 +3367,26 @@ export class TableManagementService { `${safeColumn} != '${String(value).replace(/'/g, "''")}'` ); break; - case "in": - if (Array.isArray(value) && value.length > 0) { - const values = value + case "in": { + const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; + if (inArr.length > 0) { + const values = inArr .map((v) => `'${String(v).replace(/'/g, "''")}'`) .join(", "); filterConditions.push(`${safeColumn} IN (${values})`); } break; - case "not_in": - if (Array.isArray(value) && value.length > 0) { - const values = value + } + case "not_in": { + const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; + if (notInArr.length > 0) { + const values = notInArr .map((v) => `'${String(v).replace(/'/g, "''")}'`) .join(", "); filterConditions.push(`${safeColumn} NOT IN (${values})`); } break; + } case "contains": filterConditions.push( `${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'` diff --git a/backend-node/src/utils/dataFilterUtil.ts b/backend-node/src/utils/dataFilterUtil.ts index a4e81fd6..0f472331 100644 --- a/backend-node/src/utils/dataFilterUtil.ts +++ b/backend-node/src/utils/dataFilterUtil.ts @@ -98,23 +98,27 @@ export function buildDataFilterWhereClause( paramIndex++; break; - case "in": - if (Array.isArray(value) && value.length > 0) { - const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + case "in": { + const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; + if (inArr.length > 0) { + const placeholders = inArr.map((_, idx) => `$${paramIndex + idx}`).join(", "); conditions.push(`${columnRef} IN (${placeholders})`); - params.push(...value); - paramIndex += value.length; + params.push(...inArr); + paramIndex += inArr.length; } break; + } - case "not_in": - if (Array.isArray(value) && value.length > 0) { - const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + case "not_in": { + const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; + if (notInArr.length > 0) { + const placeholders = notInArr.map((_, idx) => `$${paramIndex + idx}`).join(", "); conditions.push(`${columnRef} NOT IN (${placeholders})`); - params.push(...value); - paramIndex += value.length; + params.push(...notInArr); + paramIndex += notInArr.length; } break; + } case "contains": conditions.push(`${columnRef} LIKE $${paramIndex}`); diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 62472b96..9b5f1693 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -605,6 +605,23 @@ export const InteractiveDataTable: React.FC = ({ } }, [relatedButtonFilter]); + // TableOptionsContext 필터 변경 시 데이터 재조회 (TableSearchWidget 연동) + const filtersAppliedRef = useRef(false); + useEffect(() => { + // 초기 렌더 시 빈 배열은 무시 (불필요한 재조회 방지) + if (!filtersAppliedRef.current && filters.length === 0) return; + filtersAppliedRef.current = true; + + const filterSearchParams: Record = {}; + filters.forEach((f) => { + if (f.value !== "" && f.value !== undefined && f.value !== null) { + filterSearchParams[f.columnName] = f.value; + } + }); + loadData(1, { ...searchValues, ...filterSearchParams }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters]); + // 카테고리 타입 컬럼의 값 매핑 로드 useEffect(() => { const loadCategoryMappings = async () => { diff --git a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx index 48a7cbf9..3cffffff 100644 --- a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx +++ b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx @@ -541,8 +541,31 @@ export function DataFilterConfigPanel({ {/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */} {filter.valueType === "category" && categoryValues[filter.columnName] ? (