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/.gitignore b/.gitignore index 08276481..5e66bd12 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ dist/ build/ build/Release +# Gradle +.gradle/ +**/backend/.gradle/ + # Cache .npm .eslintcache 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/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 7cc4b29d..dc8cf064 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -108,6 +108,46 @@ export async function getUserMenus( } } +/** + * POP 메뉴 목록 조회 + * [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환 + */ +export async function getPopMenus( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const userCompanyCode = req.user?.companyCode || "ILSHIN"; + const userType = req.user?.userType; + + const result = await AdminService.getPopMenuList({ + userCompanyCode, + userType, + }); + + const response: ApiResponse = { + success: true, + message: "POP 메뉴 목록 조회 성공", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("POP 메뉴 목록 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "POP 메뉴 목록 조회 중 오류가 발생했습니다.", + error: { + code: "POP_MENU_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + /** * 메뉴 정보 조회 */ diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 2bb72876..809513b6 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -51,29 +51,24 @@ export class AuthController { logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`); + // 메뉴 조회를 위한 공통 파라미터 + const { AdminService } = await import("../services/adminService"); + const paramMap = { + userId: loginResult.userInfo.userId, + userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN", + userType: loginResult.userInfo.userType, + userLang: "ko", + }; + // 사용자의 첫 번째 접근 가능한 메뉴 조회 let firstMenuPath: string | null = null; try { - const { AdminService } = await import("../services/adminService"); - const paramMap = { - userId: loginResult.userInfo.userId, - userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN", - userType: loginResult.userInfo.userType, - userLang: "ko", - }; - const menuList = await AdminService.getUserMenuList(paramMap); logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); - // 접근 가능한 첫 번째 메뉴 찾기 - // 조건: - // 1. LEV (레벨)이 2 이상 (최상위 폴더 제외) - // 2. MENU_URL이 있고 비어있지 않음 - // 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴 const firstMenu = menuList.find((menu: any) => { const level = menu.lev || menu.level; const url = menu.menu_url || menu.url; - return level >= 2 && url && url.trim() !== "" && url !== "#"; }); @@ -94,6 +89,22 @@ export class AuthController { useType: "접속", }).catch(() => {}); + // POP 랜딩 경로 조회 + let popLandingPath: string | null = null; + try { + const popResult = await AdminService.getPopMenuList(paramMap); + if (popResult.landingMenu?.menu_url) { + popLandingPath = popResult.landingMenu.menu_url; + } else if (popResult.childMenus.length === 1) { + popLandingPath = popResult.childMenus[0].menu_url; + } else if (popResult.childMenus.length > 1) { + popLandingPath = "/pop"; + } + logger.debug(`POP 랜딩 경로: ${popLandingPath}`); + } catch (popError) { + logger.warn("POP 메뉴 조회 중 오류 (무시):", popError); + } + res.status(200).json({ success: true, message: "로그인 성공", @@ -101,6 +112,7 @@ export class AuthController { userInfo, token: loginResult.token, firstMenuPath, + popLandingPath, }, }); } else { diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index b9964962..a0779d50 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { getAdminMenus, getUserMenus, + getPopMenus, getMenuInfo, saveMenu, // 메뉴 추가 updateMenu, // 메뉴 수정 @@ -40,6 +41,7 @@ router.use(authenticateToken); // 메뉴 관련 API router.get("/menus", getAdminMenus); router.get("/user-menus", getUserMenus); +router.get("/pop-menus", getPopMenus); router.get("/menus/:menuId", getMenuInfo); router.post("/menus", saveMenu); // 메뉴 추가 router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!) diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index 730572d8..d25c6bdc 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -17,6 +17,7 @@ interface AutoGenMappingInfo { numberingRuleId: string; targetColumn: string; showResultModal?: boolean; + shareAcrossItems?: boolean; } interface HiddenMappingInfo { @@ -182,6 +183,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); } + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + + // 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번 + const sharedCodes: Record = {}; + for (const ag of allAutoGen) { + if (!ag.shareAcrossItems) continue; + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + try { + const code = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) }, + ); + sharedCodes[ag.targetColumn] = code; + generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 일괄 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } + } + for (const item of items) { const columns: string[] = ["company_code"]; const values: unknown[] = [companyCode]; @@ -225,26 +251,41 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(value); } - const allAutoGen = [ - ...(fieldMapping?.autoGenMappings ?? []), - ...(cardMapping?.autoGenMappings ?? []), - ]; for (const ag of allAutoGen) { if (!ag.numberingRuleId || !ag.targetColumn) continue; if (!isSafeIdentifier(ag.targetColumn)) continue; if (columns.includes(`"${ag.targetColumn}"`)) continue; - try { - const generatedCode = await numberingRuleService.allocateCode( - ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, - ); + + if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) { columns.push(`"${ag.targetColumn}"`); - values.push(generatedCode); - generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); - } catch (err: any) { - logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + values.push(sharedCodes[ag.targetColumn]); + } else if (!ag.shareAcrossItems) { + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } } } + if (!columns.includes('"created_date"')) { + columns.push('"created_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"updated_date"')) { + columns.push('"updated_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"writer"') && userId) { + columns.push('"writer"'); + values.push(userId); + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); await client.query( @@ -292,8 +333,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp for (let i = 0; i < lookupValues.length; i++) { const item = items[i] ?? {}; const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item); + const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; await client.query( - `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`, + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`, [resolved, companyCode, lookupValues[i]], ); processedCount++; @@ -311,9 +353,10 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`; + const autoUpdatedDb = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", "); await client.query( - `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`, + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql}${autoUpdatedDb} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`, [thenVal, elseVal, companyCode, ...lookupValues], ); processedCount += lookupValues.length; @@ -325,7 +368,14 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp if (valSource === "linked") { value = item[task.sourceField ?? ""] ?? null; } else { - value = task.fixedValue ?? ""; + const raw = task.fixedValue ?? ""; + if (raw === "__CURRENT_USER__") { + value = userId; + } else if (raw === "__CURRENT_TIME__") { + value = new Date().toISOString(); + } else { + value = raw; + } } let setSql: string; @@ -341,8 +391,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp setSql = `"${task.targetColumn}" = $1`; } + const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; await client.query( - `UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`, + `UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`, [value, companyCode, lookupValues[i]], ); processedCount++; @@ -448,6 +499,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); } + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + + // 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번 + const sharedCodes: Record = {}; + for (const ag of allAutoGen) { + if (!ag.shareAcrossItems) continue; + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + try { + const code = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) }, + ); + sharedCodes[ag.targetColumn] = code; + generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 일괄 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } + } + for (const item of items) { const columns: string[] = ["company_code"]; const values: unknown[] = [companyCode]; @@ -467,7 +543,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } - // 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼) const allHidden = [ ...(fieldMapping?.hiddenMappings ?? []), ...(cardMapping?.hiddenMappings ?? []), @@ -494,37 +569,44 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(value); } - // 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급 - const allAutoGen = [ - ...(fieldMapping?.autoGenMappings ?? []), - ...(cardMapping?.autoGenMappings ?? []), - ]; for (const ag of allAutoGen) { if (!ag.numberingRuleId || !ag.targetColumn) continue; if (!isSafeIdentifier(ag.targetColumn)) continue; if (columns.includes(`"${ag.targetColumn}"`)) continue; - try { - const generatedCode = await numberingRuleService.allocateCode( - ag.numberingRuleId, - companyCode, - { ...fieldValues, ...item }, - ); + + if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) { columns.push(`"${ag.targetColumn}"`); - values.push(generatedCode); - generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); - logger.info("[pop/execute-action] 채번 완료", { - ruleId: ag.numberingRuleId, - targetColumn: ag.targetColumn, - generatedCode, - }); - } catch (err: any) { - logger.error("[pop/execute-action] 채번 실패", { - ruleId: ag.numberingRuleId, - error: err.message, - }); + values.push(sharedCodes[ag.targetColumn]); + } else if (!ag.shareAcrossItems) { + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 채번 완료", { + ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } } } + if (!columns.includes('"created_date"')) { + columns.push('"created_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"updated_date"')) { + columns.push('"updated_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"writer"') && userId) { + columns.push('"writer"'); + values.push(userId); + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; @@ -558,6 +640,19 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(fieldValues[sourceField] ?? null); } + if (!columns.includes('"created_date"')) { + columns.push('"created_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"updated_date"')) { + columns.push('"updated_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"writer"') && userId) { + columns.push('"writer"'); + values.push(userId); + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; @@ -609,16 +704,18 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } if (valueType === "fixed") { + const autoUpd = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", "); - const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`; + const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd} WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`; await client.query(sql, [fixedValue, companyCode, ...lookupValues]); processedCount += lookupValues.length; } else { for (let i = 0; i < lookupValues.length; i++) { const item = items[i] ?? {}; const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item); + const autoUpd2 = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; await client.query( - `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`, + `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd2} WHERE company_code = $2 AND "${pkColumn}" = $3`, [resolvedValue, companyCode, lookupValues[i]] ); processedCount++; diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index e5d0c1a0..a27fcc77 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -621,6 +621,74 @@ export class AdminService { } } + /** + * POP 메뉴 목록 조회 + * menu_name_kor에 'POP'이 포함되거나 menu_desc에 [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환 + * [POP_LANDING] 태그가 있는 하위 메뉴를 landingMenu로 별도 반환 + */ + static async getPopMenuList(paramMap: any): Promise<{ parentMenu: any | null; childMenus: any[]; landingMenu: any | null }> { + try { + const { userCompanyCode, userType } = paramMap; + logger.info("AdminService.getPopMenuList 시작", { userCompanyCode, userType }); + + let queryParams: any[] = []; + let paramIndex = 1; + + let companyFilter = ""; + if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { + companyFilter = `AND COMPANY_CODE = '*'`; + } else { + companyFilter = `AND COMPANY_CODE = $${paramIndex}`; + queryParams.push(userCompanyCode); + paramIndex++; + } + + // POP L1 메뉴 조회 + const parentMenus = await query( + `SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS + FROM MENU_INFO + WHERE PARENT_OBJ_ID = 0 + AND MENU_TYPE = 1 + AND ( + MENU_DESC LIKE '%[POP]%' + OR UPPER(MENU_NAME_KOR) LIKE '%POP%' + ) + ${companyFilter} + ORDER BY SEQ + LIMIT 1`, + queryParams + ); + + if (parentMenus.length === 0) { + logger.info("POP 메뉴 없음 (L1 POP 메뉴 미발견)"); + return { parentMenu: null, childMenus: [], landingMenu: null }; + } + + const parentMenu = parentMenus[0]; + + // 하위 active 메뉴 조회 (부모와 같은 company_code로 필터링) + const childMenus = await query( + `SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS + FROM MENU_INFO + WHERE PARENT_OBJ_ID = $1 + AND STATUS = 'active' + AND COMPANY_CODE = $2 + ORDER BY SEQ`, + [parentMenu.objid, parentMenu.company_code] + ); + + // [POP_LANDING] 태그가 있는 메뉴를 랜딩 화면으로 지정 + const landingMenu = childMenus.find((m: any) => m.menu_desc?.includes("[POP_LANDING]")) || null; + + logger.info(`POP 메뉴 조회 완료: 부모=${parentMenu.menu_name_kor}, 하위=${childMenus.length}개, 랜딩=${landingMenu?.menu_name_kor || '없음'}`); + + return { parentMenu, childMenus, landingMenu }; + } catch (error) { + logger.error("AdminService.getPopMenuList 오류:", error); + throw error; + } + } + /** * 메뉴 정보 조회 */ diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index d727a96e..2ddae736 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, "''")}%'` @@ -4500,26 +4504,30 @@ export class TableManagementService { const rawColumns = await query( `SELECT - column_name as "columnName", - column_name as "displayName", - data_type as "dataType", - udt_name as "dbType", - is_nullable as "isNullable", - column_default as "defaultValue", - character_maximum_length as "maxLength", - numeric_precision as "numericPrecision", - numeric_scale as "numericScale", + c.column_name as "columnName", + c.column_name as "displayName", + c.data_type as "dataType", + c.udt_name as "dbType", + c.is_nullable as "isNullable", + c.column_default as "defaultValue", + c.character_maximum_length as "maxLength", + c.numeric_precision as "numericPrecision", + c.numeric_scale as "numericScale", CASE - WHEN column_name IN ( - SELECT column_name FROM information_schema.key_column_usage - WHERE table_name = $1 AND constraint_name LIKE '%_pkey' + WHEN c.column_name IN ( + SELECT kcu.column_name FROM information_schema.key_column_usage kcu + WHERE kcu.table_name = $1 AND kcu.constraint_name LIKE '%_pkey' ) THEN true ELSE false - END as "isPrimaryKey" - FROM information_schema.columns - WHERE table_name = $1 - AND table_schema = 'public' - ORDER BY ordinal_position`, + END as "isPrimaryKey", + col_description( + (SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')), + c.ordinal_position + ) as "columnComment" + FROM information_schema.columns c + WHERE c.table_name = $1 + AND c.table_schema = 'public' + ORDER BY c.ordinal_position`, [tableName] ); @@ -4529,10 +4537,10 @@ export class TableManagementService { displayName: col.displayName, dataType: col.dataType, dbType: col.dbType, - webType: "text", // 기본값 + webType: "text", inputType: "direct", detailSettings: "{}", - description: "", // 필수 필드 추가 + description: col.columnComment || "", isNullable: col.isNullable, isPrimaryKey: col.isPrimaryKey, defaultValue: col.defaultValue, @@ -4543,6 +4551,7 @@ export class TableManagementService { numericScale: col.numericScale ? Number(col.numericScale) : undefined, displayOrder: 0, isVisible: true, + columnComment: col.columnComment || "", })); logger.info( 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/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/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/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/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 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 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/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/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/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 62e48e08..73a8de80 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -6,8 +6,17 @@ import { LoginForm } from "@/components/auth/LoginForm"; import { LoginFooter } from "@/components/auth/LoginFooter"; export default function LoginPage() { - const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } = - useLogin(); + const { + formData, + isLoading, + error, + showPassword, + isPopMode, + handleInputChange, + handleLogin, + togglePasswordVisibility, + togglePopMode, + } = useLogin(); return (
@@ -19,9 +28,11 @@ export default function LoginPage() { isLoading={isLoading} error={error} showPassword={showPassword} + isPopMode={isPopMode} onInputChange={handleInputChange} onSubmit={handleLogin} onTogglePassword={togglePasswordVisibility} + onTogglePop={togglePopMode} /> diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index ec16c27d..7fe11270 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react"; import { useParams, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react"; +import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw, LayoutGrid, Monitor } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { useRouter } from "next/navigation"; @@ -285,14 +285,23 @@ function PopScreenViewPage() {
)} + {/* 일반 모드 네비게이션 바 */} + {!isPreviewMode && ( +
+ + {screen.screenName} + +
+ )} + {/* POP 화면 컨텐츠 */}
- {/* 현재 모드 표시 (일반 모드) */} - {!isPreviewMode && ( -
- {currentModeKey.replace("_", " ")} -
- )}
= ({ }); // 화면 할당 관련 상태 - const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당) + const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard" | "pop">("screen"); const [selectedScreen, setSelectedScreen] = useState(null); const [screens, setScreens] = useState([]); const [screenSearchText, setScreenSearchText] = useState(""); const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false); + // POP 화면 할당 관련 상태 + const [selectedPopScreen, setSelectedPopScreen] = useState(null); + const [popScreenSearchText, setPopScreenSearchText] = useState(""); + const [isPopScreenDropdownOpen, setIsPopScreenDropdownOpen] = useState(false); + const [isPopLanding, setIsPopLanding] = useState(false); + const [hasOtherPopLanding, setHasOtherPopLanding] = useState(false); + // 대시보드 할당 관련 상태 const [selectedDashboard, setSelectedDashboard] = useState(null); const [dashboards, setDashboards] = useState([]); @@ -196,8 +203,27 @@ export const MenuFormModal: React.FC = ({ toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`); }; + // POP 화면 선택 시 URL 자동 설정 + const handlePopScreenSelect = (screen: ScreenDefinition) => { + const actualScreenId = screen.screenId || screen.id; + if (!actualScreenId) { + toast.error("화면 ID를 찾을 수 없습니다."); + return; + } + + setSelectedPopScreen(screen); + setIsPopScreenDropdownOpen(false); + + const popUrl = `/pop/screens/${actualScreenId}`; + + setFormData((prev) => ({ + ...prev, + menuUrl: popUrl, + })); + }; + // URL 타입 변경 시 처리 - const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => { + const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard" | "pop") => { // console.log("🔄 URL 타입 변경:", { // from: urlType, // to: type, @@ -208,36 +234,53 @@ export const MenuFormModal: React.FC = ({ setUrlType(type); if (type === "direct") { - // 직접 입력 모드로 변경 시 선택된 화면 초기화 setSelectedScreen(null); - // URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록) + setSelectedPopScreen(null); setFormData((prev) => ({ ...prev, menuUrl: "", - screenCode: undefined, // 화면 코드도 함께 초기화 + screenCode: undefined, })); - } else { - // 화면 할당 모드로 변경 시 - // 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지 + } else if (type === "pop") { + setSelectedScreen(null); + if (selectedPopScreen) { + const actualScreenId = selectedPopScreen.screenId || selectedPopScreen.id; + setFormData((prev) => ({ + ...prev, + menuUrl: `/pop/screens/${actualScreenId}`, + })); + } else { + setFormData((prev) => ({ + ...prev, + menuUrl: "", + })); + } + } else if (type === "screen") { + setSelectedPopScreen(null); if (selectedScreen) { - console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName); - // 현재 선택된 화면으로 URL 재생성 const actualScreenId = selectedScreen.screenId || selectedScreen.id; let screenUrl = `/screens/${actualScreenId}`; - - // 관리자 메뉴인 경우 mode=admin 파라미터 추가 const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0"; if (isAdminMenu) { screenUrl += "?mode=admin"; } - setFormData((prev) => ({ ...prev, menuUrl: screenUrl, - screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지 + screenCode: selectedScreen.screenCode, })); } else { - // 선택된 화면이 없으면 URL과 screenCode 초기화 + setFormData((prev) => ({ + ...prev, + menuUrl: "", + screenCode: undefined, + })); + } + } else { + // dashboard + setSelectedScreen(null); + setSelectedPopScreen(null); + if (!selectedDashboard) { setFormData((prev) => ({ ...prev, menuUrl: "", @@ -297,8 +340,8 @@ export const MenuFormModal: React.FC = ({ const menuUrl = menu.menu_url || menu.MENU_URL || ""; - // URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정) - const isScreenUrl = menuUrl.startsWith("/screens/"); + const isPopScreenUrl = menuUrl.startsWith("/pop/screens/"); + const isScreenUrl = !isPopScreenUrl && menuUrl.startsWith("/screens/"); setFormData({ objid: menu.objid || menu.OBJID, @@ -360,10 +403,31 @@ export const MenuFormModal: React.FC = ({ }, 500); } } + } else if (isPopScreenUrl) { + setUrlType("pop"); + setSelectedScreen(null); + + // [POP_LANDING] 태그 감지 + const menuDesc = menu.menu_desc || menu.MENU_DESC || ""; + setIsPopLanding(menuDesc.includes("[POP_LANDING]")); + + const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1]; + if (popScreenId) { + const setPopScreenFromId = () => { + const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId); + if (screen) { + setSelectedPopScreen(screen); + } + }; + if (screens.length > 0) { + setPopScreenFromId(); + } else { + setTimeout(setPopScreenFromId, 500); + } + } } else if (menuUrl.startsWith("/dashboard/")) { setUrlType("dashboard"); setSelectedScreen(null); - // 대시보드 ID 추출 및 선택은 useEffect에서 처리됨 } else { setUrlType("direct"); setSelectedScreen(null); @@ -408,6 +472,7 @@ export const MenuFormModal: React.FC = ({ } else { console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType); setIsEdit(false); + setIsPopLanding(false); // 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1) let defaultMenuType = "1"; // 기본값은 사용자 @@ -470,6 +535,31 @@ export const MenuFormModal: React.FC = ({ } }, [isOpen, formData.companyCode]); + // POP 기본 화면 중복 체크: 같은 부모 하위에 이미 [POP_LANDING]이 있는 다른 메뉴가 있는지 확인 + useEffect(() => { + if (!isOpen) return; + + const checkOtherPopLanding = async () => { + try { + const res = await menuApi.getPopMenus(); + if (res.success && res.data?.landingMenu) { + const landingObjId = res.data.landingMenu.objid?.toString(); + const currentObjId = formData.objid?.toString(); + // 현재 수정 중인 메뉴가 아닌 다른 메뉴에 [POP_LANDING]이 있으면 중복 + setHasOtherPopLanding(!!landingObjId && landingObjId !== currentObjId); + } else { + setHasOtherPopLanding(false); + } + } catch { + setHasOtherPopLanding(false); + } + }; + + if (urlType === "pop") { + checkOtherPopLanding(); + } + }, [isOpen, urlType, formData.objid]); + // 화면 목록 및 대시보드 목록 로드 useEffect(() => { if (isOpen) { @@ -517,6 +607,22 @@ export const MenuFormModal: React.FC = ({ } }, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]); + // POP 화면 목록 로드 완료 후 기존 할당 설정 + useEffect(() => { + if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "pop") { + const menuUrl = formData.menuUrl; + if (menuUrl.startsWith("/pop/screens/")) { + const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1]; + if (popScreenId && !selectedPopScreen) { + const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId); + if (screen) { + setSelectedPopScreen(screen); + } + } + } + } + }, [screens, isEdit, formData.menuUrl, urlType, selectedPopScreen]); + // 드롭다운 외부 클릭 시 닫기 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -533,16 +639,20 @@ export const MenuFormModal: React.FC = ({ setIsDashboardDropdownOpen(false); setDashboardSearchText(""); } + if (!target.closest(".pop-screen-dropdown")) { + setIsPopScreenDropdownOpen(false); + setPopScreenSearchText(""); + } }; - if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) { + if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen || isPopScreenDropdownOpen) { document.addEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; - }, [isLangKeyDropdownOpen, isScreenDropdownOpen]); + }, [isLangKeyDropdownOpen, isScreenDropdownOpen, isDashboardDropdownOpen, isPopScreenDropdownOpen]); const loadCompanies = async () => { try { @@ -590,10 +700,17 @@ export const MenuFormModal: React.FC = ({ try { setLoading(true); + // POP 기본 화면 태그 처리 + let finalMenuDesc = formData.menuDesc; + if (urlType === "pop") { + const descWithoutTag = finalMenuDesc.replace(/\[POP_LANDING\]/g, "").trim(); + finalMenuDesc = isPopLanding ? `${descWithoutTag} [POP_LANDING]`.trim() : descWithoutTag; + } + // 백엔드에 전송할 데이터 변환 const submitData = { ...formData, - // 상태를 소문자로 변환 (백엔드에서 소문자 기대) + menuDesc: finalMenuDesc, status: formData.status.toLowerCase(), }; @@ -853,7 +970,7 @@ export const MenuFormModal: React.FC = ({ {/* URL 타입 선택 */} - +
+
+ + +
)} + {/* POP 화면 할당 */} + {urlType === "pop" && ( +
+
+ + + {isPopScreenDropdownOpen && ( +
+
+
+ + setPopScreenSearchText(e.target.value)} + className="pl-8" + /> +
+
+ +
+ {screens + .filter( + (screen) => + screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) || + screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()), + ) + .map((screen, index) => ( +
handlePopScreenSelect(screen)} + className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100" + > +
+
+
{screen.screenName}
+
{screen.screenCode}
+
+
ID: {screen.screenId || screen.id || "N/A"}
+
+
+ ))} + {screens.filter( + (screen) => + screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) || + screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()), + ).length === 0 &&
검색 결과가 없습니다.
} +
+
+ )} +
+ + {selectedPopScreen && ( +
+
{selectedPopScreen.screenName}
+
코드: {selectedPopScreen.screenCode}
+
생성된 URL: {formData.menuUrl}
+
+ )} + + {/* POP 기본 화면 설정 */} +
+ setIsPopLanding(e.target.checked)} + className="h-4 w-4 rounded border-gray-300 accent-primary disabled:cursor-not-allowed disabled:opacity-50" + /> + + {!isPopLanding && hasOtherPopLanding && ( + + (이미 다른 메뉴가 기본 화면으로 설정되어 있습니다) + + )} +
+ {isPopLanding && ( +

+ 프로필에서 POP 모드 전환 시 이 화면으로 바로 이동합니다. +

+ )} +
+ )} + {/* URL 직접 입력 */} {urlType === "direct" && ( ) => void; onSubmit: (e: React.FormEvent) => void; onTogglePassword: () => void; + onTogglePop: () => void; } /** @@ -24,9 +27,11 @@ export function LoginForm({ isLoading, error, showPassword, + isPopMode, onInputChange, onSubmit, onTogglePassword, + onTogglePop, }: LoginFormProps) { return ( @@ -82,6 +87,19 @@ export function LoginForm({
+ {/* POP 모드 토글 */} +
+
+ + POP 모드 +
+ +
+ {/* 로그인 버튼 */} )} + {scannedCode && ( + + )} + {scannedCode && !autoSubmit && ( + )} + {/* 사용자 배지 */} )} + + {conn.filterConfig?.targetColumn && ( +
+ + {conn.filterConfig.targetColumn} + + + {conn.filterConfig.filterMode} + + {conn.filterConfig.isSubTable && ( + + 하위 테이블 + + )} +
+ )} )} ))} - {isFilterSource ? ( - onAddConnection?.(data)} - submitLabel="연결 추가" - /> - ) : ( - onAddConnection?.(data)} - submitLabel="연결 추가" - /> - )} + onAddConnection?.(data)} + submitLabel="연결 추가" + /> ); } @@ -263,6 +205,19 @@ interface SimpleConnectionFormProps { submitLabel: string; } +function extractSubTableName(comp: PopComponentDefinitionV5): string | null { + const cfg = comp.config as Record | undefined; + if (!cfg) return null; + + const grid = cfg.cardGrid as { cells?: Array<{ timelineSource?: { processTable?: string } }> } | undefined; + if (grid?.cells) { + for (const cell of grid.cells) { + if (cell.timelineSource?.processTable) return cell.timelineSource.processTable; + } + } + return null; +} + function SimpleConnectionForm({ component, allComponents, @@ -274,6 +229,18 @@ function SimpleConnectionForm({ const [selectedTargetId, setSelectedTargetId] = React.useState( initial?.targetComponent || "" ); + const [isSubTable, setIsSubTable] = React.useState( + initial?.filterConfig?.isSubTable || false + ); + const [targetColumn, setTargetColumn] = React.useState( + initial?.filterConfig?.targetColumn || "" + ); + const [filterMode, setFilterMode] = React.useState( + initial?.filterConfig?.filterMode || "equals" + ); + + const [subColumns, setSubColumns] = React.useState([]); + const [loadingColumns, setLoadingColumns] = React.useState(false); const targetCandidates = allComponents.filter((c) => { if (c.id === component.id) return false; @@ -281,14 +248,39 @@ function SimpleConnectionForm({ return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0; }); + const sourceReg = PopComponentRegistry.getComponent(component.type); + const targetComp = allComponents.find((c) => c.id === selectedTargetId); + const targetReg = targetComp ? PopComponentRegistry.getComponent(targetComp.type) : null; + const isFilterConnection = sourceReg?.connectionMeta?.sendable?.some((s) => s.type === "filter_value") + && targetReg?.connectionMeta?.receivable?.some((r) => r.type === "filter_value"); + + const subTableName = targetComp ? extractSubTableName(targetComp) : null; + + React.useEffect(() => { + if (!isSubTable || !subTableName) { + setSubColumns([]); + return; + } + setLoadingColumns(true); + getTableColumns(subTableName) + .then((res) => { + const cols = res.success && res.data?.columns; + if (Array.isArray(cols)) { + setSubColumns(cols.map((c) => c.columnName || "").filter(Boolean)); + } + }) + .catch(() => setSubColumns([])) + .finally(() => setLoadingColumns(false)); + }, [isSubTable, subTableName]); + const handleSubmit = () => { if (!selectedTargetId) return; - const targetComp = allComponents.find((c) => c.id === selectedTargetId); + const tComp = allComponents.find((c) => c.id === selectedTargetId); const srcLabel = component.label || component.id; - const tgtLabel = targetComp?.label || targetComp?.id || "?"; + const tgtLabel = tComp?.label || tComp?.id || "?"; - onSubmit({ + const conn: Omit = { sourceComponent: component.id, sourceField: "", sourceOutput: "_auto", @@ -296,10 +288,23 @@ function SimpleConnectionForm({ targetField: "", targetInput: "_auto", label: `${srcLabel} → ${tgtLabel}`, - }); + }; + + if (isFilterConnection && isSubTable && targetColumn) { + conn.filterConfig = { + targetColumn, + filterMode: filterMode as "equals" | "contains" | "starts_with" | "range", + isSubTable: true, + }; + } + + onSubmit(conn); if (!initial) { setSelectedTargetId(""); + setIsSubTable(false); + setTargetColumn(""); + setFilterMode("equals"); } }; @@ -319,224 +324,12 @@ function SimpleConnectionForm({
어디로? - -
- - - - ); -} - -// ======================================== -// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지) -// ======================================== - -interface FilterConnectionFormProps { - component: PopComponentDefinitionV5; - meta: ComponentConnectionMeta; - allComponents: PopComponentDefinitionV5[]; - initial?: PopDataConnection; - onSubmit: (data: Omit) => void; - onCancel?: () => void; - submitLabel: string; -} - -function FilterConnectionForm({ - component, - meta, - allComponents, - initial, - onSubmit, - onCancel, - submitLabel, -}: FilterConnectionFormProps) { - const [selectedOutput, setSelectedOutput] = React.useState( - initial?.sourceOutput || meta.sendable[0]?.key || "" - ); - const [selectedTargetId, setSelectedTargetId] = React.useState( - initial?.targetComponent || "" - ); - const [selectedTargetInput, setSelectedTargetInput] = React.useState( - initial?.targetInput || "" - ); - const [filterColumns, setFilterColumns] = React.useState( - initial?.filterConfig?.targetColumns || - (initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : []) - ); - const [filterMode, setFilterMode] = React.useState< - "equals" | "contains" | "starts_with" | "range" - >(initial?.filterConfig?.filterMode || "contains"); - - const targetCandidates = allComponents.filter((c) => { - if (c.id === component.id) return false; - const reg = PopComponentRegistry.getComponent(c.type); - return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0; - }); - - const targetComp = selectedTargetId - ? allComponents.find((c) => c.id === selectedTargetId) - : null; - - const targetMeta = targetComp - ? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta - : null; - - React.useEffect(() => { - if (!selectedOutput || !targetMeta?.receivable?.length) return; - if (selectedTargetInput) return; - - const receivables = targetMeta.receivable; - const exactMatch = receivables.find((r) => r.key === selectedOutput); - if (exactMatch) { - setSelectedTargetInput(exactMatch.key); - return; - } - if (receivables.length === 1) { - setSelectedTargetInput(receivables[0].key); - } - }, [selectedOutput, targetMeta, selectedTargetInput]); - - const displayColumns = React.useMemo( - () => extractDisplayColumns(targetComp || undefined), - [targetComp] - ); - - const tableName = React.useMemo( - () => extractTableName(targetComp || undefined), - [targetComp] - ); - const [allDbColumns, setAllDbColumns] = React.useState([]); - const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false); - - React.useEffect(() => { - if (!tableName) { - setAllDbColumns([]); - return; - } - let cancelled = false; - setDbColumnsLoading(true); - getTableColumns(tableName).then((res) => { - if (cancelled) return; - if (res.success && res.data?.columns) { - setAllDbColumns(res.data.columns.map((c) => c.columnName)); - } else { - setAllDbColumns([]); - } - setDbColumnsLoading(false); - }); - return () => { cancelled = true; }; - }, [tableName]); - - const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]); - const dataOnlyColumns = React.useMemo( - () => allDbColumns.filter((c) => !displaySet.has(c)), - [allDbColumns, displaySet] - ); - const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0; - - const toggleColumn = (col: string) => { - setFilterColumns((prev) => - prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col] - ); - }; - - const handleSubmit = () => { - if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return; - - const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput); - - onSubmit({ - sourceComponent: component.id, - sourceField: "", - sourceOutput: selectedOutput, - targetComponent: selectedTargetId, - targetField: "", - targetInput: selectedTargetInput, - filterConfig: - !isEvent && filterColumns.length > 0 - ? { - targetColumn: filterColumns[0], - targetColumns: filterColumns, - filterMode, - } - : undefined, - label: buildConnectionLabel( - component, - selectedOutput, - allComponents.find((c) => c.id === selectedTargetId), - selectedTargetInput, - filterColumns - ), - }); - - if (!initial) { - setSelectedTargetId(""); - setSelectedTargetInput(""); - setFilterColumns([]); - } - }; - - return ( -
- {onCancel && ( -
-

연결 수정

- -
- )} - {!onCancel && ( -

새 연결 추가

- )} - -
- 보내는 값 - -
- -
- 받는 컴포넌트
- {targetMeta && ( -
- 받는 방식 - -
- )} - - {selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && ( -
-

필터할 컬럼

- - {dbColumnsLoading ? ( -
- - 컬럼 조회 중... -
- ) : hasAnyColumns ? ( -
- {displayColumns.length > 0 && ( -
-

화면 표시 컬럼

- {displayColumns.map((col) => ( -
- toggleColumn(col)} - /> - -
- ))} -
- )} - - {dataOnlyColumns.length > 0 && ( -
- {displayColumns.length > 0 && ( -
- )} -

데이터 전용 컬럼

- {dataOnlyColumns.map((col) => ( -
- toggleColumn(col)} - /> - -
- ))} -
- )} -
- ) : ( - setFilterColumns(e.target.value ? [e.target.value] : [])} - placeholder="컬럼명 입력" - className="h-7 text-xs" + {isFilterConnection && selectedTargetId && subTableName && ( +
+
+ { + setIsSubTable(v === true); + if (!v) setTargetColumn(""); + }} /> - )} - - {filterColumns.length > 0 && ( -

- {filterColumns.length}개 컬럼 중 하나라도 일치하면 표시 -

- )} - -
-

필터 방식

- +
+ + {isSubTable && ( +
+
+ 대상 컬럼 + {loadingColumns ? ( +
+ + 컬럼 로딩 중... +
+ ) : ( + + )} +
+ +
+ 비교 방식 + +
+
+ )}
)} @@ -662,7 +408,7 @@ function FilterConnectionForm({ size="sm" variant="outline" className="h-7 w-full text-xs" - disabled={!selectedOutput || !selectedTargetId || !selectedTargetInput} + disabled={!selectedTargetId} onClick={handleSubmit} > {!initial && } @@ -722,32 +468,3 @@ function ReceiveSection({ ); } -// ======================================== -// 유틸 -// ======================================== - -function isEventTypeConnection( - sourceMeta: ComponentConnectionMeta | undefined, - outputKey: string, - targetMeta: ComponentConnectionMeta | null | undefined, - inputKey: string, -): boolean { - const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey); - const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey); - return sourceItem?.type === "event" || targetItem?.type === "event"; -} - -function buildConnectionLabel( - source: PopComponentDefinitionV5, - _outputKey: string, - target: PopComponentDefinitionV5 | undefined, - _inputKey: string, - columns?: string[] -): string { - const srcLabel = source.label || source.id; - const tgtLabel = target?.label || target?.id || "?"; - const colInfo = columns && columns.length > 0 - ? ` [${columns.join(", ")}]` - : ""; - return `${srcLabel} → ${tgtLabel}${colInfo}`; -} diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 598f0e90..a9c7db6e 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -72,10 +72,14 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-icon": "아이콘", "pop-dashboard": "대시보드", "pop-card-list": "카드 목록", + "pop-card-list-v2": "카드 목록 V2", "pop-button": "버튼", "pop-string-list": "리스트 목록", "pop-search": "검색", + "pop-status-bar": "상태 바", "pop-field": "입력", + "pop-scanner": "스캐너", + "pop-profile": "프로필", }; // ======================================== @@ -554,7 +558,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect if (ActualComp) { // 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용 // CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용 - const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list"; + const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list" || component.type === "pop-card-list-v2"; return (
= ({ } }, [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] ? ( = ({ /> {/* 히든 토글 (입력 컬럼만) */} - {!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(", ")}) - - -
- ))} -
-
- )} -
-
-
); diff --git a/frontend/hooks/pop/executePopAction.ts b/frontend/hooks/pop/executePopAction.ts index ada6ad77..ad0981b6 100644 --- a/frontend/hooks/pop/executePopAction.ts +++ b/frontend/hooks/pop/executePopAction.ts @@ -322,7 +322,9 @@ export async function executeTaskList( } case "custom-event": - if (task.eventName) { + if (task.flowId) { + await apiClient.post(`/dataflow/node-flows/${task.flowId}/execute`, {}); + } else if (task.eventName) { publish(task.eventName, task.eventPayload ?? {}); } break; diff --git a/frontend/hooks/pop/useConnectionResolver.ts b/frontend/hooks/pop/useConnectionResolver.ts index 14bd321a..4aa03be3 100644 --- a/frontend/hooks/pop/useConnectionResolver.ts +++ b/frontend/hooks/pop/useConnectionResolver.ts @@ -20,7 +20,6 @@ import { usePopEvent } from "./usePopEvent"; import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout"; import { PopComponentRegistry, - type ConnectionMetaItem, } from "@/lib/registry/PopComponentRegistry"; interface UseConnectionResolverOptions { @@ -29,14 +28,21 @@ interface UseConnectionResolverOptions { componentTypes?: Map; } +interface AutoMatchPair { + sourceKey: string; + targetKey: string; + isFilter: boolean; +} + /** - * 소스/타겟의 connectionMeta에서 자동 매칭 가능한 이벤트 쌍을 찾는다. - * 규칙: category="event"이고 key가 동일한 쌍 + * 소스/타겟의 connectionMeta에서 자동 매칭 가능한 쌍을 찾는다. + * 규칙 1: category="event"이고 key가 동일한 쌍 (이벤트 매칭) + * 규칙 2: 소스 type="filter_value" + 타겟 type="filter_value" (필터 매칭) */ function getAutoMatchPairs( sourceType: string, targetType: string -): { sourceKey: string; targetKey: string }[] { +): AutoMatchPair[] { const sourceDef = PopComponentRegistry.getComponent(sourceType); const targetDef = PopComponentRegistry.getComponent(targetType); @@ -44,14 +50,18 @@ function getAutoMatchPairs( return []; } - const pairs: { sourceKey: string; targetKey: string }[] = []; + const pairs: AutoMatchPair[] = []; for (const s of sourceDef.connectionMeta.sendable) { - if (s.category !== "event") continue; for (const r of targetDef.connectionMeta.receivable) { - if (r.category !== "event") continue; - if (s.key === r.key) { - pairs.push({ sourceKey: s.key, targetKey: r.key }); + if (s.category === "event" && r.category === "event" && s.key === r.key) { + pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false }); + } + if (s.type === "filter_value" && r.type === "filter_value") { + pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: true }); + } + if (s.type === "all_rows" && r.type === "all_rows") { + pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false }); } } } @@ -93,10 +103,30 @@ export function useConnectionResolver({ const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`; const unsub = subscribe(sourceEvent, (payload: unknown) => { - publish(targetEvent, { - value: payload, - _connectionId: conn.id, - }); + if (pair.isFilter) { + const data = payload as Record | null; + const fieldName = data?.fieldName as string | undefined; + const filterColumns = data?.filterColumns as string[] | undefined; + const filterMode = (data?.filterMode as string) || "contains"; + // conn.filterConfig에 targetColumn이 명시되어 있으면 우선 사용 + const effectiveColumn = conn.filterConfig?.targetColumn || fieldName; + const effectiveMode = conn.filterConfig?.filterMode || filterMode; + const baseFilterConfig = effectiveColumn + ? { targetColumn: effectiveColumn, targetColumns: conn.filterConfig?.targetColumns || (filterColumns?.length ? filterColumns : [effectiveColumn]), filterMode: effectiveMode } + : conn.filterConfig; + publish(targetEvent, { + value: payload, + filterConfig: conn.filterConfig?.isSubTable + ? { ...baseFilterConfig, isSubTable: true } + : baseFilterConfig, + _connectionId: conn.id, + }); + } else { + publish(targetEvent, { + value: payload, + _connectionId: conn.id, + }); + } }); unsubscribers.push(unsub); } @@ -121,13 +151,22 @@ export function useConnectionResolver({ const unsub = subscribe(sourceEvent, (payload: unknown) => { const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`; - const enrichedPayload = { - value: payload, - filterConfig: conn.filterConfig, - _connectionId: conn.id, - }; + let resolvedFilterConfig = conn.filterConfig; + if (!resolvedFilterConfig) { + const data = payload as Record | null; + const fieldName = data?.fieldName as string | undefined; + const filterColumns = data?.filterColumns as string[] | undefined; + if (fieldName) { + const filterMode = (data?.filterMode as string) || "contains"; + resolvedFilterConfig = { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode: filterMode as "equals" | "contains" | "starts_with" | "range" }; + } + } - publish(targetEvent, enrichedPayload); + publish(targetEvent, { + value: payload, + filterConfig: resolvedFilterConfig, + _connectionId: conn.id, + }); }); unsubscribers.push(unsub); } diff --git a/frontend/hooks/useLogin.ts b/frontend/hooks/useLogin.ts index 09c32d5f..bd0cf9a2 100644 --- a/frontend/hooks/useLogin.ts +++ b/frontend/hooks/useLogin.ts @@ -20,6 +20,21 @@ export const useLogin = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(""); const [showPassword, setShowPassword] = useState(false); + const [isPopMode, setIsPopMode] = useState(false); + + // localStorage에서 POP 모드 상태 복원 + useEffect(() => { + const saved = localStorage.getItem("popLoginMode"); + if (saved === "true") setIsPopMode(true); + }, []); + + const togglePopMode = useCallback(() => { + setIsPopMode((prev) => { + const next = !prev; + localStorage.setItem("popLoginMode", String(next)); + return next; + }); + }, []); /** * 폼 입력값 변경 처리 @@ -141,17 +156,22 @@ export const useLogin = () => { // 쿠키에도 저장 (미들웨어에서 사용) document.cookie = `authToken=${result.data.token}; path=/; max-age=86400; SameSite=Lax`; - // 로그인 성공 - 첫 번째 접근 가능한 메뉴로 리다이렉트 - const firstMenuPath = result.data?.firstMenuPath; - - if (firstMenuPath) { - // 접근 가능한 메뉴가 있으면 해당 메뉴로 이동 - console.log("첫 번째 접근 가능한 메뉴로 이동:", firstMenuPath); - router.push(firstMenuPath); + if (isPopMode) { + const popPath = result.data?.popLandingPath; + if (popPath) { + router.push(popPath); + } else { + setError("POP 화면이 설정되어 있지 않습니다. 관리자에게 메뉴 관리에서 POP 화면을 설정해달라고 요청하세요."); + setIsLoading(false); + return; + } } else { - // 접근 가능한 메뉴가 없으면 메인 페이지로 이동 - console.log("접근 가능한 메뉴가 없어 메인 페이지로 이동"); - router.push(AUTH_CONFIG.ROUTES.MAIN); + const firstMenuPath = result.data?.firstMenuPath; + if (firstMenuPath) { + router.push(firstMenuPath); + } else { + router.push(AUTH_CONFIG.ROUTES.MAIN); + } } } else { // 로그인 실패 @@ -165,7 +185,7 @@ export const useLogin = () => { setIsLoading(false); } }, - [formData, validateForm, apiCall, router], + [formData, validateForm, apiCall, router, isPopMode], ); // 컴포넌트 마운트 시 기존 인증 상태 확인 @@ -179,10 +199,12 @@ export const useLogin = () => { isLoading, error, showPassword, + isPopMode, // 액션 handleInputChange, handleLogin, togglePasswordVisibility, + togglePopMode, }; }; diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index adbd53a0..7ddafaa0 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -81,6 +81,23 @@ export interface ApiResponse { errorCode?: string; } +export interface PopMenuItem { + objid: string; + menu_name_kor: string; + menu_url: string; + menu_desc: string; + seq: number; + company_code: string; + status: string; + screenId?: number; +} + +export interface PopMenuResponse { + parentMenu: PopMenuItem | null; + childMenus: PopMenuItem[]; + landingMenu: PopMenuItem | null; +} + export const menuApi = { // 관리자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시) getAdminMenus: async (): Promise> => { @@ -96,6 +113,12 @@ export const menuApi = { return response.data; }, + // POP 메뉴 목록 조회 ([POP] 태그 L1 하위 active 메뉴) + getPopMenus: async (): Promise> => { + const response = await apiClient.get("/admin/pop-menus"); + return response.data; + }, + // 관리자 메뉴 목록 조회 (메뉴 관리 화면용 - 모든 상태 표시) getAdminMenusForManagement: async (): Promise> => { const response = await apiClient.get("/admin/menus", { params: { menuType: "0", includeInactive: "true" } }); 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(); diff --git a/frontend/lib/registry/PopComponentRegistry.ts b/frontend/lib/registry/PopComponentRegistry.ts index 3793bc2d..2fe44592 100644 --- a/frontend/lib/registry/PopComponentRegistry.ts +++ b/frontend/lib/registry/PopComponentRegistry.ts @@ -35,6 +35,7 @@ export interface PopComponentDefinition { preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용 defaultProps?: Record; connectionMeta?: ComponentConnectionMeta; + getDynamicConnectionMeta?: (config: Record) => ComponentConnectionMeta; // POP 전용 속성 touchOptimized?: boolean; minTouchArea?: number; diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index f3ed2145..f5d5666b 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -36,7 +36,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { apiClient } from "@/lib/api/client"; -import { getCategoryValues } from "@/lib/api/tableCategoryValue"; +import { getCategoryValues, getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue"; export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps { // 추가 props @@ -1354,6 +1354,40 @@ export const SplitPanelLayout2Component: React.FC { + if (isDesignMode) return; + const allData = [...leftData, ...rightData]; + if (allData.length === 0) return; + + const unresolvedCodes = new Set(); + const checkValue = (v: unknown) => { + if (typeof v === "string" && (v.startsWith("CAT_") || v.startsWith("CATEGORY_"))) { + if (!categoryLabelMap[v]) unresolvedCodes.add(v); + } + }; + for (const item of allData) { + for (const val of Object.values(item)) { + if (Array.isArray(val)) { + val.forEach(checkValue); + } else { + checkValue(val); + } + } + } + + if (unresolvedCodes.size === 0) return; + + const resolveMissingLabels = async () => { + const result = await getCategoryLabelsByCodes(Array.from(unresolvedCodes)); + if (result.success && result.data && Object.keys(result.data).length > 0) { + setCategoryLabelMap((prev) => ({ ...prev, ...result.data })); + } + }; + + resolveMissingLabels(); + }, [isDesignMode, leftData, rightData, categoryLabelMap]); + // 컴포넌트 언마운트 시 DataProvider 해제 useEffect(() => { return () => { 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 cef90668..627cf1fa 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 { @@ -95,12 +96,12 @@ const ConditionCard: React.FC = ({ }; return ( -
+
{/* 헤더 */} -
+
조건 {index + 1} {!readonly && ( - )} @@ -111,7 +112,7 @@ const ConditionCard: React.FC = ({ {/* 열 범위 */}
-
-
{/* 계산 결과 */} -
+
{locationCount > 0 ? ( <> {localValues.startRow}열 ~ {localValues.endRow}열 x {localValues.levels}단 ={" "} @@ -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], ); // 미리보기 생성 @@ -626,7 +627,7 @@ export const RackStructureComponent: React.FC = ({ -
렉 라인 구조 설정 +
렉 라인 구조 설정 {!readonly && (
@@ -719,8 +720,8 @@ export const RackStructureComponent: React.FC = ({ {/* 기존 데이터 존재 알림 */} {!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && ( - - + + 해당 창고/층/구역에 {existingLocations.length}개의 위치가 이미 등록되어 있습니다. @@ -729,9 +730,9 @@ export const RackStructureComponent: React.FC = ({ {/* 현재 매핑된 값 표시 */} {(context.warehouseCode || context.warehouseName || context.floor || context.zone) && ( -
+
{(context.warehouseCode || context.warehouseName) && ( - + 창고: {context.warehouseName || context.warehouseCode} {context.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`} @@ -748,28 +749,28 @@ export const RackStructureComponent: React.FC = ({ )} {context.status && ( - 상태: {context.status} + 상태: {context.status} )}
)} {/* 안내 메시지 */} -
-
    +
    +
    1. - + 1 조건 추가 버튼을 클릭하여 렉 라인 조건을 생성하세요
    2. - + 2 각 조건마다 열 범위와 단 수를 입력하세요
    3. - + 3 예시: 조건1(1~3열, 3단), 조건2(4~6열, 5단) @@ -779,9 +780,9 @@ export const RackStructureComponent: React.FC = ({ {/* 조건 목록 또는 빈 상태 */} {conditions.length === 0 ? ( -
      -
      📦
      -

      조건을 추가하여 렉 구조를 설정하세요

      +
      +
      📦
      +

      조건을 추가하여 렉 구조를 설정하세요

      {!readonly && (