jskim-node #413

Merged
kjs merged 6 commits from jskim-node into main 2026-03-12 14:25:57 +09:00
99 changed files with 14205 additions and 1442 deletions
Showing only changes of commit cada8cd4b0 - Show all commits

View File

@ -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<void> {
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<T>(sql, params)` — 다건 조회 (배열 반환)
- `queryOne<T>(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<T>` — 표준 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 (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight">페이지 제목</h1>
<p className="text-sm text-muted-foreground">페이지 설명</p>
</div>
{/* 툴바 + 테이블 + 모달 등 */}
<XxxToolbar ... />
<XxxTable ... />
</div>
</div>
);
}
```
**핵심 규칙:**
- 모든 페이지: `"use client"` + `export default function`
- 비즈니스 로직은 커스텀 훅으로 분리
- 페이지는 훅 + UI 컴포넌트 조합에 집중
### 3.3 컴포넌트 패턴
```tsx
// frontend/components/xxx/XxxToolbar.tsx
interface XxxToolbarProps {
searchFilter: SearchFilter;
totalCount: number;
onSearchChange: (filter: Partial<SearchFilter>) => void;
onCreateClick: () => void;
}
export function XxxToolbar({
searchFilter,
totalCount,
onSearchChange,
onCreateClick,
}: XxxToolbarProps) {
return (
<div className="flex items-center justify-between">
{/* ... */}
</div>
);
}
```
**핵심 규칙:**
- `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<XxxItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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<T> {
success: boolean;
data?: T;
message?: string;
}
export async function getXxxList(params?: Record<string, any>) {
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` 라이브러리 직접 사용
- 루트 레이아웃에 `<Toaster position="top-right" />` 설정됨
### 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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">모달 제목</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">설명</DialogDescription>
</DialogHeader>
{/* 컨텐츠 */}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={onClose}>취소</Button>
<Button onClick={handleSubmit}>확인</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```
### 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. **항상 한글로 답변**

4
.gitignore vendored
View File

@ -31,6 +31,10 @@ dist/
build/
build/Release
# Gradle
.gradle/
**/backend/.gradle/
# Cache
.npm
.eslintcache

View File

@ -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",

View File

@ -108,6 +108,46 @@ export async function getUserMenus(
}
}
/**
* POP
* [POP] L1 active
*/
export async function getPopMenus(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userCompanyCode = req.user?.companyCode || "ILSHIN";
const userType = req.user?.userType;
const result = await AdminService.getPopMenuList({
userCompanyCode,
userType,
});
const response: ApiResponse<any> = {
success: true,
message: "POP 메뉴 목록 조회 성공",
data: result,
};
res.status(200).json(response);
} catch (error) {
logger.error("POP 메뉴 목록 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "POP 메뉴 목록 조회 중 오류가 발생했습니다.",
error: {
code: "POP_MENU_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/

View File

@ -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 {

View File

@ -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!)

View File

@ -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<string, string> = {};
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<string, string> = {};
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++;

View File

@ -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<any>(
`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<any>(
`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;
}
}
/**
*
*/

View File

@ -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<any>(
`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(

View File

@ -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}`);

View File

@ -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
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setIsAddModalOpen(false)} ...>
닫기
</Button>
<Button onClick={handleAdd} ...>
추가
</Button>
</DialogFooter>
```
---
## 변경 후 동작
### 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
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsAddModalOpen(false)}
className="h-9 flex-1 text-sm sm:flex-none"
>
닫기
</Button>
<Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none">
추가
</Button>
</DialogFooter>
{/* 연속 등록 모드 체크박스 - ScreenModal.tsx 패턴 */}
<div className="border-t px-4 py-3">
<div className="flex items-center gap-2">
<Checkbox
id="tree-continuous-add"
checked={continuousAdd}
onCheckedChange={(checked) => setContinuousAdd(checked as boolean)}
/>
<Label htmlFor="tree-continuous-add" className="cursor-pointer text-sm font-normal select-none">
저장 후 계속 입력 (연속 등록 모드)
</Label>
</div>
</div>
```
---
## 예상 문제 및 대응
`CategoryValueAddDialog.tsx`와 동일한 패턴이므로 별도 예상 문제 없음.
---
## 설계 원칙
- `CategoryValueAddDialog.tsx`(같은 폴더, 같은 목적)의 패턴을 그대로 따름
- 기존 수정/삭제 모달 동작은 변경하지 않음
- 하위 추가(중분류/소분류) 모달도 동일한 `handleAdd`를 사용하므로 자동 적용
- `Checkbox` import는 이미 존재 (24행)하므로 추가 import 불필요
- `Label` import는 이미 존재 (53행)하므로 추가 import 불필요
- 체크박스 위치/라벨/className 모두 `ScreenModal.tsx` (1287~1303행)과 동일

View File

@ -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 불필요

View File

@ -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단계 검증은 수동 테스트 필요 |

View File

@ -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` 두 곳의 동일 패턴을 일관되게 수정

View File

@ -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` — 화면에만 보이는 값, 들여쓰기 포함
- 데이터 무결성에 영향 없음

View File

@ -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단계 문서 정리 완료 |

View File

@ -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<Record<FormatSegmentType, string>> = {
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, string>,
): 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<string, string> = {
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 설정 섹션 아래에 추가 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-gray-700">포맷 설정</div>
<p className="text-xs text-gray-500">
위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고,
구분자/라벨을 편집할 수 있습니다
</p>
<FormatSegmentEditor
label="위치코드 포맷"
segments={formatConfig.codeSegments}
onChange={(segs) => handleFormatChange("codeSegments", segs)}
sampleValues={sampleValues}
/>
<FormatSegmentEditor
label="위치명 포맷"
segments={formatConfig.nameSegments}
onChange={(segs) => handleFormatChange("nameSegments", segs)}
sampleValues={sampleValues}
/>
</div>
```
### 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 스키마 변경 없음

View File

@ -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, ... (단 번호 - 숫자)
```

View File

@ -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단계 검증 완료, 전체 작업 완료 |

View File

@ -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 중앙 `<span>``<input>` + `/` + `<span>` 교체 |
| | | (3) `handlePageSizeChange``onConfigChange` 호출 추가 |
| | | (4) `fetchTableDataInternal`에서 `currentPage`를 단일 소스로 사용 |
| | | (5) `useMemo` 의존성에 `pageInputValue` 추가 |
| 삭제 | `PageGroupNav.tsx` | 이전 설계 산출물 삭제 (이미 삭제됨) |
- 신규 파일 생성 없음
- 백엔드 변경 없음, DB 변경 없음
- v2-table-list를 사용하는 **모든 동적 화면**에 자동 적용
---
## 설계 원칙
- **최소 변경**: `<span>` 1개를 `<input>` + 유효성 검증으로 교체. 나머지 전부 유지
- **기존 버튼 동작 무변경**: `<< < > >>` 4개 버튼의 onClick/disabled 로직은 그대로
- **`handlePageChange` 재사용**: 기존 함수를 그대로 호출
- **입력 중 페이지 이동 안 함**: onChange는 표시만 변경, Enter/blur로 실제 적용
- **유효 범위 자동 보정**: 1 미만 → 1, totalPages 초과 → totalPages, 비숫자 → 현재 값 유지
- **포커스 시 전체 선택**: 클릭하면 바로 타이핑 가능
- **`currentPage`가 단일 소스**: fetch 시 `tableConfig.pagination?.currentPage` 대신 로컬 `currentPage`만 사용 (비동기 전파 문제 방지)
- **페이지크기 변경 시 1페이지로 리셋**: `handlePageSizeChange``onConfigChange`를 호출하여 부모/백엔드 동기화

View File

@ -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 내부에 인라인으로 구현
- **근거**: 변경이 `<span>``<input>` + 핸들러 약 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 호출
```

View File

@ -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<string>`)
- [x] `currentPage` 변경 시 `pageInputValue` 동기화 (`useEffect`)
- [x] `commitPageInput` 핸들러 구현 (parseInt + clamp + handlePageChange)
- [x] paginationJSX 중앙의 `<span>``<input>` + `/` + `<span>` 교체
- [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 | 문서 최신화: 버그 수정 내역 반영, 코드 설계 섹션 제거 (구현 완료) |

View File

@ -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[기존 데이터 조회<br/>warehouse_code + floor + zone]
D --> E[미리보기 생성]
E --> F{저장 버튼}
F --> G[렉 구조 화면 감지<br/>floor && zone 필수]
G --> H[중복 체크<br/>warehouse_code + floor + zone]
H --> I[일괄 INSERT<br/>floor = 선택값]
```
### 데이터 흐름 (변경 후)
```mermaid
flowchart TD
A[사용자: 창고/구역 입력<br/>층은 선택사항] --> B{필수 필드 검증}
B -->|창고 or 구역 없음| C[경고: 해당 필드를 입력하세요]
B -->|창고+구역 있음| D{floor 값 존재?}
D -->|있음| E1[기존 데이터 조회<br/>warehouse_code + floor + zone]
D -->|없음| E2[기존 데이터 조회<br/>warehouse_code + zone]
E1 --> F[미리보기 생성]
E2 --> F
F --> G{저장 버튼}
G --> H[렉 구조 화면 감지<br/>zone만 필수]
H --> I{floor 값 존재?}
I -->|있음| J1[중복 체크<br/>warehouse_code + floor + zone]
I -->|없음| J2[중복 체크<br/>warehouse_code + zone]
J1 --> K[일괄 INSERT<br/>floor = 선택값]
J2 --> K2[일괄 INSERT<br/>floor = NULL]
```
### 컴포넌트 관계
```mermaid
graph LR
subgraph 프론트엔드
A[폼 필드<br/>창고/층/구역] -->|formData| B[RackStructureComponent<br/>필수 검증 + 미리보기]
B -->|locations 배열| C[buttonActions.ts<br/>화면 감지 + 중복 체크 + 저장]
end
subgraph 백엔드
C -->|POST /dynamic-form/save| D[DynamicFormApi<br/>데이터 저장]
D --> E[(warehouse_location<br/>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<string, any> = {
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<string, any> = {
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로 저장하여 "미입력"을 정확하게 표현 (프로젝트 표준 패턴)
- 특수 문자열("상관없음" 등) 사용하지 않음 (프로젝트 관행에 맞지 않으므로)

View File

@ -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)
```
이번 변경은 위 패턴들과 일관성을 유지합니다.

View File

@ -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 | 사용자 검증 완료, 전체 작업 완료 |

View File

@ -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: 컬럼 순서
---

View File

@ -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 (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4">
@ -19,9 +28,11 @@ export default function LoginPage() {
isLoading={isLoading}
error={error}
showPassword={showPassword}
isPopMode={isPopMode}
onInputChange={handleInputChange}
onSubmit={handleLogin}
onTogglePassword={togglePasswordVisibility}
onTogglePop={togglePopMode}
/>
<LoginFooter />

View File

@ -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() {
</div>
)}
{/* 일반 모드 네비게이션 바 */}
{!isPreviewMode && (
<div className="sticky top-0 z-50 flex h-10 items-center justify-between border-b bg-white/80 px-3 backdrop-blur">
<Button variant="ghost" size="sm" onClick={() => router.push("/pop")} className="gap-1 text-xs">
<LayoutGrid className="h-3.5 w-3.5" />
POP
</Button>
<span className="text-xs text-gray-500">{screen.screenName}</span>
<Button variant="ghost" size="sm" onClick={() => router.push("/")} className="gap-1 text-xs">
<Monitor className="h-3.5 w-3.5" />
PC
</Button>
</div>
)}
{/* POP 화면 컨텐츠 */}
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
{/* 현재 모드 표시 (일반 모드) */}
{!isPreviewMode && (
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
{currentModeKey.replace("_", " ")}
</div>
)}
<div
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-foreground" : "w-full min-h-full"}`}

View File

@ -458,6 +458,14 @@ select {
border-color: hsl(var(--destructive)) !important;
}
/* 채번 세그먼트 포커스 스타일 (shadcn Input과 동일한 3단 구조) */
.numbering-segment:focus-within {
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.5);
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/* 필수 입력 경고 문구 (입력 필드 아래, 레이아웃 영향 없음) */
.validation-error-msg-wrapper {
height: 0;

View File

@ -82,12 +82,19 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
});
// 화면 할당 관련 상태
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당)
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard" | "pop">("screen");
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [screenSearchText, setScreenSearchText] = useState("");
const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false);
// POP 화면 할당 관련 상태
const [selectedPopScreen, setSelectedPopScreen] = useState<ScreenDefinition | null>(null);
const [popScreenSearchText, setPopScreenSearchText] = useState("");
const [isPopScreenDropdownOpen, setIsPopScreenDropdownOpen] = useState(false);
const [isPopLanding, setIsPopLanding] = useState(false);
const [hasOtherPopLanding, setHasOtherPopLanding] = useState(false);
// 대시보드 할당 관련 상태
const [selectedDashboard, setSelectedDashboard] = useState<any | null>(null);
const [dashboards, setDashboards] = useState<any[]>([]);
@ -196,8 +203,27 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
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<MenuFormModalProps> = ({
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<MenuFormModalProps> = ({
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<MenuFormModalProps> = ({
}, 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<MenuFormModalProps> = ({
} 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<MenuFormModalProps> = ({
}
}, [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<MenuFormModalProps> = ({
}
}, [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<MenuFormModalProps> = ({
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<MenuFormModalProps> = ({
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<MenuFormModalProps> = ({
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
{/* URL 타입 선택 */}
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex space-x-6">
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex flex-wrap gap-x-6 gap-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="screen" id="screen" />
<Label htmlFor="screen" className="cursor-pointer">
@ -866,6 +983,12 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pop" id="pop" />
<Label htmlFor="pop" className="cursor-pointer">
POP
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="direct" id="direct" />
<Label htmlFor="direct" className="cursor-pointer">
@ -1031,6 +1154,106 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
</div>
)}
{/* POP 화면 할당 */}
{urlType === "pop" && (
<div className="space-y-2">
<div className="relative">
<Button
type="button"
variant="outline"
onClick={() => setIsPopScreenDropdownOpen(!isPopScreenDropdownOpen)}
className="w-full justify-between"
>
<span className="text-left">
{selectedPopScreen ? selectedPopScreen.screenName : "POP 화면을 선택하세요"}
</span>
<ChevronDown className="h-4 w-4" />
</Button>
{isPopScreenDropdownOpen && (
<div className="pop-screen-dropdown absolute top-full right-0 left-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-md border bg-white shadow-lg">
<div className="sticky top-0 border-b bg-white p-2">
<div className="relative">
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="POP 화면 검색..."
value={popScreenSearchText}
onChange={(e) => setPopScreenSearchText(e.target.value)}
className="pl-8"
/>
</div>
</div>
<div className="max-h-48 overflow-y-auto">
{screens
.filter(
(screen) =>
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
)
.map((screen, index) => (
<div
key={`pop-screen-${screen.screenId || screen.id || index}-${screen.screenCode || index}`}
onClick={() => handlePopScreenSelect(screen)}
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">{screen.screenName}</div>
<div className="text-xs text-gray-500">{screen.screenCode}</div>
</div>
<div className="text-xs text-gray-400">ID: {screen.screenId || screen.id || "N/A"}</div>
</div>
</div>
))}
{screens.filter(
(screen) =>
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500"> .</div>}
</div>
</div>
)}
</div>
{selectedPopScreen && (
<div className="bg-accent rounded-md border p-3">
<div className="text-sm font-medium text-blue-900">{selectedPopScreen.screenName}</div>
<div className="text-primary text-xs">: {selectedPopScreen.screenCode}</div>
<div className="text-primary text-xs"> URL: {formData.menuUrl}</div>
</div>
)}
{/* POP 기본 화면 설정 */}
<div className="flex items-center space-x-2 rounded-md border p-3">
<input
type="checkbox"
id="popLanding"
checked={isPopLanding}
disabled={!isPopLanding && hasOtherPopLanding}
onChange={(e) => setIsPopLanding(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
<label
htmlFor="popLanding"
className={`text-sm font-medium ${!isPopLanding && hasOtherPopLanding ? "text-muted-foreground" : ""}`}
>
POP
</label>
{!isPopLanding && hasOtherPopLanding && (
<span className="text-xs text-muted-foreground">
( )
</span>
)}
</div>
{isPopLanding && (
<p className="text-xs text-muted-foreground">
POP .
</p>
)}
</div>
)}
{/* URL 직접 입력 */}
{urlType === "direct" && (
<Input

View File

@ -2,7 +2,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Eye, EyeOff, Loader2, Monitor } from "lucide-react";
import { LoginFormData } from "@/types/auth";
import { ErrorMessage } from "./ErrorMessage";
@ -11,9 +12,11 @@ interface LoginFormProps {
isLoading: boolean;
error: string;
showPassword: boolean;
isPopMode: boolean;
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => 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 (
<Card className="border shadow-lg">
@ -82,6 +87,19 @@ export function LoginForm({
</div>
</div>
{/* POP 모드 토글 */}
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5">
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-slate-500" />
<span className="text-sm text-slate-600">POP </span>
</div>
<Switch
checked={isPopMode}
onCheckedChange={onTogglePop}
disabled={isLoading}
/>
</div>
{/* 로그인 버튼 */}
<Button
type="submit"

View File

@ -42,9 +42,12 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
// 바코드 리더 초기화
// 바코드 리더 초기화 + 모달 열릴 때 상태 리셋
useEffect(() => {
if (open) {
setScannedCode("");
setError("");
setIsScanning(false);
codeReaderRef.current = new BrowserMultiFormatReader();
}
@ -276,7 +279,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
{/* 스캔 가이드 오버레이 */}
{isScanning && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-32 w-32 border-4 border-primary animate-pulse rounded-lg" />
<div className="h-3/5 w-4/5 rounded-lg border-4 border-primary/70 animate-pulse" />
<div className="absolute bottom-4 left-0 right-0 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-background/80 px-4 py-2 text-xs font-medium">
<Scan className="h-4 w-4 animate-pulse text-primary" />
@ -355,6 +358,20 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
</Button>
)}
{scannedCode && (
<Button
variant="outline"
onClick={() => {
setScannedCode("");
startScanning();
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Camera className="mr-2 h-4 w-4" />
</Button>
)}
{scannedCode && !autoSubmit && (
<Button
onClick={handleConfirm}

View File

@ -19,11 +19,12 @@ import {
User,
Building2,
FileCheck,
Monitor,
} from "lucide-react";
import { useMenu } from "@/contexts/MenuContext";
import { useAuth } from "@/hooks/useAuth";
import { useProfile } from "@/hooks/useProfile";
import { MenuItem } from "@/lib/api/menu";
import { MenuItem, menuApi } from "@/lib/api/menu";
import { menuScreenApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
@ -453,6 +454,31 @@ function AppLayoutInner({ children }: AppLayoutProps) {
e.dataTransfer.setData("text/plain", menuName);
};
// POP 모드 진입 핸들러
const handlePopModeClick = async () => {
try {
const response = await menuApi.getPopMenus();
if (response.success && response.data) {
const { childMenus, landingMenu } = response.data;
if (landingMenu?.menu_url) {
router.push(landingMenu.menu_url);
} else if (childMenus.length === 0) {
toast.info("설정된 POP 화면이 없습니다");
} else if (childMenus.length === 1) {
router.push(childMenus[0].menu_url);
} else {
router.push("/pop");
}
} else {
toast.info("설정된 POP 화면이 없습니다");
}
} catch (error) {
toast.error("POP 메뉴 조회 중 오류가 발생했습니다");
}
};
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
const renderMenu = (menu: any, level: number = 0) => {
const isExpanded = expandedMenus.has(menu.id);
const isLeaf = !menu.hasChildren;
@ -576,6 +602,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="px-1 py-0.5">
<ThemeToggle />
@ -748,6 +778,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />

View File

@ -6,13 +6,14 @@ interface MainHeaderProps {
user: any;
onSidebarToggle: () => void;
onProfileClick: () => void;
onPopModeClick?: () => void;
onLogout: () => void;
}
/**
*
*/
export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) {
export function MainHeader({ user, onSidebarToggle, onProfileClick, onPopModeClick, onLogout }: MainHeaderProps) {
return (
<header className="bg-background/95 fixed top-0 z-50 h-14 min-h-14 w-full flex-shrink-0 border-b backdrop-blur">
<div className="flex h-full w-full items-center justify-between px-6">
@ -27,7 +28,7 @@ export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }:
{/* Right side - Admin Button + User Menu */}
<div className="flex h-8 items-center gap-2">
<UserDropdown user={user} onProfileClick={onProfileClick} onLogout={onLogout} />
<UserDropdown user={user} onProfileClick={onProfileClick} onPopModeClick={onPopModeClick} onLogout={onLogout} />
</div>
</div>
</header>

View File

@ -19,6 +19,11 @@ import {
clearTabCache,
} from "@/lib/tabStateCache";
// 페이지 로드(F5 새로고침) 감지용 모듈 레벨 플래그.
// 전체 페이지 로드 시 모듈이 재실행되어 false로 리셋된다.
// SPA 내비게이션에서는 모듈이 유지되므로 true로 남는다.
let hasHandledPageLoad = false;
export function TabContent() {
const tabs = useTabStore(selectTabs);
const activeTabId = useTabStore(selectActiveTabId);
@ -39,6 +44,13 @@ export function TabContent() {
// 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함)
const pathCacheRef = useRef<WeakMap<HTMLElement, string | null>>(new WeakMap());
// 페이지 로드(F5) 시 활성 탭 캐시만 삭제 → fresh API 호출 유도
// 비활성 탭 캐시는 유지하여 탭 전환 시 복원
if (!hasHandledPageLoad && activeTabId) {
hasHandledPageLoad = true;
clearTabCache(activeTabId);
}
if (activeTabId) {
mountedTabIdsRef.current.add(activeTabId);
}

View File

@ -8,19 +8,20 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LogOut, User, FileCheck } from "lucide-react";
import { LogOut, FileCheck, Monitor, User } from "lucide-react";
import { useRouter } from "next/navigation";
interface UserDropdownProps {
user: any;
onProfileClick: () => void;
onPopModeClick?: () => void;
onLogout: () => void;
}
/**
*
*/
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
export function UserDropdown({ user, onProfileClick, onPopModeClick, onLogout }: UserDropdownProps) {
const router = useRouter();
if (!user) return null;
@ -73,7 +74,6 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
? `${user.deptName}, ${user.positionName}`
: user.deptName || user.positionName || "부서 정보 없음"}
</p>
{/* 사진 상태 표시 */}
</div>
</div>
</DropdownMenuLabel>
@ -86,6 +86,12 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
{onPopModeClick && (
<DropdownMenuItem onClick={onPopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />

View File

@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { Moon, Sun } from "lucide-react";
import { Moon, Sun, Monitor } from "lucide-react";
import { WeatherInfo, UserInfo, CompanyInfo } from "./types";
interface DashboardHeaderProps {
@ -11,6 +11,7 @@ interface DashboardHeaderProps {
company: CompanyInfo;
onThemeToggle: () => void;
onUserClick: () => void;
onPcModeClick?: () => void;
}
export function DashboardHeader({
@ -20,6 +21,7 @@ export function DashboardHeader({
company,
onThemeToggle,
onUserClick,
onPcModeClick,
}: DashboardHeaderProps) {
const [mounted, setMounted] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date());
@ -81,6 +83,17 @@ export function DashboardHeader({
<div className="pop-dashboard-company-sub">{company.subTitle}</div>
</div>
{/* PC 모드 복귀 */}
{onPcModeClick && (
<button
className="pop-dashboard-theme-toggle"
onClick={onPcModeClick}
title="PC 모드로 돌아가기"
>
<Monitor size={16} />
</button>
)}
{/* 사용자 배지 */}
<button className="pop-dashboard-user-badge" onClick={onUserClick}>
<div className="pop-dashboard-user-avatar">{user.avatar}</div>

View File

@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { DashboardHeader } from "./DashboardHeader";
import { NoticeBanner } from "./NoticeBanner";
import { KpiBar } from "./KpiBar";
@ -8,6 +9,8 @@ import { MenuGrid } from "./MenuGrid";
import { ActivityList } from "./ActivityList";
import { NoticeList } from "./NoticeList";
import { DashboardFooter } from "./DashboardFooter";
import { MenuItem as DashboardMenuItem } from "./types";
import { menuApi, PopMenuItem } from "@/lib/api/menu";
import {
KPI_ITEMS,
MENU_ITEMS,
@ -17,10 +20,31 @@ import {
} from "./data";
import "./dashboard.css";
export function PopDashboard() {
const [theme, setTheme] = useState<"dark" | "light">("dark");
const CATEGORY_COLORS: DashboardMenuItem["category"][] = [
"production",
"material",
"quality",
"equipment",
"safety",
];
function convertPopMenuToMenuItem(item: PopMenuItem, index: number): DashboardMenuItem {
return {
id: item.objid,
title: item.menu_name_kor,
count: 0,
description: item.menu_desc?.replace("[POP]", "").trim() || "",
status: "",
category: CATEGORY_COLORS[index % CATEGORY_COLORS.length],
href: item.menu_url || "#",
};
}
export function PopDashboard() {
const router = useRouter();
const [theme, setTheme] = useState<"dark" | "light">("dark");
const [menuItems, setMenuItems] = useState<DashboardMenuItem[]>(MENU_ITEMS);
// 로컬 스토리지에서 테마 로드
useEffect(() => {
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
if (savedTheme) {
@ -28,6 +52,22 @@ export function PopDashboard() {
}
}, []);
// API에서 POP 메뉴 로드
useEffect(() => {
const loadPopMenus = async () => {
try {
const response = await menuApi.getPopMenus();
if (response.success && response.data && response.data.childMenus.length > 0) {
const converted = response.data.childMenus.map(convertPopMenuToMenuItem);
setMenuItems(converted);
}
} catch {
// API 실패 시 기존 하드코딩 데이터 유지
}
};
loadPopMenus();
}, []);
const handleThemeToggle = () => {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
@ -40,6 +80,10 @@ export function PopDashboard() {
}
};
const handlePcModeClick = () => {
router.push("/");
};
const handleActivityMore = () => {
alert("전체 활동 내역 화면으로 이동합니다.");
};
@ -58,13 +102,14 @@ export function PopDashboard() {
company={{ name: "탑씰", subTitle: "현장 관리 시스템" }}
onThemeToggle={handleThemeToggle}
onUserClick={handleUserClick}
onPcModeClick={handlePcModeClick}
/>
<NoticeBanner text={NOTICE_MARQUEE_TEXT} />
<KpiBar items={KPI_ITEMS} />
<MenuGrid items={MENU_ITEMS} />
<MenuGrid items={menuItems} />
<div className="pop-dashboard-bottom-section">
<ActivityList items={ACTIVITY_ITEMS} onMoreClick={handleActivityMore} />

View File

@ -150,7 +150,7 @@ export default function PopDesigner({
try {
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) {
if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) {
// v5 레이아웃 로드
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
if (!loadedLayout.settings.gapPreset) {

View File

@ -69,10 +69,12 @@ const COMPONENT_TYPE_LABELS: Record<string, string> = {
"pop-icon": "아이콘",
"pop-dashboard": "대시보드",
"pop-card-list": "카드 목록",
"pop-card-list-v2": "카드 목록 V2",
"pop-field": "필드",
"pop-button": "버튼",
"pop-string-list": "리스트 목록",
"pop-search": "검색",
"pop-status-bar": "상태 바",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
@ -169,9 +171,7 @@ export default function ComponentEditorPanel({
</div>
<div className="space-y-1">
{allComponents.map((comp) => {
const label = comp.label
|| COMPONENT_TYPE_LABELS[comp.type]
|| comp.type;
const label = comp.label || comp.id;
const isActive = comp.id === selectedComponentId;
return (
<button

View File

@ -3,7 +3,7 @@
import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput } from "lucide-react";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2 } from "lucide-react";
import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의
@ -45,6 +45,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: LayoutGrid,
description: "테이블 데이터를 카드 형태로 표시",
},
{
type: "pop-card-list-v2",
label: "카드 목록 V2",
icon: LayoutGrid,
description: "슬롯 기반 카드 (CSS Grid + 셀 타입별 렌더링)",
},
{
type: "pop-button",
label: "버튼",
@ -63,12 +69,30 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: Search,
description: "조건 입력 (텍스트/날짜/선택/모달)",
},
{
type: "pop-status-bar",
label: "상태 바",
icon: BarChart2,
description: "상태별 건수 대시보드 + 필터",
},
{
type: "pop-field",
label: "입력 필드",
icon: TextCursorInput,
description: "저장용 값 입력 (섹션별 멀티필드)",
},
{
type: "pop-scanner",
label: "스캐너",
icon: ScanLine,
description: "바코드/QR 카메라 스캔",
},
{
type: "pop-profile",
label: "프로필",
icon: UserCircle,
description: "사용자 프로필 / PC 전환 / 로그아웃",
},
];
// 드래그 가능한 컴포넌트 아이템

View File

@ -4,7 +4,6 @@ import React from "react";
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
@ -19,7 +18,6 @@ import {
} from "../types/pop-layout";
import {
PopComponentRegistry,
type ComponentConnectionMeta,
} from "@/lib/registry/PopComponentRegistry";
import { getTableColumns } from "@/lib/api/tableManagement";
@ -36,15 +34,6 @@ interface ConnectionEditorProps {
onRemoveConnection?: (connectionId: string) => void;
}
// ========================================
// 소스 컴포넌트에 filter 타입 sendable이 있는지 판단
// ========================================
function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean {
if (!meta?.sendable) return false;
return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value");
}
// ========================================
// ConnectionEditor
// ========================================
@ -84,17 +73,13 @@ export default function ConnectionEditor({
);
}
const isFilterSource = hasFilterSendable(meta);
return (
<div className="space-y-6">
{hasSendable && (
<SendSection
component={component}
meta={meta!}
allComponents={allComponents}
outgoing={outgoing}
isFilterSource={isFilterSource}
onAddConnection={onAddConnection}
onUpdateConnection={onUpdateConnection}
onRemoveConnection={onRemoveConnection}
@ -112,47 +97,14 @@ export default function ConnectionEditor({
);
}
// ========================================
// 대상 컴포넌트에서 정보 추출
// ========================================
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
if (!comp?.config) return [];
const cfg = comp.config as Record<string, unknown>;
const cols: string[] = [];
if (Array.isArray(cfg.listColumns)) {
(cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => {
if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName);
});
}
if (Array.isArray(cfg.selectedColumns)) {
(cfg.selectedColumns as string[]).forEach((c) => {
if (!cols.includes(c)) cols.push(c);
});
}
return cols;
}
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
if (!comp?.config) return "";
const cfg = comp.config as Record<string, unknown>;
const ds = cfg.dataSource as { tableName?: string } | undefined;
return ds?.tableName || "";
}
// ========================================
// 보내기 섹션
// ========================================
interface SendSectionProps {
component: PopComponentDefinitionV5;
meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[];
outgoing: PopDataConnection[];
isFilterSource: boolean;
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
onRemoveConnection?: (connectionId: string) => void;
@ -160,10 +112,8 @@ interface SendSectionProps {
function SendSection({
component,
meta,
allComponents,
outgoing,
isFilterSource,
onAddConnection,
onUpdateConnection,
onRemoveConnection,
@ -180,34 +130,20 @@ function SendSection({
{outgoing.map((conn) => (
<div key={conn.id}>
{editingId === conn.id ? (
isFilterSource ? (
<FilterConnectionForm
component={component}
meta={meta}
allComponents={allComponents}
initial={conn}
onSubmit={(data) => {
onUpdateConnection?.(conn.id, data);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
submitLabel="수정"
/>
) : (
<SimpleConnectionForm
component={component}
allComponents={allComponents}
initial={conn}
onSubmit={(data) => {
onUpdateConnection?.(conn.id, data);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
submitLabel="수정"
/>
)
<SimpleConnectionForm
component={component}
allComponents={allComponents}
initial={conn}
onSubmit={(data) => {
onUpdateConnection?.(conn.id, data);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
submitLabel="수정"
/>
) : (
<div className="flex items-center gap-1 rounded border bg-primary/10/50 px-3 py-2">
<div className="space-y-1 rounded border bg-primary/10 px-3 py-2">
<div className="flex items-center gap-1">
<span className="flex-1 truncate text-xs">
{conn.label || `${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
</span>
@ -225,27 +161,33 @@ function SendSection({
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
{conn.filterConfig?.targetColumn && (
<div className="flex flex-wrap gap-1">
<span className="rounded bg-white px-1.5 py-0.5 text-[9px] text-muted-foreground">
{conn.filterConfig.targetColumn}
</span>
<span className="rounded bg-white px-1.5 py-0.5 text-[9px] text-muted-foreground">
{conn.filterConfig.filterMode}
</span>
{conn.filterConfig.isSubTable && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] text-amber-700">
</span>
)}
</div>
)}
</div>
)}
</div>
))}
{isFilterSource ? (
<FilterConnectionForm
component={component}
meta={meta}
allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가"
/>
) : (
<SimpleConnectionForm
component={component}
allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가"
/>
)}
<SimpleConnectionForm
component={component}
allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가"
/>
</div>
);
}
@ -263,6 +205,19 @@ interface SimpleConnectionFormProps {
submitLabel: string;
}
function extractSubTableName(comp: PopComponentDefinitionV5): string | null {
const cfg = comp.config as Record<string, unknown> | 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<string>(
initial?.filterConfig?.filterMode || "equals"
);
const [subColumns, setSubColumns] = React.useState<string[]>([]);
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<PopDataConnection, "id"> = {
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({
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground">?</span>
<Select
value={selectedTargetId}
onValueChange={setSelectedTargetId}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
{targetCandidates.map((c) => (
<SelectItem key={c.id} value={c.id} className="text-xs">
{c.label || c.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
size="sm"
variant="outline"
className="h-7 w-full text-xs"
disabled={!selectedTargetId}
onClick={handleSubmit}
>
{!initial && <Plus className="mr-1 h-3 w-3" />}
{submitLabel}
</Button>
</div>
);
}
// ========================================
// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지)
// ========================================
interface FilterConnectionFormProps {
component: PopComponentDefinitionV5;
meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[];
initial?: PopDataConnection;
onSubmit: (data: Omit<PopDataConnection, "id">) => 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<string[]>(
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<string[]>([]);
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 (
<div className="space-y-2 rounded border border-dashed p-3">
{onCancel && (
<div className="flex items-center justify-between">
<p className="text-[10px] font-medium text-muted-foreground"> </p>
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
</div>
)}
{!onCancel && (
<p className="text-[10px] font-medium text-muted-foreground"> </p>
)}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={selectedOutput} onValueChange={setSelectedOutput}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{meta.sendable.map((s) => (
<SelectItem key={s.key} value={s.key} className="text-xs">
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select
value={selectedTargetId}
onValueChange={(v) => {
setSelectedTargetId(v);
setSelectedTargetInput("");
setFilterColumns([]);
setIsSubTable(false);
setTargetColumn("");
}}
>
<SelectTrigger className="h-7 text-xs">
@ -552,109 +345,62 @@ function FilterConnectionForm({
</Select>
</div>
{targetMeta && (
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={selectedTargetInput} onValueChange={setSelectedTargetInput}>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{targetMeta.receivable.map((r) => (
<SelectItem key={r.key} value={r.key} className="text-xs">
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
<div className="space-y-2 rounded bg-muted p-2">
<p className="text-[10px] font-medium text-muted-foreground"> </p>
{dbColumnsLoading ? (
<div className="flex items-center gap-2 py-2">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : hasAnyColumns ? (
<div className="space-y-2">
{displayColumns.length > 0 && (
<div className="space-y-1">
<p className="text-[9px] font-medium text-emerald-600"> </p>
{displayColumns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}-${initial?.id || "new"}`}
checked={filterColumns.includes(col)}
onCheckedChange={() => toggleColumn(col)}
/>
<label
htmlFor={`col-${col}-${initial?.id || "new"}`}
className="cursor-pointer text-xs"
>
{col}
</label>
</div>
))}
</div>
)}
{dataOnlyColumns.length > 0 && (
<div className="space-y-1">
{displayColumns.length > 0 && (
<div className="my-1 h-px bg-muted/80" />
)}
<p className="text-[9px] font-medium text-amber-600"> </p>
{dataOnlyColumns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}-${initial?.id || "new"}`}
checked={filterColumns.includes(col)}
onCheckedChange={() => toggleColumn(col)}
/>
<label
htmlFor={`col-${col}-${initial?.id || "new"}`}
className="cursor-pointer text-xs text-muted-foreground"
>
{col}
</label>
</div>
))}
</div>
)}
</div>
) : (
<Input
value={filterColumns[0] || ""}
onChange={(e) => setFilterColumns(e.target.value ? [e.target.value] : [])}
placeholder="컬럼명 입력"
className="h-7 text-xs"
{isFilterConnection && selectedTargetId && subTableName && (
<div className="space-y-2 rounded bg-muted/50 p-2">
<div className="flex items-center gap-2">
<Checkbox
id={`isSubTable_${component.id}`}
checked={isSubTable}
onCheckedChange={(v) => {
setIsSubTable(v === true);
if (!v) setTargetColumn("");
}}
/>
)}
{filterColumns.length > 0 && (
<p className="text-[10px] text-primary">
{filterColumns.length}
</p>
)}
<div className="space-y-1">
<p className="text-[10px] text-muted-foreground"> </p>
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="contains" className="text-xs"></SelectItem>
<SelectItem value="equals" className="text-xs"></SelectItem>
<SelectItem value="starts_with" className="text-xs"></SelectItem>
<SelectItem value="range" className="text-xs"></SelectItem>
</SelectContent>
</Select>
<label htmlFor={`isSubTable_${component.id}`} className="text-[10px] text-muted-foreground cursor-pointer">
({subTableName})
</label>
</div>
{isSubTable && (
<div className="space-y-2 pl-5">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
{loadingColumns ? (
<div className="flex items-center gap-1 py-1">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : (
<Select value={targetColumn} onValueChange={setTargetColumn}>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subColumns.filter(Boolean).map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={filterMode} onValueChange={setFilterMode}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals" className="text-xs"> (equals)</SelectItem>
<SelectItem value="contains" className="text-xs"> (contains)</SelectItem>
<SelectItem value="starts_with" className="text-xs"> (starts_with)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
)}
@ -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 && <Plus className="mr-1 h-3 w-3" />}
@ -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}`;
}

View File

@ -72,10 +72,14 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"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 (
<div className={cn(

View File

@ -9,7 +9,7 @@
/**
* POP
*/
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field";
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile";
/**
*
@ -33,6 +33,7 @@ export interface PopDataConnection {
targetColumn: string;
targetColumns?: string[];
filterMode: "equals" | "contains" | "starts_with" | "range";
isSubTable?: boolean;
};
label?: string;
}
@ -358,10 +359,14 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
"pop-icon": { colSpan: 1, rowSpan: 2 },
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
"pop-card-list": { colSpan: 4, rowSpan: 3 },
"pop-card-list-v2": { colSpan: 4, rowSpan: 3 },
"pop-button": { colSpan: 2, rowSpan: 1 },
"pop-string-list": { colSpan: 4, rowSpan: 3 },
"pop-search": { colSpan: 4, rowSpan: 2 },
"pop-search": { colSpan: 2, rowSpan: 1 },
"pop-status-bar": { colSpan: 6, rowSpan: 1 },
"pop-field": { colSpan: 6, rowSpan: 2 },
"pop-scanner": { colSpan: 1, rowSpan: 1 },
"pop-profile": { colSpan: 1, rowSpan: 1 },
};
/**

View File

@ -605,6 +605,23 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}
}, [relatedButtonFilter]);
// TableOptionsContext 필터 변경 시 데이터 재조회 (TableSearchWidget 연동)
const filtersAppliedRef = useRef(false);
useEffect(() => {
// 초기 렌더 시 빈 배열은 무시 (불필요한 재조회 방지)
if (!filtersAppliedRef.current && filters.length === 0) return;
filtersAppliedRef.current = true;
const filterSearchParams: Record<string, any> = {};
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 () => {

View File

@ -541,8 +541,31 @@ export function DataFilterConfigPanel({
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
<Select
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
value={
filter.operator === "in" || filter.operator === "not_in"
? Array.isArray(filter.value) && filter.value.length > 0
? filter.value[0]
: ""
: Array.isArray(filter.value)
? filter.value[0]
: filter.value
}
onValueChange={(selectedValue) => {
if (filter.operator === "in" || filter.operator === "not_in") {
const currentValues = Array.isArray(filter.value) ? filter.value : [];
if (currentValues.includes(selectedValue)) {
handleFilterChange(
filter.id,
"value",
currentValues.filter((v) => v !== selectedValue),
);
} else {
handleFilterChange(filter.id, "value", [...currentValues, selectedValue]);
}
} else {
handleFilterChange(filter.id, "value", selectedValue);
}
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue

View File

@ -109,9 +109,8 @@ export const TableOptionsToolbar: React.FC = () => {
onOpenChange={setColumnPanelOpen}
/>
<FilterPanel
tableId={selectedTableId}
open={filterPanelOpen}
onOpenChange={setFilterPanelOpen}
isOpen={filterPanelOpen}
onClose={() => setFilterPanelOpen(false)}
/>
<GroupingPanel
tableId={selectedTableId}

View File

@ -288,6 +288,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [parentValue, setParentValue] = useState<CategoryValue | null>(null);
const [continuousAdd, setContinuousAdd] = useState(false);
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
@ -512,21 +513,24 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
const response = await createCategoryValue(input);
if (response.success) {
toast.success("카테고리가 추가되었습니다");
// 폼 초기화 (모달은 닫지 않고 연속 입력)
setFormData((prev) => ({
...prev,
valueCode: "",
valueLabel: "",
description: "",
color: "",
}));
setTimeout(() => addNameRef.current?.focus(), 50);
// 기존 펼침 상태 유지하면서 데이터 새로고침
await loadTree(true);
// 부모 노드만 펼치기 (하위 추가 시)
if (parentValue) {
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
}
if (continuousAdd) {
setFormData((prev) => ({
...prev,
valueCode: "",
valueLabel: "",
description: "",
color: "",
}));
setTimeout(() => addNameRef.current?.focus(), 50);
} else {
setFormData({ valueCode: "", valueLabel: "", description: "", color: "", isActive: true });
setIsAddModalOpen(false);
}
} else {
toast.error(response.error || "추가 실패");
}
@ -818,6 +822,19 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
</Button>
</DialogFooter>
<div className="border-t px-4 py-3">
<div className="flex items-center gap-2">
<Checkbox
id="tree-continuous-add"
checked={continuousAdd}
onCheckedChange={(checked) => setContinuousAdd(checked as boolean)}
/>
<Label htmlFor="tree-continuous-add" className="cursor-pointer text-sm font-normal select-none">
( )
</Label>
</div>
</div>
</DialogContent>
</Dialog>

View File

@ -629,7 +629,7 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>((pro
): SelectOption[] => {
const result: SelectOption[] = [];
for (const item of items) {
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
result.push({
value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치)
label: prefix + item.valueLabel,

View File

@ -909,10 +909,10 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
return (
<div className="flex h-full items-center rounded-md border">
<div className="numbering-segment border-input flex h-full items-center rounded-md border outline-none transition-[color,box-shadow]">
{/* 고정 접두어 */}
{templatePrefix && (
<span className="text-muted-foreground bg-muted flex h-full items-center px-2 text-sm">
<span className="text-muted-foreground bg-muted flex h-full items-center rounded-l-[5px] px-2 text-sm">
{templatePrefix}
</span>
)}
@ -945,13 +945,13 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
}
}}
placeholder="입력"
className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none"
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm ring-0"
disabled={disabled || isGeneratingNumbering}
style={inputTextStyle}
style={{ ...inputTextStyle, outline: 'none' }}
/>
{/* 고정 접미어 */}
{templateSuffix && (
<span className="text-muted-foreground bg-muted flex h-full items-center px-2 text-sm">
<span className="text-muted-foreground bg-muted flex h-full items-center rounded-r-[5px] px-2 text-sm">
{templateSuffix}
</span>
)}

View File

@ -901,7 +901,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
): SelectOption[] => {
const result: SelectOption[] = [];
for (const item of items) {
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
result.push({
value: item.valueCode, // 🔧 valueCode를 value로 사용
label: prefix + item.valueLabel,

View File

@ -375,12 +375,15 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
// Entity 조인 컬럼 토글 (추가/제거)
const toggleEntityJoinColumn = useCallback(
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => {
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string, columnType?: string) => {
const currentJoins = config.entityJoins || [];
const existingJoinIdx = currentJoins.findIndex(
(j) => j.sourceColumn === sourceColumn && j.referenceTable === joinTableName,
);
let newEntityJoins = [...currentJoins];
let newColumns = [...config.columns];
if (existingJoinIdx >= 0) {
const existingJoin = currentJoins[existingJoinIdx];
const existingColIdx = existingJoin.columns.findIndex((c) => c.referenceField === refColumnName);
@ -388,34 +391,49 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
if (existingColIdx >= 0) {
const updatedColumns = existingJoin.columns.filter((_, i) => i !== existingColIdx);
if (updatedColumns.length === 0) {
updateConfig({ entityJoins: currentJoins.filter((_, i) => i !== existingJoinIdx) });
newEntityJoins = newEntityJoins.filter((_, i) => i !== existingJoinIdx);
} else {
const updated = [...currentJoins];
updated[existingJoinIdx] = { ...existingJoin, columns: updatedColumns };
updateConfig({ entityJoins: updated });
newEntityJoins[existingJoinIdx] = { ...existingJoin, columns: updatedColumns };
}
// config.columns에서도 제거
newColumns = newColumns.filter(c => !(c.key === displayField && c.isJoinColumn));
} else {
const updated = [...currentJoins];
updated[existingJoinIdx] = {
newEntityJoins[existingJoinIdx] = {
...existingJoin,
columns: [...existingJoin.columns, { referenceField: refColumnName, displayField }],
};
updateConfig({ entityJoins: updated });
// config.columns에 추가
newColumns.push({
key: displayField,
title: refColumnLabel,
width: "auto",
visible: true,
editable: false,
isJoinColumn: true,
inputType: columnType || "text",
});
}
} else {
updateConfig({
entityJoins: [
...currentJoins,
{
sourceColumn,
referenceTable: joinTableName,
columns: [{ referenceField: refColumnName, displayField }],
},
],
newEntityJoins.push({
sourceColumn,
referenceTable: joinTableName,
columns: [{ referenceField: refColumnName, displayField }],
});
// config.columns에 추가
newColumns.push({
key: displayField,
title: refColumnLabel,
width: "auto",
visible: true,
editable: false,
isJoinColumn: true,
inputType: columnType || "text",
});
}
updateConfig({ entityJoins: newEntityJoins, columns: newColumns });
},
[config.entityJoins, updateConfig],
[config.entityJoins, config.columns, updateConfig],
);
// Entity 조인에 특정 컬럼이 설정되어 있는지 확인
@ -604,9 +622,9 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
// 컬럼 토글 (현재 테이블 컬럼 - 입력용)
const toggleInputColumn = (column: ColumnOption) => {
const existingIndex = config.columns.findIndex((c) => c.key === column.columnName);
const existingIndex = config.columns.findIndex((c) => c.key === column.columnName && !c.isJoinColumn && !c.isSourceDisplay);
if (existingIndex >= 0) {
const newColumns = config.columns.filter((c) => c.key !== column.columnName);
const newColumns = config.columns.filter((_, i) => i !== existingIndex);
updateConfig({ columns: newColumns });
} else {
// 컬럼의 inputType과 detailSettings 정보 포함
@ -651,7 +669,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
};
const isColumnAdded = (columnName: string) => {
return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay);
return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay && !c.isJoinColumn);
};
const isSourceColumnSelected = (columnName: string) => {
@ -761,10 +779,9 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
return (
<div className="space-y-4">
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic" className="text-xs"></TabsTrigger>
<TabsTrigger value="columns" className="text-xs"></TabsTrigger>
<TabsTrigger value="entityJoin" className="text-xs">Entity </TabsTrigger>
</TabsList>
{/* 기본 설정 탭 */}
@ -1365,6 +1382,84 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
)}
</div>
{/* ===== 🆕 Entity 조인 컬럼 (표시용) ===== */}
<div className="space-y-2 mt-4">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-primary" />
<Label className="text-xs font-medium text-primary">Entity ()</Label>
</div>
<p className="text-[10px] text-muted-foreground">
FK .
</p>
{loadingEntityJoins ? (
<p className="text-muted-foreground py-2 text-xs"> ...</p>
) : entityJoinData.joinTables.length === 0 ? (
<p className="text-muted-foreground py-2 text-xs">
{entityJoinTargetTable
? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다`
: "저장 테이블을 먼저 설정해주세요"}
</p>
) : (
<div className="space-y-3">
{entityJoinData.joinTables.map((joinTable, tableIndex) => {
const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || "";
return (
<div key={tableIndex} className="space-y-1">
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-primary">
<Link2 className="h-3 w-3" />
<span>{joinTable.tableName}</span>
<span className="text-muted-foreground">({sourceColumn})</span>
</div>
<div className="max-h-40 space-y-0.5 overflow-y-auto rounded-md border border-primary/20 bg-primary/10/30 p-2">
{joinTable.availableColumns.map((column, colIndex) => {
const isActive = isEntityJoinColumnActive(
joinTable.tableName,
sourceColumn,
column.columnName,
);
const matchingCol = config.columns.find((c) => c.key === column.columnName && c.isJoinColumn);
const displayField = matchingCol?.key || column.columnName;
return (
<div
key={colIndex}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-primary/10/50",
isActive && "bg-primary/10",
)}
onClick={() =>
toggleEntityJoinColumn(
joinTable.tableName,
sourceColumn,
column.columnName,
column.columnLabel,
displayField,
column.inputType || column.dataType
)
}
>
<Checkbox
checked={isActive}
className="pointer-events-none h-3.5 w-3.5"
/>
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate text-xs">{column.columnLabel}</span>
<span className="ml-auto text-[10px] text-primary/80">
{column.inputType || column.dataType}
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
{/* 선택된 컬럼 상세 설정 - 🆕 모든 컬럼 통합, 순서 변경 가능 */}
{config.columns.length > 0 && (
<>
@ -1381,7 +1476,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
<div
className={cn(
"flex items-center gap-2 rounded-md border p-2",
col.isSourceDisplay ? "border-primary/20 bg-primary/10/50" : "border-border bg-muted/30",
(col.isSourceDisplay || col.isJoinColumn) ? "border-primary/20 bg-primary/10/50" : "border-border bg-muted/30",
col.hidden && "opacity-50",
)}
draggable
@ -1403,7 +1498,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
<GripVertical className="text-muted-foreground h-3 w-3 cursor-grab flex-shrink-0" />
{/* 확장/축소 버튼 (입력 컬럼만) */}
{!col.isSourceDisplay && (
{(!col.isSourceDisplay && !col.isJoinColumn) && (
<button
type="button"
onClick={() => setExpandedColumn(expandedColumn === col.key ? null : col.key)}
@ -1419,8 +1514,10 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
{col.isSourceDisplay ? (
<Link2 className="text-primary h-3 w-3 flex-shrink-0" title="소스 표시 (읽기 전용)" />
) : col.isJoinColumn ? (
<Link2 className="text-amber-500 h-3 w-3 flex-shrink-0" title="Entity 조인 (읽기 전용)" />
) : (
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
)}
<Input
@ -1431,7 +1528,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
/>
{/* 히든 토글 (입력 컬럼만) */}
{!col.isSourceDisplay && (
{(!col.isSourceDisplay && !col.isJoinColumn) && (
<button
type="button"
onClick={() => updateColumnProp(col.key, "hidden", !col.hidden)}
@ -1446,12 +1543,12 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
)}
{/* 자동입력 표시 아이콘 */}
{!col.isSourceDisplay && col.autoFill?.type && col.autoFill.type !== "none" && (
{(!col.isSourceDisplay && !col.isJoinColumn) && col.autoFill?.type && col.autoFill.type !== "none" && (
<Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" />
)}
{/* 편집 가능 토글 */}
{!col.isSourceDisplay && (
{(!col.isSourceDisplay && !col.isJoinColumn) && (
<button
type="button"
onClick={() => updateColumnProp(col.key, "editable", !(col.editable ?? true))}
@ -1474,6 +1571,13 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
onClick={() => {
if (col.isSourceDisplay) {
toggleSourceDisplayColumn({ columnName: col.key, displayName: col.title });
} else if (col.isJoinColumn) {
const newColumns = config.columns.filter(c => c.key !== col.key);
const newEntityJoins = config.entityJoins?.map(join => ({
...join,
columns: join.columns.filter(c => c.displayField !== col.key)
})).filter(join => join.columns.length > 0);
updateConfig({ columns: newColumns, entityJoins: newEntityJoins });
} else {
toggleInputColumn({ columnName: col.key, displayName: col.title });
}
@ -1485,7 +1589,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
</div>
{/* 확장된 상세 설정 (입력 컬럼만) */}
{!col.isSourceDisplay && expandedColumn === col.key && (
{(!col.isSourceDisplay && !col.isJoinColumn) && expandedColumn === col.key && (
<div className="ml-6 space-y-2 rounded-md border border-dashed border-input bg-muted p-2">
{/* 자동 입력 설정 */}
<div className="space-y-1">
@ -1812,120 +1916,6 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
)}
</TabsContent>
{/* Entity 조인 설정 탭 */}
<TabsContent value="entityJoin" className="mt-4 space-y-4">
<div className="space-y-2">
<div>
<h3 className="text-sm font-semibold">Entity </h3>
<p className="text-muted-foreground text-[10px]">
FK
</p>
</div>
<hr className="border-border" />
{loadingEntityJoins ? (
<p className="text-muted-foreground py-2 text-center text-xs"> ...</p>
) : entityJoinData.joinTables.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center">
<p className="text-muted-foreground text-xs">
{entityJoinTargetTable
? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다`
: "저장 테이블을 먼저 설정해주세요"}
</p>
</div>
) : (
<div className="space-y-3">
{entityJoinData.joinTables.map((joinTable, tableIndex) => {
const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || "";
return (
<div key={tableIndex} className="space-y-1">
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-primary">
<Link2 className="h-3 w-3" />
<span>{joinTable.tableName}</span>
<span className="text-muted-foreground">({sourceColumn})</span>
</div>
<div className="max-h-40 space-y-0.5 overflow-y-auto rounded-md border border-primary/20 bg-primary/10/30 p-2">
{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 (
<div
key={colIndex}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-primary/10/50",
isActive && "bg-primary/10",
)}
onClick={() =>
toggleEntityJoinColumn(
joinTable.tableName,
sourceColumn,
column.columnName,
column.columnLabel,
displayField,
)
}
>
<Checkbox
checked={isActive}
className="pointer-events-none h-3.5 w-3.5"
/>
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate text-xs">{column.columnLabel}</span>
<span className="ml-auto text-[10px] text-primary/80">
{column.inputType || column.dataType}
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
{/* 현재 설정된 Entity 조인 목록 */}
{config.entityJoins && config.entityJoins.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium"> </h4>
<div className="space-y-1">
{config.entityJoins.map((join, idx) => (
<div key={idx} className="flex items-center gap-1 rounded border bg-muted/30 px-2 py-1 text-[10px]">
<Database className="h-3 w-3 text-primary" />
<span className="font-medium">{join.sourceColumn}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span>{join.referenceTable}</span>
<span className="text-muted-foreground">
({join.columns.map((c) => c.referenceField).join(", ")})
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
updateConfig({
entityJoins: config.entityJoins!.filter((_, i) => i !== idx),
});
}}
className="ml-auto h-4 w-4 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
);

View File

@ -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;

View File

@ -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<string, string>;
}
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<string, unknown> | 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<string, unknown> | 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);
}

View File

@ -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,
};
};

View File

@ -81,6 +81,23 @@ export interface ApiResponse<T> {
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<ApiResponse<MenuItem[]>> => {
@ -96,6 +113,12 @@ export const menuApi = {
return response.data;
},
// POP 메뉴 목록 조회 ([POP] 태그 L1 하위 active 메뉴)
getPopMenus: async (): Promise<ApiResponse<PopMenuResponse>> => {
const response = await apiClient.get("/admin/pop-menus");
return response.data;
},
// 관리자 메뉴 목록 조회 (메뉴 관리 화면용 - 모든 상태 표시)
getAdminMenusForManagement: async (): Promise<ApiResponse<MenuItem[]>> => {
const response = await apiClient.get("/admin/menus", { params: { menuType: "0", includeInactive: "true" } });

View File

@ -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();

View File

@ -35,6 +35,7 @@ export interface PopComponentDefinition {
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
defaultProps?: Record<string, any>;
connectionMeta?: ComponentConnectionMeta;
getDynamicConnectionMeta?: (config: Record<string, unknown>) => ComponentConnectionMeta;
// POP 전용 속성
touchOptimized?: boolean;
minTouchArea?: number;

View File

@ -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<SplitPanelLayout2ComponentProp
loadCategoryLabels();
}, [isDesignMode, config.leftPanel?.tableName, config.rightPanel?.tableName, config.leftPanel?.displayColumns, config.rightPanel?.displayColumns, config.rightPanel?.tabConfig?.tabSourceColumn, config.leftPanel?.tabConfig?.tabSourceColumn]);
// 데이터 로드 후 미해결 카테고리 코드를 batch API로 변환
useEffect(() => {
if (isDesignMode) return;
const allData = [...leftData, ...rightData];
if (allData.length === 0) return;
const unresolvedCodes = new Set<string>();
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 () => {

View File

@ -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<FormatSegment>) => 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 (
<div
ref={setNodeRef}
style={style}
className={cn(
"grid grid-cols-[16px_56px_18px_1fr_1fr_1fr] items-center gap-1 rounded border bg-white px-2 py-1.5",
isDragging && "opacity-50",
)}
>
<div
{...attributes}
{...listeners}
className="cursor-grab text-gray-400 hover:text-gray-600"
>
<GripVertical className="h-3.5 w-3.5" />
</div>
<span className="truncate text-xs font-medium">
{SEGMENT_TYPE_LABELS[segment.type]}
</span>
<Checkbox
checked={segment.showLabel}
onCheckedChange={(checked) =>
onChange(index, { showLabel: checked === true })
}
className="h-3.5 w-3.5"
/>
<Input
value={segment.label}
onChange={(e) => onChange(index, { label: e.target.value })}
placeholder=""
className={cn(
"h-6 px-1 text-xs",
!segment.showLabel && "text-gray-400 line-through",
)}
/>
<Input
value={segment.separatorAfter}
onChange={(e) => onChange(index, { separatorAfter: e.target.value })}
placeholder=""
className="h-6 px-1 text-center text-xs"
/>
<Input
type="number"
min={0}
max={5}
value={segment.pad}
onChange={(e) =>
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",
)}
/>
</div>
);
}
// FormatSegmentEditor 메인 컴포넌트
interface FormatSegmentEditorProps {
label: string;
segments: FormatSegment[];
onChange: (segments: FormatSegment[]) => void;
sampleValues?: Record<string, string>;
}
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<FormatSegment>) => {
const updated = segments.map((seg, i) =>
i === index ? { ...seg, ...updates } : seg,
);
onChange(updated);
};
return (
<div className="space-y-2">
<div className="text-xs font-medium text-gray-600">{label}</div>
<div className="grid grid-cols-[16px_56px_18px_1fr_1fr_1fr] items-center gap-1 px-2 text-[10px] text-gray-500">
<span />
<span />
<span />
<span></span>
<span className="text-center"></span>
<span className="text-center">릿</span>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
<div className="space-y-1">
{segments.map((segment, index) => (
<SortableSegmentRow
key={sortableIds[index]}
segment={segment}
index={index}
onChange={handleSegmentChange}
/>
))}
</div>
</SortableContext>
</DndContext>
<div className="rounded bg-gray-50 px-2 py-1.5">
<span className="text-[10px] text-gray-500">: </span>
<span className="text-xs font-medium text-gray-800">
{preview || "(빈 값)"}
</span>
</div>
</div>
);
}

View File

@ -20,6 +20,7 @@ import {
GeneratedLocation,
RackStructureContext,
} from "./types";
import { defaultFormatConfig, buildFormattedString } from "./config";
// 기존 위치 데이터 타입
interface ExistingLocation {
@ -95,12 +96,12 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
};
return (
<div className="relative rounded-lg border border-border bg-white shadow-sm">
<div className="border-border relative rounded-lg border bg-white shadow-sm">
{/* 헤더 */}
<div className="flex items-center justify-between rounded-t-lg bg-primary px-4 py-2 text-white">
<div className="bg-primary flex items-center justify-between rounded-t-lg px-4 py-2 text-white">
<span className="font-medium"> {index + 1}</span>
{!readonly && (
<button onClick={() => onRemove(condition.id)} className="rounded p-1 transition-colors hover:bg-primary/90">
<button onClick={() => onRemove(condition.id)} className="hover:bg-primary/90 rounded p-1 transition-colors">
<X className="h-4 w-4" />
</button>
)}
@ -111,7 +112,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
{/* 열 범위 */}
<div className="flex items-center gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs font-medium text-foreground">
<label className="text-foreground mb-1 block text-xs font-medium">
<span className="text-destructive">*</span>
</label>
<div className="flex items-center gap-2">
@ -139,7 +140,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
</div>
</div>
<div className="w-20">
<label className="mb-1 block text-xs font-medium text-foreground">
<label className="text-foreground mb-1 block text-xs font-medium">
<span className="text-destructive">*</span>
</label>
<Input
@ -156,7 +157,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
</div>
{/* 계산 결과 */}
<div className="rounded-md bg-primary/10 px-3 py-2 text-center text-sm text-primary">
<div className="bg-primary/10 text-primary rounded-md px-3 py-2 text-center text-sm">
{locationCount > 0 ? (
<>
{localValues.startRow} ~ {localValues.endRow} x {localValues.levels} ={" "}
@ -288,11 +289,10 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
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<RackStructureComponentProps> = ({
// 기존 데이터 조회 (창고/층/구역이 변경될 때마다)
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<RackStructureComponentProps> = ({
setIsCheckingDuplicates(true);
try {
// warehouse_location 테이블에서 해당 창고/층/구역의 기존 데이터 조회
// DB에는 라벨 값으로 저장되어 있으므로 라벨 값으로 필터링
// equals 연산자를 사용하여 정확한 일치 검색 (ILIKE가 아닌 = 연산자 사용)
const searchParams = {
const searchParams: Record<string, any> = {
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<RackStructureComponentProps> = ({
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<string, string> = {
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<RackStructureComponentProps> = ({
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<div className="h-4 w-1 rounded bg-gradient-to-b from-green-500 to-primary/50" />
<div className="to-primary/50 h-4 w-1 rounded bg-gradient-to-b from-green-500" />
</CardTitle>
{!readonly && (
<div className="flex items-center gap-2">
@ -719,8 +720,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{/* 기존 데이터 존재 알림 */}
{!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && (
<Alert className="mb-4 border-primary/20 bg-primary/10">
<AlertCircle className="h-4 w-4 text-primary" />
<Alert className="border-primary/20 bg-primary/10 mb-4">
<AlertCircle className="text-primary h-4 w-4" />
<AlertDescription className="text-primary">
// <strong>{existingLocations.length}</strong> .
</AlertDescription>
@ -729,9 +730,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{/* 현재 매핑된 값 표시 */}
{(context.warehouseCode || context.warehouseName || context.floor || context.zone) && (
<div className="mb-4 flex flex-wrap gap-2 rounded-lg bg-muted p-3">
<div className="bg-muted mb-4 flex flex-wrap gap-2 rounded-lg p-3">
{(context.warehouseCode || context.warehouseName) && (
<span className="rounded bg-primary/10 px-2 py-1 text-xs text-primary">
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs">
: {context.warehouseName || context.warehouseCode}
{context.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`}
</span>
@ -748,28 +749,28 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
</span>
)}
{context.status && (
<span className="rounded bg-muted/80 px-2 py-1 text-xs text-foreground">: {context.status}</span>
<span className="bg-muted/80 text-foreground rounded px-2 py-1 text-xs">: {context.status}</span>
)}
</div>
)}
{/* 안내 메시지 */}
<div className="mb-4 rounded-lg bg-primary/10 p-4">
<ol className="space-y-1 text-sm text-primary">
<div className="bg-primary/10 mb-4 rounded-lg p-4">
<ol className="text-primary space-y-1 text-sm">
<li className="flex items-start gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
1
</span>
</li>
<li className="flex items-start gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
2
</span>
</li>
<li className="flex items-start gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
3
</span>
예시: 조건1(1~3, 3), 2(4~6, 5)
@ -779,9 +780,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{/* 조건 목록 또는 빈 상태 */}
{conditions.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border py-12">
<div className="mb-4 text-6xl text-muted-foreground/50">📦</div>
<p className="mb-4 text-muted-foreground"> </p>
<div className="border-border flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-12">
<div className="text-muted-foreground/50 mb-4 text-6xl">📦</div>
<p className="text-muted-foreground mb-4"> </p>
{!readonly && (
<Button onClick={addCondition} className="gap-1">
<Plus className="h-4 w-4" />
@ -832,15 +833,15 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{config.showStatistics && (
<div className="mb-4 grid grid-cols-3 gap-4">
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-sm text-muted-foreground"> </div>
<div className="text-muted-foreground text-sm"> </div>
<div className="text-2xl font-bold">{statistics.totalLocations}</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-sm text-muted-foreground"> </div>
<div className="text-muted-foreground text-sm"> </div>
<div className="text-2xl font-bold">{statistics.totalRows}</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-sm text-muted-foreground"> </div>
<div className="text-muted-foreground text-sm"> </div>
<div className="text-2xl font-bold">{statistics.maxLevel}</div>
</div>
</div>
@ -851,7 +852,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
<div className="rounded-lg border">
<ScrollArea className="h-[400px]">
<Table>
<TableHeader className="sticky top-0 bg-muted">
<TableHeader className="bg-muted sticky top-0">
<TableRow>
<TableHead className="w-12 text-center">No</TableHead>
<TableHead></TableHead>
@ -870,7 +871,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
<TableCell className="text-center">{idx + 1}</TableCell>
<TableCell className="font-mono">{loc.location_code}</TableCell>
<TableCell>{loc.location_name}</TableCell>
<TableCell className="text-center">{loc.floor || context?.floor || "1"}</TableCell>
<TableCell className="text-center">{loc.floor || context?.floor || "-"}</TableCell>
<TableCell className="text-center">{loc.zone || context?.zone || "A"}</TableCell>
<TableCell className="text-center">{loc.row_num.padStart(2, "0")}</TableCell>
<TableCell className="text-center">{loc.level_num}</TableCell>
@ -883,8 +884,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
</ScrollArea>
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border py-8 text-muted-foreground">
<Eye className="mb-2 h-8 w-8 text-muted-foreground/50" />
<div className="border-border text-muted-foreground flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-8">
<Eye className="text-muted-foreground/50 mb-2 h-8 w-8" />
<p> </p>
</div>
)}
@ -931,16 +932,16 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{/* 템플릿 목록 */}
{templates.length > 0 ? (
<div className="space-y-2">
<div className="text-sm font-medium text-foreground"> 릿</div>
<div className="text-foreground text-sm font-medium"> 릿</div>
<ScrollArea className="h-[200px]">
{templates.map((template) => (
<div
key={template.id}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted"
className="hover:bg-muted flex items-center justify-between rounded-lg border p-3"
>
<div>
<div className="font-medium">{template.name}</div>
<div className="text-xs text-muted-foreground">{template.conditions.length} </div>
<div className="text-muted-foreground text-xs">{template.conditions.length} </div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => loadTemplate(template)}>
@ -955,7 +956,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
</ScrollArea>
</div>
) : (
<div className="py-8 text-center text-muted-foreground"> 릿 </div>
<div className="text-muted-foreground py-8 text-center"> 릿 </div>
)}
</div>
)}

View File

@ -4,14 +4,10 @@ import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RackStructureComponentConfig, FieldMapping } from "./types";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RackStructureComponentConfig, FieldMapping, FormatSegment } from "./types";
import { defaultFormatConfig, SAMPLE_VALUES } from "./config";
import { FormatSegmentEditor } from "./FormatSegmentEditor";
interface RackStructureConfigPanelProps {
config: RackStructureComponentConfig;
@ -34,9 +30,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
tables = [],
}) => {
// 사용 가능한 컬럼 목록 추출
const [availableColumns, setAvailableColumns] = useState<
Array<{ value: string; label: string }>
>([]);
const [availableColumns, setAvailableColumns] = useState<Array<{ value: string; label: string }>>([]);
useEffect(() => {
// 모든 테이블의 컬럼을 플랫하게 추출
@ -69,14 +63,24 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
const fieldMapping = config.fieldMapping || {};
const formatConfig = config.formatConfig || defaultFormatConfig;
const handleFormatChange = (key: "codeSegments" | "nameSegments", segments: FormatSegment[]) => {
onChange({
...config,
formatConfig: {
...formatConfig,
[key]: segments,
},
});
};
return (
<div className="space-y-4">
{/* 필드 매핑 섹션 */}
<div className="space-y-3">
<div className="text-sm font-medium text-foreground"> </div>
<p className="text-xs text-muted-foreground">
</p>
<div className="text-foreground text-sm font-medium"> </div>
<p className="text-muted-foreground text-xs"> </p>
{/* 창고 코드 필드 */}
<div>
@ -207,7 +211,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
{/* 제한 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground"> </div>
<div className="text-foreground text-sm font-medium"> </div>
<div>
<Label className="text-xs"> </Label>
@ -248,7 +252,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
{/* UI 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground">UI </div>
<div className="text-foreground text-sm font-medium">UI </div>
<div className="flex items-center justify-between">
<Label className="text-xs">릿 </Label>
@ -276,12 +280,31 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.readonly ?? false}
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
<Switch checked={config.readonly ?? false} onCheckedChange={(checked) => handleChange("readonly", checked)} />
</div>
</div>
{/* 포맷 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-gray-700"> </div>
<p className="text-xs text-gray-500">
, /
</p>
<FormatSegmentEditor
label="위치코드 포맷"
segments={formatConfig.codeSegments}
onChange={(segs) => handleFormatChange("codeSegments", segs)}
sampleValues={SAMPLE_VALUES}
/>
<FormatSegmentEditor
label="위치명 포맷"
segments={formatConfig.nameSegments}
onChange={(segs) => handleFormatChange("nameSegments", segs)}
sampleValues={SAMPLE_VALUES}
/>
</div>
</div>
);
};

View File

@ -2,26 +2,107 @@
*
*/
import { RackStructureComponentConfig } from "./types";
import {
RackStructureComponentConfig,
FormatSegment,
FormatSegmentType,
LocationFormatConfig,
} from "./types";
// 세그먼트 타입별 한글 표시명
export const SEGMENT_TYPE_LABELS: Record<FormatSegmentType, string> = {
warehouseCode: "창고코드",
floor: "층",
zone: "구역",
row: "열",
level: "단",
};
// 위치코드 기본 세그먼트 (현재 하드코딩과 동일한 결과)
export const defaultCodeSegments: FormatSegment[] = [
{ type: "warehouseCode", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 0 },
{ type: "floor", enabled: true, showLabel: true, label: "층", separatorAfter: "", pad: 0 },
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
{ type: "row", enabled: true, showLabel: false, label: "", separatorAfter: "-", pad: 2 },
{ type: "level", enabled: true, showLabel: false, label: "", separatorAfter: "", pad: 0 },
];
// 위치명 기본 세그먼트 (현재 하드코딩과 동일한 결과)
export const defaultNameSegments: FormatSegment[] = [
{ type: "zone", enabled: true, showLabel: true, label: "구역", separatorAfter: "-", pad: 0 },
{ type: "row", enabled: true, showLabel: true, label: "열", separatorAfter: "-", pad: 2 },
{ type: "level", enabled: true, showLabel: true, label: "단", separatorAfter: "", pad: 0 },
];
export const defaultFormatConfig: LocationFormatConfig = {
codeSegments: defaultCodeSegments,
nameSegments: defaultNameSegments,
};
// 세그먼트 타입별 기본 한글 접미사 (context 값에 포함되어 있는 한글)
const KNOWN_SUFFIXES: Partial<Record<FormatSegmentType, string>> = {
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, string>,
): string {
const activeSegments = segments.filter(
(seg) => seg.enabled && values[seg.type],
);
return activeSegments
.map((seg, idx) => {
// 1) 원본 값에서 한글 접미사를 먼저 벗겨냄 ("A구역" → "A", "1층" → "1")
let val = stripKnownSuffix(seg.type, values[seg.type]);
// 2) showLabel이 켜져 있고 label이 있으면 붙임
if (seg.showLabel && seg.label) {
val += seg.label;
}
if (seg.pad > 0 && !isNaN(Number(val))) {
val = val.padStart(seg.pad, "0");
}
if (idx < activeSegments.length - 1) {
val += seg.separatorAfter;
}
return val;
})
.join("");
}
// 미리보기용 샘플 값
export const SAMPLE_VALUES: Record<string, string> = {
warehouseCode: "WH001",
floor: "1층",
zone: "A구역",
row: "1",
level: "1",
};
export const defaultConfig: RackStructureComponentConfig = {
// 기본 제한
maxConditions: 10,
maxRows: 99,
maxLevels: 20,
// 기본 코드 패턴
codePattern: "{warehouseCode}-{floor}{zone}-{row:02d}-{level}",
namePattern: "{zone}구역-{row:02d}열-{level}단",
// UI 설정
showTemplates: true,
showPreview: true,
showStatistics: true,
readonly: false,
// 초기 조건 없음
initialConditions: [],
};

View File

@ -43,6 +43,24 @@ export interface FieldMapping {
statusField?: string; // 사용 여부로 사용할 폼 필드명
}
// 포맷 세그먼트 (위치코드/위치명의 각 구성요소)
export type FormatSegmentType = 'warehouseCode' | 'floor' | 'zone' | 'row' | 'level';
export interface FormatSegment {
type: FormatSegmentType;
enabled: boolean; // 이 세그먼트를 포함할지 여부
showLabel: boolean; // 한글 라벨 표시 여부 (false면 값에서 라벨 제거)
label: string; // 한글 라벨 (예: "층", "구역", "열", "단")
separatorAfter: string; // 이 세그먼트 뒤의 구분자 (예: "-", "/", "")
pad: number; // 최소 자릿수 (0 = 그대로, 2 = "01"처럼 2자리 맞춤)
}
// 위치코드 + 위치명 포맷 설정
export interface LocationFormatConfig {
codeSegments: FormatSegment[];
nameSegments: FormatSegment[];
}
// 컴포넌트 설정
export interface RackStructureComponentConfig {
// 기본 설정
@ -54,8 +72,9 @@ export interface RackStructureComponentConfig {
fieldMapping?: FieldMapping;
// 위치 코드 생성 규칙
codePattern?: string; // 코드 패턴 (예: "{warehouse}-{floor}{zone}-{row:02d}-{level}")
namePattern?: string; // 이름 패턴 (예: "{zone}구역-{row:02d}열-{level}단")
codePattern?: string; // 코드 패턴 (하위 호환용 유지)
namePattern?: string; // 이름 패턴 (하위 호환용 유지)
formatConfig?: LocationFormatConfig; // 구조화된 포맷 설정
// UI 설정
showTemplates?: boolean; // 템플릿 기능 표시
@ -93,5 +112,3 @@ export interface RackStructureComponentProps {
isPreview?: boolean;
tableName?: string;
}

View File

@ -1098,23 +1098,24 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return formatNumberValue(value, format);
}
// 🆕 카테고리 매핑 찾기 (여러 키 형태 시도)
// 카테고리 매핑 찾기 (여러 키 형태 시도)
// 1. 전체 컬럼명 (예: "item_info.material")
// 2. 컬럼명만 (예: "material")
// 3. 전역 폴백: 모든 매핑에서 value 검색
let mapping = categoryMappings[columnName];
if (!mapping && columnName.includes(".")) {
// 조인된 컬럼의 경우 컬럼명만으로 다시 시도
const simpleColumnName = columnName.split(".").pop() || columnName;
mapping = categoryMappings[simpleColumnName];
}
if (mapping && mapping[String(value)]) {
const categoryData = mapping[String(value)];
const displayLabel = categoryData.label || String(value);
const strValue = String(value);
if (mapping && mapping[strValue]) {
const categoryData = mapping[strValue];
const displayLabel = categoryData.label || strValue;
const displayColor = categoryData.color || "#64748b";
// 배지로 표시
return (
<Badge
style={{
@ -1128,6 +1129,29 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
);
}
// 전역 폴백: 컬럼명으로 매핑을 못 찾았을 때, 전체 매핑에서 값 검색
if (!mapping && (strValue.startsWith("CAT_") || strValue.startsWith("CATEGORY_"))) {
for (const key of Object.keys(categoryMappings)) {
const m = categoryMappings[key];
if (m && m[strValue]) {
const categoryData = m[strValue];
const displayLabel = categoryData.label || strValue;
const displayColor = categoryData.color || "#64748b";
return (
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
}
}
}
// 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체)
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) {
return formatDateValue(value, "YYYY-MM-DD");
@ -1247,10 +1271,44 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]);
}
// 좌측 패널 dataFilter 클라이언트 사이드 적용
let filteredLeftData = result.data || [];
const leftDataFilter = componentConfig.leftPanel?.dataFilter;
if (leftDataFilter?.enabled && leftDataFilter.filters?.length > 0) {
const matchFn = leftDataFilter.matchType === "any" ? "some" : "every";
filteredLeftData = filteredLeftData.filter((item: any) => {
return leftDataFilter.filters[matchFn]((cond: any) => {
const val = item[cond.columnName];
switch (cond.operator) {
case "equals":
return val === cond.value;
case "not_equals":
return val !== cond.value;
case "in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return arr.includes(val);
}
case "not_in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return !arr.includes(val);
}
case "contains":
return String(val || "").includes(String(cond.value));
case "is_null":
return val === null || val === undefined || val === "";
case "is_not_null":
return val !== null && val !== undefined && val !== "";
default:
return true;
}
});
});
}
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
if (leftColumn && result.data.length > 0) {
result.data.sort((a, b) => {
if (leftColumn && filteredLeftData.length > 0) {
filteredLeftData.sort((a, b) => {
const aValue = String(a[leftColumn] || "");
const bValue = String(b[leftColumn] || "");
return aValue.localeCompare(bValue, "ko-KR");
@ -1258,7 +1316,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
// 계층 구조 빌드
const hierarchicalData = buildHierarchy(result.data);
const hierarchicalData = buildHierarchy(filteredLeftData);
setLeftData(hierarchicalData);
} catch (error) {
console.error("좌측 데이터 로드 실패:", error);
@ -1317,7 +1375,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
case "equals":
return value === cond.value;
case "notEquals":
case "not_equals":
return value !== cond.value;
case "in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return arr.includes(value);
}
case "not_in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return !arr.includes(value);
}
case "contains":
return String(value || "").includes(String(cond.value));
case "is_null":
@ -1634,7 +1701,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
case "equals":
return value === cond.value;
case "notEquals":
case "not_equals":
return value !== cond.value;
case "in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return arr.includes(value);
}
case "not_in": {
const arr = Array.isArray(cond.value) ? cond.value : [cond.value];
return !arr.includes(value);
}
case "contains":
return String(value || "").includes(String(cond.value));
case "is_null":
@ -2026,43 +2102,59 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
loadRightTableColumns();
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
// 좌측 테이블 카테고리 매핑 로드
// 좌측 테이블 카테고리 매핑 로드 (조인된 테이블 포함)
useEffect(() => {
const loadLeftCategoryMappings = async () => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
try {
// 1. 컬럼 메타 정보 조회
const columnsResponse = await tableTypeApi.getColumns(leftTableName);
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
if (categoryColumns.length === 0) {
setLeftCategoryMappings({});
return;
}
// 2. 각 카테고리 컬럼에 대한 값 조회
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
const tablesToLoad = new Set<string>([leftTableName]);
for (const col of categoryColumns) {
const columnName = col.columnName || col.column_name;
// 좌측 패널 컬럼 설정에서 조인된 테이블 추출
const leftColumns = componentConfig.leftPanel?.columns || [];
leftColumns.forEach((col: any) => {
const colName = col.name || col.columnName;
if (colName && colName.includes(".")) {
const joinTableName = colName.split(".")[0];
tablesToLoad.add(joinTableName);
}
});
// 각 테이블에 대해 카테고리 매핑 로드
for (const tableName of tablesToLoad) {
try {
const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values?includeInactive=true`);
const columnsResponse = await tableTypeApi.getColumns(tableName);
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
mappings[columnName] = valueMap;
console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
for (const col of categoryColumns) {
const columnName = col.columnName || col.column_name;
try {
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`);
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
// 조인된 테이블은 "테이블명.컬럼명" 형태로도 저장
const mappingKey = tableName === leftTableName ? columnName : `${tableName}.${columnName}`;
mappings[mappingKey] = valueMap;
// 컬럼명만으로도 접근 가능하도록 추가 저장
mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap };
}
} catch (error) {
console.error(`좌측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error);
}
}
} catch (error) {
console.error(`좌측 카테고리 값 조회 실패 [${columnName}]:`, error);
console.error(`좌측 카테고리 테이블 컬럼 조회 실패 [${tableName}]:`, error);
}
}
@ -2073,7 +2165,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
};
loadLeftCategoryMappings();
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
}, [componentConfig.leftPanel?.tableName, componentConfig.leftPanel?.columns, isDesignMode]);
// 우측 테이블 카테고리 매핑 로드 (조인된 테이블 포함)
useEffect(() => {
@ -3740,9 +3832,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
displayFields = configuredColumns.slice(0, 2).map((col: any) => {
const colName = typeof col === "string" ? col : col.name || col.columnName;
const colLabel = typeof col === "object" ? col.label : leftColumnLabels[colName] || colName;
const rawValue = getEntityJoinValue(item, colName);
// 카테고리 매핑이 있으면 라벨로 변환
let displayValue = rawValue;
if (rawValue != null && rawValue !== "") {
const strVal = String(rawValue);
let mapping = leftCategoryMappings[colName];
if (!mapping && colName.includes(".")) {
mapping = leftCategoryMappings[colName.split(".").pop() || colName];
}
if (mapping && mapping[strVal]) {
displayValue = mapping[strVal].label;
}
}
return {
label: colLabel,
value: item[colName],
value: displayValue,
};
});
@ -3754,10 +3859,21 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const keys = Object.keys(item).filter(
(k) => k !== "id" && k !== "ID" && k !== "children" && k !== "level" && shouldShowField(k),
);
displayFields = keys.slice(0, 2).map((key) => ({
label: leftColumnLabels[key] || key,
value: item[key],
}));
displayFields = keys.slice(0, 2).map((key) => {
const rawValue = item[key];
let displayValue = rawValue;
if (rawValue != null && rawValue !== "") {
const strVal = String(rawValue);
const mapping = leftCategoryMappings[key];
if (mapping && mapping[strVal]) {
displayValue = mapping[strVal].label;
}
}
return {
label: leftColumnLabels[key] || key,
value: displayValue,
};
});
if (index === 0) {
console.log(" ⚠️ 설정된 컬럼 없음, 자동 선택:", displayFields);

View File

@ -1955,7 +1955,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
{/* ===== 기본 설정 모달 ===== */}
<Dialog open={activeModal === "basic"} onOpenChange={(open) => !open && setActiveModal(null)}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogContent container={null} className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs"> </DialogDescription>
@ -2033,7 +2033,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
{/* ===== 좌측 패널 모달 ===== */}
<Dialog open={activeModal === "left"} onOpenChange={(open) => !open && setActiveModal(null)}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogContent container={null} className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs"> </DialogDescription>
@ -2715,7 +2715,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
{/* ===== 우측 패널 모달 ===== */}
<Dialog open={activeModal === "right"} onOpenChange={(open) => !open && setActiveModal(null)}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogContent container={null} className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs">
@ -3651,7 +3651,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
{/* ===== 추가 탭 모달 ===== */}
<Dialog open={activeModal === "tabs"} onOpenChange={(open) => !open && setActiveModal(null)}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogContent container={null} className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs">

View File

@ -12,6 +12,7 @@ import { getFilePreviewUrl } from "@/lib/api/file";
import { Button } from "@/components/ui/button";
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
import { useTabId } from "@/contexts/TabIdContext";
// 🖼️ 테이블 셀 이미지 썸네일 컴포넌트
// objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용
@ -405,6 +406,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 디버그 로그 제거 (성능 최적화)
const currentTabId = useTabId();
const buttonColor = getAdaptiveLabelColor(component.style?.labelColor);
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
@ -433,13 +436,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
width: "100%",
height: "100%",
minHeight: isDesignMode ? "300px" : "100%",
...style,
// 런타임에서는 DB의 고정 px 크기를 무시하고 부모에 맞춤
...(!isDesignMode && {
width: "100%",
height: "100%",
minWidth: 0,
}),
...style, // style prop이 위의 기본값들을 덮어씀
};
// ========================================
@ -695,13 +692,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [totalItems, setTotalItems] = useState(0);
const [pageInputValue, setPageInputValue] = useState<string>("1");
const [searchTerm, setSearchTerm] = useState("");
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const hasInitializedSort = useRef(false);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [tableLabel, setTableLabel] = useState<string>("");
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
const pageSizeKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
if (currentTabId) return `pageSize_${currentTabId}_${tableConfig.selectedTable}`;
return `pageSize_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, currentTabId]);
const [localPageSize, setLocalPageSize] = useState<number>(() => {
const key =
currentTabId && tableConfig.selectedTable
? `pageSize_${currentTabId}_${tableConfig.selectedTable}`
: tableConfig.selectedTable
? `pageSize_${tableConfig.selectedTable}`
: null;
if (key) {
const val = sessionStorage.getItem(key);
if (val) return Number(val);
}
return tableConfig.pagination?.pageSize || 20;
});
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
const [columnMeta, setColumnMeta] = useState<
Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }>
@ -811,9 +827,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [dropTargetColumnIndex, setDropTargetColumnIndex] = useState<number | null>(null);
const [isColumnDragEnabled] = useState<boolean>((tableConfig as any).enableColumnDrag ?? true);
// 🆕 State Persistence: 통합 상태 키
// 🆕 State Persistence: 통합 상태 키 (탭 ID 스코프 + sessionStorage)
const tableStateKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
if (currentTabId) return `tableState_${currentTabId}_${tableConfig.selectedTable}`;
return `tableState_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable]);
@ -1623,7 +1640,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setError(null);
try {
const page = tableConfig.pagination?.currentPage || currentPage;
const page = currentPage;
const pageSize = localPageSize;
// 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용
const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined;
@ -1886,7 +1903,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
}, [
tableConfig.selectedTable,
tableConfig.pagination?.currentPage,
tableConfig.columns,
currentPage,
localPageSize,
@ -1929,6 +1945,23 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
};
useEffect(() => {
setPageInputValue(String(currentPage));
}, [currentPage]);
const commitPageInput = () => {
const parsed = parseInt(pageInputValue, 10);
if (isNaN(parsed) || pageInputValue.trim() === "") {
setPageInputValue(String(currentPage));
return;
}
const clamped = Math.max(1, Math.min(parsed, totalPages || 1));
if (clamped !== currentPage) {
handlePageChange(clamped);
}
setPageInputValue(String(clamped));
};
const handleSort = (column: string) => {
let newSortColumn = column;
let newSortDirection: "asc" | "desc" = "asc";
@ -2989,7 +3022,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
try {
localStorage.setItem(tableStateKey, JSON.stringify(state));
sessionStorage.setItem(tableStateKey, JSON.stringify(state));
} catch (error) {
console.error("❌ 테이블 상태 저장 실패:", error);
}
@ -3012,7 +3045,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!tableStateKey) return;
try {
const saved = localStorage.getItem(tableStateKey);
const saved = sessionStorage.getItem(tableStateKey);
if (!saved) return;
const state = JSON.parse(saved);
@ -3050,7 +3083,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!tableStateKey) return;
try {
localStorage.removeItem(tableStateKey);
sessionStorage.removeItem(tableStateKey);
setColumnWidths({});
setColumnOrder([]);
setSortColumn(null);
@ -4285,8 +4318,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return (
<div className="flex max-w-full items-center gap-1.5 text-sm">
<Paperclip className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="truncate text-primary" title={fileNames}>
<Paperclip className="h-4 w-4 flex-shrink-0 text-gray-500" />
<span className="truncate text-blue-600" title={fileNames}>
{fileNames}
</span>
{files.length > 1 && <span className="text-muted-foreground flex-shrink-0 text-xs">({files.length})</span>}
@ -4475,28 +4508,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// useEffect 훅
// ========================================
// 필터 설정 localStorage 키 생성 (화면별로 독립적)
// 필터 설정 sessionStorage 키 생성 (탭 ID 스코프)
const filterSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return screenId
? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}`
: `tableList_filterSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, screenId]);
const base = screenId
? `${tableConfig.selectedTable}_screen_${screenId}`
: tableConfig.selectedTable;
if (currentTabId) return `filterSettings_${currentTabId}_${base}`;
return `filterSettings_${base}`;
}, [tableConfig.selectedTable, screenId, currentTabId]);
// 그룹 설정 localStorage 키 생성 (화면별로 독립적)
// 그룹 설정 sessionStorage 키 생성 (탭 ID 스코프)
const groupSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return screenId
? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}`
: `tableList_groupSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, screenId]);
const base = screenId
? `${tableConfig.selectedTable}_screen_${screenId}`
: tableConfig.selectedTable;
if (currentTabId) return `groupSettings_${currentTabId}_${base}`;
return `groupSettings_${base}`;
}, [tableConfig.selectedTable, screenId, currentTabId]);
// 저장된 필터 설정 불러오기
useEffect(() => {
if (!filterSettingKey || visibleColumns.length === 0) return;
try {
const saved = localStorage.getItem(filterSettingKey);
const saved = sessionStorage.getItem(filterSettingKey);
if (saved) {
const savedFilters = JSON.parse(saved);
setVisibleFilterColumns(new Set(savedFilters));
@ -4515,7 +4552,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!filterSettingKey) return;
try {
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
sessionStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
setIsFilterSettingOpen(false);
toast.success("검색 필터 설정이 저장되었습니다");
@ -4570,7 +4607,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!groupSettingKey) return;
try {
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
sessionStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
} catch (error) {
console.error("그룹 설정 저장 실패:", error);
}
@ -4650,7 +4687,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setGroupByColumns([]);
setCollapsedGroups(new Set());
if (groupSettingKey) {
localStorage.removeItem(groupSettingKey);
sessionStorage.removeItem(groupSettingKey);
}
toast.success("그룹이 해제되었습니다");
}, [groupSettingKey]);
@ -4834,7 +4871,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!groupSettingKey || visibleColumns.length === 0) return;
try {
const saved = localStorage.getItem(groupSettingKey);
const saved = sessionStorage.getItem(groupSettingKey);
if (saved) {
const savedGroups = JSON.parse(saved);
setGroupByColumns(savedGroups);
@ -5134,7 +5171,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 페이지 크기 변경 핸들러
const handlePageSizeChange = (newSize: number) => {
setLocalPageSize(newSize);
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로 이동
setCurrentPage(1);
if (pageSizeKey) {
sessionStorage.setItem(pageSizeKey, String(newSize));
}
if (onConfigChange) {
onConfigChange({
...tableConfig,
@ -5143,8 +5183,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
};
const pageSizeOptions = tableConfig.pagination?.pageSizeOptions || [5, 10, 20, 50, 100];
return (
<div className="border-border bg-background relative flex h-14 w-full flex-shrink-0 items-center justify-center border-t-2 px-4 sm:h-[60px] sm:px-6">
{/* 좌측: 페이지 크기 입력 */}
@ -5190,9 +5228,28 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<span className="text-foreground min-w-[60px] text-center text-xs font-medium sm:min-w-[80px] sm:text-sm">
{currentPage} / {totalPages || 1}
</span>
<div className="flex items-center gap-1 sm:gap-2">
<input
type="text"
inputMode="numeric"
value={pageInputValue}
onChange={(e) => setPageInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
commitPageInput();
(e.target as HTMLInputElement).blur();
}
}}
onBlur={commitPageInput}
onFocus={(e) => e.target.select()}
disabled={loading}
className="border-input bg-background focus:ring-ring h-7 w-10 rounded-md border px-1 text-center text-xs font-medium focus:ring-1 focus:outline-none sm:h-8 sm:w-12 sm:text-sm"
/>
<span className="text-muted-foreground text-xs sm:text-sm">/</span>
<span className="text-foreground text-xs font-medium sm:text-sm">
{totalPages || 1}
</span>
</div>
<Button
variant="outline"
@ -5227,7 +5284,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<div className="flex flex-col gap-1">
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">Excel</div>
<Button variant="ghost" size="sm" className="justify-start text-xs" onClick={() => exportToExcel(true)}>
<FileSpreadsheet className="mr-2 h-3 w-3 text-emerald-600" />
<FileSpreadsheet className="mr-2 h-3 w-3 text-green-600" />
Excel
</Button>
<Button
@ -5237,13 +5294,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onClick={() => exportToExcel(false)}
disabled={selectedRows.size === 0}
>
<FileSpreadsheet className="mr-2 h-3 w-3 text-emerald-600" />
<FileSpreadsheet className="mr-2 h-3 w-3 text-green-600" />
({selectedRows.size})
</Button>
<div className="border-border my-1 border-t" />
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">PDF/</div>
<Button variant="ghost" size="sm" className="justify-start text-xs" onClick={() => exportToPdf(true)}>
<FileText className="mr-2 h-3 w-3 text-destructive" />
<FileText className="mr-2 h-3 w-3 text-red-600" />
PDF
</Button>
<Button
@ -5253,7 +5310,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onClick={() => exportToPdf(false)}
disabled={selectedRows.size === 0}
>
<FileText className="mr-2 h-3 w-3 text-destructive" />
<FileText className="mr-2 h-3 w-3 text-red-600" />
({selectedRows.size})
</Button>
</div>
@ -5289,6 +5346,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
localPageSize,
onConfigChange,
tableConfig,
pageInputValue,
]);
// ========================================
@ -5300,7 +5358,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onDragStart: isDesignMode ? onDragStart : undefined,
onDragEnd: isDesignMode ? onDragEnd : undefined,
draggable: isDesignMode,
className: cn("w-full h-full overflow-hidden", className, isDesignMode && "cursor-move"),
className: cn("w-full h-full", className, isDesignMode && "cursor-move"), // customer-item-mapping과 동일
style: componentStyle,
};
@ -5370,7 +5428,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div>
)}
<div style={{ flex: 1, overflow: "auto", WebkitOverflowScrolling: "touch" }}>
<div style={{ flex: 1, overflow: "hidden" }}>
<SingleTableWithSticky
data={data}
columns={visibleColumns}
@ -5436,7 +5494,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
className="h-7 text-xs"
title="Excel 내보내기"
>
<FileSpreadsheet className="mr-1 h-3 w-3 text-emerald-600" />
<FileSpreadsheet className="mr-1 h-3 w-3 text-green-600" />
Excel
</Button>
)}
@ -5448,7 +5506,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
className="h-7 text-xs"
title="PDF 내보내기"
>
<FileText className="mr-1 h-3 w-3 text-destructive" />
<FileText className="mr-1 h-3 w-3 text-red-600" />
PDF
</Button>
)}
@ -5680,7 +5738,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
width: "100%",
height: "100%",
overflow: "auto",
WebkitOverflowScrolling: "touch",
}}
onScroll={handleVirtualScroll}
>
@ -5691,7 +5748,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
borderCollapse: "collapse",
width: "100%",
tableLayout: "fixed",
minWidth: "400px",
}}
>
{/* 헤더 (sticky) */}
@ -5909,7 +5965,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 리사이즈 핸들 (체크박스 제외) */}
{columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
<div
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-primary"
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지
onMouseDown={(e) => {
@ -6262,11 +6318,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 배치 편집: 수정된 셀 스타일 (노란 배경)
isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40",
// 🆕 유효성 에러: 빨간 테두리 및 배경
cellValidationError && "bg-destructive/10 ring-2 ring-destructive ring-inset dark:bg-destructive/15",
cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40",
// 🆕 검색 하이라이트 스타일 (노란 배경)
isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50",
// 🆕 편집 불가 컬럼 스타일 (연한 회색 배경)
column.editable === false && "bg-muted dark:bg-foreground/30",
column.editable === false && "bg-gray-50 dark:bg-gray-900/30",
)}
// 🆕 유효성 에러 툴팁
title={cellValidationError || undefined}
@ -6659,7 +6715,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 행 삭제 */}
<button
className="hover:bg-destructive hover:text-destructive-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-destructive"
className="hover:bg-destructive hover:text-destructive-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-red-600"
onClick={async () => {
if (confirm("이 행을 삭제하시겠습니까?")) {
try {
@ -6726,7 +6782,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="ghost"
size="sm"
onClick={() => removeFilterGroup(group.id)}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
>
<X className="h-4 w-4" />
</Button>
@ -6786,7 +6842,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
variant="ghost"
size="sm"
onClick={() => removeFilterCondition(group.id, condition.id)}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
disabled={group.conditions.length === 1}
>
<X className="h-3 w-3" />

View File

@ -16,12 +16,13 @@ import "./pop-text";
import "./pop-icon";
import "./pop-dashboard";
import "./pop-card-list";
import "./pop-card-list-v2";
import "./pop-button";
import "./pop-string-list";
import "./pop-search";
import "./pop-status-bar";
import "./pop-field";
// 향후 추가될 컴포넌트들:
// import "./pop-list";
import "./pop-scanner";
import "./pop-profile";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,104 @@
"use client";
/**
* pop-card-list-v2
*
* .
* CSS Grid .
*/
import React from "react";
import { LayoutGrid, Package } from "lucide-react";
import type { PopCardListV2Config } from "../types";
import { CARD_SCROLL_DIRECTION_LABELS, CARD_SIZE_LABELS } from "../types";
interface PopCardListV2PreviewProps {
config?: PopCardListV2Config;
}
export function PopCardListV2PreviewComponent({ config }: PopCardListV2PreviewProps) {
const scrollDirection = config?.scrollDirection || "vertical";
const cardSize = config?.cardSize || "medium";
const dataSource = config?.dataSource;
const cardGrid = config?.cardGrid;
const hasTable = !!dataSource?.tableName;
const cellCount = cardGrid?.cells?.length || 0;
return (
<div className="flex h-full w-full flex-col bg-muted/30 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-muted-foreground">
<LayoutGrid className="h-4 w-4" />
<span className="text-xs font-medium"> V2</span>
</div>
<div className="flex gap-1">
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[9px] text-primary">
{CARD_SCROLL_DIRECTION_LABELS[scrollDirection]}
</span>
<span className="rounded bg-secondary px-1.5 py-0.5 text-[9px] text-secondary-foreground">
{CARD_SIZE_LABELS[cardSize]}
</span>
</div>
</div>
{!hasTable ? (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<Package className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground"> </p>
</div>
</div>
) : (
<>
<div className="mb-2 text-center">
<span className="rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
{dataSource!.tableName}
</span>
<span className="ml-1 text-[10px] text-muted-foreground/60">
({cellCount})
</span>
</div>
<div className="flex flex-1 flex-col gap-2">
{[0, 1].map((cardIdx) => (
<div key={cardIdx} className="rounded-md border bg-card p-2">
{cellCount === 0 ? (
<div className="flex h-12 items-center justify-center">
<span className="text-[10px] text-muted-foreground"> </span>
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: cardGrid!.colWidths?.length
? cardGrid!.colWidths.map((w) => w || "1fr").join(" ")
: `repeat(${cardGrid!.cols || 1}, 1fr)`,
gridTemplateRows: `repeat(${cardGrid!.rows || 1}, minmax(16px, auto))`,
gap: "2px",
}}
>
{cardGrid!.cells.map((cell) => (
<div
key={cell.id}
className="rounded border border-dashed border-border/50 bg-muted/20 px-1 py-0.5"
style={{
gridColumn: `${cell.col} / span ${cell.colSpan || 1}`,
gridRow: `${cell.row} / span ${cell.rowSpan || 1}`,
}}
>
<span className="text-[8px] text-muted-foreground">
{cell.type}
{cell.columnName ? `: ${cell.columnName}` : ""}
</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</>
)}
</div>
);
}

View File

@ -0,0 +1,733 @@
"use client";
/**
* pop-card-list-v2
*
* CardV2Grid에서 type별 dispatch로 .
* pop-card-list의 pop-string-list의 CardModeView .
*/
import React, { useMemo, useState } from "react";
import {
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
Loader2, CheckCircle2, CircleDot, Clock,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep, ActionButtonDef } from "../types";
import { DEFAULT_CARD_IMAGE, VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC } from "../types";
import type { ButtonVariant } from "../pop-button";
type RowData = Record<string, unknown>;
// ===== 공통 유틸 =====
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
ShoppingCart, Package, Truck, Box, Archive, Heart, Star,
};
function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) {
if (!name) return <ShoppingCart size={size} />;
const IconComp = LUCIDE_ICON_MAP[name];
if (!IconComp) return <ShoppingCart size={size} />;
return <IconComp size={size} />;
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return "-";
if (typeof value === "number") return value.toLocaleString();
if (typeof value === "boolean") return value ? "예" : "아니오";
if (value instanceof Date) return value.toLocaleDateString();
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
}
return String(value);
}
const FONT_SIZE_MAP = { xs: "10px", sm: "11px", md: "12px", lg: "14px" } as const;
const FONT_WEIGHT_MAP = { normal: 400, medium: 500, bold: 700 } as const;
// ===== 셀 렌더러 Props =====
export interface CellRendererProps {
cell: CardCellDefinitionV2;
row: RowData;
inputValue?: number;
isCarted?: boolean;
isButtonLoading?: boolean;
onInputClick?: (e: React.MouseEvent) => void;
onCartAdd?: () => void;
onCartCancel?: () => void;
onButtonClick?: (cell: CardCellDefinitionV2, row: RowData) => void;
onActionButtonClick?: (taskPreset: string, row: RowData, buttonConfig?: Record<string, unknown>) => void;
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
packageEntries?: PackageEntry[];
inputUnit?: string;
}
// ===== 메인 디스패치 =====
export function renderCellV2(props: CellRendererProps): React.ReactNode {
switch (props.cell.type) {
case "text":
return <TextCell {...props} />;
case "field":
return <FieldCell {...props} />;
case "image":
return <ImageCell {...props} />;
case "badge":
return <BadgeCell {...props} />;
case "button":
return <ButtonCell {...props} />;
case "number-input":
return <NumberInputCell {...props} />;
case "cart-button":
return <CartButtonCell {...props} />;
case "package-summary":
return <PackageSummaryCell {...props} />;
case "status-badge":
return <StatusBadgeCell {...props} />;
case "timeline":
return <TimelineCell {...props} />;
case "action-buttons":
return <ActionButtonsCell {...props} />;
case "footer-status":
return <FooterStatusCell {...props} />;
default:
return <span className="text-[10px] text-muted-foreground"> </span>;
}
}
// ===== 1. text =====
function TextCell({ cell, row }: CellRendererProps) {
const value = cell.columnName ? row[cell.columnName] : "";
const fs = FONT_SIZE_MAP[cell.fontSize || "md"];
const fw = FONT_WEIGHT_MAP[cell.fontWeight || "normal"];
return (
<span
className="truncate"
style={{ fontSize: fs, fontWeight: fw, color: cell.textColor || undefined }}
>
{formatValue(value)}
</span>
);
}
// ===== 2. field (라벨+값) =====
function FieldCell({ cell, row, inputValue }: CellRendererProps) {
const valueType = cell.valueType || "column";
const fs = FONT_SIZE_MAP[cell.fontSize || "md"];
const displayValue = useMemo(() => {
if (valueType !== "formula") {
const raw = cell.columnName ? row[cell.columnName] : undefined;
const formatted = formatValue(raw);
return cell.unit ? `${formatted} ${cell.unit}` : formatted;
}
if (cell.formulaLeft && cell.formulaOperator) {
const rightVal =
(cell.formulaRightType || "input") === "input"
? (inputValue ?? 0)
: Number(row[cell.formulaRight || ""] ?? 0);
const leftVal = Number(row[cell.formulaLeft] ?? 0);
let result: number | null = null;
switch (cell.formulaOperator) {
case "+": result = leftVal + rightVal; break;
case "-": result = leftVal - rightVal; break;
case "*": result = leftVal * rightVal; break;
case "/": result = rightVal !== 0 ? leftVal / rightVal : null; break;
}
if (result !== null && isFinite(result)) {
const formatted = (Math.round(result * 100) / 100).toLocaleString();
return cell.unit ? `${formatted} ${cell.unit}` : formatted;
}
return "-";
}
return "-";
}, [valueType, cell, row, inputValue]);
const isFormula = valueType === "formula";
const isLabelLeft = cell.labelPosition === "left";
return (
<div
className={isLabelLeft ? "flex items-baseline gap-1" : "flex flex-col"}
style={{ fontSize: fs }}
>
{cell.label && (
<span className="shrink-0 text-[10px] text-muted-foreground">
{cell.label}{isLabelLeft ? ":" : ""}
</span>
)}
<span
className="truncate font-medium"
style={{ color: cell.textColor || (isFormula ? "#ea580c" : undefined) }}
>
{displayValue}
</span>
</div>
);
}
// ===== 3. image =====
function ImageCell({ cell, row }: CellRendererProps) {
const value = cell.columnName ? row[cell.columnName] : "";
const imageUrl = value ? String(value) : (cell.defaultImage || DEFAULT_CARD_IMAGE);
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-md border bg-muted/30">
<img
src={imageUrl}
alt={cell.label || ""}
className="h-full w-full object-contain p-1"
onError={(e) => {
const target = e.target as HTMLImageElement;
if (target.src !== DEFAULT_CARD_IMAGE) target.src = DEFAULT_CARD_IMAGE;
}}
/>
</div>
);
}
// ===== 4. badge =====
function BadgeCell({ cell, row }: CellRendererProps) {
const value = cell.columnName ? row[cell.columnName] : "";
return (
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
{formatValue(value)}
</span>
);
}
// ===== 5. button =====
function ButtonCell({ cell, row, isButtonLoading, onButtonClick }: CellRendererProps) {
return (
<Button
variant={cell.buttonVariant || "outline"}
size="sm"
className="h-7 text-[10px]"
disabled={isButtonLoading}
onClick={(e) => {
e.stopPropagation();
onButtonClick?.(cell, row);
}}
>
{isButtonLoading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : null}
{cell.label || formatValue(cell.columnName ? row[cell.columnName] : "")}
</Button>
);
}
// ===== 6. number-input =====
function NumberInputCell({ cell, row, inputValue, onInputClick }: CellRendererProps) {
const unit = cell.inputUnit || "EA";
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onInputClick?.(e);
}}
className="w-full rounded-lg border-2 border-input bg-background px-2 py-1.5 text-center hover:border-primary active:bg-muted"
>
<span className="block text-lg font-bold leading-tight">
{(inputValue ?? 0).toLocaleString()}
</span>
<span className="block text-[12px] text-muted-foreground">{unit}</span>
</button>
);
}
// ===== 7. cart-button =====
function CartButtonCell({ cell, row, isCarted, onCartAdd, onCartCancel }: CellRendererProps) {
const iconSize = 18;
const label = cell.cartLabel || "담기";
const cancelLabel = cell.cartCancelLabel || "취소";
if (isCarted) {
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onCartCancel?.(); }}
className="flex w-full flex-col items-center justify-center gap-0.5 rounded-xl bg-destructive px-2 py-1.5 text-destructive-foreground transition-colors duration-150 hover:bg-destructive/90 active:bg-destructive/80"
>
<X size={iconSize} />
<span className="text-[10px] font-semibold leading-tight">{cancelLabel}</span>
</button>
);
}
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onCartAdd?.(); }}
className="flex w-full flex-col items-center justify-center gap-0.5 rounded-xl bg-linear-to-br from-amber-400 to-orange-500 px-2 py-1.5 text-white transition-colors duration-150 hover:from-amber-500 hover:to-orange-600 active:from-amber-600 active:to-orange-700"
>
{cell.cartIconType === "emoji" && cell.cartIconValue ? (
<span style={{ fontSize: `${iconSize}px` }}>{cell.cartIconValue}</span>
) : (
<DynamicLucideIcon name={cell.cartIconValue} size={iconSize} />
)}
<span className="text-[10px] font-semibold leading-tight">{label}</span>
</button>
);
}
// ===== 8. package-summary =====
function PackageSummaryCell({ cell, packageEntries, inputUnit }: CellRendererProps) {
if (!packageEntries || packageEntries.length === 0) return null;
return (
<div className="w-full border-t bg-emerald-50">
{packageEntries.map((entry, idx) => (
<div key={idx} className="flex items-center justify-between px-3 py-1.5">
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] font-bold text-white">
</span>
<Package className="h-4 w-4 text-emerald-600" />
<span className="text-xs font-medium text-emerald-700">
{entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}
</span>
</div>
<span className="text-xs font-bold text-emerald-700">
= {entry.totalQuantity.toLocaleString()}{inputUnit || "EA"}
</span>
</div>
))}
</div>
);
}
// ===== 9. status-badge =====
const STATUS_COLORS: Record<string, { bg: string; text: string }> = {
waiting: { bg: "#94a3b820", text: "#64748b" },
accepted: { bg: "#3b82f620", text: "#2563eb" },
in_progress: { bg: "#f59e0b20", text: "#d97706" },
completed: { bg: "#10b98120", text: "#059669" },
};
function StatusBadgeCell({ cell, row }: CellRendererProps) {
const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined;
const effectiveValue = hasSubStatus
? row[VIRTUAL_SUB_STATUS]
: (cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : ""));
const strValue = String(effectiveValue || "");
const mapped = cell.statusMap?.find((m) => m.value === strValue);
if (mapped) {
return (
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
style={{ backgroundColor: `${mapped.color}20`, color: mapped.color }}
>
{mapped.label}
</span>
);
}
const defaultColors = STATUS_COLORS[strValue];
if (defaultColors) {
const labelMap: Record<string, string> = {
waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료",
};
return (
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
style={{ backgroundColor: defaultColors.bg, color: defaultColors.text }}
>
{labelMap[strValue] || strValue}
</span>
);
}
return (
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{formatValue(effectiveValue)}
</span>
);
}
// ===== 10. timeline =====
type TimelineStyle = { chipBg: string; chipText: string; icon: React.ReactNode };
const TIMELINE_SEMANTIC_STYLES: Record<string, TimelineStyle> = {
done: { chipBg: "#10b981", chipText: "#ffffff", icon: <CheckCircle2 className="h-2.5 w-2.5" /> },
active: { chipBg: "#3b82f6", chipText: "#ffffff", icon: <CircleDot className="h-2.5 w-2.5 animate-pulse" /> },
pending: { chipBg: "#e2e8f0", chipText: "#64748b", icon: <Clock className="h-2.5 w-2.5" /> },
};
// 레거시 status 값 → semantic 매핑 (기존 데이터 호환)
const LEGACY_STATUS_TO_SEMANTIC: Record<string, string> = {
completed: "done", in_progress: "active", accepted: "active", waiting: "pending",
};
function getTimelineStyle(step: TimelineProcessStep): TimelineStyle {
if (step.semantic) return TIMELINE_SEMANTIC_STYLES[step.semantic] || TIMELINE_SEMANTIC_STYLES.pending;
const fallback = LEGACY_STATUS_TO_SEMANTIC[step.status];
return TIMELINE_SEMANTIC_STYLES[fallback || "pending"];
}
function TimelineCell({ cell, row }: CellRendererProps) {
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
if (!processFlow || processFlow.length === 0) {
const fallback = cell.processColumn ? row[cell.processColumn] : "";
return (
<span className="text-[10px] text-muted-foreground">
{formatValue(fallback)}
</span>
);
}
const maxVisible = cell.visibleCount || 5;
const currentIdx = processFlow.findIndex((s) => s.isCurrent);
type DisplayItem =
| { kind: "step"; step: TimelineProcessStep }
| { kind: "count"; count: number; side: "before" | "after" };
// 현재 항목 기준으로 앞뒤 배분하여 축약
const displayItems = useMemo((): DisplayItem[] => {
if (processFlow.length <= maxVisible) {
return processFlow.map((s) => ({ kind: "step" as const, step: s }));
}
const effectiveIdx = Math.max(0, currentIdx);
const priority = cell.timelinePriority || "before";
// 숫자칩 2개를 제외한 나머지를 앞뒤로 배분 (priority에 따라 여분 슬롯 방향 결정)
const slotForSteps = maxVisible - 2;
const half = Math.floor(slotForSteps / 2);
const extra = slotForSteps - half - 1;
const beforeSlots = priority === "before" ? Math.max(half, extra) : Math.min(half, extra);
const afterSlots = slotForSteps - beforeSlots - 1;
let startIdx = effectiveIdx - beforeSlots;
let endIdx = effectiveIdx + afterSlots;
// 경계 보정
if (startIdx < 0) {
endIdx = Math.min(processFlow.length - 1, endIdx + Math.abs(startIdx));
startIdx = 0;
}
if (endIdx >= processFlow.length) {
startIdx = Math.max(0, startIdx - (endIdx - processFlow.length + 1));
endIdx = processFlow.length - 1;
}
const items: DisplayItem[] = [];
const beforeCount = startIdx;
const afterCount = processFlow.length - 1 - endIdx;
if (beforeCount > 0) {
items.push({ kind: "count", count: beforeCount, side: "before" });
}
for (let i = startIdx; i <= endIdx; i++) {
items.push({ kind: "step", step: processFlow[i] });
}
if (afterCount > 0) {
items.push({ kind: "count", count: afterCount, side: "after" });
}
return items;
}, [processFlow, maxVisible, currentIdx]);
const [modalOpen, setModalOpen] = useState(false);
const completedCount = processFlow.filter((s) => (s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status]) === "done").length;
const totalCount = processFlow.length;
return (
<>
<div
className={cn(
"flex w-full items-center gap-0.5 overflow-hidden px-0.5",
cell.showDetailModal !== false && "cursor-pointer",
cell.align === "center" ? "justify-center" : cell.align === "right" ? "justify-end" : "justify-start",
)}
onClick={cell.showDetailModal !== false ? (e) => { e.stopPropagation(); setModalOpen(true); } : undefined}
title={cell.showDetailModal !== false ? "클릭하여 전체 현황 보기" : undefined}
>
{displayItems.map((item, idx) => {
const isLast = idx === displayItems.length - 1;
if (item.kind === "count") {
return (
<React.Fragment key={`cnt-${item.side}`}>
<div
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-bold text-muted-foreground"
title={item.side === "before" ? `이전 ${item.count}` : `이후 ${item.count}`}
>
{item.count}
</div>
{!isLast && <div className="h-px w-1.5 shrink-0 border-t border-dashed border-muted-foreground/40" />}
</React.Fragment>
);
}
const styles = getTimelineStyle(item.step);
return (
<React.Fragment key={item.step.seqNo}>
<div
className="flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5"
style={{
backgroundColor: styles.chipBg,
color: styles.chipText,
outline: item.step.isCurrent ? "2px solid #2563eb" : "none",
outlineOffset: "1px",
}}
title={`${item.step.seqNo}. ${item.step.processName} (${item.step.status})`}
>
{styles.icon}
<span className="max-w-[48px] truncate text-[9px] font-medium leading-tight">
{item.step.processName}
</span>
</div>
{!isLast && <div className="h-px w-1.5 shrink-0 border-t border-dashed border-muted-foreground/40" />}
</React.Fragment>
);
})}
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{totalCount} {completedCount}
</DialogDescription>
</DialogHeader>
<div className="space-y-0">
{processFlow.map((step, idx) => {
const styles = getTimelineStyle(step);
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
const statusLabel = sem === "done" ? "완료" : sem === "active" ? "진행" : "대기";
return (
<div key={step.seqNo} className="flex items-center">
{/* 세로 연결선 + 아이콘 */}
<div className="flex w-8 shrink-0 flex-col items-center">
{idx > 0 && <div className="h-3 w-px bg-border" />}
<div
className="flex h-6 w-6 items-center justify-center rounded-full"
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
>
{styles.icon}
</div>
{idx < processFlow.length - 1 && <div className="h-3 w-px bg-border" />}
</div>
{/* 항목 정보 */}
<div className={cn(
"ml-2 flex flex-1 items-center justify-between rounded-md px-3 py-2",
step.isCurrent && "bg-primary/5 ring-1 ring-primary/30",
)}>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{step.seqNo}</span>
<span className={cn(
"text-sm",
step.isCurrent ? "font-semibold" : "font-medium",
)}>
{step.processName}
</span>
{step.isCurrent && (
<Star className="h-3 w-3 fill-primary text-primary" />
)}
</div>
<span
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
>
{statusLabel}
</span>
</div>
</div>
);
})}
</div>
{/* 하단 진행률 바 */}
<div className="space-y-1 pt-2">
<div className="flex justify-between text-xs text-muted-foreground">
<span></span>
<span>{totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%` }}
/>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
// ===== 11. action-buttons =====
function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" {
const cond = btn.showCondition;
if (!cond || cond.type === "always") return "visible";
let matched = false;
if (cond.type === "timeline-status") {
const subStatus = row[VIRTUAL_SUB_STATUS];
matched = subStatus !== undefined && String(subStatus) === cond.value;
} else if (cond.type === "column-value" && cond.column) {
matched = String(row[cond.column] ?? "") === (cond.value ?? "");
} else {
return "visible";
}
if (matched) return "visible";
return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden";
}
function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) {
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
const currentProcess = processFlow?.find((s) => s.isCurrent);
const currentProcessId = currentProcess?.processId;
if (cell.actionButtons && cell.actionButtons.length > 0) {
const evaluated = cell.actionButtons.map((btn) => ({
btn,
state: evaluateShowCondition(btn, row),
}));
const activeBtn = evaluated.find((e) => e.state === "visible");
const disabledBtn = activeBtn ? null : evaluated.find((e) => e.state === "disabled");
const pick = activeBtn || disabledBtn;
if (!pick) return null;
const { btn, state } = pick;
return (
<div className="flex items-center gap-1">
<Button
variant={btn.variant || "outline"}
size="sm"
disabled={state === "disabled"}
className="h-7 text-[10px]"
onClick={(e) => {
e.stopPropagation();
const actions = (btn.clickActions && btn.clickActions.length > 0) ? btn.clickActions : [btn.clickAction];
const firstAction = actions[0];
const config: Record<string, unknown> = {
...firstAction,
__allActions: actions,
selectModeConfig: firstAction.selectModeButtons
? { filterStatus: btn.showCondition?.value || "", buttons: firstAction.selectModeButtons }
: undefined,
};
if (currentProcessId !== undefined) config.__processId = currentProcessId;
if (firstAction.type === "select-mode" && onEnterSelectMode) {
onEnterSelectMode(btn.showCondition?.value || "", config);
return;
}
onActionButtonClick?.(btn.label, row, config);
}}
>
{btn.label}
</Button>
</div>
);
}
// 기존 구조 (actionRules) 폴백
const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined;
const statusValue = hasSubStatus
? String(row[VIRTUAL_SUB_STATUS] || "")
: (cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : ""));
const rules = cell.actionRules || [];
const matchedRule = rules.find((r) => r.whenStatus === statusValue);
if (!matchedRule) return null;
return (
<div className="flex items-center gap-1">
{matchedRule.buttons.map((btn, idx) => (
<Button
key={idx}
variant={btn.variant || "outline"}
size="sm"
className="h-7 text-[10px]"
onClick={(e) => {
e.stopPropagation();
const config = { ...(btn as Record<string, unknown>) };
if (currentProcessId !== undefined) config.__processId = currentProcessId;
if (btn.clickMode === "select-mode" && onEnterSelectMode) {
onEnterSelectMode(matchedRule.whenStatus, config);
return;
}
onActionButtonClick?.(btn.taskPreset, row, config);
}}
>
{btn.label}
</Button>
))}
</div>
);
}
// ===== 12. footer-status =====
function FooterStatusCell({ cell, row }: CellRendererProps) {
const value = cell.footerStatusColumn ? row[cell.footerStatusColumn] : "";
const strValue = String(value || "");
const mapped = cell.footerStatusMap?.find((m) => m.value === strValue);
if (!strValue && !cell.footerLabel) return null;
return (
<div
className="flex w-full items-center justify-between px-2 py-1"
style={{ borderTop: cell.showTopBorder !== false ? "1px solid hsl(var(--border))" : "none" }}
>
{cell.footerLabel && (
<span className="text-[10px] text-muted-foreground">{cell.footerLabel}</span>
)}
{mapped ? (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-semibold"
style={{ backgroundColor: `${mapped.color}20`, color: mapped.color }}
>
{mapped.label}
</span>
) : strValue ? (
<span className="text-[10px] font-medium text-muted-foreground">
{strValue}
</span>
) : null}
</div>
);
}

View File

@ -0,0 +1,61 @@
"use client";
/**
* pop-card-list-v2
*
* import side-effect로 PopComponentRegistry에
*/
import { PopComponentRegistry } from "../../PopComponentRegistry";
import { PopCardListV2Component } from "./PopCardListV2Component";
import { PopCardListV2ConfigPanel } from "./PopCardListV2Config";
import { PopCardListV2PreviewComponent } from "./PopCardListV2Preview";
import type { PopCardListV2Config } from "../types";
const defaultConfig: PopCardListV2Config = {
dataSource: { tableName: "" },
cardGrid: {
rows: 1,
cols: 1,
colWidths: ["1fr"],
rowHeights: ["32px"],
gap: 4,
showCellBorder: true,
cells: [],
},
gridColumns: 3,
cardGap: 8,
scrollDirection: "vertical",
overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 },
cardClickAction: "none",
};
PopComponentRegistry.registerComponent({
id: "pop-card-list-v2",
name: "카드 목록 V2",
description: "슬롯 기반 카드 레이아웃 (CSS Grid + 셀 타입별 렌더링)",
category: "display",
icon: "LayoutGrid",
component: PopCardListV2Component,
configPanel: PopCardListV2ConfigPanel,
preview: PopCardListV2PreviewComponent,
defaultProps: defaultConfig,
connectionMeta: {
sendable: [
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
{ key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" },
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
{ key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" },
],
receivable: [
{ key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
{ key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
{ key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" },
{ key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" },
],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -0,0 +1,163 @@
/**
* pop-card-list v1 -> v2
*
* PopCardListConfig의 (/////)
* CardGridConfigV2 PopCardListV2Config를 .
*/
import type {
PopCardListConfig,
PopCardListV2Config,
CardCellDefinitionV2,
} from "../types";
export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Config {
const cells: CardCellDefinitionV2[] = [];
let nextRow = 1;
// 1. 헤더 행 (코드 + 제목)
if (old.cardTemplate?.header?.codeField || old.cardTemplate?.header?.titleField) {
if (old.cardTemplate.header.codeField) {
cells.push({
id: "h-code",
row: nextRow,
col: 1,
rowSpan: 1,
colSpan: 1,
type: "text",
columnName: old.cardTemplate.header.codeField,
fontSize: "sm",
textColor: "hsl(var(--muted-foreground))",
});
}
if (old.cardTemplate.header.titleField) {
cells.push({
id: "h-title",
row: nextRow,
col: 2,
rowSpan: 1,
colSpan: old.cardTemplate.header.codeField ? 2 : 3,
type: "text",
columnName: old.cardTemplate.header.titleField,
fontSize: "md",
fontWeight: "bold",
});
}
nextRow++;
}
// 2. 이미지 (왼쪽, 본문 높이만큼 rowSpan)
const bodyFieldCount = old.cardTemplate?.body?.fields?.length || 0;
const bodyRowSpan = Math.max(1, bodyFieldCount);
if (old.cardTemplate?.image?.enabled) {
cells.push({
id: "img",
row: nextRow,
col: 1,
rowSpan: bodyRowSpan,
colSpan: 1,
type: "image",
columnName: old.cardTemplate.image.imageColumn || "",
defaultImage: old.cardTemplate.image.defaultImage,
});
}
// 3. 본문 필드들 (이미지 오른쪽)
const fieldStartCol = old.cardTemplate?.image?.enabled ? 2 : 1;
const fieldColSpan = old.cardTemplate?.image?.enabled ? 2 : 3;
const hasRightActions = !!(old.inputField?.enabled || old.cartAction);
(old.cardTemplate?.body?.fields || []).forEach((field, i) => {
cells.push({
id: `f-${i}`,
row: nextRow + i,
col: fieldStartCol,
rowSpan: 1,
colSpan: hasRightActions ? fieldColSpan - 1 : fieldColSpan,
type: "field",
columnName: field.columnName,
label: field.label,
valueType: field.valueType,
formulaLeft: field.formulaLeft,
formulaOperator: field.formulaOperator as CardCellDefinitionV2["formulaOperator"],
formulaRight: field.formulaRight,
formulaRightType: field.formulaRightType as CardCellDefinitionV2["formulaRightType"],
unit: field.unit,
textColor: field.textColor,
});
});
// 4. 수량 입력 + 담기 버튼 (오른쪽 열)
const rightCol = 3;
if (old.inputField?.enabled) {
cells.push({
id: "input",
row: nextRow,
col: rightCol,
rowSpan: Math.ceil(bodyRowSpan / 2),
colSpan: 1,
type: "number-input",
inputUnit: old.inputField.unit,
limitColumn: old.inputField.limitColumn || old.inputField.maxColumn,
});
}
if (old.cartAction) {
cells.push({
id: "cart",
row: nextRow + Math.ceil(bodyRowSpan / 2),
col: rightCol,
rowSpan: Math.floor(bodyRowSpan / 2) || 1,
colSpan: 1,
type: "cart-button",
cartLabel: old.cartAction.label,
cartCancelLabel: old.cartAction.cancelLabel,
cartIconType: old.cartAction.iconType,
cartIconValue: old.cartAction.iconValue,
});
}
// 5. 포장 요약 (마지막 행, full-width)
if (old.packageConfig?.enabled) {
const summaryRow = nextRow + bodyRowSpan;
cells.push({
id: "pkg",
row: summaryRow,
col: 1,
rowSpan: 1,
colSpan: 3,
type: "package-summary",
});
}
// 그리드 크기 계산
const maxRow = cells.length > 0 ? Math.max(...cells.map((c) => c.row + c.rowSpan - 1)) : 1;
const maxCol = 3;
return {
dataSource: old.dataSource,
cardGrid: {
rows: maxRow,
cols: maxCol,
colWidths: old.cardTemplate?.image?.enabled
? ["1fr", "2fr", "1fr"]
: ["1fr", "2fr", "1fr"],
gap: 2,
showCellBorder: false,
cells,
},
scrollDirection: old.scrollDirection,
cardSize: old.cardSize,
gridColumns: old.gridColumns,
gridRows: old.gridRows,
cardGap: 8,
overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 },
cardClickAction: "none",
responsiveDisplay: old.responsiveDisplay,
inputField: old.inputField,
packageConfig: old.packageConfig,
cartAction: old.cartAction,
cartListMode: old.cartListMode,
saveMapping: old.saveMapping,
};
}

View File

@ -256,6 +256,12 @@ export function PopCardListComponent({
return unsub;
}, [componentId, subscribe]);
// 전체 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용)
useEffect(() => {
if (!componentId || loading) return;
publish(`__comp_output__${componentId}__all_rows`, rows);
}, [componentId, rows, loading, publish]);
// cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용
const cartRef = useRef(cart);
cartRef.current = cart;

View File

@ -2039,16 +2039,29 @@ function FilterSettingsSection({
{filters.map((filter, index) => (
<div
key={index}
className="flex items-center gap-1 rounded-md border bg-card p-1.5"
className="space-y-1.5 rounded-md border bg-card p-2"
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-muted-foreground">
{index + 1}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<Select
value={filter.column || ""}
onValueChange={(val) =>
updateFilter(index, { ...filter, column: val })
}
>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue placeholder="컬럼" />
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
@ -2058,45 +2071,36 @@ function FilterSettingsSection({
))}
</SelectContent>
</Select>
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, {
...filter,
operator: val as FilterOperator,
})
}
>
<SelectTrigger className="h-7 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) =>
updateFilter(index, { ...filter, value: e.target.value })
}
placeholder="값"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
<div className="flex items-center gap-2">
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, {
...filter,
operator: val as FilterOperator,
})
}
>
<SelectTrigger className="h-8 w-20 shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) =>
updateFilter(index, { ...filter, value: e.target.value })
}
placeholder="값 입력"
className="h-8 flex-1 text-xs"
/>
</div>
</div>
))}
</div>
@ -2663,46 +2667,51 @@ function FilterCriteriaSection({
) : (
<div className="space-y-2">
{filters.map((filter, index) => (
<div key={index} className="flex items-center gap-1 rounded-md border bg-card p-1.5">
<div className="flex-1">
<GroupedColumnSelect
columnGroups={columnGroups}
value={filter.column || undefined}
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
placeholder="컬럼 선택"
<div key={index} className="space-y-1.5 rounded-md border bg-card p-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-muted-foreground">
{index + 1}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<GroupedColumnSelect
columnGroups={columnGroups}
value={filter.column || undefined}
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
placeholder="컬럼 선택"
/>
<div className="flex items-center gap-2">
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, { ...filter, operator: val as FilterOperator })
}
>
<SelectTrigger className="h-8 w-20 shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })}
placeholder="값 입력"
className="h-8 flex-1 text-xs"
/>
</div>
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, { ...filter, operator: val as FilterOperator })
}
>
<SelectTrigger className="h-7 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })}
placeholder="값"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>

View File

@ -61,6 +61,7 @@ PopComponentRegistry.registerComponent({
connectionMeta: {
sendable: [
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
{ key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" },
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },

View File

@ -34,6 +34,7 @@ export interface ColumnInfo {
type: string;
udtName: string;
isPrimaryKey?: boolean;
comment?: string;
}
// ===== SQL 값 이스케이프 =====
@ -330,6 +331,7 @@ export async function fetchTableColumns(
type: col.dataType || col.data_type || col.type || "unknown",
udtName: col.dbType || col.udt_name || col.udtName || "unknown",
isPrimaryKey: col.isPrimaryKey === true || col.isPrimaryKey === "true" || col.is_primary_key === true || col.is_primary_key === "true",
comment: col.columnComment || col.description || "",
}));
}
}

View File

@ -203,6 +203,32 @@ export function PopFieldComponent({
return unsub;
}, [componentId, subscribe, cfg.readSource, fetchReadSourceData]);
useEffect(() => {
const unsub = subscribe("scan_auto_fill", (payload: unknown) => {
const data = payload as Record<string, unknown> | null;
if (!data || typeof data !== "object") return;
const fieldNames = new Set<string>();
for (const section of cfg.sections) {
for (const f of section.fields ?? []) {
if (f.fieldName) fieldNames.add(f.fieldName);
}
}
const matched: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (fieldNames.has(key)) {
matched[key] = value;
}
}
if (Object.keys(matched).length > 0) {
setAllValues((prev) => ({ ...prev, ...matched }));
}
});
return unsub;
}, [subscribe, cfg.sections]);
// 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → allValues + saveConfig 응답
useEffect(() => {
if (!componentId) return;
@ -220,7 +246,7 @@ export function PopFieldComponent({
? {
targetTable: cfg.saveConfig.tableName,
columnMapping: Object.fromEntries(
(cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn])
(cfg.saveConfig.fieldMappings || []).map((m) => [fieldIdToName[m.fieldId] || m.fieldId, m.targetColumn])
),
autoGenMappings: (cfg.saveConfig.autoGenMappings || [])
.filter((m) => m.numberingRuleId)
@ -228,6 +254,7 @@ export function PopFieldComponent({
numberingRuleId: m.numberingRuleId!,
targetColumn: m.targetColumn,
showResultModal: m.showResultModal,
shareAcrossItems: m.shareAcrossItems ?? false,
})),
hiddenMappings: (cfg.saveConfig.hiddenMappings || [])
.filter((m) => m.targetColumn)
@ -247,7 +274,7 @@ export function PopFieldComponent({
}
);
return unsub;
}, [componentId, subscribe, publish, allValues, cfg.saveConfig]);
}, [componentId, subscribe, publish, allValues, cfg.saveConfig, fieldIdToName]);
// 필드 값 변경 핸들러
const handleFieldChange = useCallback(

View File

@ -398,8 +398,19 @@ function SaveTabContent({
syncAndUpdateSaveMappings((prev) =>
prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m))
);
if (partial.targetColumn !== undefined) {
const newFieldName = partial.targetColumn || "";
const sections = cfg.sections.map((s) => ({
...s,
fields: (s.fields ?? []).map((f) =>
f.id === fieldId ? { ...f, fieldName: newFieldName } : f
),
}));
onUpdateConfig({ sections });
}
},
[syncAndUpdateSaveMappings]
[syncAndUpdateSaveMappings, cfg, onUpdateConfig]
);
// --- 숨은 필드 매핑 로직 ---
@ -1337,7 +1348,19 @@ function SaveTabContent({
/>
<Label className="text-[10px]"> </Label>
</div>
<div className="flex items-center gap-1.5">
<Switch
checked={m.shareAcrossItems ?? false}
onCheckedChange={(v) => updateAutoGenMapping(m.id, { shareAcrossItems: v })}
/>
<Label className="text-[10px]"> </Label>
</div>
</div>
{m.shareAcrossItems && (
<p className="text-[9px] text-muted-foreground">
</p>
)}
</div>
);
})}
@ -1414,7 +1437,7 @@ function SectionEditor({
const newField: PopFieldItem = {
id: fieldId,
inputType: "text",
fieldName: fieldId,
fieldName: "",
labelText: "",
readOnly: false,
};

View File

@ -153,6 +153,7 @@ export interface PopFieldAutoGenMapping {
numberingRuleId?: string;
showInForm: boolean;
showResultModal: boolean;
shareAcrossItems?: boolean;
}
export interface PopFieldSaveConfig {

View File

@ -0,0 +1,336 @@
"use client";
import React, { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Monitor, LayoutGrid, LogOut, UserCircle } from "lucide-react";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { useAuth } from "@/hooks/useAuth";
// ========================================
// 타입 정의
// ========================================
type AvatarSize = "sm" | "md" | "lg";
export interface PopProfileConfig {
avatarSize?: AvatarSize;
showDashboardLink?: boolean;
showPcMode?: boolean;
showLogout?: boolean;
}
const DEFAULT_CONFIG: PopProfileConfig = {
avatarSize: "md",
showDashboardLink: true,
showPcMode: true,
showLogout: true,
};
const AVATAR_SIZE_MAP: Record<AvatarSize, { container: string; text: string; px: number }> = {
sm: { container: "h-8 w-8", text: "text-sm", px: 32 },
md: { container: "h-10 w-10", text: "text-base", px: 40 },
lg: { container: "h-12 w-12", text: "text-lg", px: 48 },
};
const AVATAR_SIZE_LABELS: Record<AvatarSize, string> = {
sm: "작은 (32px)",
md: "보통 (40px)",
lg: "큰 (48px)",
};
// ========================================
// 뷰어 컴포넌트
// ========================================
interface PopProfileComponentProps {
config?: PopProfileConfig;
componentId?: string;
screenId?: string;
}
function PopProfileComponent({ config: rawConfig }: PopProfileComponentProps) {
const router = useRouter();
const { user, isLoggedIn, logout } = useAuth();
const [open, setOpen] = useState(false);
const config = useMemo(() => ({
...DEFAULT_CONFIG,
...rawConfig,
}), [rawConfig]);
const sizeInfo = AVATAR_SIZE_MAP[config.avatarSize || "md"];
const initial = user?.userName?.substring(0, 1)?.toUpperCase() || "?";
const handlePcMode = () => {
setOpen(false);
router.push("/");
};
const handleDashboard = () => {
setOpen(false);
router.push("/pop");
};
const handleLogout = async () => {
setOpen(false);
await logout();
};
const handleLogin = () => {
setOpen(false);
router.push("/login");
};
return (
<div className="flex h-full w-full items-center justify-center">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"flex items-center justify-center rounded-full",
"bg-primary text-primary-foreground font-bold",
"border-2 border-primary/20 cursor-pointer",
"transition-all duration-150",
"hover:scale-105 hover:border-primary/40",
"active:scale-95",
sizeInfo.container,
sizeInfo.text,
)}
style={{ minWidth: sizeInfo.px, minHeight: sizeInfo.px }}
>
{user?.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
<img
src={user.photo}
alt={user.userName || "User"}
className="h-full w-full rounded-full object-cover"
/>
) : (
initial
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-60 p-0"
align="end"
sideOffset={8}
>
{isLoggedIn && user ? (
<>
{/* 사용자 정보 */}
<div className="flex items-center gap-3 border-b p-4">
<div className={cn(
"flex shrink-0 items-center justify-center rounded-full",
"bg-primary text-primary-foreground font-bold",
"h-10 w-10 text-base",
)}>
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
<img
src={user.photo}
alt={user.userName || "User"}
className="h-full w-full rounded-full object-cover"
/>
) : (
initial
)}
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-semibold">
{user.userName || "사용자"} ({user.userId || ""})
</span>
<span className="truncate text-xs text-muted-foreground">
{user.deptName || "부서 정보 없음"}
</span>
</div>
</div>
{/* 메뉴 항목 */}
<div className="p-1.5">
{config.showDashboardLink && (
<button
onClick={handleDashboard}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm transition-colors hover:bg-accent"
style={{ minHeight: 48 }}
>
<LayoutGrid className="h-4 w-4 text-muted-foreground" />
POP
</button>
)}
{config.showPcMode && (
<button
onClick={handlePcMode}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm transition-colors hover:bg-accent"
style={{ minHeight: 48 }}
>
<Monitor className="h-4 w-4 text-muted-foreground" />
PC
</button>
)}
{config.showLogout && (
<>
<div className="mx-2 my-1 border-t" />
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm text-destructive transition-colors hover:bg-destructive/10"
style={{ minHeight: 48 }}
>
<LogOut className="h-4 w-4" />
</button>
</>
)}
</div>
</>
) : (
<div className="p-4">
<p className="mb-3 text-center text-sm text-muted-foreground">
</p>
<button
onClick={handleLogin}
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-3 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
style={{ minHeight: 48 }}
>
</button>
</div>
)}
</PopoverContent>
</Popover>
</div>
);
}
// ========================================
// 설정 패널
// ========================================
interface PopProfileConfigPanelProps {
config: PopProfileConfig;
onUpdate: (config: PopProfileConfig) => void;
}
function PopProfileConfigPanel({ config: rawConfig, onUpdate }: PopProfileConfigPanelProps) {
const config = useMemo(() => ({
...DEFAULT_CONFIG,
...rawConfig,
}), [rawConfig]);
const updateConfig = (partial: Partial<PopProfileConfig>) => {
onUpdate({ ...config, ...partial });
};
return (
<div className="space-y-4 p-3">
{/* 아바타 크기 */}
<div className="space-y-1.5">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={config.avatarSize || "md"}
onValueChange={(v) => updateConfig({ avatarSize: v as AvatarSize })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.entries(AVATAR_SIZE_LABELS) as [AvatarSize, string][]).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs sm:text-sm">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 메뉴 항목 토글 */}
<div className="space-y-3">
<Label className="text-xs sm:text-sm"> </Label>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">POP </Label>
<Switch
checked={config.showDashboardLink ?? true}
onCheckedChange={(v) => updateConfig({ showDashboardLink: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">PC </Label>
<Switch
checked={config.showPcMode ?? true}
onCheckedChange={(v) => updateConfig({ showPcMode: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground"></Label>
<Switch
checked={config.showLogout ?? true}
onCheckedChange={(v) => updateConfig({ showLogout: v })}
/>
</div>
</div>
</div>
);
}
// ========================================
// 디자이너 미리보기
// ========================================
function PopProfilePreview({ config }: { config?: PopProfileConfig }) {
const size = AVATAR_SIZE_MAP[config?.avatarSize || "md"];
return (
<div className="flex h-full w-full items-center justify-center gap-2">
<div className={cn(
"flex items-center justify-center rounded-full",
"bg-primary/20 text-primary",
size.container, size.text,
)}>
<UserCircle className="h-5 w-5" />
</div>
<span className="text-xs text-muted-foreground"></span>
</div>
);
}
// ========================================
// 레지스트리 등록
// ========================================
PopComponentRegistry.registerComponent({
id: "pop-profile",
name: "프로필",
description: "사용자 프로필 / PC 전환 / 로그아웃",
category: "action",
icon: "UserCircle",
component: PopProfileComponent,
configPanel: PopProfileConfigPanel,
preview: PopProfilePreview,
defaultProps: {
avatarSize: "md",
showDashboardLink: true,
showPcMode: true,
showLogout: true,
},
connectionMeta: {
sendable: [],
receivable: [],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -0,0 +1,694 @@
"use client";
import React, { useState, useCallback, useMemo, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { ScanLine } from "lucide-react";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { BarcodeScanModal } from "@/components/common/BarcodeScanModal";
import type {
PopDataConnection,
PopComponentDefinitionV5,
} from "@/components/pop/designer/types/pop-layout";
// ========================================
// 타입 정의
// ========================================
export interface ScanFieldMapping {
sourceKey: string;
outputIndex: number;
label: string;
targetComponentId: string;
targetFieldName: string;
enabled: boolean;
}
export interface PopScannerConfig {
barcodeFormat: "all" | "1d" | "2d";
autoSubmit: boolean;
showLastScan: boolean;
buttonLabel: string;
buttonVariant: "default" | "outline" | "secondary";
parseMode: "none" | "auto" | "json";
fieldMappings: ScanFieldMapping[];
}
// 연결된 컴포넌트의 필드 정보
interface ConnectedFieldInfo {
componentId: string;
componentName: string;
componentType: string;
fieldName: string;
fieldLabel: string;
}
const DEFAULT_SCANNER_CONFIG: PopScannerConfig = {
barcodeFormat: "all",
autoSubmit: true,
showLastScan: false,
buttonLabel: "스캔",
buttonVariant: "default",
parseMode: "none",
fieldMappings: [],
};
// ========================================
// 파싱 유틸리티
// ========================================
function tryParseJson(raw: string): Record<string, string> | null {
try {
const parsed = JSON.parse(raw);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed)) {
result[k] = String(v);
}
return result;
}
} catch {
// JSON이 아닌 경우
}
return null;
}
function parseScanResult(
raw: string,
mode: PopScannerConfig["parseMode"]
): Record<string, string> | null {
if (mode === "none") return null;
return tryParseJson(raw);
}
// ========================================
// 연결된 컴포넌트 필드 추출
// ========================================
function getConnectedFields(
componentId?: string,
connections?: PopDataConnection[],
allComponents?: PopComponentDefinitionV5[],
): ConnectedFieldInfo[] {
if (!componentId || !connections || !allComponents) return [];
const targetIds = connections
.filter((c) => c.sourceComponent === componentId)
.map((c) => c.targetComponent);
const uniqueTargetIds = [...new Set(targetIds)];
const fields: ConnectedFieldInfo[] = [];
for (const tid of uniqueTargetIds) {
const comp = allComponents.find((c) => c.id === tid);
if (!comp?.config) continue;
const compCfg = comp.config as Record<string, unknown>;
const compType = comp.type || "";
const compName = (comp as Record<string, unknown>).label as string || comp.type || tid;
// pop-search: filterColumns (복수) 또는 modalConfig.valueField 또는 fieldName (단일)
const filterCols = compCfg.filterColumns as string[] | undefined;
const modalCfg = compCfg.modalConfig as { valueField?: string; displayField?: string } | undefined;
if (Array.isArray(filterCols) && filterCols.length > 0) {
for (const col of filterCols) {
fields.push({
componentId: tid,
componentName: compName,
componentType: compType,
fieldName: col,
fieldLabel: col,
});
}
} else if (modalCfg?.valueField) {
fields.push({
componentId: tid,
componentName: compName,
componentType: compType,
fieldName: modalCfg.valueField,
fieldLabel: (compCfg.placeholder as string) || modalCfg.valueField,
});
} else if (compCfg.fieldName && typeof compCfg.fieldName === "string") {
fields.push({
componentId: tid,
componentName: compName,
componentType: compType,
fieldName: compCfg.fieldName,
fieldLabel: (compCfg.placeholder as string) || compCfg.fieldName as string,
});
}
// pop-field: sections 내 fields
const sections = compCfg.sections as Array<{
fields?: Array<{ id: string; fieldName?: string; labelText?: string }>;
}> | undefined;
if (Array.isArray(sections)) {
for (const section of sections) {
for (const f of section.fields ?? []) {
if (f.fieldName) {
fields.push({
componentId: tid,
componentName: compName,
componentType: compType,
fieldName: f.fieldName,
fieldLabel: f.labelText || f.fieldName,
});
}
}
}
}
}
return fields;
}
// ========================================
// 메인 컴포넌트
// ========================================
interface PopScannerComponentProps {
config?: PopScannerConfig;
label?: string;
isDesignMode?: boolean;
screenId?: string;
componentId?: string;
}
function PopScannerComponent({
config,
isDesignMode,
screenId,
componentId,
}: PopScannerComponentProps) {
const cfg = { ...DEFAULT_SCANNER_CONFIG, ...(config || {}) };
const { publish } = usePopEvent(screenId || "");
const [lastScan, setLastScan] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const handleScanSuccess = useCallback(
(barcode: string) => {
setLastScan(barcode);
setModalOpen(false);
if (!componentId) return;
if (cfg.parseMode === "none") {
publish(`__comp_output__${componentId}__scan_value`, barcode);
return;
}
const parsed = parseScanResult(barcode, cfg.parseMode);
if (!parsed) {
publish(`__comp_output__${componentId}__scan_value`, barcode);
return;
}
if (cfg.parseMode === "auto") {
publish("scan_auto_fill", parsed);
publish(`__comp_output__${componentId}__scan_value`, barcode);
return;
}
if (cfg.fieldMappings.length === 0) {
publish(`__comp_output__${componentId}__scan_value`, barcode);
return;
}
for (const mapping of cfg.fieldMappings) {
if (!mapping.enabled) continue;
const value = parsed[mapping.sourceKey];
if (value === undefined) continue;
publish(
`__comp_output__${componentId}__scan_field_${mapping.outputIndex}`,
value
);
if (mapping.targetComponentId && mapping.targetFieldName) {
publish(
`__comp_input__${mapping.targetComponentId}__set_value`,
{ fieldName: mapping.targetFieldName, value }
);
}
}
},
[componentId, publish, cfg.parseMode, cfg.fieldMappings],
);
const handleClick = useCallback(() => {
if (isDesignMode) return;
setModalOpen(true);
}, [isDesignMode]);
return (
<div className="flex h-full w-full items-center justify-center">
<Button
variant={cfg.buttonVariant}
size="icon"
onClick={handleClick}
className="h-full w-full rounded-md transition-transform active:scale-95"
>
<ScanLine className="h-7! w-7!" />
<span className="sr-only">{cfg.buttonLabel}</span>
</Button>
{cfg.showLastScan && lastScan && (
<div className="absolute inset-x-0 bottom-0 truncate bg-background/80 px-1 text-center text-[8px] text-muted-foreground backdrop-blur-sm">
{lastScan}
</div>
)}
{!isDesignMode && (
<BarcodeScanModal
open={modalOpen}
onOpenChange={setModalOpen}
barcodeFormat={cfg.barcodeFormat}
autoSubmit={cfg.autoSubmit}
onScanSuccess={handleScanSuccess}
/>
)}
</div>
);
}
// ========================================
// 설정 패널
// ========================================
const FORMAT_LABELS: Record<string, string> = {
all: "모든 형식",
"1d": "1D 바코드",
"2d": "2D 바코드 (QR)",
};
const VARIANT_LABELS: Record<string, string> = {
default: "기본 (Primary)",
outline: "외곽선 (Outline)",
secondary: "보조 (Secondary)",
};
const PARSE_MODE_LABELS: Record<string, string> = {
none: "없음 (단일 값)",
auto: "자동 (검색 필드명과 매칭)",
json: "JSON (수동 매핑)",
};
interface PopScannerConfigPanelProps {
config: PopScannerConfig;
onUpdate: (config: PopScannerConfig) => void;
allComponents?: PopComponentDefinitionV5[];
connections?: PopDataConnection[];
componentId?: string;
}
function PopScannerConfigPanel({
config,
onUpdate,
allComponents,
connections,
componentId,
}: PopScannerConfigPanelProps) {
const cfg = { ...DEFAULT_SCANNER_CONFIG, ...config };
const update = (partial: Partial<PopScannerConfig>) => {
onUpdate({ ...cfg, ...partial });
};
const connectedFields = useMemo(
() => getConnectedFields(componentId, connections, allComponents),
[componentId, connections, allComponents],
);
const buildMappingsFromFields = useCallback(
(fields: ConnectedFieldInfo[], existing: ScanFieldMapping[]): ScanFieldMapping[] => {
return fields.map((f, i) => {
const prev = existing.find(
(m) => m.targetComponentId === f.componentId && m.targetFieldName === f.fieldName
);
return {
sourceKey: prev?.sourceKey ?? f.fieldName,
outputIndex: i,
label: f.fieldLabel,
targetComponentId: f.componentId,
targetFieldName: f.fieldName,
enabled: prev?.enabled ?? true,
};
});
},
[],
);
const toggleMapping = (fieldName: string, componentId: string) => {
const updated = cfg.fieldMappings.map((m) =>
m.targetFieldName === fieldName && m.targetComponentId === componentId
? { ...m, enabled: !m.enabled }
: m
);
update({ fieldMappings: updated });
};
const updateMappingSourceKey = (fieldName: string, componentId: string, sourceKey: string) => {
const updated = cfg.fieldMappings.map((m) =>
m.targetFieldName === fieldName && m.targetComponentId === componentId
? { ...m, sourceKey }
: m
);
update({ fieldMappings: updated });
};
useEffect(() => {
if (cfg.parseMode !== "json" || connectedFields.length === 0) return;
const synced = buildMappingsFromFields(connectedFields, cfg.fieldMappings);
const isSame =
synced.length === cfg.fieldMappings.length &&
synced.every(
(s, i) =>
s.targetComponentId === cfg.fieldMappings[i]?.targetComponentId &&
s.targetFieldName === cfg.fieldMappings[i]?.targetFieldName,
);
if (!isSame) {
onUpdate({ ...cfg, fieldMappings: synced });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectedFields, cfg.parseMode]);
return (
<div className="space-y-4 pr-1 pb-16">
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Select
value={cfg.barcodeFormat}
onValueChange={(v) => update({ barcodeFormat: v as PopScannerConfig["barcodeFormat"] })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FORMAT_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Input
value={cfg.buttonLabel}
onChange={(e) => update({ buttonLabel: e.target.value })}
placeholder="스캔"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Select
value={cfg.buttonVariant}
onValueChange={(v) => update({ buttonVariant: v as PopScannerConfig["buttonVariant"] })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(VARIANT_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground">
{cfg.autoSubmit
? "바코드 인식 즉시 값 전달 (확인 버튼 생략)"
: "인식 후 확인 버튼을 눌러야 값 전달"}
</p>
</div>
<Switch
checked={cfg.autoSubmit}
onCheckedChange={(v) => update({ autoSubmit: v })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<Switch
checked={cfg.showLastScan}
onCheckedChange={(v) => update({ showLastScan: v })}
/>
</div>
{/* 파싱 설정 섹션 */}
<div className="border-t pt-4">
<Label className="text-xs font-semibold"> </Label>
<p className="mb-3 text-[10px] text-muted-foreground">
/QR에 ,
</p>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Select
value={cfg.parseMode}
onValueChange={(v) => {
const mode = v as PopScannerConfig["parseMode"];
update({
parseMode: mode,
fieldMappings: mode === "none" ? [] : cfg.fieldMappings,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(PARSE_MODE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{cfg.parseMode === "auto" && (
<div className="mt-3 rounded-md bg-muted/50 p-3">
<p className="text-[10px] font-medium"> </p>
<p className="mt-1 text-[10px] text-muted-foreground">
QR/ JSON .
</p>
{connectedFields.length > 0 && (
<div className="mt-2 space-y-1">
<p className="text-[10px] font-medium"> :</p>
{connectedFields.map((f, i) => (
<div key={i} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span className="font-mono text-primary">{f.fieldName}</span>
<span>- {f.fieldLabel}</span>
<span className="text-muted-foreground/50">({f.componentName})</span>
</div>
))}
<p className="mt-1 text-[10px] text-muted-foreground">
QR에 JSON .
</p>
</div>
)}
{connectedFields.length === 0 && (
<p className="mt-2 text-[10px] text-muted-foreground">
.
.
</p>
)}
</div>
)}
{cfg.parseMode === "json" && (
<div className="mt-3 space-y-3">
<p className="text-[10px] text-muted-foreground">
, JSON .
JSON .
</p>
{connectedFields.length === 0 ? (
<div className="rounded-md bg-muted/50 p-3">
<p className="text-[10px] text-muted-foreground">
.
.
</p>
</div>
) : (
<div className="space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="space-y-1.5">
{cfg.fieldMappings.map((mapping) => (
<div
key={`${mapping.targetComponentId}_${mapping.targetFieldName}`}
className="flex items-start gap-2 rounded-md border p-2"
>
<Checkbox
id={`map_${mapping.targetComponentId}_${mapping.targetFieldName}`}
checked={mapping.enabled}
onCheckedChange={() =>
toggleMapping(mapping.targetFieldName, mapping.targetComponentId)
}
className="mt-0.5"
/>
<div className="flex-1 space-y-1">
<label
htmlFor={`map_${mapping.targetComponentId}_${mapping.targetFieldName}`}
className="flex cursor-pointer items-center gap-1 text-[11px]"
>
<span className="font-mono text-primary">
{mapping.targetFieldName}
</span>
<span className="text-muted-foreground">
({mapping.label})
</span>
</label>
{mapping.enabled && (
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground">
JSON :
</span>
<Input
value={mapping.sourceKey}
onChange={(e) =>
updateMappingSourceKey(
mapping.targetFieldName,
mapping.targetComponentId,
e.target.value,
)
}
placeholder={mapping.targetFieldName}
className="h-6 text-[10px]"
/>
</div>
)}
</div>
</div>
))}
</div>
{cfg.fieldMappings.some((m) => m.enabled) && (
<div className="rounded-md bg-muted/50 p-2">
<p className="text-[10px] font-medium text-muted-foreground"> :</p>
<ul className="mt-1 space-y-0.5">
{cfg.fieldMappings
.filter((m) => m.enabled)
.map((m, i) => (
<li key={i} className="text-[10px] text-muted-foreground">
<span className="font-mono">{m.sourceKey || "?"}</span>
{" -> "}
<span className="font-mono text-primary">{m.targetFieldName}</span>
{m.label && <span className="ml-1">({m.label})</span>}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
);
}
// ========================================
// 미리보기
// ========================================
function PopScannerPreview({ config }: { config?: PopScannerConfig }) {
const cfg = config || DEFAULT_SCANNER_CONFIG;
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden">
<Button
variant={cfg.buttonVariant}
size="icon"
className="pointer-events-none h-full w-full rounded-md"
tabIndex={-1}
>
<ScanLine className="h-7! w-7!" />
</Button>
</div>
);
}
// ========================================
// 동적 sendable 생성
// ========================================
function buildSendableMeta(config?: PopScannerConfig) {
const base = [
{
key: "scan_value",
label: "스캔 값 (원본)",
type: "filter_value" as const,
category: "filter" as const,
description: "파싱 전 원본 스캔 결과 (단일 값 모드이거나 파싱 실패 시)",
},
];
if (config?.fieldMappings && config.fieldMappings.length > 0) {
for (const mapping of config.fieldMappings) {
base.push({
key: `scan_field_${mapping.outputIndex}`,
label: mapping.label || `스캔 필드 ${mapping.outputIndex}`,
type: "filter_value" as const,
category: "filter" as const,
description: `파싱된 필드: JSON 키 "${mapping.sourceKey}"`,
});
}
}
return base;
}
// ========================================
// 레지스트리 등록
// ========================================
PopComponentRegistry.registerComponent({
id: "pop-scanner",
name: "스캐너",
description: "바코드/QR 카메라 스캔",
category: "input",
icon: "ScanLine",
component: PopScannerComponent,
configPanel: PopScannerConfigPanel,
preview: PopScannerPreview,
defaultProps: DEFAULT_SCANNER_CONFIG,
connectionMeta: {
sendable: buildSendableMeta(),
receivable: [],
},
getDynamicConnectionMeta: (config: Record<string, unknown>) => ({
sendable: buildSendableMeta(config as unknown as PopScannerConfig),
receivable: [],
}),
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -18,12 +18,21 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { Search, ChevronRight, Loader2, X } from "lucide-react";
import { Search, ChevronRight, Loader2, X, CalendarDays } from "lucide-react";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { format, startOfWeek, endOfWeek, startOfMonth, endOfMonth } from "date-fns";
import { ko } from "date-fns/locale";
import { usePopEvent } from "@/hooks/pop";
import { dataApi } from "@/lib/api/data";
import type {
PopSearchConfig,
DatePresetOption,
DateSelectionMode,
ModalSelectConfig,
ModalSearchMode,
ModalFilterTab,
@ -62,9 +71,21 @@ export function PopSearchComponent({
const [modalDisplayText, setModalDisplayText] = useState("");
const [simpleModalOpen, setSimpleModalOpen] = useState(false);
const fieldKey = config.fieldName || componentId || "search";
const normalizedType = normalizeInputType(config.inputType as string);
const isModalType = normalizedType === "modal";
const fieldKey = isModalType
? (config.modalConfig?.valueField || config.fieldName || componentId || "search")
: (config.fieldName || componentId || "search");
const resolveFilterMode = useCallback(() => {
if (config.filterMode) return config.filterMode;
if (normalizedType === "date") {
const mode: DateSelectionMode = config.dateSelectionMode || "single";
return mode === "range" ? "range" : "equals";
}
return "contains";
}, [config.filterMode, config.dateSelectionMode, normalizedType]);
const emitFilterChanged = useCallback(
(newValue: unknown) => {
@ -72,15 +93,18 @@ export function PopSearchComponent({
setSharedData(`search_${fieldKey}`, newValue);
if (componentId) {
const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey];
publish(`__comp_output__${componentId}__filter_value`, {
fieldName: fieldKey,
filterColumns,
value: newValue,
filterMode: resolveFilterMode(),
});
}
publish("filter_changed", { [fieldKey]: newValue });
},
[fieldKey, publish, setSharedData, componentId]
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns]
);
useEffect(() => {
@ -88,15 +112,40 @@ export function PopSearchComponent({
const unsub = subscribe(
`__comp_input__${componentId}__set_value`,
(payload: unknown) => {
const data = payload as { value?: unknown } | unknown;
const data = payload as { value?: unknown; displayText?: string } | unknown;
const incoming = typeof data === "object" && data && "value" in data
? (data as { value: unknown }).value
: data;
if (isModalType && incoming != null) {
setModalDisplayText(String(incoming));
}
emitFilterChanged(incoming);
}
);
return unsub;
}, [componentId, subscribe, emitFilterChanged]);
}, [componentId, subscribe, emitFilterChanged, isModalType]);
useEffect(() => {
const unsub = subscribe("scan_auto_fill", (payload: unknown) => {
const data = payload as Record<string, unknown> | null;
if (!data || typeof data !== "object") return;
const myKey = config.fieldName;
if (!myKey) return;
const targetKeys = config.filterColumns?.length ? config.filterColumns : [myKey];
for (const key of targetKeys) {
if (key in data) {
if (isModalType) setModalDisplayText(String(data[key]));
emitFilterChanged(data[key]);
return;
}
}
if (myKey in data) {
if (isModalType) setModalDisplayText(String(data[myKey]));
emitFilterChanged(data[myKey]);
}
});
return unsub;
}, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]);
const handleModalOpen = useCallback(() => {
if (!config.modalConfig) return;
@ -116,29 +165,30 @@ export function PopSearchComponent({
[config.modalConfig, emitFilterChanged]
);
const handleModalClear = useCallback(() => {
setModalDisplayText("");
emitFilterChanged("");
}, [emitFilterChanged]);
const showLabel = config.labelVisible !== false && !!config.labelText;
return (
<div
className={cn(
"flex h-full w-full overflow-hidden",
showLabel && config.labelPosition === "left"
? "flex-row items-center gap-2 p-1.5"
: "flex-col justify-center gap-0.5 p-1.5"
)}
className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5"
>
{showLabel && (
<span className="shrink-0 truncate text-[10px] font-medium text-muted-foreground">
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
{config.labelText}
</span>
)}
<div className="min-w-0">
<div className="min-w-0 w-full flex-1 flex flex-col justify-center">
<SearchInputRenderer
config={config}
value={value}
onChange={emitFilterChanged}
modalDisplayText={modalDisplayText}
onModalOpen={handleModalOpen}
onModalClear={handleModalClear}
/>
</div>
@ -165,9 +215,10 @@ interface InputRendererProps {
onChange: (v: unknown) => void;
modalDisplayText?: string;
onModalOpen?: () => void;
onModalClear?: () => void;
}
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) {
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear }: InputRendererProps) {
const normalized = normalizeInputType(config.inputType as string);
switch (normalized) {
case "text":
@ -175,12 +226,24 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa
return <TextSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
case "select":
return <SelectSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
case "date": {
const dateMode: DateSelectionMode = config.dateSelectionMode || "single";
return dateMode === "range"
? <DateRangeInput config={config} value={value} onChange={onChange} />
: <DateSingleInput config={config} value={String(value ?? "")} onChange={onChange} />;
}
case "date-preset":
return <DatePresetSearchInput config={config} value={value} onChange={onChange} />;
case "toggle":
return <ToggleSearchInput value={Boolean(value)} onChange={onChange} />;
case "modal":
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} />;
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} onClear={onModalClear} />;
case "status-chip":
return (
<div className="flex h-full items-center px-2 text-[10px] text-muted-foreground">
pop-status-bar
</div>
);
default:
return <PlaceholderInput inputType={config.inputType} />;
}
@ -215,7 +278,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
const isNumber = config.inputType === "number";
return (
<div className="relative">
<div className="relative h-full">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type={isNumber ? "number" : "text"}
@ -224,12 +287,283 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={config.placeholder || (isNumber ? "숫자 입력" : "검색어 입력")}
className="h-8 pl-7 text-xs"
className="h-full min-h-8 pl-7 text-xs"
/>
</div>
);
}
// ========================================
// date 서브타입 - 단일 날짜
// ========================================
function DateSingleInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
const [open, setOpen] = useState(false);
const useModal = config.calendarDisplay === "modal";
const selected = value ? new Date(value + "T00:00:00") : undefined;
const handleSelect = useCallback(
(day: Date | undefined) => {
if (!day) return;
onChange(format(day, "yyyy-MM-dd"));
setOpen(false);
},
[onChange]
);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange("");
},
[onChange]
);
const triggerButton = (
<Button
variant="outline"
className={cn(
"h-full min-h-8 w-full justify-start gap-1.5 px-2 text-xs font-normal",
!value && "text-muted-foreground"
)}
onClick={useModal ? () => setOpen(true) : undefined}
>
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-left">
{value ? format(new Date(value + "T00:00:00"), "yyyy.MM.dd (EEE)", { locale: ko }) : (config.placeholder || "날짜 선택")}
</span>
{value && (
<span
role="button"
tabIndex={-1}
onClick={handleClear}
onKeyDown={(e) => { if (e.key === "Enter") handleClear(e as unknown as React.MouseEvent); }}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</span>
)}
</Button>
);
if (useModal) {
return (
<>
{triggerButton}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[360px] p-0">
<DialogHeader className="px-4 pt-4 pb-0">
<DialogTitle className="text-sm"> </DialogTitle>
</DialogHeader>
<div className="flex justify-center pb-4">
<Calendar
mode="single"
selected={selected}
onSelect={handleSelect}
locale={ko}
defaultMonth={selected || new Date()}
className="touch-date-calendar"
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{triggerButton}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
onSelect={handleSelect}
locale={ko}
defaultMonth={selected || new Date()}
/>
</PopoverContent>
</Popover>
);
}
// ========================================
// date 서브타입 - 기간 선택 (프리셋 + Calendar Range)
// ========================================
interface DateRangeValue { from?: string; to?: string }
const RANGE_PRESETS = [
{ key: "today", label: "오늘" },
{ key: "this-week", label: "이번주" },
{ key: "this-month", label: "이번달" },
] as const;
function computeRangePreset(key: string): DateRangeValue {
const now = new Date();
const fmt = (d: Date) => format(d, "yyyy-MM-dd");
switch (key) {
case "today":
return { from: fmt(now), to: fmt(now) };
case "this-week":
return { from: fmt(startOfWeek(now, { weekStartsOn: 1 })), to: fmt(endOfWeek(now, { weekStartsOn: 1 })) };
case "this-month":
return { from: fmt(startOfMonth(now)), to: fmt(endOfMonth(now)) };
default:
return {};
}
}
function DateRangeInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) {
const [open, setOpen] = useState(false);
const useModal = config.calendarDisplay === "modal";
const rangeVal: DateRangeValue = (typeof value === "object" && value !== null)
? value as DateRangeValue
: (typeof value === "string" && value ? { from: value, to: value } : {});
const calendarRange = useMemo(() => {
if (!rangeVal.from) return undefined;
return {
from: new Date(rangeVal.from + "T00:00:00"),
to: rangeVal.to ? new Date(rangeVal.to + "T00:00:00") : undefined,
};
}, [rangeVal.from, rangeVal.to]);
const activePreset = RANGE_PRESETS.find((p) => {
const preset = computeRangePreset(p.key);
return preset.from === rangeVal.from && preset.to === rangeVal.to;
})?.key ?? null;
const handlePreset = useCallback(
(key: string) => {
const preset = computeRangePreset(key);
onChange(preset);
},
[onChange]
);
const handleRangeSelect = useCallback(
(range: { from?: Date; to?: Date } | undefined) => {
if (!range?.from) return;
const from = format(range.from, "yyyy-MM-dd");
const to = range.to ? format(range.to, "yyyy-MM-dd") : from;
onChange({ from, to });
if (range.to) setOpen(false);
},
[onChange]
);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange({});
},
[onChange]
);
const displayText = rangeVal.from
? rangeVal.from === rangeVal.to
? format(new Date(rangeVal.from + "T00:00:00"), "MM/dd (EEE)", { locale: ko })
: `${format(new Date(rangeVal.from + "T00:00:00"), "MM/dd", { locale: ko })} ~ ${rangeVal.to ? format(new Date(rangeVal.to + "T00:00:00"), "MM/dd", { locale: ko }) : ""}`
: "";
const presetBar = (
<div className="flex gap-1 px-3">
{RANGE_PRESETS.map((p) => (
<Button
key={p.key}
variant={activePreset === p.key ? "default" : "outline"}
size="sm"
className="h-7 flex-1 px-1 text-[10px]"
onClick={() => {
handlePreset(p.key);
setOpen(false);
}}
>
{p.label}
</Button>
))}
</div>
);
const calendarEl = (
<Calendar
mode="range"
selected={calendarRange}
onSelect={handleRangeSelect}
locale={ko}
defaultMonth={calendarRange?.from || new Date()}
numberOfMonths={1}
className={useModal ? "touch-date-calendar" : undefined}
/>
);
const triggerButton = (
<Button
variant="outline"
className={cn(
"h-full min-h-8 w-full justify-start gap-1.5 px-2 text-xs font-normal",
!rangeVal.from && "text-muted-foreground"
)}
onClick={useModal ? () => setOpen(true) : undefined}
>
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-left">
{displayText || (config.placeholder || "기간 선택")}
</span>
{rangeVal.from && (
<span
role="button"
tabIndex={-1}
onClick={handleClear}
onKeyDown={(e) => { if (e.key === "Enter") handleClear(e as unknown as React.MouseEvent); }}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</span>
)}
</Button>
);
if (useModal) {
return (
<>
{triggerButton}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[360px] p-0">
<DialogHeader className="px-4 pt-4 pb-0">
<DialogTitle className="text-sm"> </DialogTitle>
</DialogHeader>
<div className="space-y-2 pb-4">
{presetBar}
<div className="flex justify-center">
{calendarEl}
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{triggerButton}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="space-y-2 pt-2 pb-1">
{presetBar}
{calendarEl}
</div>
</PopoverContent>
</Popover>
);
}
// ========================================
// select 서브타입
// ========================================
@ -237,7 +571,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
return (
<Select value={value || undefined} onValueChange={(v) => onChange(v)}>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger className="h-full min-h-8 text-xs">
<SelectValue placeholder={config.placeholder || "선택"} />
</SelectTrigger>
<SelectContent>
@ -266,7 +600,7 @@ function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchC
};
return (
<div className="flex flex-wrap gap-1">
<div className="flex h-full flex-wrap items-center gap-1">
{presets.map((preset) => (
<Button key={preset} variant={currentPreset === preset ? "default" : "outline"} size="sm" className="h-7 px-2 text-[10px]" onClick={() => handleSelect(preset)}>
{DATE_PRESET_LABELS[preset]}
@ -282,7 +616,7 @@ function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchC
function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) {
return (
<div className="flex items-center gap-2">
<div className="flex h-full items-center gap-2">
<Switch checked={value} onCheckedChange={(checked) => onChange(checked)} />
<span className="text-xs text-muted-foreground">{value ? "ON" : "OFF"}</span>
</div>
@ -293,17 +627,32 @@ function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v:
// modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기
// ========================================
function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) {
function ModalSearchInput({ config, displayText, onClick, onClear }: { config: PopSearchConfig; displayText: string; onClick?: () => void; onClear?: () => void }) {
const hasValue = !!displayText;
return (
<div
className="flex h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
className="flex h-full min-h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick?.(); }}
>
<span className="flex-1 truncate text-xs">{displayText || config.placeholder || "선택..."}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className={`flex-1 truncate text-xs ${hasValue ? "" : "text-muted-foreground"}`}>
{displayText || config.placeholder || "선택..."}
</span>
{hasValue && onClear ? (
<button
type="button"
className="ml-1 shrink-0 rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onClear(); }}
aria-label="선택 해제"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
</div>
);
}
@ -314,7 +663,7 @@ function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchC
function PlaceholderInput({ inputType }: { inputType: string }) {
return (
<div className="flex h-8 items-center rounded-md border border-dashed border-muted-foreground/30 px-3">
<div className="flex h-full min-h-8 items-center rounded-md border border-dashed border-muted-foreground/30 px-3">
<span className="text-[10px] text-muted-foreground">{inputType} ( )</span>
</div>
);
@ -382,6 +731,7 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
columnLabels,
displayStyle = "table",
displayField,
distinct,
} = modalConfig;
const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : [];
@ -393,13 +743,25 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
setLoading(true);
try {
const result = await dataApi.getTableData(tableName, { page: 1, size: 200 });
setAllRows(result.data || []);
let rows = result.data || [];
if (distinct && displayField) {
const seen = new Set<string>();
rows = rows.filter((row) => {
const val = String(row[displayField] ?? "");
if (seen.has(val)) return false;
seen.add(val);
return true;
});
}
setAllRows(rows);
} catch {
setAllRows([]);
} finally {
setLoading(false);
}
}, [tableName]);
}, [tableName, distinct, displayField]);
useEffect(() => {
if (open) {

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -13,7 +13,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown, AlertTriangle } from "lucide-react";
import {
Popover,
PopoverContent,
@ -30,6 +30,9 @@ import {
import type {
PopSearchConfig,
SearchInputType,
SearchFilterMode,
DateSelectionMode,
CalendarDisplayMode,
DatePresetOption,
ModalSelectConfig,
ModalDisplayStyle,
@ -38,6 +41,7 @@ import type {
} from "./types";
import {
SEARCH_INPUT_TYPE_LABELS,
SEARCH_FILTER_MODE_LABELS,
DATE_PRESET_LABELS,
MODAL_DISPLAY_STYLE_LABELS,
MODAL_SEARCH_MODE_LABELS,
@ -57,7 +61,6 @@ const DEFAULT_CONFIG: PopSearchConfig = {
placeholder: "검색어 입력",
debounceMs: 500,
triggerOnEnter: true,
labelPosition: "top",
labelText: "",
labelVisible: true,
};
@ -69,9 +72,12 @@ const DEFAULT_CONFIG: PopSearchConfig = {
interface ConfigPanelProps {
config: PopSearchConfig | undefined;
onUpdate: (config: PopSearchConfig) => void;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
export function PopSearchConfigPanel({ config, onUpdate, allComponents, connections, componentId }: ConfigPanelProps) {
const [step, setStep] = useState(0);
const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) };
const cfg: PopSearchConfig = {
@ -110,7 +116,7 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
</div>
{step === 0 && <StepBasicSettings cfg={cfg} update={update} />}
{step === 1 && <StepDetailSettings cfg={cfg} update={update} />}
{step === 1 && <StepDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />}
<div className="flex justify-between pt-2">
<Button
@ -145,6 +151,9 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
interface StepProps {
cfg: PopSearchConfig;
update: (partial: Partial<PopSearchConfig>) => void;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
function StepBasicSettings({ cfg, update }: StepProps) {
@ -189,33 +198,17 @@ function StepBasicSettings({ cfg, update }: StepProps) {
</div>
{cfg.labelVisible !== false && (
<>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={cfg.labelText || ""}
onChange={(e) => update({ labelText: e.target.value })}
placeholder="예: 거래처명"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.labelPosition || "top"}
onValueChange={(v) => update({ labelPosition: v as "top" | "left" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top" className="text-xs"> ()</SelectItem>
<SelectItem value="left" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
</>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={cfg.labelText || ""}
onChange={(e) => update({ labelText: e.target.value })}
placeholder="예: 거래처명"
className="h-8 text-xs"
/>
</div>
)}
</div>
);
}
@ -224,18 +217,29 @@ function StepBasicSettings({ cfg, update }: StepProps) {
// STEP 2: 타입별 상세 설정
// ========================================
function StepDetailSettings({ cfg, update }: StepProps) {
function StepDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const normalized = normalizeInputType(cfg.inputType as string);
switch (normalized) {
case "text":
case "number":
return <TextDetailSettings cfg={cfg} update={update} />;
return <TextDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "select":
return <SelectDetailSettings cfg={cfg} update={update} />;
return <SelectDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "date":
return <DateDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "date-preset":
return <DatePresetDetailSettings cfg={cfg} update={update} />;
return <DatePresetDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "modal":
return <ModalDetailSettings cfg={cfg} update={update} />;
case "status-chip":
return (
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-[10px] text-muted-foreground">
pop-status-bar .
&quot; &quot; .
</p>
</div>
);
case "toggle":
return (
<div className="rounded-lg bg-muted/50 p-3">
@ -255,11 +259,278 @@ function StepDetailSettings({ cfg, update }: StepProps) {
}
}
// ========================================
// 공통: 필터 연결 설정 섹션
// ========================================
interface FilterConnectionSectionProps {
cfg: PopSearchConfig;
update: (partial: Partial<PopSearchConfig>) => void;
showFieldName: boolean;
fixedFilterMode?: SearchFilterMode;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
interface ConnectedComponentInfo {
tableNames: string[];
displayedColumns: Set<string>;
}
/**
* tableName과 .
*/
function getConnectedComponentInfo(
componentId?: string,
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[],
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[],
): ConnectedComponentInfo {
const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() };
if (!componentId || !connections || !allComponents) return empty;
const targetIds = connections
.filter((c) => c.sourceComponent === componentId)
.map((c) => c.targetComponent);
const tableNames = new Set<string>();
const displayedColumns = new Set<string>();
for (const tid of targetIds) {
const comp = allComponents.find((c) => c.id === tid);
if (!comp?.config) continue;
const compCfg = comp.config as Record<string, any>;
const tn = compCfg.dataSource?.tableName;
if (tn) tableNames.add(tn);
// pop-card-list: cardTemplate에서 사용 중인 컬럼 수집
const tpl = compCfg.cardTemplate;
if (tpl) {
if (tpl.header?.codeField) displayedColumns.add(tpl.header.codeField);
if (tpl.header?.titleField) displayedColumns.add(tpl.header.titleField);
if (tpl.image?.imageColumn) displayedColumns.add(tpl.image.imageColumn);
if (Array.isArray(tpl.body?.fields)) {
for (const f of tpl.body.fields) {
if (f.columnName) displayedColumns.add(f.columnName);
}
}
}
// pop-string-list: selectedColumns / listColumns
if (Array.isArray(compCfg.selectedColumns)) {
for (const col of compCfg.selectedColumns) displayedColumns.add(col);
}
if (Array.isArray(compCfg.listColumns)) {
for (const lc of compCfg.listColumns) {
if (lc.columnName) displayedColumns.add(lc.columnName);
}
}
}
return { tableNames: Array.from(tableNames), displayedColumns };
}
function FilterConnectionSection({ cfg, update, showFieldName, fixedFilterMode, allComponents, connections, componentId }: FilterConnectionSectionProps) {
const connInfo = useMemo(
() => getConnectedComponentInfo(componentId, connections, allComponents),
[componentId, connections, allComponents],
);
const [targetColumns, setTargetColumns] = useState<ColumnTypeInfo[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
const connectedTablesKey = connInfo.tableNames.join(",");
useEffect(() => {
if (connInfo.tableNames.length === 0) {
setTargetColumns([]);
return;
}
let cancelled = false;
setColumnsLoading(true);
Promise.all(connInfo.tableNames.map((t) => getTableColumns(t)))
.then((results) => {
if (cancelled) return;
const allCols: ColumnTypeInfo[] = [];
const seen = new Set<string>();
for (const res of results) {
if (res.success && res.data?.columns) {
for (const col of res.data.columns) {
if (!seen.has(col.columnName)) {
seen.add(col.columnName);
allCols.push(col);
}
}
}
}
setTargetColumns(allCols);
})
.finally(() => { if (!cancelled) setColumnsLoading(false); });
return () => { cancelled = true; };
}, [connectedTablesKey]); // eslint-disable-line react-hooks/exhaustive-deps
const hasConnection = connInfo.tableNames.length > 0;
const { displayedCols, otherCols } = useMemo(() => {
if (connInfo.displayedColumns.size === 0) {
return { displayedCols: [] as ColumnTypeInfo[], otherCols: targetColumns };
}
const displayed: ColumnTypeInfo[] = [];
const others: ColumnTypeInfo[] = [];
for (const col of targetColumns) {
if (connInfo.displayedColumns.has(col.columnName)) {
displayed.push(col);
} else {
others.push(col);
}
}
return { displayedCols: displayed, otherCols: others };
}, [targetColumns, connInfo.displayedColumns]);
const selectedFilterCols = cfg.filterColumns || (cfg.fieldName ? [cfg.fieldName] : []);
const toggleFilterColumn = (colName: string) => {
const current = new Set(selectedFilterCols);
if (current.has(colName)) {
current.delete(colName);
} else {
current.add(colName);
}
const next = Array.from(current);
update({
filterColumns: next,
fieldName: next[0] || "",
});
};
const renderColumnCheckbox = (col: ColumnTypeInfo) => (
<div key={col.columnName} className="flex items-center gap-2">
<Checkbox
id={`filter_col_${col.columnName}`}
checked={selectedFilterCols.includes(col.columnName)}
onCheckedChange={() => toggleFilterColumn(col.columnName)}
/>
<Label htmlFor={`filter_col_${col.columnName}`} className="text-[10px]">
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
</Label>
</div>
);
return (
<div className="space-y-3">
<div className="flex items-center gap-2 border-t pt-3">
<span className="text-[10px] font-medium text-muted-foreground"> </span>
</div>
{!hasConnection && (
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<p className="text-[9px] text-amber-700">
.
.
</p>
</div>
)}
{hasConnection && showFieldName && (
<div className="space-y-1">
<Label className="text-[10px]">
<span className="text-destructive">*</span>
</Label>
{columnsLoading ? (
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : targetColumns.length > 0 ? (
<div className="max-h-48 space-y-2 overflow-y-auto rounded border p-2">
{displayedCols.length > 0 && (
<div className="space-y-1">
<p className="text-[9px] font-medium text-primary"> </p>
{displayedCols.map(renderColumnCheckbox)}
</div>
)}
{displayedCols.length > 0 && otherCols.length > 0 && (
<div className="border-t" />
)}
{otherCols.length > 0 && (
<div className="space-y-1">
<p className="text-[9px] font-medium text-muted-foreground"> </p>
{otherCols.map(renderColumnCheckbox)}
</div>
)}
</div>
) : (
<p className="text-[9px] text-muted-foreground">
</p>
)}
{selectedFilterCols.length === 0 && hasConnection && !columnsLoading && targetColumns.length > 0 && (
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<p className="text-[9px] text-amber-700">
</p>
</div>
)}
{selectedFilterCols.length > 0 && (
<p className="text-[9px] text-muted-foreground">
{selectedFilterCols.length} -
</p>
)}
{selectedFilterCols.length === 0 && (
<p className="text-[9px] text-muted-foreground">
( )
</p>
)}
</div>
)}
{fixedFilterMode ? (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<div className="flex h-8 items-center rounded-md border bg-muted px-3 text-xs text-muted-foreground">
{SEARCH_FILTER_MODE_LABELS[fixedFilterMode]}
</div>
<p className="text-[9px] text-muted-foreground">
{SEARCH_FILTER_MODE_LABELS[fixedFilterMode]}
</p>
</div>
) : (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.filterMode || "contains"}
onValueChange={(v) => update({ filterMode: v as SearchFilterMode })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SEARCH_FILTER_MODE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
)}
</div>
);
}
// ========================================
// text/number 상세 설정
// ========================================
function TextDetailSettings({ cfg, update }: StepProps) {
function TextDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
return (
<div className="space-y-3">
<div className="space-y-1">
@ -285,6 +556,8 @@ function TextDetailSettings({ cfg, update }: StepProps) {
/>
<Label htmlFor="triggerOnEnter" className="text-[10px]">Enter </Label>
</div>
<FilterConnectionSection cfg={cfg} update={update} showFieldName allComponents={allComponents} connections={connections} componentId={componentId} />
</div>
);
}
@ -293,7 +566,7 @@ function TextDetailSettings({ cfg, update }: StepProps) {
// select 상세 설정
// ========================================
function SelectDetailSettings({ cfg, update }: StepProps) {
function SelectDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const options = cfg.options || [];
const addOption = () => {
@ -329,6 +602,90 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
<Plus className="mr-1 h-3 w-3" />
</Button>
<FilterConnectionSection cfg={cfg} update={update} showFieldName allComponents={allComponents} connections={connections} componentId={componentId} />
</div>
);
}
// ========================================
// date 상세 설정
// ========================================
const DATE_SELECTION_MODE_LABELS: Record<DateSelectionMode, string> = {
single: "단일 날짜",
range: "기간 선택",
};
const CALENDAR_DISPLAY_LABELS: Record<CalendarDisplayMode, string> = {
popover: "팝오버 (PC용)",
modal: "모달 (터치/POP용)",
};
function DateDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const mode: DateSelectionMode = cfg.dateSelectionMode || "single";
const calDisplay: CalendarDisplayMode = cfg.calendarDisplay || "modal";
const autoFilterMode = mode === "range" ? "range" : "equals";
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={mode}
onValueChange={(v) => update({ dateSelectionMode: v as DateSelectionMode })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(DATE_SELECTION_MODE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
{mode === "single"
? "캘린더에서 날짜 하나를 선택합니다"
: "프리셋(오늘/이번주/이번달) + 캘린더 기간 선택"}
</p>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={calDisplay}
onValueChange={(v) => update({ calendarDisplay: v as CalendarDisplayMode })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(CALENDAR_DISPLAY_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
{calDisplay === "modal"
? "터치 친화적인 큰 모달로 캘린더가 열립니다"
: "입력란 아래에 작은 팝오버로 열립니다"}
</p>
</div>
<FilterConnectionSection
cfg={cfg}
update={update}
showFieldName
fixedFilterMode={autoFilterMode}
allComponents={allComponents}
connections={connections}
componentId={componentId}
/>
</div>
);
}
@ -337,7 +694,7 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
// date-preset 상세 설정
// ========================================
function DatePresetDetailSettings({ cfg, update }: StepProps) {
function DatePresetDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"];
const activePresets = cfg.datePresets || ["today", "this-week", "this-month"];
@ -366,6 +723,8 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) {
&quot;&quot; UI가 ( )
</p>
)}
<FilterConnectionSection cfg={cfg} update={update} showFieldName fixedFilterMode="range" allComponents={allComponents} connections={connections} componentId={componentId} />
</div>
);
}
@ -647,6 +1006,21 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
</p>
</div>
{/* 중복 제거 (Distinct) */}
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<Checkbox
id="modal_distinct"
checked={mc.distinct ?? false}
onCheckedChange={(checked) => updateModal({ distinct: !!checked })}
/>
<Label htmlFor="modal_distinct" className="text-[10px]"> (Distinct)</Label>
</div>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
{/* 검색창에 보일 값 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
@ -694,8 +1068,11 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
(: 회사코드)
</p>
</div>
<FilterConnectionSection cfg={cfg} update={update} showFieldName={false} />
</>
)}
</div>
);
}

View File

@ -1,7 +1,7 @@
// ===== pop-search 전용 타입 =====
// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나.
/** 검색 필드 입력 타입 (9종) */
/** 검색 필드 입력 타입 (10종) */
export type SearchInputType =
| "text"
| "number"
@ -11,7 +11,8 @@ export type SearchInputType =
| "multi-select"
| "combo"
| "modal"
| "toggle";
| "toggle"
| "status-chip";
/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */
export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid";
@ -22,6 +23,12 @@ export function normalizeInputType(t: string): SearchInputType {
return t as SearchInputType;
}
/** 날짜 선택 모드 */
export type DateSelectionMode = "single" | "range";
/** 캘린더 표시 방식 (POP 터치 환경에서는 modal 권장) */
export type CalendarDisplayMode = "popover" | "modal";
/** 날짜 프리셋 옵션 */
export type DatePresetOption = "today" | "this-week" | "this-month" | "custom";
@ -46,6 +53,9 @@ export type ModalDisplayStyle = "table" | "icon";
/** 모달 검색 방식 */
export type ModalSearchMode = "contains" | "starts-with" | "equals";
/** 검색 값을 대상 리스트에 전달할 때의 필터링 방식 */
export type SearchFilterMode = "contains" | "equals" | "starts_with" | "range";
/** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */
export type ModalFilterTab = "korean" | "alphabet";
@ -64,6 +74,22 @@ export interface ModalSelectConfig {
displayField: string;
valueField: string;
/** displayField 기준 중복 제거 */
distinct?: boolean;
}
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
export type StatusChipStyle = "tab" | "pill";
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
export interface StatusChipConfig {
showCount?: boolean;
countColumn?: string;
allowAll?: boolean;
allLabel?: string;
chipStyle?: StatusChipStyle;
useSubCount?: boolean;
}
/** pop-search 전체 설정 */
@ -81,18 +107,28 @@ export interface PopSearchConfig {
options?: SelectOption[];
optionsDataSource?: SelectDataSource;
// date 전용
dateSelectionMode?: DateSelectionMode;
calendarDisplay?: CalendarDisplayMode;
// date-preset 전용
datePresets?: DatePresetOption[];
// modal 전용
modalConfig?: ModalSelectConfig;
// status-chip 전용
statusChipConfig?: StatusChipConfig;
// 라벨
labelText?: string;
labelVisible?: boolean;
// 스타일
labelPosition?: "top" | "left";
// 연결된 리스트에 필터를 보낼 때의 매칭 방식
filterMode?: SearchFilterMode;
// 필터 대상 컬럼 복수 선택 (fieldName은 대표 컬럼, filterColumns는 전체 대상)
filterColumns?: string[];
}
/** 기본 설정값 (레지스트리 + 컴포넌트 공유) */
@ -102,7 +138,6 @@ export const DEFAULT_SEARCH_CONFIG: PopSearchConfig = {
placeholder: "검색어 입력",
debounceMs: 500,
triggerOnEnter: true,
labelPosition: "top",
labelText: "",
labelVisible: true,
};
@ -126,6 +161,13 @@ export const SEARCH_INPUT_TYPE_LABELS: Record<SearchInputType, string> = {
combo: "자동완성",
modal: "모달",
toggle: "토글",
"status-chip": "상태 칩 (대시보드)",
};
/** 상태 칩 스타일 라벨 (설정 패널용) */
export const STATUS_CHIP_STYLE_LABELS: Record<StatusChipStyle, string> = {
tab: "탭 (큰 숫자)",
pill: "알약 (작은 뱃지)",
};
/** 모달 보여주기 방식 라벨 */
@ -147,6 +189,14 @@ export const MODAL_FILTER_TAB_LABELS: Record<ModalFilterTab, string> = {
alphabet: "ABC",
};
/** 검색 필터 방식 라벨 (설정 패널용) */
export const SEARCH_FILTER_MODE_LABELS: Record<SearchFilterMode, string> = {
contains: "포함",
equals: "일치",
starts_with: "시작",
range: "범위",
};
/** 한글 초성 추출 */
const KOREAN_CONSONANTS = [
"ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",

View File

@ -38,9 +38,23 @@ export function ColumnCombobox({
const filtered = useMemo(() => {
if (!search) return columns;
const q = search.toLowerCase();
return columns.filter((c) => c.name.toLowerCase().includes(q));
return columns.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(c.comment && c.comment.toLowerCase().includes(q))
);
}, [columns, search]);
const selectedCol = useMemo(
() => columns.find((c) => c.name === value),
[columns, value],
);
const displayValue = selectedCol
? selectedCol.comment
? `${selectedCol.name} (${selectedCol.comment})`
: selectedCol.name
: "";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@ -50,7 +64,7 @@ export function ColumnCombobox({
aria-expanded={open}
className="mt-1 h-8 w-full justify-between text-xs"
>
{value || placeholder}
<span className="truncate">{displayValue || placeholder}</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@ -61,7 +75,7 @@ export function ColumnCombobox({
>
<Command shouldFilter={false}>
<CommandInput
placeholder="컬럼명 검색..."
placeholder="컬럼명 또는 한글명 검색..."
className="text-xs"
value={search}
onValueChange={setSearch}
@ -88,8 +102,15 @@ export function ColumnCombobox({
value === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center gap-2">
<span>{col.name}</span>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span>{col.name}</span>
{col.comment && (
<span className="text-[11px] text-muted-foreground">
({col.comment})
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground">
{col.type}
</span>

View File

@ -0,0 +1,243 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { cn } from "@/lib/utils";
import { usePopEvent } from "@/hooks/pop";
import type { StatusBarConfig, StatusChipOption } from "./types";
import { DEFAULT_STATUS_BAR_CONFIG } from "./types";
interface PopStatusBarComponentProps {
config: StatusBarConfig;
label?: string;
screenId?: string;
componentId?: string;
}
export function PopStatusBarComponent({
config: rawConfig,
label,
screenId,
componentId,
}: PopStatusBarComponentProps) {
const config = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) };
const { publish, subscribe } = usePopEvent(screenId || "");
const [selectedValue, setSelectedValue] = useState<string>("");
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
const [autoSubStatusColumn, setAutoSubStatusColumn] = useState<string | null>(null);
// all_rows 이벤트 구독
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__all_rows`,
(payload: unknown) => {
const data = payload as { value?: unknown } | unknown;
const inner =
typeof data === "object" && data && "value" in data
? (data as { value: unknown }).value
: data;
if (
typeof inner === "object" &&
inner &&
!Array.isArray(inner) &&
"rows" in inner
) {
const envelope = inner as {
rows?: unknown;
subStatusColumn?: string | null;
};
if (Array.isArray(envelope.rows))
setAllRows(envelope.rows as Record<string, unknown>[]);
setAutoSubStatusColumn(envelope.subStatusColumn ?? null);
} else if (Array.isArray(inner)) {
setAllRows(inner as Record<string, unknown>[]);
setAutoSubStatusColumn(null);
}
}
);
return unsub;
}, [componentId, subscribe]);
// 외부에서 값 설정 이벤트 구독
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__set_value`,
(payload: unknown) => {
const data = payload as { value?: unknown } | unknown;
const incoming =
typeof data === "object" && data && "value" in data
? (data as { value: unknown }).value
: data;
setSelectedValue(String(incoming ?? ""));
}
);
return unsub;
}, [componentId, subscribe]);
const emitFilter = useCallback(
(newValue: string) => {
setSelectedValue(newValue);
if (!componentId) return;
const baseColumn = config.filterColumn || config.countColumn || "";
const subActive = config.useSubCount && !!autoSubStatusColumn;
const filterColumns = subActive
? [...new Set([baseColumn, autoSubStatusColumn!].filter(Boolean))]
: [baseColumn].filter(Boolean);
publish(`__comp_output__${componentId}__filter_value`, {
fieldName: baseColumn,
filterColumns,
value: newValue,
filterMode: "equals",
_source: "status-bar",
});
},
[componentId, publish, config.filterColumn, config.countColumn, config.useSubCount, autoSubStatusColumn]
);
const chipCfg = config;
const showCount = chipCfg.showCount !== false;
const baseCountColumn = chipCfg.countColumn || "";
const useSubCount = chipCfg.useSubCount || false;
const hideUntilSubFilter = chipCfg.hideUntilSubFilter || false;
const allowAll = chipCfg.allowAll !== false;
const allLabel = chipCfg.allLabel || "전체";
const chipStyle = chipCfg.chipStyle || "tab";
const options: StatusChipOption[] = chipCfg.options || [];
// 하위 필터(공정) 활성 여부
const subFilterActive = useSubCount && !!autoSubStatusColumn;
// hideUntilSubFilter가 켜져있으면서 아직 공정 선택이 안 된 경우 숨김
const shouldHide = hideUntilSubFilter && !subFilterActive;
const effectiveCountColumn =
subFilterActive ? autoSubStatusColumn : baseCountColumn;
const counts = useMemo(() => {
if (!showCount || !effectiveCountColumn || allRows.length === 0)
return new Map<string, number>();
const map = new Map<string, number>();
for (const row of allRows) {
if (row == null || typeof row !== "object") continue;
const v = String(row[effectiveCountColumn] ?? "");
map.set(v, (map.get(v) || 0) + 1);
}
return map;
}, [allRows, effectiveCountColumn, showCount]);
const totalCount = allRows.length;
const chipItems = useMemo(() => {
const items: { value: string; label: string; count: number }[] = [];
if (allowAll) {
items.push({ value: "", label: allLabel, count: totalCount });
}
for (const opt of options) {
items.push({
value: opt.value,
label: opt.label,
count: counts.get(opt.value) || 0,
});
}
return items;
}, [options, counts, totalCount, allowAll, allLabel]);
const showLabel = !!label;
if (shouldHide) {
return (
<div className="flex h-full w-full items-center justify-center p-1.5">
<span className="text-[10px] text-muted-foreground/50">
{chipCfg.hiddenMessage || "조건을 선택하면 상태별 현황이 표시됩니다"}
</span>
</div>
);
}
if (chipStyle === "pill") {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5">
{showLabel && (
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
{label}
</span>
)}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{chipItems.map((item) => {
const isActive = selectedValue === item.value;
return (
<button
key={item.value}
type="button"
onClick={() => emitFilter(item.value)}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
)}
>
{item.label}
{showCount && (
<span
className={cn(
"ml-0.5 min-w-[18px] rounded-full px-1 py-0.5 text-center text-[10px] font-bold leading-none",
isActive
? "bg-primary-foreground/20 text-primary-foreground"
: "bg-background text-foreground"
)}
>
{item.count}
</span>
)}
</button>
);
})}
</div>
</div>
);
}
// tab 스타일 (기본)
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5">
{showLabel && (
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
{label}
</span>
)}
<div className="flex min-w-0 flex-1 items-center justify-center gap-2">
{chipItems.map((item) => {
const isActive = selectedValue === item.value;
return (
<button
key={item.value}
type="button"
onClick={() => emitFilter(item.value)}
className={cn(
"flex min-w-[60px] flex-col items-center justify-center rounded-lg px-3 py-1.5 transition-colors",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "bg-muted/60 text-muted-foreground hover:bg-accent"
)}
>
{showCount && (
<span className="text-lg font-bold leading-tight">
{item.count}
</span>
)}
<span className="text-[10px] font-medium leading-tight">
{item.label}
</span>
</button>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,489 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Trash2, Loader2, AlertTriangle, RefreshCw } from "lucide-react";
import { getTableColumns } from "@/lib/api/tableManagement";
import { dataApi } from "@/lib/api/data";
import type { ColumnTypeInfo } from "@/lib/api/tableManagement";
import type { StatusBarConfig, StatusChipStyle, StatusChipOption } from "./types";
import { DEFAULT_STATUS_BAR_CONFIG, STATUS_CHIP_STYLE_LABELS } from "./types";
interface ConfigPanelProps {
config: StatusBarConfig | undefined;
onUpdate: (config: StatusBarConfig) => void;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
export function PopStatusBarConfigPanel({
config: rawConfig,
onUpdate,
allComponents,
connections,
componentId,
}: ConfigPanelProps) {
const cfg = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) };
const update = (partial: Partial<StatusBarConfig>) => {
onUpdate({ ...cfg, ...partial });
};
const options = cfg.options || [];
const removeOption = (index: number) => {
update({ options: options.filter((_, i) => i !== index) });
};
const updateOption = (
index: number,
field: keyof StatusChipOption,
val: string
) => {
update({
options: options.map((opt, i) =>
i === index ? { ...opt, [field]: val } : opt
),
});
};
// 연결된 카드 컴포넌트의 테이블 컬럼 가져오기
const connectedTableName = useMemo(() => {
if (!componentId || !connections || !allComponents) return null;
const targetIds = connections
.filter((c) => c.sourceComponent === componentId)
.map((c) => c.targetComponent);
const sourceIds = connections
.filter((c) => c.targetComponent === componentId)
.map((c) => c.sourceComponent);
const peerIds = [...new Set([...targetIds, ...sourceIds])];
for (const pid of peerIds) {
const comp = allComponents.find((c) => c.id === pid);
if (!comp?.config) continue;
const compCfg = comp.config as Record<string, unknown>;
const ds = compCfg.dataSource as { tableName?: string } | undefined;
if (ds?.tableName) return ds.tableName;
}
return null;
}, [componentId, connections, allComponents]);
const [targetColumns, setTargetColumns] = useState<ColumnTypeInfo[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
// 집계 컬럼의 고유값 (옵션 선택용)
const [distinctValues, setDistinctValues] = useState<string[]>([]);
const [distinctLoading, setDistinctLoading] = useState(false);
useEffect(() => {
if (!connectedTableName) {
setTargetColumns([]);
return;
}
let cancelled = false;
setColumnsLoading(true);
getTableColumns(connectedTableName)
.then((res) => {
if (cancelled) return;
if (res.success && res.data?.columns) {
setTargetColumns(res.data.columns);
}
})
.finally(() => {
if (!cancelled) setColumnsLoading(false);
});
return () => {
cancelled = true;
};
}, [connectedTableName]);
const fetchDistinctValues = useCallback(async (tableName: string, column: string) => {
setDistinctLoading(true);
try {
const res = await dataApi.getTableData(tableName, { page: 1, size: 9999 });
const vals = new Set<string>();
for (const row of res.data) {
const v = row[column];
if (v != null && String(v).trim() !== "") {
vals.add(String(v));
}
}
const sorted = [...vals].sort();
setDistinctValues(sorted);
return sorted;
} catch {
setDistinctValues([]);
return [];
} finally {
setDistinctLoading(false);
}
}, []);
// 집계 컬럼 변경 시 고유값 새로 가져오기
useEffect(() => {
const col = cfg.countColumn;
if (!connectedTableName || !col) {
setDistinctValues([]);
return;
}
fetchDistinctValues(connectedTableName, col);
}, [connectedTableName, cfg.countColumn, fetchDistinctValues]);
const handleAutoFill = useCallback(async () => {
if (!connectedTableName || !cfg.countColumn) return;
const vals = await fetchDistinctValues(connectedTableName, cfg.countColumn);
if (vals.length === 0) return;
const newOptions: StatusChipOption[] = vals.map((v) => {
const existing = options.find((o) => o.value === v);
return { value: v, label: existing?.label || v };
});
update({ options: newOptions });
}, [connectedTableName, cfg.countColumn, options, fetchDistinctValues]);
const addOptionFromValue = (value: string) => {
if (options.some((o) => o.value === value)) return;
update({
options: [...options, { value, label: value }],
});
};
return (
<div className="space-y-4">
{/* --- 칩 옵션 목록 --- */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
{connectedTableName && cfg.countColumn && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[9px]"
onClick={handleAutoFill}
disabled={distinctLoading}
>
{distinctLoading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<RefreshCw className="mr-1 h-3 w-3" />
)}
DB에서
</Button>
)}
</div>
{cfg.useSubCount && (
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<p className="text-[9px] text-amber-700">
. DB .
</p>
</div>
)}
{options.length === 0 && (
<p className="text-[9px] text-muted-foreground">
{connectedTableName && cfg.countColumn
? "\"DB에서 자동 채우기\"를 클릭하거나 아래에서 추가하세요."
: "옵션이 없습니다. 먼저 집계 컬럼을 선택한 후 추가하세요."}
</p>
)}
{options.map((opt, i) => (
<div key={i} className="flex items-center gap-1">
<Input
value={opt.value}
onChange={(e) => updateOption(i, "value", e.target.value)}
placeholder="DB 값"
className="h-7 flex-1 text-[10px]"
/>
<Input
value={opt.label}
onChange={(e) => updateOption(i, "label", e.target.value)}
placeholder="표시 라벨"
className="h-7 flex-1 text-[10px]"
/>
<button
type="button"
onClick={() => removeOption(i)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
{/* 고유값에서 추가 */}
{distinctValues.length > 0 && (
<div className="space-y-1">
<Label className="text-[9px] text-muted-foreground">
(DB에서 )
</Label>
<div className="flex flex-wrap gap-1">
{distinctValues
.filter((dv) => !options.some((o) => o.value === dv))
.map((dv) => (
<button
key={dv}
type="button"
onClick={() => addOptionFromValue(dv)}
className="flex h-6 items-center gap-1 rounded-full border border-dashed px-2 text-[9px] text-muted-foreground transition-colors hover:border-primary hover:text-primary"
>
<Plus className="h-2.5 w-2.5" />
{dv}
</button>
))}
{distinctValues.every((dv) => options.some((o) => o.value === dv)) && (
<p className="text-[9px] text-muted-foreground"> </p>
)}
</div>
</div>
)}
{/* 수동 추가 */}
<Button
variant="outline"
size="sm"
className="h-7 w-full text-[10px]"
onClick={() => {
update({
options: [
...options,
{ value: "", label: "" },
],
});
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{/* --- 전체 보기 칩 --- */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<Checkbox
id="allowAll"
checked={cfg.allowAll !== false}
onCheckedChange={(checked) => update({ allowAll: Boolean(checked) })}
/>
<Label htmlFor="allowAll" className="text-[10px]">
&quot;&quot;
</Label>
</div>
<p className="pl-5 text-[9px] text-muted-foreground">
</p>
{cfg.allowAll !== false && (
<div className="space-y-1 pl-5">
<Label className="text-[9px] text-muted-foreground">
</Label>
<Input
value={cfg.allLabel || ""}
onChange={(e) => update({ allLabel: e.target.value })}
placeholder="전체"
className="h-7 text-[10px]"
/>
</div>
)}
</div>
{/* --- 건수 표시 --- */}
<div className="flex items-center gap-2">
<Checkbox
id="showCount"
checked={cfg.showCount !== false}
onCheckedChange={(checked) => update({ showCount: Boolean(checked) })}
/>
<Label htmlFor="showCount" className="text-[10px]">
</Label>
</div>
{cfg.showCount !== false && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
{columnsLoading ? (
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : targetColumns.length > 0 ? (
<Select
value={cfg.countColumn || ""}
onValueChange={(v) => update({ countColumn: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="집계 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{targetColumns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
className="text-xs"
>
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">
({col.columnName})
</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={cfg.countColumn || ""}
onChange={(e) => update({ countColumn: e.target.value })}
placeholder="예: status"
className="h-8 text-xs"
/>
)}
<p className="text-[9px] text-muted-foreground">
</p>
</div>
)}
{cfg.showCount !== false && (
<div className="space-y-2 rounded bg-muted/30 p-2">
<div className="flex items-center gap-2">
<Checkbox
id="useSubCount"
checked={cfg.useSubCount || false}
onCheckedChange={(checked) =>
update({ useSubCount: Boolean(checked) })
}
/>
<Label htmlFor="useSubCount" className="text-[10px]">
</Label>
</div>
{cfg.useSubCount && (
<>
<p className="pl-5 text-[9px] text-muted-foreground">
</p>
<div className="mt-1 flex items-center gap-2 pl-5">
<Checkbox
id="hideUntilSubFilter"
checked={cfg.hideUntilSubFilter || false}
onCheckedChange={(checked) =>
update({ hideUntilSubFilter: Boolean(checked) })
}
/>
<Label htmlFor="hideUntilSubFilter" className="text-[10px]">
</Label>
</div>
{cfg.hideUntilSubFilter && (
<div className="space-y-1 pl-5">
<Label className="text-[9px] text-muted-foreground">
</Label>
<Input
value={cfg.hiddenMessage || ""}
onChange={(e) => update({ hiddenMessage: e.target.value })}
placeholder="조건을 선택하면 상태별 현황이 표시됩니다"
className="h-7 text-[10px]"
/>
</div>
)}
</>
)}
</div>
)}
{/* --- 칩 스타일 --- */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.chipStyle || "tab"}
onValueChange={(v) => update({ chipStyle: v as StatusChipStyle })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(STATUS_CHIP_STYLE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
: + / 알약: 작은
</p>
</div>
{/* --- 필터 컬럼 --- */}
<div className="space-y-1 border-t pt-3">
<Label className="text-[10px]"> </Label>
{!connectedTableName && (
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<p className="text-[9px] text-amber-700">
.
</p>
</div>
)}
{connectedTableName && (
<>
{columnsLoading ? (
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : targetColumns.length > 0 ? (
<Select
value={cfg.filterColumn || cfg.countColumn || ""}
onValueChange={(v) => update({ filterColumn: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필터 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{targetColumns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
className="text-xs"
>
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">
({col.columnName})
</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={cfg.filterColumn || ""}
onChange={(e) => update({ filterColumn: e.target.value })}
placeholder="예: status"
className="h-8 text-xs"
/>
)}
<p className="text-[9px] text-muted-foreground">
(
)
</p>
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,87 @@
"use client";
import { PopComponentRegistry } from "../../PopComponentRegistry";
import { PopStatusBarComponent } from "./PopStatusBarComponent";
import { PopStatusBarConfigPanel } from "./PopStatusBarConfig";
import type { StatusBarConfig } from "./types";
import { DEFAULT_STATUS_BAR_CONFIG } from "./types";
function PopStatusBarPreviewComponent({
config,
label,
}: {
config?: StatusBarConfig;
label?: string;
}) {
const cfg = config || DEFAULT_STATUS_BAR_CONFIG;
const options = cfg.options || [];
const displayLabel = label || "상태 바";
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
<span className="text-[10px] font-medium text-muted-foreground">
{displayLabel}
</span>
<div className="flex items-center gap-1">
{options.length === 0 ? (
<span className="text-[9px] text-muted-foreground">
</span>
) : (
options.slice(0, 4).map((opt) => (
<div
key={opt.value}
className="flex flex-col items-center rounded bg-muted/60 px-2 py-0.5"
>
<span className="text-[10px] font-bold leading-tight">0</span>
<span className="text-[8px] leading-tight text-muted-foreground">
{opt.label}
</span>
</div>
))
)}
</div>
</div>
);
}
PopComponentRegistry.registerComponent({
id: "pop-status-bar",
name: "상태 바",
description: "상태별 건수 대시보드 + 필터",
category: "display",
icon: "BarChart3",
component: PopStatusBarComponent,
configPanel: PopStatusBarConfigPanel,
preview: PopStatusBarPreviewComponent,
defaultProps: DEFAULT_STATUS_BAR_CONFIG,
connectionMeta: {
sendable: [
{
key: "filter_value",
label: "필터 값",
type: "filter_value",
category: "filter",
description: "선택한 상태 칩 값을 카드에 필터로 전달",
},
],
receivable: [
{
key: "all_rows",
label: "전체 데이터",
type: "all_rows",
category: "data",
description: "연결된 카드의 전체 데이터를 받아 상태별 건수 집계",
},
{
key: "set_value",
label: "값 설정",
type: "filter_value",
category: "filter",
description: "외부에서 선택 값 설정",
},
],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -0,0 +1,48 @@
// ===== pop-status-bar 전용 타입 =====
// 상태 칩 대시보드 컴포넌트. 카드 데이터를 집계하여 상태별 건수 표시 + 필터 발행.
/** 상태 칩 표시 스타일 */
export type StatusChipStyle = "tab" | "pill";
/** 개별 옵션 */
export interface StatusChipOption {
value: string;
label: string;
}
/** status-bar 전용 설정 */
export interface StatusBarConfig {
showCount?: boolean;
countColumn?: string;
allowAll?: boolean;
allLabel?: string;
chipStyle?: StatusChipStyle;
/** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */
useSubCount?: boolean;
/** 하위 필터(공정 선택 등)가 활성화되기 전까지 칩을 숨김 */
hideUntilSubFilter?: boolean;
/** 칩 숨김 상태일 때 표시할 안내 문구 */
hiddenMessage?: string;
options?: StatusChipOption[];
/** 필터 대상 컬럼명 (기본: countColumn) */
filterColumn?: string;
/** 추가 필터 대상 컬럼 (하위 테이블 등) */
filterColumns?: string[];
}
/** 기본 설정값 */
export const DEFAULT_STATUS_BAR_CONFIG: StatusBarConfig = {
showCount: true,
allowAll: true,
allLabel: "전체",
chipStyle: "tab",
options: [],
};
/** 칩 스타일 라벨 (설정 패널용) */
export const STATUS_CHIP_STYLE_LABELS: Record<StatusChipStyle, string> = {
tab: "탭 (큰 숫자)",
pill: "알약 (작은 뱃지)",
};

View File

@ -193,10 +193,9 @@ export function PopStringListComponent({
row: RowData,
filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } }
): boolean => {
const searchValue = String(filter.value).toLowerCase();
if (!searchValue) return true;
const fc = filter.filterConfig;
const mode = fc?.filterMode || "contains";
const columns: string[] =
fc?.targetColumns?.length
? fc.targetColumns
@ -208,17 +207,46 @@ export function PopStringListComponent({
if (columns.length === 0) return true;
const mode = fc?.filterMode || "contains";
// range 모드: { from, to } 객체 또는 단일 날짜 문자열 지원
if (mode === "range") {
const val = filter.value as { from?: string; to?: string } | string;
let from = "";
let to = "";
if (typeof val === "object" && val !== null) {
from = val.from || "";
to = val.to || "";
} else {
from = String(val || "");
to = from;
}
if (!from && !to) return true;
return columns.some((col) => {
const cellDate = String(row[col] ?? "").slice(0, 10);
if (!cellDate) return false;
if (from && cellDate < from) return false;
if (to && cellDate > to) return false;
return true;
});
}
// 문자열 기반 필터 (contains, equals, starts_with)
const searchValue = String(filter.value ?? "").toLowerCase();
if (!searchValue) return true;
// 날짜 패턴 감지 (YYYY-MM-DD): equals 비교 시 ISO 타임스탬프에서 날짜만 추출
const isDateValue = /^\d{4}-\d{2}-\d{2}$/.test(searchValue);
const matchCell = (cellValue: string) => {
const target = isDateValue && mode === "equals" ? cellValue.slice(0, 10) : cellValue;
switch (mode) {
case "equals":
return cellValue === searchValue;
return target === searchValue;
case "starts_with":
return cellValue.startsWith(searchValue);
return target.startsWith(searchValue);
case "contains":
default:
return cellValue.includes(searchValue);
return target.includes(searchValue);
}
};

View File

@ -722,3 +722,264 @@ export interface PopCardListConfig {
cartListMode?: CartListModeConfig;
saveMapping?: CardListSaveMapping;
}
// =============================================
// pop-card-list-v2 전용 타입 (슬롯 기반 카드)
// =============================================
import type { ButtonMainAction, ButtonVariant, ConfirmConfig } from "./pop-button";
export type CardCellType =
| "text"
| "field"
| "image"
| "badge"
| "button"
| "number-input"
| "cart-button"
| "package-summary"
| "status-badge"
| "timeline"
| "action-buttons"
| "footer-status";
// timeline 셀에서 사용하는 하위 단계 데이터
export interface TimelineProcessStep {
seqNo: number;
processName: string;
status: string; // DB 원본 값 (또는 derivedFrom에 의해 변환된 값)
semantic?: "pending" | "active" | "done"; // 시각적 의미 (렌더러 색상 결정)
isCurrent: boolean;
processId?: string | number; // 공정 테이블 레코드 PK (접수 등 UPDATE 대상 특정용)
rawData?: Record<string, unknown>; // 하위 테이블 원본 행 (하위 필터 매칭용)
}
// timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정
export interface TimelineDataSource {
processTable: string; // 하위 데이터 테이블명 (예: work_order_process)
foreignKey: string; // 메인 테이블 id와 매칭되는 FK 컬럼 (예: wo_id)
seqColumn: string; // 순서 컬럼 (예: seq_no)
nameColumn: string; // 표시명 컬럼 (예: process_name)
statusColumn: string; // 상태 컬럼 (예: status)
// 상태 값 매핑: DB값 → 시맨틱 (동적 배열, 순서대로 표시)
// 레거시 호환: 기존 { waiting, accepted, inProgress, completed } 객체도 런타임에서 자동 변환
statusMappings?: StatusValueMapping[];
}
export type TimelineStatusSemantic = "pending" | "active" | "done";
export interface StatusValueMapping {
dbValue: string; // DB에 저장된 실제 값 (또는 파생 상태의 식별값)
label: string; // 화면에 보이는 이름
semantic: TimelineStatusSemantic; // 타임라인 색상 결정 (pending=회색, active=파랑, done=초록)
isDerived?: boolean; // true면 DB에 없는 자동 판별 상태 (이전 공정 완료 시 변환)
}
export interface CardCellDefinitionV2 {
id: string;
row: number;
col: number;
rowSpan: number;
colSpan: number;
type: CardCellType;
// 공통
columnName?: string;
label?: string;
labelPosition?: "top" | "left";
fontSize?: "xs" | "sm" | "md" | "lg";
fontWeight?: "normal" | "medium" | "bold";
textColor?: string;
align?: "left" | "center" | "right";
verticalAlign?: "top" | "middle" | "bottom";
// field 타입 전용 (CardFieldBinding 흡수)
valueType?: "column" | "formula";
formulaLeft?: string;
formulaOperator?: "+" | "-" | "*" | "/";
formulaRight?: string;
formulaRightType?: "input" | "column";
unit?: string;
// image 타입 전용
defaultImage?: string;
// button 타입 전용
buttonAction?: ButtonMainAction;
buttonVariant?: ButtonVariant;
buttonConfirm?: ConfirmConfig;
// number-input 타입 전용
inputUnit?: string;
limitColumn?: string;
autoInitMax?: boolean;
// cart-button 타입 전용
cartLabel?: string;
cartCancelLabel?: string;
cartIconType?: "lucide" | "emoji";
cartIconValue?: string;
// status-badge 타입 전용
statusColumn?: string;
statusMap?: Array<{ value: string; label: string; color: string }>;
// timeline 타입 전용: 공정 데이터 소스 설정
timelineSource?: TimelineDataSource;
processColumn?: string;
processStatusColumn?: string;
currentHighlight?: boolean;
visibleCount?: number;
timelinePriority?: "before" | "after";
showDetailModal?: boolean;
// action-buttons 타입 전용 (신규: 버튼 중심 구조)
actionButtons?: ActionButtonDef[];
// action-buttons 타입 전용 (구: 조건 중심 구조, 하위호환)
actionRules?: Array<{
whenStatus: string;
buttons: Array<ActionButtonConfig>;
}>;
// footer-status 타입 전용
footerLabel?: string;
footerStatusColumn?: string;
footerStatusMap?: Array<{ value: string; label: string; color: string }>;
showTopBorder?: boolean;
}
export interface ActionButtonUpdate {
column: string;
value?: string;
valueType: "static" | "currentUser" | "currentTime" | "columnRef";
}
// 액션 버튼 클릭 시 동작 모드
export type ActionButtonClickMode = "status-change" | "modal-open" | "select-mode";
// 액션 버튼 개별 설정
export interface ActionButtonConfig {
label: string;
variant: ButtonVariant;
taskPreset: string;
confirm?: ConfirmConfig;
targetTable?: string;
confirmMessage?: string;
allowMultiSelect?: boolean;
updates?: ActionButtonUpdate[];
clickMode?: ActionButtonClickMode;
selectModeConfig?: SelectModeConfig;
}
// 선택 모드 설정
export interface SelectModeConfig {
filterStatus?: string;
buttons: Array<SelectModeButtonConfig>;
}
// 선택 모드 하단 버튼 설정
export interface SelectModeButtonConfig {
label: string;
variant: ButtonVariant;
clickMode: "status-change" | "modal-open" | "cancel-select";
targetTable?: string;
updates?: ActionButtonUpdate[];
confirmMessage?: string;
modalScreenId?: string;
}
// ===== 버튼 중심 구조 (신규) =====
export interface ActionButtonShowCondition {
type: "timeline-status" | "column-value" | "always";
value?: string;
column?: string;
unmatchBehavior?: "hidden" | "disabled";
}
export interface ActionButtonClickAction {
type: "immediate" | "select-mode" | "modal-open";
targetTable?: string;
updates?: ActionButtonUpdate[];
confirmMessage?: string;
selectModeButtons?: SelectModeButtonConfig[];
modalScreenId?: string;
// 외부 테이블 조인 설정 (DB 직접 선택 시)
joinConfig?: {
sourceColumn: string; // 메인 테이블의 FK 컬럼
targetColumn: string; // 외부 테이블의 매칭 컬럼
};
}
export interface ActionButtonDef {
label: string;
variant: ButtonVariant;
showCondition?: ActionButtonShowCondition;
/** 단일 액션 (하위호환) 또는 다중 액션 체이닝 */
clickAction: ActionButtonClickAction;
clickActions?: ActionButtonClickAction[];
}
export interface CardGridConfigV2 {
rows: number;
cols: number;
colWidths: string[];
rowHeights?: string[];
gap: number;
showCellBorder: boolean;
cells: CardCellDefinitionV2[];
}
// ----- V2 카드 선택 동작 -----
export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open";
export interface V2CardClickModalConfig {
screenId: string;
modalTitle?: string;
condition?: {
type: "timeline-status" | "column-value" | "always";
value?: string;
column?: string;
};
}
// ----- V2 오버플로우 설정 -----
export interface V2OverflowConfig {
mode: "loadMore" | "pagination";
visibleCount: number;
loadMoreCount?: number;
pageSize?: number;
}
// ----- pop-card-list-v2 전체 설정 -----
export interface PopCardListV2Config {
dataSource: CardListDataSource;
cardGrid: CardGridConfigV2;
selectedColumns?: string[];
gridColumns?: number;
gridRows?: number;
scrollDirection?: CardScrollDirection;
/** @deprecated 열 수(gridColumns)로 카드 크기 결정. 하위 호환용 */
cardSize?: CardSize;
cardGap?: number;
overflow?: V2OverflowConfig;
cardClickAction?: V2CardClickAction;
cardClickModalConfig?: V2CardClickModalConfig;
/** 연결된 필터 값이 전달되기 전까지 데이터 비표시 */
hideUntilFiltered?: boolean;
responsiveDisplay?: CardResponsiveConfig;
inputField?: CardInputFieldConfig;
packageConfig?: CardPackageConfig;
cartAction?: CardCartActionConfig;
cartListMode?: CartListModeConfig;
saveMapping?: CardListSaveMapping;
}
/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */
export const VIRTUAL_SUB_STATUS = "__subStatus__" as const;
export const VIRTUAL_SUB_SEMANTIC = "__subSemantic__" as const;
export const VIRTUAL_SUB_PROCESS = "__subProcessName__" as const;
export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const;

View File

@ -78,13 +78,30 @@ export function loadTabCache(tabId: string): TabCacheData | null {
}
/**
*
* .
* tab-cache-{tabId} (tableState_, pageSize_, filterSettings_, groupSettings_) .
*/
export function clearTabCache(tabId: string): void {
if (typeof window === "undefined") return;
try {
sessionStorage.removeItem(CACHE_PREFIX + tabId);
const suffix = `_${tabId}_`;
const keysToRemove: string[] = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (
key &&
(key.startsWith("tableState" + suffix) ||
key.startsWith("pageSize" + suffix) ||
key.startsWith("filterSettings" + suffix) ||
key.startsWith("groupSettings" + suffix))
) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((k) => sessionStorage.removeItem(k));
} catch {
// ignore
}

View File

@ -689,11 +689,10 @@ export class ButtonActionExecutor {
return false;
}
// 🆕 렉 구조 등록 화면 감지 (warehouse_location 테이블 + floor/zone 필드 있음 + 렉 구조 데이터 없음)
// 이 경우 일반 저장을 차단하고 미리보기 생성을 요구
// 렉 구조 등록 화면 감지 (warehouse_location 테이블 + zone 필드 있음 + 렉 구조 데이터 없음)
// floor는 선택 입력이므로 감지 조건에서 제외
const isRackStructureScreen =
context.tableName === "warehouse_location" &&
context.formData?.floor &&
context.formData?.zone &&
!rackStructureLocations;
@ -2085,15 +2084,18 @@ export class ButtonActionExecutor {
const floor = firstLocation.floor;
const zone = firstLocation.zone;
if (warehouseCode && floor && zone) {
if (warehouseCode && zone) {
try {
// search 파라미터를 사용하여 백엔드에서 필터링 (filters는 백엔드에서 처리 안됨)
const searchParams: Record<string, any> = {
warehouse_code: { value: warehouseCode, operator: "equals" },
zone: { value: zone, operator: "equals" },
};
if (floor) {
searchParams.floor = { value: floor, operator: "equals" };
}
const existingResponse = await DynamicFormApi.getTableData(tableName, {
search: {
warehouse_code: { value: warehouseCode, operator: "equals" },
floor: { value: floor, operator: "equals" },
zone: { value: zone, operator: "equals" },
},
search: searchParams,
page: 1,
pageSize: 1000,
});

View File

@ -265,6 +265,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -306,6 +307,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -339,6 +341,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@ -3054,6 +3057,7 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0",
@ -3707,6 +3711,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.6"
},
@ -3774,6 +3779,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@ -4087,6 +4093,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@ -6587,6 +6594,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -6597,6 +6605,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -6639,6 +6648,7 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@ -6721,6 +6731,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@ -7353,6 +7364,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -8503,7 +8515,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/d3": {
"version": "7.9.0",
@ -8825,6 +8838,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -9584,6 +9598,7 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -9672,6 +9687,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@ -9773,6 +9789,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -10944,6 +10961,7 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@ -11724,7 +11742,8 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/levn": {
"version": "0.4.1",
@ -13063,6 +13082,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -13356,6 +13376,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@ -13385,6 +13406,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@ -13433,6 +13455,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@ -13636,6 +13659,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -13705,6 +13729,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -13755,6 +13780,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -13787,7 +13813,8 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-leaflet": {
"version": "5.0.0",
@ -14095,6 +14122,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -14117,7 +14145,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/recharts/node_modules/redux-thunk": {
"version": "3.1.0",
@ -15147,7 +15176,8 @@
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@ -15235,6 +15265,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -15583,6 +15614,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -149,6 +149,7 @@ export const useTabStore = create<TabState>()(
},
refreshTab: (tabId) => {
clearTabCache(tabId);
set((state) => ({
refreshKeys: { ...state.refreshKeys, [tabId]: (state.refreshKeys[tabId] || 0) + 1 },
}));

View File

@ -14,6 +14,7 @@ export interface LoginResponse {
token?: string;
userInfo?: any;
firstMenuPath?: string | null;
popLandingPath?: string | null;
};
errorCode?: string;
}