Compare commits

..

18 Commits

Author SHA1 Message Date
kjs ec3cb8155f Merge branch 'ycshin-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-16 14:51:51 +09:00
kjs 1a319d1785 feat: enhance V2TimelineSchedulerConfigPanel with filter and view mode options
- Added new filter and linking settings section to the V2TimelineSchedulerConfigPanel, allowing users to manage static filters and linked filters more effectively.
- Introduced view mode options to switch between different display modes in the timeline scheduler.
- Updated the configuration types and added new toolbar action settings to support custom actions in the timeline toolbar.
- Enhanced the overall user experience by providing more flexible filtering and display options.

These updates aim to improve the functionality and usability of the timeline scheduler within the ERP system, enabling better data management and visualization.

Made-with: Cursor
2026-03-16 14:51:34 +09:00
syc0123 7e02fff717 fix: update default button size in V2ButtonPrimary component
- Changed the default width of the V2ButtonPrimary component from 140 to 100 pixels to improve UI consistency and responsiveness.
- This adjustment aligns the button size with design specifications for better user experience.
2026-03-16 11:28:03 +09:00
syc0123 a5890bbd67 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-03-16 10:45:00 +09:00
syc0123 3225a7bb21 feat: add bulk update script for COMPANY_7 button styles
- Introduced a new script `btn-bulk-update-company7.ts` to facilitate bulk updates of button styles for COMPANY_7.
- The script includes functionalities for testing, running updates, creating backups, and restoring from backups.
- Implemented logic to dynamically apply button styles based on action types, ensuring consistent UI across the application.
- Updated documentation to reflect changes in button icon mapping and dynamic loading of icons.

This addition enhances the maintainability and consistency of button styles for COMPANY_7, streamlining the update process.
2026-03-16 10:38:12 +09:00
SeongHyun Kim 224338d75f Merge branch 'ksh-v2-work' into main
POP 디자이너 GRID-V6 + BLOCK DETAIL Phase 2~3 + 낙관적 잠금 기능을 main에 통합한다.
주요 병합 내용:
- GRID-V6 정사각형 블록 그리드 시스템 (842ac27d)
- POP 그리드 명칭 통일 + Dead Code 제거 (320100c4)
- BLOCK DETAIL Phase 2: 생산 공정 백엔드 API (create-work-processes, timer)
- BLOCK DETAIL Phase 3: pop-work-detail 컴포넌트 + 모달 캔버스 시스템
- 낙관적 잠금 + 소유자 기반 액션 제어 + 디자이너 설정 UI
- LOCK-OWNER 카드 비활성화 UI 보완
충돌 해결: 0건 (자동 병합 완료)
2026-03-16 10:37:31 +09:00
SeongHyun Kim 138f309c09 Merge branch 'ksh-partial-quantity-flow' into ksh-v2-work
BLOCK DETAIL Phase 2~3 + 낙관적 잠금 기능을 ksh-v2-work에 통합한다.
주요 병합 내용:
- BLOCK DETAIL Phase 2: 생산 공정 관리 백엔드 API (create-work-processes, timer)
- BLOCK DETAIL Phase 3: pop-work-detail 컴포넌트 + 모달 캔버스 시스템
- 낙관적 잠금 + 소유자 기반 액션 제어 + 디자이너 설정 UI
- LOCK-OWNER 카드 비활성화 UI 누락분 반영
충돌 해결: 0건 (자동 병합 완료)
2026-03-16 10:34:04 +09:00
SeongHyun Kim 3bd0eff82e feat: BLOCK DETAIL Phase 3 - pop-work-detail 컴포넌트 + 모달 캔버스 시스템
세부진행화면(4502)의 프론트엔드 구현: pop-work-detail 컴포넌트 신규 생성과
디자이너 모달 캔버스 편집을 통해, 카드 클릭 시 공정별 체크리스트/검사/실적 상세
작업 화면을 내부 모달로 표시할 수 있게 한다.
[신규] pop-work-detail 컴포넌트 (4파일)
- PopWorkDetailComponent: parentRow → 현재 공정 추출 → process_work_result 조회,
  좌측 사이드바(PRE/IN/POST 3단계 작업항목 그룹) + 우측 체크리스트(5종: check/inspect/
  input/procedure/material) + 타이머 제어(start/pause/resume) + 수량 등록 + 공정 완료
- PopWorkDetailConfig: showTimer/showQuantityInput/phaseLabels 설정 패널
- PopWorkDetailPreview: 디자이너 프리뷰
- index.tsx: PopComponentRegistry 등록 (category: display, touchOptimized)
[모달 캔버스 시스템] PopDesigner.tsx 대규모 리팩토링
- handleMoveComponent/handleResizeComponent/handleRequestResize:
  layout 직접 참조 → setLayout(prev => ...) 함수형 업데이트로 전환
  + activeCanvasId 분기: main이면 기존 로직, modal-*이면 modals 배열 내 해당 모달 수정
- PopCardListV2Config: 모달 캔버스 생성/열기 버튼 (usePopDesignerContext 연동)
- PopCardListV2Component: modal-* screenId → setSharedData + __pop_modal_open__ 이벤트
- PopViewerWithModals: parentRow prop + fullscreen 모달 지원 + flex 레이아웃
[기타]
- ComponentPalette: pop-work-detail 팔레트 항목 + ClipboardCheck 아이콘
- pop-layout.ts: PopComponentType에 pop-work-detail 추가, 기본 크기 38x26
- PopRenderer: COMPONENT_TYPE_LABELS에 pop-work-detail 추가
- types.ts: PopWorkDetailConfig 인터페이스
- PopCanvas.tsx: activeLayout.components 참조 수정 (모달 캔버스 호환)
DB 변경 0건. 백엔드 변경 0건.
2026-03-16 10:32:58 +09:00
kjs 2d7a30a6f5 Merge pull request 'jskim-node' (#417) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/417
2026-03-16 09:35:46 +09:00
SeongHyun Kim ed0f3393f6 Merge branch 'ksh-v2-work' into ksh-partial-quantity-flow
ksh-v2-work의 최신 변경사항을 동기화한다.
주요 병합 내용:
- GRID-V6 정사각형 블록 그리드 시스템 (842ac27d)
- POP 그리드 시스템 명칭 통일 + Dead Code 제거 (320100c4)
- 다수 PC 화면 config-panel 리팩토링 (jskim/mhkim/gbpark)
- V2 컴포넌트 config-panel 신규 18종
- 감사 로그 기능 강화
충돌 해결: 0건 (자동 병합 완료)
2026-03-13 16:58:58 +09:00
SeongHyun Kim 320100c4e2 refactor: POP 그리드 시스템 명칭 통일 + Dead Code 제거
V5→V6 전환 과정에서 누적된 버전 접미사, 미사용 함수, 레거시 잔재를
정리하여 코드 관리성을 확보한다. 14개 파일 수정, 365줄 순감.
[타입 리네이밍] (14개 파일)
- PopLayoutDataV5 → PopLayoutData
- PopComponentDefinitionV5 → PopComponentDefinition
- PopGlobalSettingsV5 → PopGlobalSettings
- PopModeOverrideV5 → PopModeOverride
- createEmptyPopLayoutV5 → createEmptyLayout
- isV5Layout → isPopLayout
- addComponentToV5Layout → addComponentToLayout
- createComponentDefinitionV5 → createComponentDefinition
- 구 이름은 deprecated 별칭으로 유지 (하위 호환)
[Dead Code 삭제] (gridUtils.ts -350줄)
- getAdjustedBreakpoint, convertPositionToMode, isOutOfBounds,
  mouseToGridPosition, gridToPixelPosition, isValidPosition,
  clampPosition, autoLayoutComponents (전부 외부 사용 0건)
- needsReview + ReviewPanel/ReviewItem (항상 false, 미사용)
- getEffectiveComponentPosition export → 내부 함수로 전환
[레거시 로더 분리] (신규 legacyLoader.ts)
- convertV5LayoutToV6 → loadLegacyLayout (legacyLoader.ts)
- V5 변환 상수/함수를 gridUtils에서 분리
[주석 정리]
- "v5 그리드" → "POP 블록 그리드"
- "하위 호환용" → "뷰포트 프리셋" / "레이아웃 설정용"
- 파일 헤더, 섹션 구분, 함수 JSDoc 정리
기능 변경 0건. DB 변경 0건. 백엔드 변경 0건.
2026-03-13 16:39:51 +09:00
SeongHyun Kim 842ac27d60 feat: V6 정사각형 블록 그리드 시스템 실험 구현
고정 칸 수(4/6/8/12) 기반의 V5 그리드를 24px 정사각형 블록 기반의
동적 칸 수 시스템으로 교체한다. 뷰포트 너비에 따라 블록 수가 자동
계산되며(375px=13칸, 1024px=38칸), 작은 화면에서는 행 그룹 리플로우
(CSS Flexbox wrap 원리)로 자동 재배치된다.
[그리드 코어]
- pop-layout.ts: BLOCK_SIZE=24, BLOCK_GAP=2, BLOCK_PADDING=8,
  getBlockColumns() 동적 칸 수 계산, GRID_BREAKPOINTS V6 값
- gridUtils.ts: 행 그룹 리플로우(방식 F) - 같은 행 묶음 처리,
  최소 2x2칸 터치 보장, 메인 컨텐츠 전체 너비 확장
- PopRenderer.tsx: repeat(N, 1fr) 블록 렌더링, 동적 칸 수
- PopCanvas.tsx: 뷰포트 프리셋 동적 칸 수, 블록 좌표 변환
[V5→V6 런타임 변환]
- convertV5LayoutToV6: DB 미수정, 로드 시 메모리 변환
  12칸 좌표 → 38칸 블록 변환, V5 overrides 제거
- PopDesigner/page.tsx: 로드 지점에 변환 함수 삽입
[충돌 해결]
- ComponentEditorPanel: 높이 표시/모드 라벨 V6 수치
- PopCardListConfig: 카드 추천 threshold V6 기준
- PopDesigner: handleHideComponent 기본 모드 제한 해제
[기본 사이즈]
- 소형(2x2): 아이콘, 프로필, 스캐너
- 중형(8x4): 검색, 버튼, 텍스트
- 대형(19x6~10): 카드, 대시보드, 필드
DB 변경 0건, 백엔드 변경 0건, 컴포넌트 코드 변경 0건.
2026-03-13 16:03:24 +09:00
SeongHyun Kim c4d7b16538 fix: LOCK-OWNER 카드 비활성화 UI 누락분 반영
a2c532c7 커밋에서 누락된 CardV2 잠금 UI를 반영한다.
- locked 계산: ownerSortColumn 값이 존재하고 현재 사용자와 불일치 시 true
- isLockedByOther prop을 CardV2에 전달
- 잠금 카드: opacity-50, cursor-not-allowed, onClick/onKeyDown 차단, tabIndex=-1
2026-03-13 14:23:26 +09:00
SeongHyun Kim c067c37390 feat: BLOCK DETAIL Phase 2 - 생산 공정 관리 백엔드 API 파이프라인
작업지시 생성 시 공정+체크리스트 일괄 생성과 공정별 타이머 제어를
위한 백엔드 API 파이프라인을 구축한다.
[신규] popProductionController.ts
- createWorkProcesses: POST /api/pop/production/create-work-processes
  - item_routing_detail + process_mng JOIN으로 공정 목록 조회
  - work_order_process INSERT (공정별)
  - process_work_result INSERT SELECT (마스터 스냅샷 복사)
  - 중복 호출 방지 (409 Conflict)
  - 1 트랜잭션 처리
- controlTimer: POST /api/pop/production/timer
  - start: started_at 설정 + status waiting->in_progress (멱등)
  - pause: paused_at 설정
  - resume: total_paused_time 누적 + paused_at 초기화
[신규] popProductionRoutes.ts
- authenticateToken 미들웨어 전역 적용
- 2개 POST 엔드포인트 등록
[수정] app.ts
- popProductionRoutes import + /api/pop/production 라우트 등록
2026-03-13 14:19:54 +09:00
SeongHyun Kim a2c532c7c7 feat: 낙관적 잠금 + 소유자 기반 액션 제어 + 디자이너 설정 UI
동시 접수 충돌 방지(preCondition WHERE + 409 에러), 소유자 일치 시에만
버튼 활성화(owner-match showCondition), 본인 카드 우선 정렬(ownerSortColumn)을
구현하고 디자이너에서 설정할 수 있는 UI 3종을 추가한다.
[백엔드]
- popActionRoutes: TaskBody에 preCondition 추가, data-update WHERE 조건 삽입,
  rowCount=0 시 409 Conflict 반환 (isPreConditionFail)
[프론트엔드 - 런타임]
- types.ts: ActionPreCondition 인터페이스, owner-match 타입, ownerSortColumn 필드
- cell-renderers: evaluateShowCondition에 owner-match 분기 + currentUserId prop
- PopCardListV2Component: useAuth 연동, preCondition 전달/409 처리,
  ownerSortColumn 기반 카드 정렬, currentUserId 하위 전달
[프론트엔드 - 디자이너 설정 UI]
- PopCardListV2Config: showCondition 드롭다운에 "소유자 일치" 옵션 + 컬럼 선택,
  ImmediateActionEditor에 "사전 조건(중복 방지)" 토글 + 검증 컬럼/기대값/실패 메시지,
  TabActions에 "소유자 우선 정렬" 컬럼 드롭다운
2026-03-12 18:26:47 +09:00
syc0123 1a11b08487 feat: implement real-time numbering preview with manual input handling
- Enhanced the `previewCode` endpoint to accept a new `manualInputValue` parameter, allowing for dynamic sequence generation based on user input.
- Updated the `NumberingRuleService` to skip legacy sequence lookups when manual input is not provided, ensuring accurate initial sequence display.
- Integrated debounce functionality in the `V2Input` component to optimize API calls for real-time suffix updates as users type.
- Refactored category resolution logic into a helper function to reduce code duplication and improve maintainability.

These changes significantly improve the user experience by providing immediate feedback on numbering sequences based on manual inputs.
2026-03-12 16:07:13 +09:00
syc0123 93c6c45ce8 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node 2026-03-12 11:34:00 +09:00
syc0123 8b7e31031d refactor: Improve numbering rule service for manual prefix handling and sequence allocation
- Modified the `buildPrefixKey` function to include an optional `manualValues` parameter, allowing manual input values to be incorporated into the prefix key.
- Adjusted the sequence allocation process in `allocateCode` to extract manual values before building the prefix key, ensuring accurate prefix generation.
- Removed the fallback to the "BULK1" value in manual configurations, preventing unintended overwrites and ensuring user input is prioritized.
- Enhanced the `joinPartsWithSeparators` function to prevent consecutive separators when handling empty parts, improving the output format.
- Added a new migration script to clean up existing "BULK1" values from the database, ensuring data integrity.

These changes address several issues related to manual input handling and improve the overall functionality of the numbering rule service.
2026-03-12 10:12:56 +09:00
49 changed files with 5149 additions and 1538 deletions

View File

@ -0,0 +1,318 @@
/**
* (company_7)
*
* :
* npx ts-node scripts/btn-bulk-update-company7.ts --test # 1 (ROLLBACK)
* npx ts-node scripts/btn-bulk-update-company7.ts --run # (COMMIT)
* npx ts-node scripts/btn-bulk-update-company7.ts --backup #
* npx ts-node scripts/btn-bulk-update-company7.ts --restore #
*/
import { Pool } from "pg";
// ── 배포 DB 연결 ──
const pool = new Pool({
connectionString:
"postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor",
});
const COMPANY_CODE = "COMPANY_7";
const BACKUP_TABLE = "screen_layouts_v2_backup_20260313";
// ── 액션별 기본 아이콘 매핑 (frontend/lib/button-icon-map.tsx 기준) ──
const actionIconMap: Record<string, string> = {
save: "Check",
delete: "Trash2",
edit: "Pencil",
navigate: "ArrowRight",
modal: "Maximize2",
transferData: "SendHorizontal",
excel_download: "Download",
excel_upload: "Upload",
quickInsert: "Zap",
control: "Settings",
barcode_scan: "ScanLine",
operation_control: "Truck",
event: "Send",
copy: "Copy",
};
const FALLBACK_ICON = "SquareMousePointer";
function getIconForAction(actionType?: string): string {
if (actionType && actionIconMap[actionType]) {
return actionIconMap[actionType];
}
return FALLBACK_ICON;
}
// ── 버튼 컴포넌트인지 판별 (최상위 + 탭 내부 둘 다 지원) ──
function isTopLevelButton(comp: any): boolean {
return (
comp.url?.includes("v2-button-primary") ||
comp.overrides?.type === "v2-button-primary"
);
}
function isTabChildButton(comp: any): boolean {
return comp.componentType === "v2-button-primary";
}
function isButtonComponent(comp: any): boolean {
return isTopLevelButton(comp) || isTabChildButton(comp);
}
// ── 탭 위젯인지 판별 ──
function isTabsWidget(comp: any): boolean {
return (
comp.url?.includes("v2-tabs-widget") ||
comp.overrides?.type === "v2-tabs-widget"
);
}
// ── 버튼 스타일 변경 (최상위 버튼용: overrides 사용) ──
function applyButtonStyle(config: any, actionType: string | undefined) {
const iconName = getIconForAction(actionType);
config.displayMode = "icon-text";
config.icon = {
name: iconName,
type: "lucide",
size: "보통",
...(config.icon?.color ? { color: config.icon.color } : {}),
};
config.iconTextPosition = "right";
config.iconGap = 6;
if (!config.style) config.style = {};
delete config.style.width; // 레거시 하드코딩 너비 제거 (size.width만 사용)
config.style.borderRadius = "8px";
config.style.labelColor = "#FFFFFF";
config.style.fontSize = "12px";
config.style.fontWeight = "normal";
config.style.labelTextAlign = "left";
if (actionType === "delete") {
config.style.backgroundColor = "#F04544";
} else if (actionType === "excel_upload" || actionType === "excel_download") {
config.style.backgroundColor = "#212121";
} else {
config.style.backgroundColor = "#3B83F6";
}
}
function updateButtonStyle(comp: any): boolean {
if (isTopLevelButton(comp)) {
const overrides = comp.overrides || {};
const actionType = overrides.action?.type;
if (!comp.size) comp.size = {};
comp.size.height = 40;
applyButtonStyle(overrides, actionType);
comp.overrides = overrides;
return true;
}
if (isTabChildButton(comp)) {
const config = comp.componentConfig || {};
const actionType = config.action?.type;
if (!comp.size) comp.size = {};
comp.size.height = 40;
applyButtonStyle(config, actionType);
comp.componentConfig = config;
// 탭 내부 버튼은 렌더러가 comp.style (최상위)에서 스타일을 읽음
if (!comp.style) comp.style = {};
comp.style.borderRadius = "8px";
comp.style.labelColor = "#FFFFFF";
comp.style.fontSize = "12px";
comp.style.fontWeight = "normal";
comp.style.labelTextAlign = "left";
comp.style.backgroundColor = config.style.backgroundColor;
return true;
}
return false;
}
// ── 백업 테이블 생성 ──
async function createBackup() {
console.log(`\n=== 백업 테이블 생성: ${BACKUP_TABLE} ===`);
const exists = await pool.query(
`SELECT to_regclass($1) AS tbl`,
[BACKUP_TABLE],
);
if (exists.rows[0].tbl) {
console.log(`백업 테이블이 이미 존재합니다: ${BACKUP_TABLE}`);
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
console.log(`기존 백업 레코드 수: ${count.rows[0].count}`);
return;
}
await pool.query(
`CREATE TABLE ${BACKUP_TABLE} AS
SELECT * FROM screen_layouts_v2
WHERE company_code = $1`,
[COMPANY_CODE],
);
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
console.log(`백업 완료. 레코드 수: ${count.rows[0].count}`);
}
// ── 백업에서 원복 ──
async function restoreFromBackup() {
console.log(`\n=== 백업에서 원복: ${BACKUP_TABLE} ===`);
const result = await pool.query(
`UPDATE screen_layouts_v2 AS target
SET layout_data = backup.layout_data,
updated_at = backup.updated_at
FROM ${BACKUP_TABLE} AS backup
WHERE target.screen_id = backup.screen_id
AND target.company_code = backup.company_code
AND target.layer_id = backup.layer_id`,
);
console.log(`원복 완료. 변경된 레코드 수: ${result.rowCount}`);
}
// ── 메인: 버튼 일괄 변경 ──
async function updateButtons(testMode: boolean) {
const modeLabel = testMode ? "테스트 (1건, ROLLBACK)" : "전체 실행 (COMMIT)";
console.log(`\n=== 버튼 일괄 변경 시작 [${modeLabel}] ===`);
// company_7 레코드 조회
const rows = await pool.query(
`SELECT screen_id, layer_id, company_code, layout_data
FROM screen_layouts_v2
WHERE company_code = $1
ORDER BY screen_id, layer_id`,
[COMPANY_CODE],
);
console.log(`대상 레코드 수: ${rows.rowCount}`);
if (!rows.rowCount) {
console.log("변경할 레코드가 없습니다.");
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
let totalUpdated = 0;
let totalButtons = 0;
const targetRows = testMode ? [rows.rows[0]] : rows.rows;
for (const row of targetRows) {
const layoutData = row.layout_data;
if (!layoutData?.components || !Array.isArray(layoutData.components)) {
continue;
}
let buttonsInRow = 0;
for (const comp of layoutData.components) {
// 최상위 버튼 처리
if (updateButtonStyle(comp)) {
buttonsInRow++;
}
// 탭 위젯 내부 버튼 처리
if (isTabsWidget(comp)) {
const tabs = comp.overrides?.tabs || [];
for (const tab of tabs) {
const tabComps = tab.components || [];
for (const tabComp of tabComps) {
if (updateButtonStyle(tabComp)) {
buttonsInRow++;
}
}
}
}
}
if (buttonsInRow > 0) {
await client.query(
`UPDATE screen_layouts_v2
SET layout_data = $1, updated_at = NOW()
WHERE screen_id = $2 AND company_code = $3 AND layer_id = $4`,
[JSON.stringify(layoutData), row.screen_id, row.company_code, row.layer_id],
);
totalUpdated++;
totalButtons += buttonsInRow;
console.log(
` screen_id=${row.screen_id}, layer_id=${row.layer_id} → 버튼 ${buttonsInRow}개 변경`,
);
// 테스트 모드: 변경 전후 비교를 위해 첫 번째 버튼 출력
if (testMode) {
const sampleBtn = layoutData.components.find(isButtonComponent);
if (sampleBtn) {
console.log("\n--- 변경 후 샘플 버튼 ---");
console.log(JSON.stringify(sampleBtn, null, 2));
}
}
}
}
console.log(`\n--- 결과 ---`);
console.log(`변경된 레코드: ${totalUpdated}`);
console.log(`변경된 버튼: ${totalButtons}`);
if (testMode) {
await client.query("ROLLBACK");
console.log("\n[테스트 모드] ROLLBACK 완료. 실제 DB 변경 없음.");
} else {
await client.query("COMMIT");
console.log("\nCOMMIT 완료.");
}
} catch (err) {
await client.query("ROLLBACK");
console.error("\n에러 발생. ROLLBACK 완료.", err);
throw err;
} finally {
client.release();
}
}
// ── CLI 진입점 ──
async function main() {
const arg = process.argv[2];
if (!arg || !["--test", "--run", "--backup", "--restore"].includes(arg)) {
console.log("사용법:");
console.log(" --test : 1건 테스트 (ROLLBACK, DB 변경 없음)");
console.log(" --run : 전체 실행 (COMMIT)");
console.log(" --backup : 백업 테이블 생성");
console.log(" --restore : 백업에서 원복");
process.exit(1);
}
try {
if (arg === "--backup") {
await createBackup();
} else if (arg === "--restore") {
await restoreFromBackup();
} else if (arg === "--test") {
await createBackup();
await updateButtons(true);
} else if (arg === "--run") {
await createBackup();
await updateButtons(false);
}
} catch (err) {
console.error("스크립트 실행 실패:", err);
process.exit(1);
} finally {
await pool.end();
}
}
main();

View File

@ -125,6 +125,7 @@ import entitySearchRoutes, {
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머)
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
@ -260,6 +261,7 @@ app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/pop", popActionRoutes); // POP 액션 실행
app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리
app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes);

View File

@ -314,13 +314,14 @@ router.post(
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
const { formData, manualInputValue } = req.body;
try {
const previewCode = await numberingRuleService.previewCode(
ruleId,
companyCode,
formData
formData,
manualInputValue
);
return res.json({ success: true, data: { generatedCode: previewCode } });
} catch (error: any) {

View File

@ -0,0 +1,291 @@
import { Response } from "express";
import { getPool } from "../database/db";
import logger from "../utils/logger";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
/**
* D-BE1: 작업지시
* PC에서 . 1 work_order_process + process_work_result .
*/
export const createWorkProcesses = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_instruction_id, item_code, routing_version_id, plan_qty } =
req.body;
if (!work_instruction_id || !routing_version_id) {
return res.status(400).json({
success: false,
message:
"work_instruction_id와 routing_version_id는 필수입니다.",
});
}
logger.info("[pop/production] create-work-processes 요청", {
companyCode,
userId,
work_instruction_id,
item_code,
routing_version_id,
plan_qty,
});
await client.query("BEGIN");
// 중복 호출 방지: 이미 생성된 공정이 있는지 확인
const existCheck = await client.query(
`SELECT COUNT(*) as cnt FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`,
[work_instruction_id, companyCode]
);
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
await client.query("ROLLBACK");
return res.status(409).json({
success: false,
message: "이미 공정이 생성된 작업지시입니다.",
});
}
// 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명)
const routingDetails = await client.query(
`SELECT rd.id, rd.seq_no, rd.process_code,
COALESCE(pm.process_name, rd.process_code) as process_name,
rd.is_required, rd.is_fixed_order, rd.standard_time
FROM item_routing_detail rd
LEFT JOIN process_mng pm ON pm.process_code = rd.process_code
AND pm.company_code = rd.company_code
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
ORDER BY CAST(rd.seq_no AS int) NULLS LAST`,
[routing_version_id, companyCode]
);
if (routingDetails.rows.length === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "라우팅 버전에 등록된 공정이 없습니다.",
});
}
const processes: Array<{
id: string;
seq_no: string;
process_name: string;
checklist_count: number;
}> = [];
let totalChecklists = 0;
for (const rd of routingDetails.rows) {
// 2. work_order_process INSERT
const wopResult = await client.query(
`INSERT INTO work_order_process (
company_code, wo_id, seq_no, process_code, process_name,
is_required, is_fixed_order, standard_time, plan_qty,
status, routing_detail_id, writer
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`,
[
companyCode,
work_instruction_id,
rd.seq_no,
rd.process_code,
rd.process_name,
rd.is_required,
rd.is_fixed_order,
rd.standard_time,
plan_qty || null,
"waiting",
rd.id,
userId,
]
);
const wopId = wopResult.rows[0].id;
// 3. process_work_result INSERT (스냅샷 복사)
// process_work_item + process_work_item_detail에서 해당 routing_detail의 항목 조회 후 복사
const snapshotResult = await client.query(
`INSERT INTO process_work_result (
company_code, work_order_process_id,
source_work_item_id, source_detail_id,
work_phase, item_title, item_sort_order,
detail_content, detail_type, detail_sort_order, is_required,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
input_type, lookup_target, display_fields, duration_minutes,
status, writer
)
SELECT
pwi.company_code, $1,
pwi.id, pwd.id,
pwi.work_phase, pwi.title, pwi.sort_order::text,
pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required,
pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit,
pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text,
'pending', $2
FROM process_work_item pwi
JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id
AND pwd.company_code = pwi.company_code
WHERE pwi.routing_detail_id = $3
AND pwi.company_code = $4
ORDER BY pwi.sort_order, pwd.sort_order`,
[wopId, userId, rd.id, companyCode]
);
const checklistCount = snapshotResult.rowCount ?? 0;
totalChecklists += checklistCount;
processes.push({
id: wopId,
seq_no: rd.seq_no,
process_name: rd.process_name,
checklist_count: checklistCount,
});
logger.info("[pop/production] 공정 생성 완료", {
wopId,
processName: rd.process_name,
checklistCount,
});
}
await client.query("COMMIT");
logger.info("[pop/production] create-work-processes 완료", {
companyCode,
work_instruction_id,
total_processes: processes.length,
total_checklists: totalChecklists,
});
return res.json({
success: true,
data: {
processes,
total_processes: processes.length,
total_checklists: totalChecklists,
},
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("[pop/production] create-work-processes 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "공정 생성 중 오류가 발생했습니다.",
});
} finally {
client.release();
}
};
/**
* D-BE2: 타이머 API (//)
*/
export const controlTimer = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id, action } = req.body;
if (!work_order_process_id || !action) {
return res.status(400).json({
success: false,
message: "work_order_process_id와 action은 필수입니다.",
});
}
if (!["start", "pause", "resume"].includes(action)) {
return res.status(400).json({
success: false,
message: "action은 start, pause, resume 중 하나여야 합니다.",
});
}
logger.info("[pop/production] timer 요청", {
companyCode,
userId,
work_order_process_id,
action,
});
let result;
switch (action) {
case "start":
// 최초 1회만 설정, 이미 있으면 무시
result = await pool.query(
`UPDATE work_order_process
SET started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END,
status = CASE WHEN status = 'waiting' THEN 'in_progress' ELSE status END,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
RETURNING id, started_at, status`,
[work_order_process_id, companyCode]
);
break;
case "pause":
result = await pool.query(
`UPDATE work_order_process
SET paused_at = NOW()::text,
updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND paused_at IS NULL
RETURNING id, paused_at`,
[work_order_process_id, companyCode]
);
break;
case "resume":
// 일시정지 시간 누적 후 paused_at 초기화
result = await pool.query(
`UPDATE work_order_process
SET total_paused_time = (
COALESCE(total_paused_time::int, 0)
+ EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int
)::text,
paused_at = NULL,
updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND paused_at IS NOT NULL
RETURNING id, total_paused_time`,
[work_order_process_id, companyCode]
);
break;
}
if (!result || result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "대상 공정을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.",
});
}
logger.info("[pop/production] timer 완료", {
action,
work_order_process_id,
result: result.rows[0],
});
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("[pop/production] timer 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "타이머 처리 중 오류가 발생했습니다.",
});
}
};

View File

@ -104,6 +104,11 @@ interface TaskBody {
manualItemField?: string;
manualPkColumn?: string;
cartScreenId?: string;
preCondition?: {
column: string;
expectedValue: string;
failMessage?: string;
};
}
function resolveStatusValue(
@ -334,14 +339,30 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
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${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[resolved, companyCode, lookupValues[i]],
let condWhere = `WHERE company_code = $2 AND "${pkColumn}" = $3`;
const condParams: unknown[] = [resolved, companyCode, lookupValues[i]];
if (task.preCondition?.column && task.preCondition?.expectedValue) {
if (!isSafeIdentifier(task.preCondition.column)) throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`);
condWhere += ` AND "${task.preCondition.column}" = $4`;
condParams.push(task.preCondition.expectedValue);
}
const condResult = await client.query(
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} ${condWhere}`,
condParams,
);
if (task.preCondition && condResult.rowCount === 0) {
const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다.");
(err as any).isPreConditionFail = true;
throw err;
}
processedCount++;
}
} else if (opType === "db-conditional") {
// DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중')
if (task.preCondition) {
logger.warn("[pop/execute-action] db-conditional에는 preCondition 미지원, 무시됨", {
taskId: task.id, preCondition: task.preCondition,
});
}
if (!task.compareColumn || !task.compareOperator || !task.compareWith) break;
if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break;
@ -392,10 +413,24 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
}
const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
await client.query(
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[value, companyCode, lookupValues[i]],
let whereSql = `WHERE company_code = $2 AND "${pkColumn}" = $3`;
const queryParams: unknown[] = [value, companyCode, lookupValues[i]];
if (task.preCondition?.column && task.preCondition?.expectedValue) {
if (!isSafeIdentifier(task.preCondition.column)) {
throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`);
}
whereSql += ` AND "${task.preCondition.column}" = $4`;
queryParams.push(task.preCondition.expectedValue);
}
const updateResult = await client.query(
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} ${whereSql}`,
queryParams,
);
if (task.preCondition && updateResult.rowCount === 0) {
const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다.");
(err as any).isPreConditionFail = true;
throw err;
}
processedCount++;
}
}
@ -746,6 +781,16 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
});
} catch (error: any) {
await client.query("ROLLBACK");
if (error.isPreConditionFail) {
logger.warn("[pop/execute-action] preCondition 실패", { message: error.message });
return res.status(409).json({
success: false,
message: error.message,
errorCode: "PRE_CONDITION_FAIL",
});
}
logger.error("[pop/execute-action] 오류:", error);
return res.status(500).json({
success: false,

View File

@ -0,0 +1,15 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
createWorkProcesses,
controlTimer,
} from "../controllers/popProductionController";
const router = Router();
router.use(authenticateToken);
router.post("/create-work-processes", createWorkProcesses);
router.post("/timer", controlTimer);
export default router;

View File

@ -39,7 +39,9 @@ function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globa
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
result += sep;
if (val || !result.endsWith(sep)) {
result += sep;
}
}
});
return result;
@ -74,16 +76,22 @@ class NumberingRuleService {
*/
private async buildPrefixKey(
rule: NumberingRuleConfig,
formData?: Record<string, any>
formData?: Record<string, any>,
manualValues?: string[]
): Promise<string> {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const prefixParts: string[] = [];
let manualIndex = 0;
for (const part of sortedParts) {
if (part.partType === "sequence") continue;
if (part.generationMethod === "manual") {
// 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로)
const manualValue = manualValues?.[manualIndex] || "";
manualIndex++;
if (manualValue) {
prefixParts.push(manualValue);
}
continue;
}
@ -1078,22 +1086,30 @@ class NumberingRuleService {
* @param ruleId ID
* @param companyCode
* @param formData ( )
* @param manualInputValue ( )
*/
async previewCode(
ruleId: string,
companyCode: string,
formData?: Record<string, any>
formData?: Record<string, any>,
manualInputValue?: string
): Promise<string> {
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// prefix_key 기반 순번 조회
const prefixKey = await this.buildPrefixKey(rule, formData);
// 수동 파트가 있는데 입력값이 없으면 레거시 공용 시퀀스 조회를 건너뜀
const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual");
const skipSequenceLookup = hasManualPart && !manualInputValue;
const manualValues = manualInputValue ? [manualInputValue] : undefined;
const prefixKey = await this.buildPrefixKey(rule, formData, manualValues);
const pool = getPool();
const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
const currentSeq = skipSequenceLookup
? 0
: await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
logger.info("미리보기: prefix_key 기반 순번 조회", {
ruleId, prefixKey, currentSeq,
ruleId, prefixKey, currentSeq, skipSequenceLookup,
});
const parts = await Promise.all(rule.parts
@ -1108,7 +1124,8 @@ class NumberingRuleService {
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
const nextSequence = currentSeq + 1;
const startFrom = autoConfig.startFrom || 1;
const nextSequence = currentSeq + startFrom;
return String(nextSequence).padStart(length, "0");
}
@ -1150,110 +1167,8 @@ class NumberingRuleService {
return autoConfig.textValue || "TEXT";
}
case "category": {
// 카테고리 기반 코드 생성
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
logger.warn("카테고리 키 또는 폼 데이터 없음", {
categoryKey,
hasFormData: !!formData,
});
return "";
}
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
// 폼 데이터에서 해당 컬럼의 값 가져오기
const selectedValue = formData[columnName];
logger.info("카테고리 파트 처리", {
categoryKey,
columnName,
selectedValue,
formDataKeys: Object.keys(formData),
mappingsCount: categoryMappings.length,
});
if (!selectedValue) {
logger.warn("카테고리 값이 선택되지 않음", {
columnName,
formDataKeys: Object.keys(formData),
});
return "";
}
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
const selectedValueStr = String(selectedValue);
let mapping = categoryMappings.find((m: any) => {
// ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우)
if (m.categoryValueId?.toString() === selectedValueStr)
return true;
// valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우)
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr)
return true;
// 라벨로 매칭 (폴백)
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
// 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도
if (!mapping) {
try {
const pool = getPool();
const [catTableName, catColumnName] = categoryKey.includes(".")
? categoryKey.split(".")
: [categoryKey, categoryKey];
const cvResult = await pool.query(
`SELECT value_id, value_code, value_label FROM category_values
WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[catTableName, catColumnName, selectedValueStr]
);
if (cvResult.rows.length > 0) {
const resolvedId = cvResult.rows[0].value_id;
const resolvedLabel = cvResult.rows[0].value_label;
mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
if (m.categoryValueLabel === resolvedLabel) return true;
return false;
});
if (mapping) {
logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", {
valueCode: selectedValueStr,
resolvedId,
resolvedLabel,
format: mapping.format,
});
}
}
} catch (lookupError: any) {
logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message });
}
}
if (mapping) {
logger.info("카테고리 매핑 적용", {
selectedValue,
format: mapping.format,
categoryValueLabel: mapping.categoryValueLabel,
});
return mapping.format || "";
}
logger.warn("카테고리 매핑을 찾을 수 없음", {
selectedValue,
availableMappings: categoryMappings.map((m: any) => ({
id: m.categoryValueId,
label: m.categoryValueLabel,
})),
});
return "";
}
case "category":
return this.resolveCategoryFormat(autoConfig, formData);
case "reference": {
const refColumn = autoConfig.referenceColumnName;
@ -1302,11 +1217,29 @@ class NumberingRuleService {
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성
const prefixKey = await this.buildPrefixKey(rule, formData);
// 1단계: 수동 값 추출 (buildPrefixKey 전에 수행해야 prefix_key에 포함 가능)
const manualParts = rule.parts.filter(
(p: any) => p.generationMethod === "manual"
);
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
extractedManualValues = await this.extractManualValuesFromInput(
rule, userInputCode, formData
);
// 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용 (수동 파트 1개인 경우만)
if (extractedManualValues.length === 0 && manualParts.length === 1) {
extractedManualValues = [userInputCode];
logger.info("수동 값 추출 폴백: userInputCode 전체 사용", { userInputCode });
}
}
// 2단계: prefix_key 빌드 (수동 값 포함)
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
// 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득
// 3단계: 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득
let allocatedSequence = 0;
if (hasSequence) {
allocatedSequence = await this.incrementSequenceForPrefix(
@ -1320,136 +1253,15 @@ class NumberingRuleService {
}
logger.info("allocateCode: prefix_key 기반 순번 할당", {
ruleId, prefixKey, allocatedSequence,
ruleId, prefixKey, allocatedSequence, extractedManualValues,
});
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
const manualParts = rule.parts.filter(
(p: any) => p.generationMethod === "manual"
);
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
const previewParts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => {
if (part.generationMethod === "manual") {
return "____";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return "X".repeat(length);
}
case "text":
return autoConfig.textValue || "";
case "date":
return "DATEPART";
case "category": {
const catKey2 = autoConfig.categoryKey;
const catMappings2 = autoConfig.categoryMappings || [];
if (!catKey2 || !formData) {
return "CATEGORY";
}
const colName2 = catKey2.includes(".")
? catKey2.split(".")[1]
: catKey2;
const selVal2 = formData[colName2];
if (!selVal2) {
return "CATEGORY";
}
const selValStr2 = String(selVal2);
let catMapping2 = catMappings2.find((m: any) => {
if (m.categoryValueId?.toString() === selValStr2) return true;
if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true;
if (m.categoryValueLabel === selValStr2) return true;
return false;
});
if (!catMapping2) {
try {
const pool2 = getPool();
const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2];
const cvr2 = await pool2.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct2, cc2, selValStr2]
);
if (cvr2.rows.length > 0) {
const rid2 = cvr2.rows[0].value_id;
const rlabel2 = cvr2.rows[0].value_label;
catMapping2 = catMappings2.find((m: any) => {
if (m.categoryValueId?.toString() === String(rid2)) return true;
if (m.categoryValueLabel === rlabel2) return true;
return false;
});
}
} catch { /* ignore */ }
}
return catMapping2?.format || "CATEGORY";
}
case "reference": {
const refCol2 = autoConfig.referenceColumnName;
if (refCol2 && formData && formData[refCol2]) {
return String(formData[refCol2]);
}
return "REF";
}
default:
return "";
}
}));
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
const templateParts = previewTemplate.split("____");
if (templateParts.length > 1) {
let remainingCode = userInputCode;
for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i];
const suffix = templateParts[i + 1];
if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length);
}
if (suffix) {
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
const manualEndIndex = suffixStart
? remainingCode.indexOf(suffixStart)
: remainingCode.length;
if (manualEndIndex > 0) {
extractedManualValues.push(
remainingCode.slice(0, manualEndIndex)
);
remainingCode = remainingCode.slice(manualEndIndex);
}
} else {
extractedManualValues.push(remainingCode);
}
}
}
logger.info(
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`
);
}
let manualPartIndex = 0;
const parts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => {
if (part.generationMethod === "manual") {
const manualValue =
extractedManualValues[manualPartIndex] ||
part.manualConfig?.value ||
"";
const manualValue = extractedManualValues[manualPartIndex] || "";
manualPartIndex++;
return manualValue;
}
@ -1459,7 +1271,9 @@ class NumberingRuleService {
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return String(allocatedSequence).padStart(length, "0");
const startFrom = autoConfig.startFrom || 1;
const actualSequence = allocatedSequence + startFrom - 1;
return String(actualSequence).padStart(length, "0");
}
case "number": {
@ -1496,65 +1310,14 @@ class NumberingRuleService {
return autoConfig.textValue || "TEXT";
}
case "category": {
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
return "";
}
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
const selectedValue = formData[columnName];
if (!selectedValue) {
return "";
}
const selectedValueStr = String(selectedValue);
let allocMapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
if (!allocMapping) {
try {
const pool3 = getPool();
const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
const cvr3 = await pool3.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct3, cc3, selectedValueStr]
);
if (cvr3.rows.length > 0) {
const rid3 = cvr3.rows[0].value_id;
const rlabel3 = cvr3.rows[0].value_label;
allocMapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(rid3)) return true;
if (m.categoryValueLabel === rlabel3) return true;
return false;
});
}
} catch { /* ignore */ }
}
if (allocMapping) {
return allocMapping.format || "";
}
return "";
}
case "category":
return this.resolveCategoryFormat(autoConfig, formData);
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
return "";
}
@ -1593,6 +1356,139 @@ class NumberingRuleService {
return this.allocateCode(ruleId, companyCode);
}
/**
*
* 릿 ("____")
*/
private async extractManualValuesFromInput(
rule: NumberingRuleConfig,
userInputCode: string,
formData?: Record<string, any>
): Promise<string[]> {
const extractedValues: string[] = [];
const previewParts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => {
if (part.generationMethod === "manual") {
return "____";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return "X".repeat(length);
}
case "text":
return autoConfig.textValue || "";
case "date":
return "DATEPART";
case "category":
return this.resolveCategoryFormat(autoConfig, formData);
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "";
}
default:
return "";
}
}));
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
const templateParts = previewTemplate.split("____");
if (templateParts.length > 1) {
let remainingCode = userInputCode;
for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i];
const suffix = templateParts[i + 1];
if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length);
}
if (suffix) {
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
const manualEndIndex = suffixStart
? remainingCode.indexOf(suffixStart)
: remainingCode.length;
if (manualEndIndex > 0) {
extractedValues.push(
remainingCode.slice(0, manualEndIndex)
);
remainingCode = remainingCode.slice(manualEndIndex);
}
} else {
extractedValues.push(remainingCode);
}
}
}
logger.info(
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedValues)}`
);
return extractedValues;
}
/**
* format
* categoryKey + formData로 , format
*/
private async resolveCategoryFormat(
autoConfig: Record<string, any>,
formData?: Record<string, any>
): Promise<string> {
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) return "";
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
const selectedValue = formData[columnName];
if (!selectedValue) return "";
const selectedValueStr = String(selectedValue);
let mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
// 매핑 못 찾으면 category_values에서 valueCode → valueId 역변환
if (!mapping) {
try {
const pool = getPool();
const [tableName, colName] = categoryKey.includes(".")
? categoryKey.split(".")
: [categoryKey, categoryKey];
const result = await pool.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[tableName, colName, selectedValueStr]
);
if (result.rows.length > 0) {
const resolvedId = result.rows[0].value_id;
const resolvedLabel = result.rows[0].value_label;
mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
if (m.categoryValueLabel === resolvedLabel) return true;
return false;
});
}
} catch { /* ignore */ }
}
return mapping?.format || "";
}
private formatDate(date: Date, format: string): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");

View File

@ -323,7 +323,7 @@ interface ButtonComponentConfig {
| 파일 | 내용 |
|------|------|
| `frontend/lib/button-icon-map.ts` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 |
| `frontend/lib/button-icon-map.tsx` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 |
---
@ -338,3 +338,52 @@ interface ButtonComponentConfig {
- 외부 SVG 붙여넣기도 지원 → 관리자가 회사 로고 등 자체 아이콘을 등록 가능
- lucide 커스텀 아이콘은 `componentConfig.customIcons`에, SVG 아이콘은 `componentConfig.customSvgIcons`에 저장
- lucide 아이콘 렌더링: 아이콘 이름 → 컴포넌트 매핑, SVG 아이콘 렌더링: `dangerouslySetInnerHTML` + DOMPurify 정화
- **동적 아이콘 로딩**: `iconMap`에 명시적으로 import되지 않은 lucide 아이콘도 `getLucideIcon()` 호출 시 `lucide-react`의 전체 아이콘(`icons`)에서 자동 조회 후 캐싱 → 화면 관리에서 선택한 모든 lucide 아이콘이 실제 화면에서도 렌더링됨
- **커스텀 아이콘 전역 관리 (미구현)**: 커스텀 아이콘을 버튼별(`componentConfig`)이 아닌 시스템 전역(`custom_icon_registry` 테이블)으로 관리하여, 한번 추가한 커스텀 아이콘이 모든 화면의 모든 버튼에서 사용 가능하도록 확장 예정
---
## [미구현] 커스텀 아이콘 전역 관리
### 현재 문제
- 커스텀 아이콘이 `componentConfig.customIcons`에 저장 → **해당 버튼에서만** 보임
- 저장1 버튼에 추가한 커스텀 아이콘이 저장2 버튼, 다른 화면에서는 안 보임
- 같은 아이콘을 쓰려면 매번 검색해서 다시 추가해야 함
### 변경 후 동작
- 커스텀 아이콘을 **회사(company_code) 단위 전역**으로 관리
- 어떤 화면의 어떤 버튼에서든 커스텀 아이콘 추가 → 모든 화면의 모든 버튼에서 커스텀란에 표시
- 버튼 액션 종류와 무관하게 모든 커스텀 아이콘이 노출
### DB 테이블 (신규)
```sql
CREATE TABLE custom_icon_registry (
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
company_code VARCHAR(500) NOT NULL,
icon_name VARCHAR(500) NOT NULL,
icon_type VARCHAR(500) DEFAULT 'lucide', -- 'lucide' | 'svg'
svg_data TEXT, -- SVG일 경우 원본 데이터
created_date TIMESTAMP DEFAULT now(),
updated_date TIMESTAMP DEFAULT now(),
writer VARCHAR(500)
);
CREATE INDEX idx_custom_icon_registry_company ON custom_icon_registry(company_code);
```
### 백엔드 API (신규)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/custom-icons` | 커스텀 아이콘 목록 조회 (company_code 필터) |
| POST | `/api/custom-icons` | 커스텀 아이콘 추가 |
| DELETE | `/api/custom-icons/:id` | 커스텀 아이콘 삭제 |
### 프론트엔드 변경
- `ButtonConfigPanel` — 커스텀 아이콘 조회/추가/삭제를 API 호출로 변경
- 기존 `componentConfig.customIcons` 데이터는 하위 호환으로 병합 표시 (점진적 마이그레이션)
- `componentConfig.customSvgIcons`도 동일하게 전역 테이블로 이관

View File

@ -145,8 +145,24 @@
- **결정**: lucide-react에서 export되는 전체 아이콘 이름 목록을 검색 가능
- **근거**: 관리자가 "어떤 아이콘이 있는지" 모르므로 검색 기능이 필수
- **구현**: lucide 아이콘 이름 배열을 상수로 관리하고, CommandInput으로 필터링
- **주의**: 전체 아이콘 컴포넌트를 import하지 않고, 이름 배열만 관리 → 선택 시에만 해당 아이콘을 매핑에 추가
- **구현**: `lucide-react``icons` 객체에서 `Object.keys()`로 전체 이름 목록을 가져오고, CommandInput으로 필터링
- **주의**: `allLucideIcons``button-icon-map.tsx`에서 re-export하여 import를 중앙화
### 18. 커스텀 아이콘 전역 관리 (미구현)
- **결정**: 커스텀 아이콘을 버튼별(`componentConfig`) → 시스템 전역(`custom_icon_registry` 테이블)으로 변경
- **근거**: 현재는 버튼 A에서 추가한 커스텀 아이콘이 버튼 B, 다른 화면에서 안 보여 매번 재등록 필요. 아이콘은 시각적 자원이므로 액션이나 화면에 종속될 이유가 없음
- **범위 검토**: 버튼별 < 화면 단위 < **시스템 전역(채택)** 같은 아이콘을 여러 화면에서 재사용하는 ERP 특성에 시스템 전역이 가장 적합
- **저장**: `custom_icon_registry` 테이블 (company_code 멀티테넌시), lucide 이름 또는 SVG 데이터 저장
- **하위 호환**: 기존 `componentConfig.customIcons` 데이터는 병합 표시 후 점진적 마이그레이션
### 19. 동적 아이콘 로딩 (getLucideIcon fallback)
- **결정**: `getLucideIcon(name)``iconMap`에 없는 아이콘을 `lucide-react``icons` 전체 객체에서 동적으로 조회 후 캐싱
- **근거**: 화면 관리에서 커스텀 lucide 아이콘을 선택하면 `componentConfig.customIcons`에 이름만 저장됨. 디자이너 세션에서는 `addToIconMap()`으로 런타임에 등록되지만, 실제 화면(뷰어) 로드 시에는 `iconMap`에 해당 아이콘이 없어 렌더링 실패. `icons` fallback을 추가하면 **어떤 lucide 아이콘이든 이름만으로 자동 렌더링**
- **구현**: `button-icon-map.tsx``import { icons as allLucideIcons } from "lucide-react"` 추가, `getLucideIcon()`에서 `iconMap` miss 시 `allLucideIcons[name]` 조회 후 `iconMap`에 캐싱
- **번들 영향**: `icons` 전체 객체 import로 번들 크기 증가 (~100-200KB). ERP 애플리케이션 특성상 수용 가능한 수준이며, 관리자가 선택한 모든 아이콘이 실제 화면에서 동작하는 것이 더 중요
- **대안 검토**: 뷰어 로드 시 `customIcons`를 순회하여 개별 등록 → 기각 (모든 뷰어 컴포넌트에 로직 추가 필요, 누락 위험)
---
@ -159,7 +175,7 @@
| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewer.tsx` | 버튼 렌더링 분기 (2041~2059행) |
| 위젯 (수정) | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) |
| 최적화 버튼 (수정) | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 렌더링 (643~674행) |
| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.ts` | 액션별 추천 아이콘 + 동적 렌더링 유틸 |
| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.tsx` | 액션별 추천 아이콘 + 동적 렌더링 유틸 + allLucideIcons fallback |
| 타입 정의 (참고) | `frontend/types/screen.ts` | ComponentData, componentConfig 타입 |
---
@ -169,17 +185,21 @@
### lucide-react 아이콘 동적 렌더링
```typescript
// button-icon-map.ts
import { Check, Save, Trash2, Pencil, ... } from "lucide-react";
// button-icon-map.tsx
import { Check, Save, ..., icons as allLucideIcons, type LucideIcon } from "lucide-react";
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Check, Save, Trash2, Pencil, ...
};
// 추천 아이콘은 명시적 import, 나머지는 동적 조회
const iconMap: Record<string, LucideIcon> = { Check, Save, ... };
export function renderButtonIcon(name: string, size: string | number) {
const IconComponent = iconMap[name];
if (!IconComponent) return null;
return <IconComponent style={getIconSizeStyle(size)} />;
export function getLucideIcon(name: string): LucideIcon | undefined {
if (iconMap[name]) return iconMap[name];
// iconMap에 없으면 lucide-react 전체에서 동적 조회 후 캐싱
const found = allLucideIcons[name as keyof typeof allLucideIcons];
if (found) {
iconMap[name] = found;
return found;
}
return undefined;
}
```

View File

@ -125,12 +125,30 @@
- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 복귀 → 아이콘 모드 유지 확인
- [x] deprecated 액션에서 디폴트 폴백 아이콘(SquareMousePointer) 표시 확인
### 6단계: 정리
### 6단계: 동적 아이콘 로딩 (뷰어 렌더링 누락 수정)
- [x] TypeScript 컴파일 에러 없음 확인 (우리 파일 6개 모두 0 에러)
- [x] `button-icon-map.tsx``icons as allLucideIcons` import 추가
- [x] `getLucideIcon()``iconMap` miss 시 `allLucideIcons` fallback 조회 + 캐싱
- [x] `allLucideIcons``button-icon-map.tsx`에서 re-export (import 중앙화)
- [x] `ButtonConfigPanel.tsx``lucide-react` 직접 import 제거, `button-icon-map`에서 import로 통합
- [x] 화면 관리에서 선택한 커스텀 lucide 아이콘이 실제 화면(뷰어)에서도 렌더링됨 확인
### 7단계: 정리
- [x] TypeScript 컴파일 에러 없음 확인
- [x] 불필요한 import 없음 확인
- [x] 문서 3개 최신화 (동적 로딩 반영)
- [x] 이 체크리스트 완료 표시 업데이트
### 8단계: 커스텀 아이콘 전역 관리 (미구현)
- [ ] `custom_icon_registry` 테이블 마이그레이션 SQL 작성 및 실행 (개발섭 + 본섭)
- [ ] 백엔드 API 구현 (GET/POST/DELETE `/api/custom-icons`)
- [ ] 프론트엔드 API 클라이언트 함수 추가 (`lib/api/`)
- [ ] `ButtonConfigPanel` — 커스텀 아이콘 조회/추가/삭제를 전역 API로 변경
- [ ] 기존 `componentConfig.customIcons` 하위 호환 병합 처리
- [ ] 검증: 화면 A에서 추가한 커스텀 아이콘이 화면 B에서도 보이는지 확인
---
## 변경 이력
@ -156,3 +174,6 @@
| 2026-03-04 | 텍스트 위치 4방향 설정 추가 (왼쪽/오른쪽/위쪽/아래쪽) |
| 2026-03-04 | 버튼 테두리 이중 적용 수정 — position wrapper에서 border strip, border shorthand 제거 |
| 2026-03-04 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 |
| 2026-03-13 | 동적 아이콘 로딩 — `getLucideIcon()` fallback으로 `allLucideIcons` 조회+캐싱, import 중앙화 |
| 2026-03-13 | 문서 3개 최신화 (계획서 설계 원칙, 맥락노트 결정사항 #18, 체크리스트 6-7단계) |
| 2026-03-13 | 커스텀 아이콘 전역 관리 계획 추가 (8단계, 미구현) — DB 테이블 + API + 프론트 변경 예정 |

View File

@ -0,0 +1,171 @@
# BTN - 버튼 UI 스타일 기준정보
## 1. 스타일 기준
### 공통 스타일
| 항목 | 값 |
|---|---|
| 높이 | 40px |
| 표시모드 | 아이콘 + 텍스트 (icon-text) |
| 아이콘 | 액션별 첫 번째 기본 아이콘 (자동 선택) |
| 아이콘 크기 비율 | 보통 |
| 아이콘-텍스트 간격 | 6px |
| 텍스트 위치 | 오른쪽 (아이콘 왼쪽, 텍스트 오른쪽) |
| 테두리 모서리 | 8px |
| 테두리 색상/두께 | 없음 (투명, borderWidth: 0) |
| 텍스트 색상 | #FFFFFF (흰색) |
| 텍스트 크기 | 12px |
| 텍스트 굵기 | normal (보통) |
| 텍스트 정렬 | 왼쪽 |
### 배경색 (액션별)
| 액션 타입 | 배경색 | 비고 |
|---|---|---|
| `delete` | `#F04544` | 빨간색 |
| `excel_download`, `excel_upload`, `multi_table_excel_upload` | `#212121` | 검정색 |
| 그 외 모든 액션 | `#3B83F6` | 파란색 (기본값) |
배경색은 디자이너에서 액션을 변경하면 자동으로 바뀐다.
### 너비 (텍스트 글자수별)
| 글자수 | 너비 |
|---|---|
| 6글자 이하 | 140px |
| 7글자 이상 | 160px |
### 액션별 기본 아이콘
디자이너에서 표시모드를 "아이콘" 또는 "아이콘+텍스트"로 변경하면 액션에 맞는 첫 번째 아이콘이 자동 선택된다.
소스: `frontend/lib/button-icon-map.tsx` > `actionIconMap`
| action.type | 기본 아이콘 |
|---|---|
| `save` | Check |
| `delete` | Trash2 |
| `edit` | Pencil |
| `navigate` | ArrowRight |
| `modal` | Maximize2 |
| `transferData` | SendHorizontal |
| `excel_download` | Download |
| `excel_upload` | Upload |
| `quickInsert` | Zap |
| `control` | Settings |
| `barcode_scan` | ScanLine |
| `operation_control` | Truck |
| `event` | Send |
| `copy` | Copy |
| (그 외/없음) | SquareMousePointer |
---
## 2. 코드 반영 현황
### 컴포넌트 기본값 (신규 버튼 생성 시 적용)
| 파일 | 내용 |
|---|---|
| `frontend/lib/registry/components/v2-button-primary/index.ts` | defaultConfig, defaultSize (140x40) |
| `frontend/lib/registry/components/v2-button-primary/config.ts` | ButtonPrimaryDefaultConfig |
### 액션 변경 시 배경색 자동 변경
| 파일 | 내용 |
|---|---|
| `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 액션 변경 시 배경색/텍스트색 자동 설정 |
### 렌더링 배경색 우선순위
| 파일 | 내용 |
|---|---|
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | 배경색 결정 우선순위 개선 |
배경색 결정 순서:
1. `webTypeConfig.backgroundColor`
2. `componentConfig.backgroundColor`
3. `component.style.backgroundColor`
4. `componentConfig.style.backgroundColor`
5. `component.style.labelColor` (레거시 호환)
6. 액션별 기본 배경색 (`#F04544` / `#212121` / `#3B83F6`)
### 미반영 (추후 작업)
- split-panel 내부 버튼의 코드 기본값 (split-panel 컴포넌트가 자체 생성하는 버튼)
---
## 3. DB 데이터 매핑 (layout_data JSON)
버튼은 `layout_data.components[]` 배열 안에 `url``v2-button-primary`인 컴포넌트로 저장된다.
| 항목 | JSON 위치 | 값 |
|---|---|---|
| 높이 | `size.height` | `40` |
| 너비 | `size.width` | `140` 또는 `160` |
| 표시모드 | `overrides.displayMode` | `"icon-text"` |
| 아이콘 이름 | `overrides.icon.name` | 액션별 영문 이름 |
| 아이콘 타입 | `overrides.icon.type` | `"lucide"` |
| 아이콘 크기 | `overrides.icon.size` | `"보통"` |
| 텍스트 위치 | `overrides.iconTextPosition` | `"right"` |
| 아이콘-텍스트 간격 | `overrides.iconGap` | `6` |
| 테두리 모서리 | `overrides.style.borderRadius` | `"8px"` |
| 텍스트 색상 | `overrides.style.labelColor` | `"#FFFFFF"` |
| 텍스트 크기 | `overrides.style.fontSize` | `"12px"` |
| 텍스트 굵기 | `overrides.style.fontWeight` | `"normal"` |
| 텍스트 정렬 | `overrides.style.labelTextAlign` | `"left"` |
| 배경색 | `overrides.style.backgroundColor` | 액션별 색상 |
버튼이 위치하는 구조별 경로:
- 일반 버튼: `layout_data.components[]`
- 탭 위젯 내부: `layout_data.components[].overrides.tabs[].components[]`
- split-panel 내부: `layout_data.components[].overrides.rightPanel.components[]`
---
## 4. 탑씰(COMPANY_7) 일괄 변경 작업 기록
### 대상
- **회사**: 탑씰 (company_code = 'COMPANY_7')
- **테이블**: screen_layouts_v2 (배포서버)
- **스크립트**: `backend-node/scripts/btn-bulk-update-company7.ts`
- **백업 테이블**: `screen_layouts_v2_backup_company7`
### 작업 이력
| 날짜 | 작업 내용 | 비고 |
|---|---|---|
| 2026-03-13 | 백업 테이블 생성 | |
| 2026-03-13 | 전체 버튼 공통 스타일 일괄 적용 | 높이, 아이콘, 텍스트 스타일, 배경색, 모서리 |
| 2026-03-13 | 탭 위젯 내부 버튼 스타일 보정 | componentConfig + root style 양쪽 적용 |
| 2026-03-13 | fontWeight "400" → "normal" 보정 | |
| 2026-03-13 | overrides.style.width 제거 | size.width와 충돌 방지 |
| 2026-03-13 | save 액션 55개에 "저장" 텍스트 명시 | |
| 2026-03-13 | "엑셀다운로드" → "Excel" 텍스트 통일 | |
| 2026-03-13 | Excel 버튼 배경색 #212121 통일 | |
| 2026-03-13 | 전체 버튼 너비 140px 통일 | |
| 2026-03-13 | 7글자 이상 버튼 너비 160px 재조정 | |
| 2026-03-13 | split-panel 내부 버튼 스타일 적용 | BOM관리 등 7개 버튼 |
### 스킵 항목
- `transferData` 액션의 텍스트 없는 버튼 1개 (screen=5976)
### 알려진 이슈
- **반응형 너비 불일치**: 디자이너에서 설정한 `size.width`가 실제 화면(`ResponsiveGridRenderer`)에서 반영되지 않을 수 있음. 버튼 wrapper에 `width` 속성이 누락되어 flex shrink-to-fit 동작으로 너비가 줄어드는 현상. 세로(height)는 정상 반영됨.
### 원복 (필요 시)
```sql
UPDATE screen_layouts_v2 AS target
SET layout_data = backup.layout_data
FROM screen_layouts_v2_backup_company7 AS backup
WHERE target.layout_id = backup.layout_id;
```
### 백업 테이블 정리
```sql
DROP TABLE screen_layouts_v2_backup_company7;
```

View File

@ -0,0 +1,420 @@
# [계획서] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
> 관련 문서: [맥락노트](./MPN[맥락]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md)
## 개요
기준정보 - 품목 정보 등록 모달에서 품번(`item_number`) 채번의 세 가지 문제를 해결합니다.
1. **BULK1 덮어쓰기 문제**: 사용자가 "ㅁㅁㅁ"을 입력해도 수동 값 추출이 실패하여 DB 숨은 값 `manualConfig.value = "BULK1"`로 덮어씌워짐
2. **순번 공유 문제**: `buildPrefixKey`가 수동 파트를 건너뛰어 모든 접두어가 같은 시퀀스 카운터를 공유함
3. **연속 구분자(--) 문제**: 카테고리가 비었을 때 `joinPartsWithSeparators`가 빈 파트에도 구분자를 붙여 `--` 발생 + 템플릿 불일치로 수동 값 추출 실패 → `userInputCode` 전체(구분자 포함)가 수동 값이 됨
---
## 현재 동작
### 채번 규칙 구성 (옵션설정 > 코드설정)
```
규칙1(카테고리/재질, 자동) → "-" → 규칙2(문자, 직접입력) → "-" → 규칙3(순번, 자동, 3자리, 시작=5)
```
### 실제 저장 흐름 (사용자가 "ㅁㅁㅁ" 입력 시)
1. 모달 열림 → `_numberingRuleId` 설정됨 (TextInputComponent L117-128)
2. 사용자가 "ㅁㅁㅁ" 입력 → `formData.item_number = "ㅁㅁㅁ"`
3. 저장 클릭 → `buttonActions.ts``_numberingRuleId` 확인 → `allocateCode(ruleId, "ㅁㅁㅁ", formData)` 호출
4. 백엔드: 템플릿 기반 수동 값 추출 시도 → **실패** (입력 "ㅁㅁㅁ"이 템플릿 "CATEGORY-____-XXX"와 불일치)
5. 폴백: `manualConfig.value = "BULK1"` 사용 → **사용자 입력 "ㅁㅁㅁ" 완전 무시됨**
6. `buildPrefixKey`가 수동 파트를 건너뜀 → prefix_key에 접두어 미포함 → 공유 카운터 사용
7. 결과: **-BULK1-015** (사용자가 뭘 입력하든 항상 BULK1, 항상 공유 카운터)
### 문제 1: 순번 공유 (buildPrefixKey)
**위치**: `numberingRuleService.ts` L85-88
```typescript
if (part.generationMethod === "manual") {
// 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로)
continue; // ← 접두어별 순번 분리를 막는 원인
}
```
`continue` 때문에 수동 입력값이 prefix_key에 포함되지 않습니다.
"ㅁㅁㅁ", "ㅇㅇㅇ", "BULK1" 전부 **같은 시퀀스 카운터를 공유**합니다.
### 문제 2: BULK1 덮어쓰기 (추출 실패 + manualConfig.value 폴백)
**발생 흐름**:
1. 사용자가 "ㅁㅁㅁ" 입력 → `userInputCode = "ㅁㅁㅁ"` 으로 `allocateCode` 호출
2. `allocateCode` 내부에서 **prefix_key를 먼저 빌드** (L1306) → 수동 값 추출은 그 이후 (L1332-1442)
3. 템플릿 기반 수동 값 추출 시도 (L1411-1436):
```
템플릿: "카테고리값-____-XXX" (카테고리값-수동입력위치-순번)
사용자 입력: "ㅁㅁㅁ"
```
4. "ㅁㅁㅁ"은 "카테고리값-"으로 시작하지 않음 → `startsWith` 불일치 → **추출 실패**`extractedManualValues = []`
5. 코드 조합 단계 (L1448-1454)에서 폴백 체인 동작:
```typescript
const manualValue =
extractedManualValues[0] || // undefined (추출 실패)
part.manualConfig?.value || // "BULK1" (DB 숨은 값) ← 여기서 덮어씌워짐
"";
```
6. 결과: `-BULK1-015` (사용자 입력 "ㅁㅁㅁ"이 완전히 무시됨)
**DB 숨은 값 원인**:
- DB `numbering_rule_parts.manual_config` 컬럼에 `{"value": "BULK1", "placeholder": "..."}` 저장됨
- `ManualConfigPanel.tsx`에는 `placeholder` 입력란만 있고 **`value` 입력란이 없음**
- 플레이스홀더 수정 시 `{ ...config, placeholder: ... }` 스프레드로 기존 `value: "BULK1"`이 계속 보존됨
### 문제 3: 연속 구분자(--) 문제
**발생 흐름**:
1. 카테고리 미선택 → 카테고리 파트 값 = `""` (빈 문자열)
2. `joinPartsWithSeparators`가 빈 파트에도 구분자 `-`를 추가 → 연속 빈 파트 시 `--` 발생
3. 사용자 입력 필드에 `-제발-015` 형태로 표시 (선행 `-`)
4. `extractManualValuesFromInput`에서 템플릿이 `CATEGORY-____-XXX`로 생성됨 (실제 값 `""` 대신 플레이스홀더 `"CATEGORY"` 사용)
5. 입력 `-제발-015``CATEGORY-`로 시작하지 않음 → 추출 실패
6. 폴백: `userInputCode` 전체 `-제발-015`가 수동 값이 됨
7. 코드 조합: `""` + `-` + `-제발-015` + `-` + `003` = `--제발-015-003`
### 정상 동작 확인된 부분
| 항목 | 상태 | 근거 |
|------|------|------|
| `_numberingRuleId` 유지 | 정상 | 사용자 입력해도 allocateCode가 호출됨 |
| 시퀀스 증가 | 정상 | 순번이 증가하고 있음 (015 등) |
| 코드 조합 | 정상 | 구분자, 파트 순서 등 올바르게 결합됨 |
### 비정상 확인된 부분
| 항목 | 상태 | 근거 |
|------|------|------|
| 수동 값 추출 | **실패** | 사용자 입력 "ㅁㅁㅁ"이 템플릿과 불일치 → 추출 실패 → BULK1 폴백 |
| prefix_key 분리 | **실패** | `buildPrefixKey`가 수동 파트 skip → 모든 접두어가 같은 시퀀스 공유 |
| 연속 구분자 | **실패** | 빈 파트에 구분자 추가 + 템플릿 플레이스홀더 불일치 → `--` 발생 |
---
## 변경 후 동작
### prefix_key에 수동 파트 값 포함
```
현재: prefix_key = 카테고리값만 (수동 파트 무시)
변경: prefix_key = 카테고리값 + "|" + 수동입력값
```
### allocateCode 실행 순서 변경
```
현재: buildPrefixKey → 시퀀스 할당 → 수동 값 추출 → 코드 조합
변경: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합
```
### 순번 동작
```
"ㅁㅁㅁ" 첫 등록 → prefix_key="카테고리|ㅁㅁㅁ", sequence=1 → -ㅁㅁㅁ-001
"ㅁㅁㅁ" 두번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=2 → -ㅁㅁㅁ-002
"ㅇㅇㅇ" 첫 등록 → prefix_key="카테고리|ㅇㅇㅇ", sequence=1 → -ㅇㅇㅇ-001
"ㅁㅁㅁ" 세번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=3 → -ㅁㅁㅁ-003
```
### BULK1 폴백 제거 (코드 + DB 이중 조치)
```
코드: 폴백 체인에서 manualConfig.value 제거 → extractedManualValues만 사용
DB: manual_config에서 "value": "BULK1" 키 제거 → 유령 기본값 정리
```
### 연속 구분자 방지 + 템플릿 정합성 복원
```
joinPartsWithSeparators: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음
extractManualValuesFromInput: 카테고리/참조 빈 값 시 "" 반환 (플레이스홀더 "CATEGORY"/"REF" 대신)
→ 템플릿이 실제 코드 구조와 일치 → 추출 성공 → -- 방지
```
---
## 시각적 예시
| 사용자 입력 | 현재 동작 | 원인 | 변경 후 동작 |
|------------|----------|------|-------------|
| `ㅁㅁㅁ` (첫번째) | `-BULK1-015` | 추출 실패 → BULK1 폴백 + 공유 카운터 | `카테고리값-ㅁㅁㅁ-001` |
| `ㅁㅁㅁ` (두번째) | `-BULK1-016` | 동일 | `카테고리값-ㅁㅁㅁ-002` |
| `ㅇㅇㅇ` (첫번째) | `-BULK1-017` | 동일 | `카테고리값-ㅇㅇㅇ-001` |
| (입력 안 함) | `-BULK1-018` | manualConfig.value 폴백 | 에러 반환 (수동 파트 필수 입력) |
| 카테고리 비었을 때 | `--제발-015-003` | 빈 파트 구분자 중복 + 템플릿 불일치 | `-제발-001` |
---
## 아키텍처
```mermaid
sequenceDiagram
participant User as 사용자
participant BA as buttonActions.ts
participant API as allocateNumberingCode API
participant NRS as numberingRuleService
participant DB as numbering_rule_sequences
User->>BA: 저장 클릭 (item_number = "ㅁㅁㅁ")
BA->>API: allocateCode(ruleId, "ㅁㅁㅁ", formData)
API->>NRS: allocateCode()
Note over NRS: 1단계: 수동 값 추출 (buildPrefixKey 전에 수행)
NRS->>NRS: extractManualValuesFromInput("ㅁㅁㅁ")
Note over NRS: 템플릿 파싱 실패 → 폴백: userInputCode 전체 사용
NRS->>NRS: extractedManualValues = ["ㅁㅁㅁ"]
Note over NRS: 2단계: prefix_key 빌드 (수동 값 포함)
NRS->>NRS: buildPrefixKey(rule, formData, ["ㅁㅁㅁ"])
Note over NRS: prefix_key = "카테고리값|ㅁㅁㅁ"
Note over NRS: 3단계: 시퀀스 할당
NRS->>DB: UPSERT sequences (prefix_key="카테고리값|ㅁㅁㅁ")
DB-->>NRS: current_sequence = 1
Note over NRS: 4단계: 코드 조합
NRS->>NRS: 카테고리값 + "-" + "ㅁㅁㅁ" + "-" + "001"
NRS-->>API: "카테고리값-ㅁㅁㅁ-001"
API-->>BA: generatedCode
BA->>BA: formData.item_number = "카테고리값-ㅁㅁㅁ-001"
```
---
## 변경 대상 파일
| 파일 | 변경 내용 | 규모 |
|------|----------|------|
| `backend-node/src/services/numberingRuleService.ts` | `buildPrefixKey``manualValues` 파라미터 추가, `allocateCode`에서 수동 값 추출 순서 변경 + 폴백 체인 정리, `extractManualValuesFromInput` 헬퍼 분리, `joinPartsWithSeparators` 연속 구분자 방지, 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경, `previewCode``manualInputValue` 파라미터 추가 + `startFrom` 적용 | ~80줄 |
| `backend-node/src/controllers/numberingRuleController.ts` | preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가 | ~2줄 |
| `frontend/lib/api/numberingRule.ts` | `previewNumberingCode``manualInputValue` 파라미터 추가 | ~3줄 |
| `frontend/components/v2/V2Input.tsx` | 수동 입력값 변경 시 디바운스(300ms) preview API 호출 + suffix(순번) 실시간 갱신 | ~35줄 |
| `db/migrations/1053_remove_bulk1_manual_config_value.sql` | `numbering_rule_parts.manual_config`에서 `value: "BULK1"` 제거 | SQL 1건 |
### buildPrefixKey 호출부 영향 분석
| 호출부 | 위치 | `manualValues` 전달 | 영향 |
|--------|------|---------------------|------|
| `previewCode` | L1091 | `manualInputValue` 전달 시 포함 | 접두어별 정확한 순번 조회 |
| `allocateCode` | L1332 | 전달 | prefix_key에 수동 값 포함됨 |
### 멀티테넌시 체크
| 항목 | 상태 | 근거 |
|------|------|------|
| `buildPrefixKey` | 영향 없음 | 시그니처만 확장, company_code 관련 변경 없음 |
| `allocateCode` | 이미 준수 | L1302에서 `companyCode`로 규칙 조회, L1313에서 시퀀스 할당 시 `companyCode` 전달 |
| `joinPartsWithSeparators` | 영향 없음 | 순수 문자열 조합 함수, company_code 무관 |
| DB 마이그레이션 | 해당 없음 | JSONB 내부 값 정리, company_code 무관 |
---
## 코드 설계
### 1. `joinPartsWithSeparators` 수정 - 연속 구분자 방지
**위치**: L36-48
**변경**: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음
```typescript
function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string {
let result = "";
partValues.forEach((val, idx) => {
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
if (val || !result.endsWith(sep)) {
result += sep;
}
}
});
return result;
}
```
### 2. `buildPrefixKey` 수정 - 수동 파트 값을 prefix에 포함
**위치**: L75-88
**변경**: 세 번째 파라미터 `manualValues` 추가. 전달되면 prefix_key에 포함.
```typescript
private async buildPrefixKey(
rule: NumberingRuleConfig,
formData?: Record<string, any>,
manualValues?: string[]
): Promise<string> {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const prefixParts: string[] = [];
let manualIndex = 0;
for (const part of sortedParts) {
if (part.partType === "sequence") continue;
if (part.generationMethod === "manual") {
const manualValue = manualValues?.[manualIndex] || "";
manualIndex++;
if (manualValue) {
prefixParts.push(manualValue);
}
continue;
}
// ... 나머지 기존 로직 (text, date, category, reference 등) 그대로 유지 ...
}
return prefixParts.join("|");
}
```
**하위 호환성**: `manualValues`는 optional. `previewCode`(L1091)는 전달하지 않으므로 동작 변화 없음.
### 3. `allocateCode` 수정 - 수동 값 추출 순서 변경 + 폴백 정리
**위치**: L1290-1584
**핵심 변경 2가지**:
(A) 기존에는 `buildPrefixKey`(L1306) → 수동 값 추출(L1332) 순서였으나, **수동 값 추출 → `buildPrefixKey`** 순서로 변경.
(B) 코드 조합 단계(L1448-1454)에서 `manualConfig.value` 폴백 제거.
```typescript
async allocateCode(ruleId, companyCode, formData?, userInputCode?) {
// ... 규칙 조회 ...
// 1단계: 수동 파트 값 추출 (buildPrefixKey 호출 전에 수행)
const manualParts = rule.parts.filter(p => p.generationMethod === "manual");
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
extractedManualValues = await this.extractManualValuesFromInput(
rule, userInputCode, formData
);
// 폴백: 추출 실패 시 userInputCode 전체를 수동 값으로 사용
if (extractedManualValues.length === 0 && manualParts.length === 1) {
extractedManualValues = [userInputCode];
}
}
// 2단계: 수동 값을 포함하여 prefix_key 빌드
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
// 3단계: 시퀀스 할당 (기존 로직 그대로)
// 4단계: 코드 조합 (manualConfig.value 폴백 제거)
// 기존: extractedManualValues[i] || part.manualConfig?.value || ""
// 변경: extractedManualValues[i] || ""
}
```
### 4. `extractManualValuesFromInput` 헬퍼 분리 + 템플릿 정합성 복원
기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출.
로직 자체는 변경 없음, 위치만 이동.
카테고리/참조 파트의 빈 값 처리를 실제 코드 생성과 일치시킴.
```typescript
private async extractManualValuesFromInput(
rule: NumberingRuleConfig,
userInputCode: string,
formData?: Record<string, any>
): Promise<string[]> {
// 기존 L1332-1442의 로직을 그대로 이동
// 변경: 카테고리/참조 빈 값 시 "CATEGORY"/"REF" 대신 "" 반환
// → 템플릿이 실제 코드 구조와 일치 → 추출 성공률 향상
}
```
### 5. DB 마이그레이션 - BULK1 유령 기본값 제거
**파일**: `db/migrations/1053_remove_bulk1_manual_config_value.sql`
`numbering_rule_parts.manual_config` 컬럼에서 `value` 키를 제거합니다.
```sql
-- manual_config에서 "value" 키 제거 (BULK1 유령 기본값 정리)
UPDATE numbering_rule_parts
SET manual_config = manual_config - 'value'
WHERE generation_method = 'manual'
AND manual_config ? 'value'
AND manual_config->>'value' = 'BULK1';
```
> PostgreSQL JSONB 연산자 `-`를 사용하여 특정 키만 제거.
> `manual_config`의 나머지 필드(`placeholder` 등)는 유지됨.
> "BULK1" 값을 가진 레코드만 대상으로 하여 안전성 확보.
---
## 설계 원칙
- **변경 범위 최소화**: `numberingRuleService.ts` 코드 변경 + DB 마이그레이션 1건
- **이중 조치**: 코드에서 `manualConfig.value` 폴백 제거 + DB에서 유령 값 정리
- `buildPrefixKey``manualValues`는 optional → 기존 호출부(`previewCode` 등)에 영향 없음
- `allocateCode` 내부 로직 순서만 변경 (추출 → prefix_key 빌드), 새 로직 추가 아님
- 수동 값 추출 로직은 기존 코드를 헬퍼로 분리할 뿐, 로직 자체는 변경 없음
- DB 마이그레이션은 "BULK1" 값만 정확히 타겟팅하여 부작용 방지
- `TextInputComponent.tsx` 변경 불필요 (현재 동작이 올바름)
- 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요
- `joinPartsWithSeparators`는 연속 구분자만 방지, 기존 구분자 구조 유지
- 템플릿 카테고리/참조 빈 값을 실제 코드와 일치시켜 추출 성공률 향상
---
## 실시간 순번 미리보기 (추가 기능)
### 배경
품목 등록 모달에서 수동 입력 세그먼트 우측에 표시되는 순번(suffix)이 입력값과 무관하게 고정되어 있었음. 사용자가 "ㅇㅇ"을 입력하면 해당 접두어로 이미 몇 개가 등록되었는지에 따라 순번이 달라져야 함.
### 목표 동작
```
모달 열림 : -[입력하시오]-005 (startFrom=5 기반 기본 순번)
"ㅇㅇ" 입력 : -[ㅇㅇ]-005 (기존 "ㅇㅇ" 등록 0건)
저장 후 재입력 "ㅇㅇ": -[ㅇㅇ]-006 (기존 "ㅇㅇ" 등록 1건)
```
### 아키텍처
```mermaid
sequenceDiagram
participant User as 사용자
participant V2 as V2Input
participant API as previewNumberingCode
participant BE as numberingRuleService.previewCode
participant DB as numbering_rule_sequences
User->>V2: 수동 입력 "ㅇㅇ"
Note over V2: 디바운스 300ms
V2->>API: preview(ruleId, formData, "ㅇㅇ")
API->>BE: previewCode(ruleId, companyCode, formData, "ㅇㅇ")
BE->>BE: buildPrefixKey(rule, formData, ["ㅇㅇ"])
Note over BE: prefix_key = "카테고리|ㅇㅇ"
BE->>DB: getSequenceForPrefix(prefix_key)
DB-->>BE: currentSeq = 0
Note over BE: nextSequence = 0 + startFrom(5) = 5
BE-->>API: "-____-005"
API-->>V2: generatedCode
V2->>V2: suffix = "-005" 갱신
Note over V2: 화면 표시: -[ㅇㅇ]-005
```
### 변경 내용
1. **백엔드 컨트롤러**: preview 엔드포인트가 `req.body.manualInputValue` 수신
2. **백엔드 서비스**: `previewCode``manualInputValue`를 받아 `buildPrefixKey`에 전달 → 접두어별 정확한 시퀀스 조회
3. **백엔드 서비스**: 수동 파트가 있는데 `manualInputValue`가 없는 초기 상태 → 레거시 공용 시퀀스 조회 건너뜀, `currentSeq = 0` 사용 → `startFrom` 기본값 표시
4. **프론트엔드 API**: `previewNumberingCode``manualInputValue` 파라미터 추가
5. **V2Input**: `manualInputValue` 변경 시 디바운스(300ms) preview API 재호출 → `numberingTemplateRef` 갱신 → suffix 실시간 업데이트
6. **V2Input**: 카테고리 변경 시 초기 useEffect에서도 현재 `manualInputValue`를 preview에 전달 → 카테고리 변경/삭제 시 순번 즉시 반영
7. **코드 정리**: 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼로 통합 (약 100줄 감소)

View File

@ -0,0 +1,161 @@
# [맥락노트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md)
---
## 왜 이 작업을 하는가
- 기준정보 - 품목정보 등록 모달에서 품번 인풋에 사용자가 값을 입력해도 무시되고 "BULK1"로 저장됨
- 서로 다른 접두어("ㅁㅁㅁ", "ㅇㅇㅇ")를 입력해도 전부 같은 시퀀스 카운터를 공유함
- 카테고리 미선택 시 `--제발-015-003` 처럼 연속 구분자가 발생함
- 사용자 입력이 반영되고, 접두어별로 독립된 순번이 부여되어야 함
---
## 핵심 결정 사항과 근거
### 1. 수동 값 추출을 buildPrefixKey 전으로 이동
- **결정**: `allocateCode` 내부에서 수동 값 추출 → buildPrefixKey 순서로 변경
- **근거**: 기존에는 buildPrefixKey(L1306)가 먼저 실행된 후 수동 값 추출(L1332)이 진행됨. 수동 값이 prefix_key에 포함되려면 추출이 먼저 되어야 함
- **대안 검토**: buildPrefixKey 내부에서 직접 추출 → 기각 (역할 분리 위반, previewCode 호출에도 영향)
### 2. buildPrefixKey에 수동 파트 값 포함
- **결정**: `manualValues` optional 파라미터 추가, 전달되면 prefix_key에 포함
- **근거**: 기존 `continue`(L85-87)로 수동 파트가 prefix_key에서 제외되어 모든 접두어가 같은 시퀀스를 공유함
- **하위호환**: optional 파라미터이므로 `previewCode`(L1091) 등 기존 호출부는 영향 없음
### 3. 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용
- **결정**: 수동 파트가 1개이고 템플릿 기반 추출이 실패하면 `userInputCode` 전체를 수동 값으로 사용
- **근거**: 사용자가 "ㅁㅁㅁ"처럼 접두어 부분만 입력하면 템플릿 "카테고리값-____-XXX"와 불일치. `startsWith` 조건 실패로 추출이 안 됨. 이 경우 입력 전체가 수동 값임
- **제한**: 수동 파트가 2개 이상이면 이 폴백 불가 (어디서 분리할지 알 수 없음)
### 4. 코드 조합에서 manualConfig.value 폴백 제거
- **결정**: `extractedManualValues[i] || part.manualConfig?.value || ""``extractedManualValues[i] || ""`
- **근거**: `manualConfig.value`는 UI에서 입력/편집할 수 없는 유령 필드. `ManualConfigPanel.tsx``value` 입력란이 없어 DB에 한번 저장되면 스프레드 연산자로 계속 보존됨
- **이중 조치**: 코드에서 폴백 제거 + DB 마이그레이션으로 기존 "BULK1" 값 정리
### 5. DB 마이그레이션은 BULK1만 타겟팅
- **결정**: `manual_config->>'value' = 'BULK1'` 조건으로 한정
- **근거**: 다른 value가 의도적으로 설정된 경우가 있을 수 있음. 확인된 문제("BULK1")만 정리하여 부작용 방지
- **대안 검토**: 전체 `manual_config.value` 키 제거 → 보류 (운영 판단 필요)
### 6. extractManualValuesFromInput 헬퍼 분리
- **결정**: 기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출
- **근거**: 추출 로직이 약 110줄로 `allocateCode`가 과도하게 비대함. 헬퍼로 분리하면 순서 변경도 자연스러움
- **원칙**: 로직 자체는 변경 없음, 위치만 이동 (구조적 변경과 행위적 변경 분리)
### 7. 프론트엔드 변경 불필요
- **결정**: 프론트엔드 코드 수정 없음
- **근거**: `_numberingRuleId`가 사용자 입력 시에도 유지되고 있음 확인. `buttonActions.ts`가 정상적으로 `allocateCode`를 호출함. 문제는 백엔드 로직에만 있음
### 8. joinPartsWithSeparators 연속 구분자 방지
- **결정**: 빈 파트 뒤에 이미 같은 구분자가 있으면 중복 추가하지 않음
- **근거**: 카테고리가 비면 파트 값 `""` + 구분자 `-`가 반복되어 `--` 발생. 구분자 구조(`-ㅁㅁㅁ-001`)는 유지하되 연속(`--`)만 방지
- **조건**: `if (val || !result.endsWith(sep))` — 값이 있으면 항상 추가, 값이 없으면 이미 같은 구분자로 끝나면 스킵
### 9. 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경
- **결정**: `extractManualValuesFromInput` 내부의 카테고리/참조 빈 값 반환을 `"CATEGORY"`/`"REF"` → `""`로 변경
- **근거**: 실제 코드 생성에서 빈 카테고리는 `""`인데 템플릿에서 `"CATEGORY"`를 쓰면 구조 불일치로 추출 실패. 로그로 확인: `userInputCode=-제발-015, previewTemplate=CATEGORY-____-XXX, extractedManualValues=[]`
- **카테고리 있을 때**: `catMapping2?.format` 반환은 수정 전후 동일하여 영향 없음
---
## 관련 파일 위치
| 구분 | 파일 경로 | 설명 |
|------|----------|------|
| 수정 대상 | `backend-node/src/services/numberingRuleService.ts` | joinPartsWithSeparators(L36), buildPrefixKey(L75), extractManualValuesFromInput(신규), allocateCode(L1296) |
| 신규 생성 | `db/migrations/1053_remove_bulk1_manual_config_value.sql` | BULK1 유령 값 정리 마이그레이션 |
| 변경 없음 | `frontend/components/screen/widgets/TextInputComponent.tsx` | _numberingRuleId 유지 확인 완료 |
| 변경 없음 | `frontend/lib/registry/components/numbering-rule/config.ts` | 채번 설정 레지스트리 |
| 변경 없음 | `frontend/components/screen/config-panels/NumberConfigPanel.tsx` | 채번 규칙 설정 패널 |
| 참고 | `backend-node/src/controllers/numberingRuleController.ts` | allocateNumberingCode 컨트롤러 |
---
## 기술 참고
### allocateCode 실행 순서 (변경 전 → 후)
```
변경 전: buildPrefixKey(L1306) → 시퀀스 할당 → 수동 값 추출(L1332) → 코드 조합
변경 후: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합
```
### prefix_key 구성 (변경 전 → 후)
```
변경 전: "카테고리값" (수동 파트 무시, 모든 접두어가 같은 키)
변경 후: "카테고리값|ㅁㅁㅁ" (수동 파트 포함, 접두어별 독립 키)
```
### 폴백 체인 (변경 전 → 후)
```
변경 전: extractedManualValues[i] || manualConfig.value || ""
변경 후: extractedManualValues[i] || ""
```
### joinPartsWithSeparators 연속 구분자 방지 (변경 전 → 후)
```
변경 전: "" + "-" + "" + "-" + "ㅁㅁㅁ" → "--ㅁㅁㅁ"
변경 후: "" + "-" (이미 "-"로 끝남, 스킵) + "ㅁㅁㅁ" → "-ㅁㅁㅁ"
```
### 템플릿 정합성 (변경 전 → 후)
```
변경 전: 카테고리 비었을 때 템플릿 = "CATEGORY-____-XXX" / 입력 = "-제발-015" → 불일치 → 추출 실패
변경 후: 카테고리 비었을 때 템플릿 = "-____-XXX" / 입력 = "-제발-015" → 일치 → 추출 성공
```
### 10. 실시간 순번 미리보기 구현 방식
- **결정**: V2Input에서 `manualInputValue` 변경 시 디바운스(300ms)로 preview API를 재호출하여 suffix(순번)를 갱신
- **근거**: 기존 preview API는 `manualInputValue` 없이 호출되어 모든 접두어가 같은 기본 순번을 표시함. 접두어별 정확한 순번을 보여주려면 preview 시점에도 수동 값을 전달하여 해당 prefix_key의 시퀀스를 조회해야 함
- **대안 검토**: 프론트엔드에서 카운트 API를 별도 호출 → 기각 (기존 `previewCode` 흐름 재사용이 프로젝트 관행에 부합)
- **디바운스 300ms**: 사용자 타이핑 중 과도한 API 호출 방지. 프로젝트 기존 패턴(검색 디바운스 등)과 동일
### 11. previewCode에 manualInputValue 전달
- **결정**: `previewCode` 시그니처에 `manualInputValue?: string` 추가, `buildPrefixKey``[manualInputValue]`로 전달
- **근거**: `buildPrefixKey`가 이미 `manualValues` optional 파라미터를 지원하므로 자연스럽게 확장 가능. 순번 조회 시 접두어별 독립 시퀀스를 정확히 반영함
- **하위호환**: optional 파라미터이므로 기존 호출(`formData`만 전달)에 영향 없음
### 12. 초기 상태에서 레거시 시퀀스 조회 방지
- **결정**: `previewCode`에서 수동 파트가 있는데 `manualInputValue`가 없으면 시퀀스 조회를 건너뛰고 `currentSeq = 0` 사용
- **근거**: 수정 전에는 모든 할당이 수동 파트 없는 공용 prefix_key를 사용했으므로 레거시 시퀀스가 누적되어 있음(예: 16). 모달 초기 상태에서 이 공용 키를 조회하면 `-016`이 표시됨. 아직 어떤 접두어인지 모르는 상태이므로 `startFrom` 기본값을 보여주는 것이 정확함
- **`currentSeq = 0` + `startFrom`**: `nextSequence = 0 + startFrom(5) = 5``-005` 표시. 사용자가 입력하면 디바운스 preview가 해당 접두어의 실제 시퀀스를 조회
### 13. 카테고리 변경 시 수동 입력값 포함하여 순번 재조회
- **결정**: 초기 useEffect(카테고리 변경 트리거)에서 `previewNumberingCode` 호출 시 현재 `manualInputValue`도 함께 전달
- **근거**: 카테고리를 바꾸거나 삭제하면 prefix_key가 달라지므로 순번도 달라져야 함. 기존에는 입력값 변경과 카테고리 변경이 별도 트리거여서 카테고리 변경 시 수동 값이 누락됨
- **빈 입력값 처리**: `manualInputValue || undefined`로 처리하여 빈 문자열일 때는 기존처럼 `skipSequenceLookup` 작동
### 14. 카테고리 해석 로직 resolveCategoryFormat 헬퍼 통합
- **결정**: `previewCode`, `allocateCode`, `extractManualValuesFromInput` 3곳에 복붙된 카테고리 매핑 해석 로직을 `resolveCategoryFormat` private 메서드로 추출
- **근거**: 동일 로직 약 50줄이 3곳에 복사되어 있었음 (변수명만 pool2/ct2/cc2 등으로 다름). 한 곳을 수정하면 나머지도 동일하게 수정해야 하는 유지보수 위험
- **원칙**: 구조적 변경만 수행 (로직 변경 없음)
### BULK1이 DB에 남아있는 이유
```
ManualConfigPanel.tsx: placeholder 입력란만 존재 (value 입력란 없음)
플레이스홀더 수정 시: { ...existingConfig, placeholder: newValue }
→ 기존 config에 value: "BULK1"이 있으면 스프레드로 계속 보존됨
→ UI에서 제거 불가능한 유령 값
```

View File

@ -0,0 +1,100 @@
# [체크리스트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [맥락노트](./MPN[맥락]-품번-수동접두어채번.md)
---
## 공정 상태
- 전체 진행률: **100%** (전체 완료)
- 현재 단계: 완료
---
## 구현 체크리스트
### 1단계: 구조적 변경 (행위 변경 없음)
- [x] `numberingRuleService.ts`에서 수동 값 추출 로직을 `extractManualValuesFromInput` private 메서드로 분리
- [x] 기존 `allocateCode` 내부에서 분리한 메서드 호출로 교체
- [x] 기존 동작과 동일한지 확인 (구조적 변경만, 행위 변경 없음)
### 2단계: buildPrefixKey 수정
- [x] `buildPrefixKey` 시그니처에 `manualValues?: string[]` 파라미터 추가
- [x] 수동 파트 처리 로직 변경: `continue``manualValues`에서 값 꺼내 `prefixParts`에 추가
- [x] `previewCode` 호출부에 영향 없음 확인 (optional 파라미터)
### 3단계: allocateCode 순서 변경 + 폴백 정리
- [x] 수동 값 추출 로직을 `buildPrefixKey` 호출 전으로 이동
- [x] 수동 파트 1개 + 추출 실패 시 `userInputCode` 전체를 수동 값으로 사용하는 폴백 추가
- [x] `buildPrefixKey` 호출 시 `extractedManualValues`를 세 번째 인자로 전달
- [x] 코드 조합 단계에서 `part.manualConfig?.value` 폴백 제거
### 4단계: DB 마이그레이션
- [x] `db/migrations/1053_remove_bulk1_manual_config_value.sql` 작성
- [x] `manual_config->>'value' = 'BULK1'` 조건으로 JSONB에서 `value` 키 제거
- [x] 마이그레이션 실행 (9건 정리 완료)
### 5단계: 연속 구분자(--) 방지
- [x] `joinPartsWithSeparators`에서 빈 파트 뒤 연속 구분자 방지 로직 추가
- [x] `extractManualValuesFromInput`에서 카테고리/참조 빈 값 시 `""` 반환 (템플릿 정합성)
### 6단계: 검증
- [x] 카테고리 선택 + 수동입력 "ㅁㅁㅁ" → 카테고리값-ㅁㅁㅁ-001 생성 확인
- [x] 카테고리 미선택 + 수동입력 "ㅁㅁㅁ" → -ㅁㅁㅁ-001 생성 확인 (-- 아님)
- [x] 같은 접두어 "ㅁㅁㅁ" 재등록 → -ㅁㅁㅁ-002 순번 증가 확인
- [x] 다른 접두어 "ㅇㅇㅇ" 등록 → -ㅇㅇㅇ-001 독립 시퀀스 확인
- [x] 수동 파트 없는 채번 규칙 동작 영향 없음 확인
- [x] previewCode (미리보기) 동작 영향 없음 확인
- [x] BULK1이 더 이상 생성되지 않음 확인
### 7단계: 실시간 순번 미리보기
- [x] 백엔드 컨트롤러: preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가
- [x] 백엔드 서비스: `previewCode``manualInputValue` 파라미터 추가, `buildPrefixKey`에 전달
- [x] 프론트엔드 API: `previewNumberingCode``manualInputValue` 파라미터 추가
- [x] V2Input: `manualInputValue` 변경 시 디바운스(300ms) preview API 호출 + suffix 갱신
- [x] 백엔드 서비스: 초기 상태(수동 입력 없음) 시 레거시 공용 시퀀스 조회 건너뜀 → startFrom 기본값 표시
- [x] V2Input: 카테고리 변경 시 초기 useEffect에서도 `manualInputValue` 전달 → 순번 즉시 반영
- [x] 린트 에러 없음 확인
### 8단계: 코드 정리
- [x] 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼 추출 (약 100줄 감소)
- [x] 임시 변수명 정리 (pool2/ct2/cc2 등 복붙 흔적 제거)
- [x] 린트 에러 없음 확인
### 9단계: 정리
- [x] 계획서/맥락노트/체크리스트 최신화
---
## 알려진 이슈 (보류)
| 이슈 | 설명 | 상태 |
|------|------|------|
| 저장 실패 시 순번 갭 | allocateCode와 saveFormData가 별도 트랜잭션이라 저장 실패해도 순번 소비됨 | 보류 |
| 유령 데이터 | 중복 품명으로 간헐적 저장 성공 + 리스트 미노출 | 보류 |
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 |
| 2026-03-11 | 1-4단계 구현 완료 |
| 2026-03-11 | 5단계 추가 구현 (연속 구분자 방지 + 템플릿 정합성 복원) |
| 2026-03-11 | 계맥체 최신화 완료. 문제 4-5 보류 |
| 2026-03-12 | 7단계 실시간 순번 미리보기 구현 완료 (백엔드/프론트엔드 4파일) |
| 2026-03-12 | 계맥체 최신화 완료 |
| 2026-03-12 | 초기 상태 레거시 시퀀스 조회 방지 수정 + 계맥체 반영 |
| 2026-03-12 | 카테고리 변경 시 수동 입력값 포함 순번 재조회 수정 |
| 2026-03-12 | resolveCategoryFormat 헬퍼 추출 코드 정리 + 계맥체 최신화 |
| 2026-03-12 | 6단계 검증 완료. 전체 완료 |

View File

@ -17,14 +17,17 @@ import { ScreenContextProvider } from "@/contexts/ScreenContext";
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
import {
PopLayoutDataV5,
PopLayoutData,
GridMode,
isV5Layout,
createEmptyPopLayoutV5,
isPopLayout,
createEmptyLayout,
GAP_PRESETS,
GRID_BREAKPOINTS,
BLOCK_GAP,
BLOCK_PADDING,
detectGridMode,
} from "@/components/pop/designer/types/pop-layout";
import { loadLegacyLayout } from "@/components/pop/designer/utils/legacyLoader";
// POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import)
import "@/lib/registry/pop-components";
import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals";
@ -79,7 +82,7 @@ function PopScreenViewPage() {
const { user } = useAuth();
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
const [layout, setLayout] = useState<PopLayoutData>(createEmptyLayout());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -116,22 +119,22 @@ function PopScreenViewPage() {
try {
const popLayout = await screenApi.getLayoutPop(screenId);
if (popLayout && isV5Layout(popLayout)) {
// v5 레이아웃 로드
setLayout(popLayout);
if (popLayout && isPopLayout(popLayout)) {
const v6Layout = loadLegacyLayout(popLayout);
setLayout(v6Layout);
const componentCount = Object.keys(popLayout.components).length;
console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
} else if (popLayout) {
// 다른 버전 레이아웃은 빈 v5로 처리
console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version);
setLayout(createEmptyPopLayoutV5());
setLayout(createEmptyLayout());
} else {
console.log("[POP] 레이아웃 없음");
setLayout(createEmptyPopLayoutV5());
setLayout(createEmptyLayout());
}
} catch (layoutError) {
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
setLayout(createEmptyPopLayoutV5());
setLayout(createEmptyLayout());
}
} catch (error) {
console.error("[POP] 화면 로드 실패:", error);
@ -318,12 +321,8 @@ function PopScreenViewPage() {
style={{ maxWidth: 1366 }}
>
{(() => {
// Gap 프리셋 계산
const currentGapPreset = layout.settings.gapPreset || "medium";
const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0;
const breakpoint = GRID_BREAKPOINTS[currentModeKey];
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
const adjustedGap = BLOCK_GAP;
const adjustedPadding = BLOCK_PADDING;
return (
<PopViewerWithModals

View File

@ -4,8 +4,8 @@ import { useCallback, useRef, useState, useEffect, useMemo } from "react";
import { useDrop } from "react-dnd";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV5,
PopComponentDefinitionV5,
PopLayoutData,
PopComponentDefinition,
PopComponentType,
PopGridPosition,
GridMode,
@ -17,8 +17,12 @@ import {
ModalSizePreset,
MODAL_SIZE_PRESETS,
resolveModalWidth,
BLOCK_SIZE,
BLOCK_GAP,
BLOCK_PADDING,
getBlockColumns,
} from "./types/pop-layout";
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react";
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react";
import { useDrag } from "react-dnd";
import { Button } from "@/components/ui/button";
import {
@ -30,13 +34,12 @@ import {
} from "@/components/ui/select";
import { toast } from "sonner";
import PopRenderer from "./renderers/PopRenderer";
import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, needsReview } from "./utils/gridUtils";
import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions } from "./utils/gridUtils";
import { DND_ITEM_TYPES } from "./constants";
/**
*
* @param relX X ( )
* @param relY Y ( )
* V6: 캔버스
* (BLOCK_SIZE) 1fr
*/
function calcGridPosition(
relX: number,
@ -47,21 +50,13 @@ function calcGridPosition(
gap: number,
padding: number
): { col: number; row: number } {
// 패딩 제외한 좌표
const x = relX - padding;
const y = relY - padding;
// 사용 가능한 너비 (패딩과 gap 제외)
const availableWidth = canvasWidth - padding * 2 - gap * (columns - 1);
const colWidth = availableWidth / columns;
const cellStride = BLOCK_SIZE + gap;
// 셀+gap 단위로 계산
const cellStride = colWidth + gap;
const rowStride = rowHeight + gap;
// 그리드 좌표 (1부터 시작)
const col = Math.max(1, Math.min(columns, Math.floor(x / cellStride) + 1));
const row = Math.max(1, Math.floor(y / rowStride) + 1);
const row = Math.max(1, Math.floor(y / cellStride) + 1);
return { col, row };
}
@ -78,13 +73,13 @@ interface DragItemMoveComponent {
}
// ========================================
// 프리셋 해상도 (4개 모드) - 너비만 정의
// V6: 프리셋 해상도 (블록 칸 수 동적 계산)
// ========================================
const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, icon: Smartphone },
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 600, icon: Smartphone },
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 820, icon: Tablet },
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, icon: Tablet },
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: `모바일↕ (${getBlockColumns(375)}칸)`, width: 375, icon: Smartphone },
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: `모바일↔ (${getBlockColumns(600)}칸)`, width: 600, icon: Smartphone },
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: `태블릿↕ (${getBlockColumns(820)}칸)`, width: 820, icon: Tablet },
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: `태블릿↔ (${getBlockColumns(1024)}칸)`, width: 1024, icon: Tablet },
] as const;
type ViewportPreset = GridMode;
@ -100,13 +95,13 @@ const CANVAS_EXTRA_ROWS = 3; // 여유 행 수
// Props
// ========================================
interface PopCanvasProps {
layout: PopLayoutDataV5;
layout: PopLayoutData;
selectedComponentId: string | null;
currentMode: GridMode;
onModeChange: (mode: GridMode) => void;
onSelectComponent: (id: string | null) => void;
onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => void;
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinition>) => void;
onDeleteComponent: (componentId: string) => void;
onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void;
onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void;
@ -168,7 +163,7 @@ export default function PopCanvas({
}, [layout.modals]);
// activeCanvasId에 따라 렌더링할 layout 분기
const activeLayout = useMemo((): PopLayoutDataV5 => {
const activeLayout = useMemo((): PopLayoutData => {
if (activeCanvasId === "main") return layout;
const modal = layout.modals?.find(m => m.id === activeCanvasId);
if (!modal) return layout; // fallback
@ -202,15 +197,22 @@ export default function PopCanvas({
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
// 현재 뷰포트 해상도
// V6: 뷰포트에서 동적 블록 칸 수 계산
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
const breakpoint = GRID_BREAKPOINTS[currentMode];
const dynamicColumns = getBlockColumns(customWidth);
const breakpoint = {
...GRID_BREAKPOINTS[currentMode],
columns: dynamicColumns,
rowHeight: BLOCK_SIZE,
gap: BLOCK_GAP,
padding: BLOCK_PADDING,
label: `${dynamicColumns}칸 블록`,
};
// Gap 프리셋 적용
// V6: 블록 간격 고정 (프리셋 무관)
const currentGapPreset = layout.settings.gapPreset || "medium";
const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0;
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
const adjustedGap = BLOCK_GAP;
const adjustedPadding = BLOCK_PADDING;
// 숨김 컴포넌트 ID 목록 (activeLayout 기반)
const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || [];
@ -399,9 +401,9 @@ export default function PopCanvas({
const effectivePositions = getAllEffectivePositions(activeLayout, currentMode);
// 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기
// 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
// 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
const currentEffectivePos = effectivePositions.get(dragItem.componentId);
const componentData = layout.components[dragItem.componentId];
const componentData = activeLayout.components[dragItem.componentId];
if (!currentEffectivePos && !componentData) return;
@ -470,22 +472,8 @@ export default function PopCanvas({
);
}, [activeLayout.components, hiddenComponentIds]);
// 검토 필요 컴포넌트 목록
const reviewComponents = useMemo(() => {
return visibleComponents.filter(comp => {
const hasOverride = !!activeLayout.overrides?.[currentMode]?.positions?.[comp.id];
return needsReview(currentMode, hasOverride);
});
}, [visibleComponents, activeLayout.overrides, currentMode]);
// 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때)
const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0;
// 12칸 모드가 아닐 때만 패널 표시
// 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시
const hasGridComponents = Object.keys(activeLayout.components).length > 0;
const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents);
const showRightPanel = showReviewPanel || showHiddenPanel;
return (
<div className="flex h-full flex-col bg-muted">
@ -666,7 +654,7 @@ export default function PopCanvas({
<div
className="relative mx-auto my-8 origin-top overflow-visible flex gap-4"
style={{
width: showRightPanel
width: showHiddenPanel
? `${customWidth + 32 + 220}px` // 오른쪽 패널 공간 추가
: `${customWidth + 32}px`,
minHeight: `${dynamicCanvasHeight + 32}px`,
@ -774,20 +762,11 @@ export default function PopCanvas({
</div>
{/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */}
{showRightPanel && (
{showHiddenPanel && (
<div
className="flex flex-col gap-3"
style={{ marginTop: "32px" }}
>
{/* 검토 필요 패널 */}
{showReviewPanel && (
<ReviewPanel
components={reviewComponents}
selectedComponentId={selectedComponentId}
onSelectComponent={onSelectComponent}
/>
)}
{/* 숨김 컴포넌트 패널 */}
{showHiddenPanel && (
<HiddenPanel
@ -805,7 +784,7 @@ export default function PopCanvas({
{/* 하단 정보 */}
<div className="flex items-center justify-between border-t bg-background px-4 py-2">
<div className="text-xs text-muted-foreground">
{breakpoint.label} - {breakpoint.columns} ( : {breakpoint.rowHeight}px)
V6 - {dynamicColumns} (: {BLOCK_SIZE}px, : {BLOCK_GAP}px)
</div>
<div className="text-xs text-muted-foreground">
Space + 드래그: 패닝 | Ctrl + :
@ -819,99 +798,12 @@ export default function PopCanvas({
// 검토 필요 영역 (오른쪽 패널)
// ========================================
interface ReviewPanelProps {
components: PopComponentDefinitionV5[];
selectedComponentId: string | null;
onSelectComponent: (id: string | null) => void;
}
function ReviewPanel({
components,
selectedComponentId,
onSelectComponent,
}: ReviewPanelProps) {
return (
<div
className="flex flex-col rounded-lg border-2 border-dashed border-primary/40 bg-primary/5"
style={{
width: "200px",
maxHeight: "300px",
}}
>
{/* 헤더 */}
<div className="flex items-center gap-2 border-b border-primary/20 bg-primary/5 px-3 py-2 rounded-t-lg">
<AlertTriangle className="h-4 w-4 text-primary" />
<span className="text-xs font-semibold text-primary">
({components.length})
</span>
</div>
{/* 컴포넌트 목록 */}
<div className="flex-1 overflow-auto p-2 space-y-2">
{components.map((comp) => (
<ReviewItem
key={comp.id}
component={comp}
isSelected={selectedComponentId === comp.id}
onSelect={() => onSelectComponent(comp.id)}
/>
))}
</div>
{/* 안내 문구 */}
<div className="border-t border-primary/20 px-3 py-2 bg-primary/10 rounded-b-lg">
<p className="text-[10px] text-primary leading-tight">
.
</p>
</div>
</div>
);
}
// ========================================
// 검토 필요 아이템 (ReviewPanel 내부)
// ========================================
interface ReviewItemProps {
component: PopComponentDefinitionV5;
isSelected: boolean;
onSelect: () => void;
}
function ReviewItem({
component,
isSelected,
onSelect,
}: ReviewItemProps) {
return (
<div
className={cn(
"flex flex-col gap-1 rounded-md border-2 p-2 cursor-pointer transition-all",
isSelected
? "border-primary bg-primary/10 shadow-sm"
: "border-primary/20 bg-background hover:border-primary/60 hover:bg-primary/10"
)}
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
>
<span className="text-xs font-medium text-primary line-clamp-1">
{component.label || component.id}
</span>
<span className="text-[10px] text-primary bg-primary/10 rounded px-1.5 py-0.5 self-start">
</span>
</div>
);
}
// ========================================
// 숨김 컴포넌트 영역 (오른쪽 패널)
// ========================================
interface HiddenPanelProps {
components: PopComponentDefinitionV5[];
components: PopComponentDefinition[];
selectedComponentId: string | null;
onSelectComponent: (id: string | null) => void;
onHideComponent?: (componentId: string) => void;
@ -997,7 +889,7 @@ function HiddenPanel({
// ========================================
interface HiddenItemProps {
component: PopComponentDefinitionV5;
component: PopComponentDefinition;
isSelected: boolean;
onSelect: () => void;
}

View File

@ -19,21 +19,22 @@ import PopCanvas from "./PopCanvas";
import ComponentEditorPanel from "./panels/ComponentEditorPanel";
import ComponentPalette from "./panels/ComponentPalette";
import {
PopLayoutDataV5,
PopLayoutData,
PopComponentType,
PopComponentDefinitionV5,
PopComponentDefinition,
PopGridPosition,
GridMode,
GapPreset,
createEmptyPopLayoutV5,
isV5Layout,
addComponentToV5Layout,
createComponentDefinitionV5,
createEmptyLayout,
isPopLayout,
addComponentToLayout,
createComponentDefinition,
GRID_BREAKPOINTS,
PopModalDefinition,
PopDataConnection,
} from "./types/pop-layout";
import { getAllEffectivePositions } from "./utils/gridUtils";
import { loadLegacyLayout } from "./utils/legacyLoader";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
import { PopDesignerContext } from "./PopDesignerContext";
@ -59,10 +60,10 @@ export default function PopDesigner({
// ========================================
// 레이아웃 상태
// ========================================
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
const [layout, setLayout] = useState<PopLayoutData>(createEmptyLayout());
// 히스토리
const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
const [history, setHistory] = useState<PopLayoutData[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// UI 상태
@ -84,7 +85,7 @@ export default function PopDesigner({
const [activeCanvasId, setActiveCanvasId] = useState<string>("main");
// 선택된 컴포넌트 (activeCanvasId에 따라 메인 또는 모달에서 조회)
const selectedComponent: PopComponentDefinitionV5 | null = (() => {
const selectedComponent: PopComponentDefinition | null = (() => {
if (!selectedComponentId) return null;
if (activeCanvasId === "main") {
return layout.components[selectedComponentId] || null;
@ -96,7 +97,7 @@ export default function PopDesigner({
// ========================================
// 히스토리 관리
// ========================================
const saveToHistory = useCallback((newLayout: PopLayoutDataV5) => {
const saveToHistory = useCallback((newLayout: PopLayoutData) => {
setHistory((prev) => {
const newHistory = prev.slice(0, historyIndex + 1);
newHistory.push(JSON.parse(JSON.stringify(newLayout)));
@ -150,14 +151,13 @@ export default function PopDesigner({
try {
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) {
// v5 레이아웃 로드
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
if (loadedLayout && isPopLayout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) {
if (!loadedLayout.settings.gapPreset) {
loadedLayout.settings.gapPreset = "medium";
}
setLayout(loadedLayout);
setHistory([loadedLayout]);
const v6Layout = loadLegacyLayout(loadedLayout);
setLayout(v6Layout);
setHistory([v6Layout]);
setHistoryIndex(0);
// 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지)
@ -175,7 +175,7 @@ export default function PopDesigner({
console.log(`POP 레이아웃 로드: ${existingIds.length}개 컴포넌트, idCounter: ${maxId + 1}`);
} else {
// 새 화면 또는 빈 레이아웃
const emptyLayout = createEmptyPopLayoutV5();
const emptyLayout = createEmptyLayout();
setLayout(emptyLayout);
setHistory([emptyLayout]);
setHistoryIndex(0);
@ -184,7 +184,7 @@ export default function PopDesigner({
} catch (error) {
console.error("레이아웃 로드 실패:", error);
toast.error("레이아웃을 불러오는데 실패했습니다");
const emptyLayout = createEmptyPopLayoutV5();
const emptyLayout = createEmptyLayout();
setLayout(emptyLayout);
setHistory([emptyLayout]);
setHistoryIndex(0);
@ -225,13 +225,13 @@ export default function PopDesigner({
if (activeCanvasId === "main") {
// 메인 캔버스
const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
const newLayout = addComponentToLayout(layout, componentId, type, position, `${type} ${idCounter}`);
setLayout(newLayout);
saveToHistory(newLayout);
} else {
// 모달 캔버스
setLayout(prev => {
const comp = createComponentDefinitionV5(componentId, type, position, `${type} ${idCounter}`);
const comp = createComponentDefinition(componentId, type, position, `${type} ${idCounter}`);
const newLayout = {
...prev,
modals: (prev.modals || []).map(m => {
@ -250,7 +250,7 @@ export default function PopDesigner({
);
const handleUpdateComponent = useCallback(
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
(componentId: string, updates: Partial<PopComponentDefinition>) => {
// 함수적 업데이트로 stale closure 방지
setLayout((prev) => {
if (activeCanvasId === "main") {
@ -303,7 +303,7 @@ export default function PopDesigner({
const newId = `conn_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const newConnection: PopDataConnection = { ...conn, id: newId };
const prevConnections = prev.dataFlow?.connections || [];
const newLayout: PopLayoutDataV5 = {
const newLayout: PopLayoutData = {
...prev,
dataFlow: {
...prev.dataFlow,
@ -322,7 +322,7 @@ export default function PopDesigner({
(connectionId: string, conn: Omit<PopDataConnection, "id">) => {
setLayout((prev) => {
const prevConnections = prev.dataFlow?.connections || [];
const newLayout: PopLayoutDataV5 = {
const newLayout: PopLayoutData = {
...prev,
dataFlow: {
...prev.dataFlow,
@ -343,7 +343,7 @@ export default function PopDesigner({
(connectionId: string) => {
setLayout((prev) => {
const prevConnections = prev.dataFlow?.connections || [];
const newLayout: PopLayoutDataV5 = {
const newLayout: PopLayoutData = {
...prev,
dataFlow: {
...prev.dataFlow,
@ -389,97 +389,156 @@ export default function PopDesigner({
const handleMoveComponent = useCallback(
(componentId: string, newPosition: PopGridPosition) => {
const component = layout.components[componentId];
if (!component) return;
// 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정
if (currentMode === "tablet_landscape") {
const newLayout = {
...layout,
components: {
...layout.components,
[componentId]: {
...component,
position: newPosition,
},
},
};
setLayout(newLayout);
saveToHistory(newLayout);
setHasChanges(true);
} else {
// 다른 모드인 경우: 오버라이드에 저장
// 숨김 상태였던 컴포넌트를 이동하면 숨김 해제도 함께 처리
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
const isHidden = currentHidden.includes(componentId);
const newHidden = isHidden
? currentHidden.filter(id => id !== componentId)
: currentHidden;
const newLayout = {
...layout,
overrides: {
...layout.overrides,
[currentMode]: {
...layout.overrides?.[currentMode],
positions: {
...layout.overrides?.[currentMode]?.positions,
[componentId]: newPosition,
setLayout((prev) => {
if (activeCanvasId === "main") {
const component = prev.components[componentId];
if (!component) return prev;
if (currentMode === "tablet_landscape") {
const newLayout = {
...prev,
components: {
...prev.components,
[componentId]: { ...component, position: newPosition },
},
// 숨김 배열 업데이트 (빈 배열이면 undefined로)
hidden: newHidden.length > 0 ? newHidden : undefined,
},
},
};
setLayout(newLayout);
saveToHistory(newLayout);
setHasChanges(true);
}
};
saveToHistory(newLayout);
return newLayout;
} else {
const currentHidden = prev.overrides?.[currentMode]?.hidden || [];
const newHidden = currentHidden.filter(id => id !== componentId);
const newLayout = {
...prev,
overrides: {
...prev.overrides,
[currentMode]: {
...prev.overrides?.[currentMode],
positions: {
...prev.overrides?.[currentMode]?.positions,
[componentId]: newPosition,
},
hidden: newHidden.length > 0 ? newHidden : undefined,
},
},
};
saveToHistory(newLayout);
return newLayout;
}
} else {
// 모달 캔버스
const newLayout = {
...prev,
modals: (prev.modals || []).map(m => {
if (m.id !== activeCanvasId) return m;
const component = m.components[componentId];
if (!component) return m;
if (currentMode === "tablet_landscape") {
return {
...m,
components: {
...m.components,
[componentId]: { ...component, position: newPosition },
},
};
} else {
const currentHidden = m.overrides?.[currentMode]?.hidden || [];
const newHidden = currentHidden.filter(id => id !== componentId);
return {
...m,
overrides: {
...m.overrides,
[currentMode]: {
...m.overrides?.[currentMode],
positions: {
...m.overrides?.[currentMode]?.positions,
[componentId]: newPosition,
},
hidden: newHidden.length > 0 ? newHidden : undefined,
},
},
};
}
}),
};
saveToHistory(newLayout);
return newLayout;
}
});
setHasChanges(true);
},
[layout, saveToHistory, currentMode]
[saveToHistory, currentMode, activeCanvasId]
);
const handleResizeComponent = useCallback(
(componentId: string, newPosition: PopGridPosition) => {
const component = layout.components[componentId];
if (!component) return;
// 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정
if (currentMode === "tablet_landscape") {
const newLayout = {
...layout,
components: {
...layout.components,
[componentId]: {
...component,
position: newPosition,
},
},
};
setLayout(newLayout);
// 리사이즈는 드래그 중 계속 호출되므로 히스토리는 마우스업 시에만 저장
// 현재는 간단히 매번 저장 (최적화 가능)
setHasChanges(true);
} else {
// 다른 모드인 경우: 오버라이드에 저장
const newLayout = {
...layout,
overrides: {
...layout.overrides,
[currentMode]: {
...layout.overrides?.[currentMode],
positions: {
...layout.overrides?.[currentMode]?.positions,
[componentId]: newPosition,
setLayout((prev) => {
if (activeCanvasId === "main") {
const component = prev.components[componentId];
if (!component) return prev;
if (currentMode === "tablet_landscape") {
return {
...prev,
components: {
...prev.components,
[componentId]: { ...component, position: newPosition },
},
},
},
};
setLayout(newLayout);
setHasChanges(true);
}
};
} else {
return {
...prev,
overrides: {
...prev.overrides,
[currentMode]: {
...prev.overrides?.[currentMode],
positions: {
...prev.overrides?.[currentMode]?.positions,
[componentId]: newPosition,
},
},
},
};
}
} else {
// 모달 캔버스
return {
...prev,
modals: (prev.modals || []).map(m => {
if (m.id !== activeCanvasId) return m;
const component = m.components[componentId];
if (!component) return m;
if (currentMode === "tablet_landscape") {
return {
...m,
components: {
...m.components,
[componentId]: { ...component, position: newPosition },
},
};
} else {
return {
...m,
overrides: {
...m.overrides,
[currentMode]: {
...m.overrides?.[currentMode],
positions: {
...m.overrides?.[currentMode]?.positions,
[componentId]: newPosition,
},
},
},
};
}
}),
};
}
});
setHasChanges(true);
},
[layout, currentMode]
[currentMode, activeCanvasId]
);
const handleResizeEnd = useCallback(
@ -493,51 +552,87 @@ export default function PopDesigner({
// 컴포넌트가 자신의 rowSpan/colSpan을 동적으로 변경 요청 (CardList 확장 등)
const handleRequestResize = useCallback(
(componentId: string, newRowSpan: number, newColSpan?: number) => {
const component = layout.components[componentId];
if (!component) return;
setLayout((prev) => {
const buildPosition = (comp: PopComponentDefinition) => ({
...comp.position,
rowSpan: newRowSpan,
...(newColSpan !== undefined ? { colSpan: newColSpan } : {}),
});
const newPosition = {
...component.position,
rowSpan: newRowSpan,
...(newColSpan !== undefined ? { colSpan: newColSpan } : {}),
};
// 기본 모드(tablet_landscape)인 경우: 원본 position 직접 수정
if (currentMode === "tablet_landscape") {
const newLayout = {
...layout,
components: {
...layout.components,
[componentId]: {
...component,
position: newPosition,
},
},
};
setLayout(newLayout);
saveToHistory(newLayout);
setHasChanges(true);
} else {
// 다른 모드인 경우: 오버라이드에 저장
const newLayout = {
...layout,
overrides: {
...layout.overrides,
[currentMode]: {
...layout.overrides?.[currentMode],
positions: {
...layout.overrides?.[currentMode]?.positions,
[componentId]: newPosition,
if (activeCanvasId === "main") {
const component = prev.components[componentId];
if (!component) return prev;
const newPosition = buildPosition(component);
if (currentMode === "tablet_landscape") {
const newLayout = {
...prev,
components: {
...prev.components,
[componentId]: { ...component, position: newPosition },
},
},
},
};
setLayout(newLayout);
saveToHistory(newLayout);
setHasChanges(true);
}
};
saveToHistory(newLayout);
return newLayout;
} else {
const newLayout = {
...prev,
overrides: {
...prev.overrides,
[currentMode]: {
...prev.overrides?.[currentMode],
positions: {
...prev.overrides?.[currentMode]?.positions,
[componentId]: newPosition,
},
},
},
};
saveToHistory(newLayout);
return newLayout;
}
} else {
// 모달 캔버스
const newLayout = {
...prev,
modals: (prev.modals || []).map(m => {
if (m.id !== activeCanvasId) return m;
const component = m.components[componentId];
if (!component) return m;
const newPosition = buildPosition(component);
if (currentMode === "tablet_landscape") {
return {
...m,
components: {
...m.components,
[componentId]: { ...component, position: newPosition },
},
};
} else {
return {
...m,
overrides: {
...m.overrides,
[currentMode]: {
...m.overrides?.[currentMode],
positions: {
...m.overrides?.[currentMode]?.positions,
[componentId]: newPosition,
},
},
},
};
}
}),
};
saveToHistory(newLayout);
return newLayout;
}
});
setHasChanges(true);
},
[layout, currentMode, saveToHistory]
[currentMode, saveToHistory, activeCanvasId]
);
// ========================================
@ -605,9 +700,6 @@ export default function PopDesigner({
// ========================================
const handleHideComponent = useCallback((componentId: string) => {
// 12칸 모드에서는 숨기기 불가
if (currentMode === "tablet_landscape") return;
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
// 이미 숨겨져 있으면 무시

View File

@ -1,4 +1,4 @@
// POP 디자이너 컴포넌트 export (v5 그리드 시스템)
// POP 디자이너 컴포넌트 export (블록 그리드 시스템)
// 타입
export * from "./types";
@ -17,11 +17,12 @@ export { default as PopRenderer } from "./renderers/PopRenderer";
// 유틸리티
export * from "./utils/gridUtils";
export * from "./utils/legacyLoader";
// 핵심 타입 재export (편의)
export type {
PopLayoutDataV5,
PopComponentDefinitionV5,
PopLayoutData,
PopComponentDefinition,
PopComponentType,
PopGridPosition,
GridMode,

View File

@ -3,10 +3,12 @@
import React from "react";
import { cn } from "@/lib/utils";
import {
PopComponentDefinitionV5,
PopComponentDefinition,
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
BLOCK_SIZE,
getBlockColumns,
} from "../types/pop-layout";
import {
Settings,
@ -31,15 +33,15 @@ import ConnectionEditor from "./ConnectionEditor";
interface ComponentEditorPanelProps {
/** 선택된 컴포넌트 */
component: PopComponentDefinitionV5 | null;
component: PopComponentDefinition | null;
/** 현재 모드 */
currentMode: GridMode;
/** 컴포넌트 업데이트 */
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
onUpdateComponent?: (updates: Partial<PopComponentDefinition>) => void;
/** 추가 className */
className?: string;
/** 그리드에 배치된 모든 컴포넌트 */
allComponents?: PopComponentDefinitionV5[];
allComponents?: PopComponentDefinition[];
/** 컴포넌트 선택 콜백 */
onSelectComponent?: (componentId: string) => void;
/** 현재 선택된 컴포넌트 ID */
@ -247,11 +249,11 @@ export default function ComponentEditorPanel({
// ========================================
interface PositionFormProps {
component: PopComponentDefinitionV5;
component: PopComponentDefinition;
currentMode: GridMode;
isDefaultMode: boolean;
columns: number;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
onUpdate?: (updates: Partial<PopComponentDefinition>) => void;
}
function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) {
@ -378,7 +380,7 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
</span>
</div>
<p className="text-xs text-muted-foreground">
: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px
: {position.rowSpan * BLOCK_SIZE + (position.rowSpan - 1) * 2}px
</p>
</div>
@ -400,13 +402,13 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
// ========================================
interface ComponentSettingsFormProps {
component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
component: PopComponentDefinition;
onUpdate?: (updates: Partial<PopComponentDefinition>) => void;
currentMode?: GridMode;
previewPageIndex?: number;
onPreviewPage?: (pageIndex: number) => void;
modals?: PopModalDefinition[];
allComponents?: PopComponentDefinitionV5[];
allComponents?: PopComponentDefinition[];
connections?: PopDataConnection[];
}
@ -464,16 +466,16 @@ function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIn
// ========================================
interface VisibilityFormProps {
component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
component: PopComponentDefinition;
onUpdate?: (updates: Partial<PopComponentDefinition>) => void;
}
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
const modes: Array<{ key: GridMode; label: string }> = [
{ key: "tablet_landscape", label: "태블릿 가로 (12칸)" },
{ key: "tablet_portrait", label: "태블릿 세로 (8칸)" },
{ key: "mobile_landscape", label: "모바일 가로 (6칸)" },
{ key: "mobile_portrait", label: "모바일 세로 (4칸)" },
{ key: "tablet_landscape", label: `태블릿 가로 (${getBlockColumns(1024)}칸)` },
{ key: "tablet_portrait", label: `태블릿 세로 (${getBlockColumns(820)}칸)` },
{ key: "mobile_landscape", label: `모바일 가로 (${getBlockColumns(600)}칸)` },
{ key: "mobile_portrait", label: `모바일 세로 (${getBlockColumns(375)}칸)` },
];
const handleVisibilityChange = (mode: GridMode, visible: boolean) => {

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, ScanLine, UserCircle, BarChart2 } from "lucide-react";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2, ClipboardCheck } from "lucide-react";
import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의
@ -93,6 +93,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: UserCircle,
description: "사용자 프로필 / PC 전환 / 로그아웃",
},
{
type: "pop-work-detail",
label: "작업 상세",
icon: ClipboardCheck,
description: "공정별 체크리스트/검사/실적 상세 작업 화면",
},
];
// 드래그 가능한 컴포넌트 아이템

View File

@ -13,7 +13,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import {
PopComponentDefinitionV5,
PopComponentDefinition,
PopDataConnection,
} from "../types/pop-layout";
import {
@ -26,8 +26,8 @@ import { getTableColumns } from "@/lib/api/tableManagement";
// ========================================
interface ConnectionEditorProps {
component: PopComponentDefinitionV5;
allComponents: PopComponentDefinitionV5[];
component: PopComponentDefinition;
allComponents: PopComponentDefinition[];
connections: PopDataConnection[];
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
@ -102,8 +102,8 @@ export default function ConnectionEditor({
// ========================================
interface SendSectionProps {
component: PopComponentDefinitionV5;
allComponents: PopComponentDefinitionV5[];
component: PopComponentDefinition;
allComponents: PopComponentDefinition[];
outgoing: PopDataConnection[];
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
@ -197,15 +197,15 @@ function SendSection({
// ========================================
interface SimpleConnectionFormProps {
component: PopComponentDefinitionV5;
allComponents: PopComponentDefinitionV5[];
component: PopComponentDefinition;
allComponents: PopComponentDefinition[];
initial?: PopDataConnection;
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
onCancel?: () => void;
submitLabel: string;
}
function extractSubTableName(comp: PopComponentDefinitionV5): string | null {
function extractSubTableName(comp: PopComponentDefinition): string | null {
const cfg = comp.config as Record<string, unknown> | undefined;
if (!cfg) return null;
@ -423,8 +423,8 @@ function SimpleConnectionForm({
// ========================================
interface ReceiveSectionProps {
component: PopComponentDefinitionV5;
allComponents: PopComponentDefinitionV5[];
component: PopComponentDefinition;
allComponents: PopComponentDefinition[];
incoming: PopDataConnection[];
}

View File

@ -5,14 +5,18 @@ import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils";
import { DND_ITEM_TYPES } from "../constants";
import {
PopLayoutDataV5,
PopComponentDefinitionV5,
PopLayoutData,
PopComponentDefinition,
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
GridBreakpoint,
detectGridMode,
PopComponentType,
BLOCK_SIZE,
BLOCK_GAP,
BLOCK_PADDING,
getBlockColumns,
} from "../types/pop-layout";
import {
convertAndResolvePositions,
@ -27,7 +31,7 @@ import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
interface PopRendererProps {
/** v5 레이아웃 데이터 */
layout: PopLayoutDataV5;
layout: PopLayoutData;
/** 현재 뷰포트 너비 */
viewportWidth: number;
/** 현재 모드 (자동 감지 또는 수동 지정) */
@ -80,6 +84,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-field": "입력",
"pop-scanner": "스캐너",
"pop-profile": "프로필",
"pop-work-detail": "작업 상세",
};
// ========================================
@ -107,18 +112,27 @@ export default function PopRenderer({
}: PopRendererProps) {
const { gridConfig, components, overrides } = layout;
// 현재 모드 (자동 감지 또는 지정)
// V6: 뷰포트 너비에서 블록 칸 수 동적 계산
const mode = currentMode || detectGridMode(viewportWidth);
const breakpoint = GRID_BREAKPOINTS[mode];
const columns = getBlockColumns(viewportWidth);
// Gap/Padding: 오버라이드 우선, 없으면 기본값 사용
const finalGap = overrideGap !== undefined ? overrideGap : breakpoint.gap;
const finalPadding = overridePadding !== undefined ? overridePadding : breakpoint.padding;
// V6: 블록 간격 고정
const finalGap = overrideGap !== undefined ? overrideGap : BLOCK_GAP;
const finalPadding = overridePadding !== undefined ? overridePadding : BLOCK_PADDING;
// 하위 호환: breakpoint 객체 (ResizeHandles 등에서 사용)
const breakpoint: GridBreakpoint = {
columns,
rowHeight: BLOCK_SIZE,
gap: finalGap,
padding: finalPadding,
label: `${columns}칸 블록`,
};
// 숨김 컴포넌트 ID 목록
const hiddenIds = overrides?.[mode]?.hidden || [];
// 동적 행 수 계산 (가이드 셀 + Grid 스타일 공유, 숨김 컴포넌트 제외)
// 동적 행 수 계산
const dynamicRowCount = useMemo(() => {
const visibleComps = Object.values(components).filter(
comp => !hiddenIds.includes(comp.id)
@ -131,19 +145,17 @@ export default function PopRenderer({
return Math.max(10, maxRowEnd + 3);
}, [components, overrides, mode, hiddenIds]);
// CSS Grid 스타일
// 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집)
// 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능)
// V6: CSS Grid - 열은 1fr(뷰포트 꽉 채움), 행은 고정 BLOCK_SIZE
const rowTemplate = isDesignMode
? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`
: `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`;
? `repeat(${dynamicRowCount}, ${BLOCK_SIZE}px)`
: `repeat(${dynamicRowCount}, minmax(${BLOCK_SIZE}px, auto))`;
const autoRowHeight = isDesignMode
? `${breakpoint.rowHeight}px`
: `minmax(${breakpoint.rowHeight}px, auto)`;
? `${BLOCK_SIZE}px`
: `minmax(${BLOCK_SIZE}px, auto)`;
const gridStyle = useMemo((): React.CSSProperties => ({
display: "grid",
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gridTemplateRows: rowTemplate,
gridAutoRows: autoRowHeight,
gap: `${finalGap}px`,
@ -151,15 +163,15 @@ export default function PopRenderer({
minHeight: "100%",
backgroundColor: "#ffffff",
position: "relative",
}), [breakpoint, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]);
}), [columns, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]);
// 그리드 가이드 셀 생성 (동적 행 수)
// 그리드 가이드 셀 생성
const gridCells = useMemo(() => {
if (!isDesignMode || !showGridGuide) return [];
const cells = [];
for (let row = 1; row <= dynamicRowCount; row++) {
for (let col = 1; col <= breakpoint.columns; col++) {
for (let col = 1; col <= columns; col++) {
cells.push({
id: `cell-${col}-${row}`,
col,
@ -168,10 +180,10 @@ export default function PopRenderer({
}
}
return cells;
}, [isDesignMode, showGridGuide, breakpoint.columns, dynamicRowCount]);
}, [isDesignMode, showGridGuide, columns, dynamicRowCount]);
// visibility 체크
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
const isVisible = (comp: PopComponentDefinition): boolean => {
if (!comp.visibility) return true;
const modeVisibility = comp.visibility[mode];
return modeVisibility !== false;
@ -196,7 +208,7 @@ export default function PopRenderer({
};
// 오버라이드 적용 또는 자동 재배치
const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => {
const getEffectivePosition = (comp: PopComponentDefinition): PopGridPosition => {
// 1순위: 오버라이드가 있으면 사용
const override = overrides?.[mode]?.positions?.[comp.id];
if (override) {
@ -214,7 +226,7 @@ export default function PopRenderer({
};
// 오버라이드 숨김 체크
const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => {
const isHiddenByOverride = (comp: PopComponentDefinition): boolean => {
return overrides?.[mode]?.hidden?.includes(comp.id) ?? false;
};
@ -311,7 +323,7 @@ export default function PopRenderer({
// ========================================
interface DraggableComponentProps {
component: PopComponentDefinitionV5;
component: PopComponentDefinition;
position: PopGridPosition;
positionStyle: React.CSSProperties;
isSelected: boolean;
@ -412,7 +424,7 @@ function DraggableComponent({
// ========================================
interface ResizeHandlesProps {
component: PopComponentDefinitionV5;
component: PopComponentDefinition;
position: PopGridPosition;
breakpoint: GridBreakpoint;
viewportWidth: number;
@ -533,7 +545,7 @@ function ResizeHandles({
// ========================================
interface ComponentContentProps {
component: PopComponentDefinitionV5;
component: PopComponentDefinition;
effectivePosition: PopGridPosition;
isDesignMode: boolean;
isSelected: boolean;
@ -603,7 +615,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
// ========================================
function renderActualComponent(
component: PopComponentDefinitionV5,
component: PopComponentDefinition,
effectivePosition?: PopGridPosition,
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void,
screenId?: string,

View File

@ -1,6 +1,4 @@
// POP 디자이너 레이아웃 타입 정의
// v5.0: CSS Grid 기반 그리드 시스템
// 2024-02 버전 통합: v1~v4 제거, v5 단일 버전
// POP 블록 그리드 레이아웃 타입 정의
// ========================================
// 공통 타입
@ -9,7 +7,7 @@
/**
* POP
*/
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";
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" | "pop-work-detail";
/**
*
@ -99,24 +97,39 @@ export interface PopLayoutMetadata {
}
// ========================================
// v5 그리드 기반 레이아웃
// v6 정사각형 블록 그리드 시스템
// ========================================
// 핵심: CSS Grid로 정확한 위치 지정
// - 열/행 좌표로 배치 (col, row)
// - 칸 단위 크기 (colSpan, rowSpan)
// - Material Design 브레이크포인트 기반
// 핵심: 균일한 정사각형 블록 (24px x 24px)
// - 열/행 좌표로 배치 (col, row) - 블록 단위
// - 뷰포트 너비에 따라 칸 수 동적 계산
// - 단일 좌표계 (모드별 변환 불필요)
/**
* (4)
* V6
*/
export const BLOCK_SIZE = 24; // 블록 크기 (px, 정사각형)
export const BLOCK_GAP = 2; // 블록 간격 (px)
export const BLOCK_PADDING = 8; // 캔버스 패딩 (px)
/**
*
*/
export function getBlockColumns(viewportWidth: number): number {
const available = viewportWidth - BLOCK_PADDING * 2;
return Math.max(1, Math.floor((available + BLOCK_GAP) / (BLOCK_SIZE + BLOCK_GAP)));
}
/**
* ( )
*/
export type GridMode =
| "mobile_portrait" // 4칸
| "mobile_landscape" // 6칸
| "tablet_portrait" // 8칸
| "tablet_landscape"; // 12칸 (기본)
| "mobile_portrait"
| "mobile_landscape"
| "tablet_portrait"
| "tablet_landscape";
/**
*
*
*/
export interface GridBreakpoint {
minWidth?: number;
@ -129,50 +142,43 @@ export interface GridBreakpoint {
}
/**
*
* (768px, 1024px) +
* V6 ( )
* columns는
*/
export const GRID_BREAKPOINTS: Record<GridMode, GridBreakpoint> = {
// 스마트폰 세로 (iPhone SE ~ Galaxy S25 Ultra)
mobile_portrait: {
maxWidth: 479,
columns: 4,
rowHeight: 40,
gap: 8,
padding: 12,
label: "모바일 세로 (4칸)",
columns: getBlockColumns(375),
rowHeight: BLOCK_SIZE,
gap: BLOCK_GAP,
padding: BLOCK_PADDING,
label: `모바일 세로 (${getBlockColumns(375)}칸)`,
},
// 스마트폰 가로 + 소형 태블릿
mobile_landscape: {
minWidth: 480,
maxWidth: 767,
columns: 6,
rowHeight: 44,
gap: 8,
padding: 16,
label: "모바일 가로 (6칸)",
columns: getBlockColumns(600),
rowHeight: BLOCK_SIZE,
gap: BLOCK_GAP,
padding: BLOCK_PADDING,
label: `모바일 가로 (${getBlockColumns(600)}칸)`,
},
// 태블릿 세로 (iPad Mini ~ iPad Pro)
tablet_portrait: {
minWidth: 768,
maxWidth: 1023,
columns: 8,
rowHeight: 48,
gap: 12,
padding: 16,
label: "태블릿 세로 (8칸)",
columns: getBlockColumns(820),
rowHeight: BLOCK_SIZE,
gap: BLOCK_GAP,
padding: BLOCK_PADDING,
label: `태블릿 세로 (${getBlockColumns(820)}칸)`,
},
// 태블릿 가로 + 데스크톱 (기본)
tablet_landscape: {
minWidth: 1024,
columns: 12,
rowHeight: 48,
gap: 16,
padding: 24,
label: "태블릿 가로 (12칸)",
columns: getBlockColumns(1024),
rowHeight: BLOCK_SIZE,
gap: BLOCK_GAP,
padding: BLOCK_PADDING,
label: `태블릿 가로 (${getBlockColumns(1024)}칸)`,
},
} as const;
@ -183,7 +189,6 @@ export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape";
/**
*
* GRID_BREAKPOINTS와
*/
export function detectGridMode(viewportWidth: number): GridMode {
if (viewportWidth < 480) return "mobile_portrait";
@ -193,31 +198,31 @@ export function detectGridMode(viewportWidth: number): GridMode {
}
/**
* v5 ( )
* POP
*/
export interface PopLayoutDataV5 {
export interface PopLayoutData {
version: "pop-5.0";
// 그리드 설정
gridConfig: PopGridConfig;
// 컴포넌트 정의 (ID → 정의)
components: Record<string, PopComponentDefinitionV5>;
components: Record<string, PopComponentDefinition>;
// 데이터 흐름
dataFlow: PopDataFlow;
// 전역 설정
settings: PopGlobalSettingsV5;
settings: PopGlobalSettings;
// 메타데이터
metadata?: PopLayoutMetadata;
// 모드별 오버라이드 (위치 변경용)
overrides?: {
mobile_portrait?: PopModeOverrideV5;
mobile_landscape?: PopModeOverrideV5;
tablet_portrait?: PopModeOverrideV5;
mobile_portrait?: PopModeOverride;
mobile_landscape?: PopModeOverride;
tablet_portrait?: PopModeOverride;
};
// 모달 캔버스 목록 (버튼의 "모달 열기" 액션으로 생성)
@ -225,17 +230,17 @@ export interface PopLayoutDataV5 {
}
/**
*
* (V6: 블록 )
*/
export interface PopGridConfig {
// 행 높이 (px) - 1행의 기본 높이
rowHeight: number; // 기본 48px
// 행 높이 = 블록 크기 (px)
rowHeight: number; // V6 기본 24px (= BLOCK_SIZE)
// 간격 (px)
gap: number; // 기본 8px
gap: number; // V6 기본 2px (= BLOCK_GAP)
// 패딩 (px)
padding: number; // 기본 16px
padding: number; // V6 기본 8px (= BLOCK_PADDING)
}
/**
@ -249,9 +254,9 @@ export interface PopGridPosition {
}
/**
* v5
* POP
*/
export interface PopComponentDefinitionV5 {
export interface PopComponentDefinition {
id: string;
type: PopComponentType;
label?: string;
@ -274,7 +279,7 @@ export interface PopComponentDefinitionV5 {
}
/**
* Gap
* Gap (V6: 단일 medium만 , )
*/
export type GapPreset = "narrow" | "medium" | "wide";
@ -287,18 +292,18 @@ export interface GapPresetConfig {
}
/**
* Gap
* Gap (V6: 모두 - )
*/
export const GAP_PRESETS: Record<GapPreset, GapPresetConfig> = {
narrow: { multiplier: 0.5, label: "좁게" },
medium: { multiplier: 1.0, label: "보통" },
wide: { multiplier: 1.5, label: "넓게" },
narrow: { multiplier: 1.0, label: "기본" },
medium: { multiplier: 1.0, label: "기본" },
wide: { multiplier: 1.0, label: "기본" },
};
/**
* v5
* POP
*/
export interface PopGlobalSettingsV5 {
export interface PopGlobalSettings {
// 터치 최소 크기 (px)
touchTargetMin: number; // 기본 48
@ -310,9 +315,9 @@ export interface PopGlobalSettingsV5 {
}
/**
* v5
* (/)
*/
export interface PopModeOverrideV5 {
export interface PopModeOverride {
// 컴포넌트별 위치 오버라이드
positions?: Record<string, Partial<PopGridPosition>>;
@ -321,18 +326,18 @@ export interface PopModeOverrideV5 {
}
// ========================================
// v5 유틸리티 함수
// 레이아웃 유틸리티 함수
// ========================================
/**
* v5
* POP
*/
export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
export const createEmptyLayout = (): PopLayoutData => ({
version: "pop-5.0",
gridConfig: {
rowHeight: 48,
gap: 8,
padding: 16,
rowHeight: BLOCK_SIZE,
gap: BLOCK_GAP,
padding: BLOCK_PADDING,
},
components: {},
dataFlow: { connections: [] },
@ -344,40 +349,46 @@ export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
});
/**
* v5
* POP
*/
export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
export const isPopLayout = (layout: any): layout is PopLayoutData => {
return layout?.version === "pop-5.0";
};
/**
* ( )
* ( , V6)
*
* (2x2) : . , ,
* (8x4) : , , /
* (8x6) : , ,
* (19x8~) : , ,
*/
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
"pop-sample": { colSpan: 2, rowSpan: 1 },
"pop-text": { colSpan: 3, rowSpan: 1 },
"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: 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 },
"pop-sample": { colSpan: 8, rowSpan: 6 },
"pop-text": { colSpan: 8, rowSpan: 4 },
"pop-icon": { colSpan: 2, rowSpan: 2 },
"pop-dashboard": { colSpan: 19, rowSpan: 10 },
"pop-card-list": { colSpan: 19, rowSpan: 10 },
"pop-card-list-v2": { colSpan: 19, rowSpan: 10 },
"pop-button": { colSpan: 8, rowSpan: 4 },
"pop-string-list": { colSpan: 19, rowSpan: 10 },
"pop-search": { colSpan: 8, rowSpan: 4 },
"pop-status-bar": { colSpan: 19, rowSpan: 4 },
"pop-field": { colSpan: 19, rowSpan: 6 },
"pop-scanner": { colSpan: 2, rowSpan: 2 },
"pop-profile": { colSpan: 2, rowSpan: 2 },
"pop-work-detail": { colSpan: 38, rowSpan: 26 },
};
/**
* v5
* POP
*/
export const createComponentDefinitionV5 = (
export const createComponentDefinition = (
id: string,
type: PopComponentType,
position: PopGridPosition,
label?: string
): PopComponentDefinitionV5 => ({
): PopComponentDefinition => ({
id,
type,
label,
@ -385,21 +396,21 @@ export const createComponentDefinitionV5 = (
});
/**
* v5
* POP
*/
export const addComponentToV5Layout = (
layout: PopLayoutDataV5,
export const addComponentToLayout = (
layout: PopLayoutData,
componentId: string,
type: PopComponentType,
position: PopGridPosition,
label?: string
): PopLayoutDataV5 => {
): PopLayoutData => {
const newLayout = { ...layout };
// 컴포넌트 정의 추가
newLayout.components = {
...newLayout.components,
[componentId]: createComponentDefinitionV5(componentId, type, position, label),
[componentId]: createComponentDefinition(componentId, type, position, label),
};
return newLayout;
@ -474,12 +485,12 @@ export interface PopModalDefinition {
/** 모달 내부 그리드 설정 */
gridConfig: PopGridConfig;
/** 모달 내부 컴포넌트 */
components: Record<string, PopComponentDefinitionV5>;
components: Record<string, PopComponentDefinition>;
/** 모드별 오버라이드 */
overrides?: {
mobile_portrait?: PopModeOverrideV5;
mobile_landscape?: PopModeOverrideV5;
tablet_portrait?: PopModeOverrideV5;
mobile_portrait?: PopModeOverride;
mobile_landscape?: PopModeOverride;
tablet_portrait?: PopModeOverride;
};
/** 모달 프레임 설정 (닫기 방식) */
frameConfig?: {
@ -495,15 +506,29 @@ export interface PopModalDefinition {
}
// ========================================
// 레거시 타입 별칭 (하위 호환 - 추후 제거)
// 레거시 타입 별칭 (이전 코드 호환용)
// ========================================
// 기존 코드에서 import 오류 방지용
/** @deprecated v5에서는 PopLayoutDataV5 사용 */
export type PopLayoutData = PopLayoutDataV5;
/** @deprecated PopLayoutData 사용 */
export type PopLayoutDataV5 = PopLayoutData;
/** @deprecated v5에서는 PopComponentDefinitionV5 사용 */
export type PopComponentDefinition = PopComponentDefinitionV5;
/** @deprecated PopComponentDefinition 사용 */
export type PopComponentDefinitionV5 = PopComponentDefinition;
/** @deprecated v5에서는 PopGridPosition 사용 */
export type GridPosition = PopGridPosition;
/** @deprecated PopGlobalSettings 사용 */
export type PopGlobalSettingsV5 = PopGlobalSettings;
/** @deprecated PopModeOverride 사용 */
export type PopModeOverrideV5 = PopModeOverride;
/** @deprecated createEmptyLayout 사용 */
export const createEmptyPopLayoutV5 = createEmptyLayout;
/** @deprecated isPopLayout 사용 */
export const isV5Layout = isPopLayout;
/** @deprecated addComponentToLayout 사용 */
export const addComponentToV5Layout = addComponentToLayout;
/** @deprecated createComponentDefinition 사용 */
export const createComponentDefinitionV5 = createComponentDefinition;

View File

@ -1,217 +1,106 @@
// POP 그리드 유틸리티 (리플로우, 겹침 해결, 위치 계산)
import {
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
GridBreakpoint,
GapPreset,
GAP_PRESETS,
PopLayoutDataV5,
PopComponentDefinitionV5,
PopLayoutData,
} from "../types/pop-layout";
// ========================================
// Gap/Padding 조정
// 리플로우 (행 그룹 기반 자동 재배치)
// ========================================
/**
* Gap breakpoint의 gap/padding
*
* @param base breakpoint
* @param preset Gap ("narrow" | "medium" | "wide")
* @returns breakpoint (gap, padding )
*/
export function getAdjustedBreakpoint(
base: GridBreakpoint,
preset: GapPreset
): GridBreakpoint {
const multiplier = GAP_PRESETS[preset]?.multiplier || 1.0;
return {
...base,
gap: Math.round(base.gap * multiplier),
padding: Math.max(8, Math.round(base.padding * multiplier)), // 최소 8px
};
}
// ========================================
// 그리드 위치 변환
// ========================================
/**
* 12
*/
export function convertPositionToMode(
position: PopGridPosition,
targetMode: GridMode
): PopGridPosition {
const sourceColumns = 12;
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
// 같은 칸 수면 그대로 반환
if (sourceColumns === targetColumns) {
return position;
}
const ratio = targetColumns / sourceColumns;
// 열 위치 변환
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
// 범위 초과 방지
if (newCol > targetColumns) {
newCol = 1;
}
if (newCol + newColSpan - 1 > targetColumns) {
newColSpan = targetColumns - newCol + 1;
}
return {
col: newCol,
row: position.row,
colSpan: Math.max(1, newColSpan),
rowSpan: position.rowSpan,
};
}
/**
*
*
* v5.1 :
* - col > targetColumns인
* - 방지: 모든
*
*
* CSS Flexbox wrap .
* 1.
* 2. 2x2칸 ( )
* 3. ( )
* 4. 50%
* 5.
*/
export function convertAndResolvePositions(
components: Array<{ id: string; position: PopGridPosition }>,
targetMode: GridMode
): Array<{ id: string; position: PopGridPosition }> {
// 엣지 케이스: 빈 배열
if (components.length === 0) {
return [];
}
if (components.length === 0) return [];
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
const designColumns = GRID_BREAKPOINTS["tablet_landscape"].columns;
// 1단계: 각 컴포넌트를 비율로 변환 (원본 col 보존)
const converted = components.map(comp => ({
id: comp.id,
position: convertPositionToMode(comp.position, targetMode),
originalCol: comp.position.col, // 원본 col 보존
}));
if (targetColumns >= designColumns) {
return components.map(c => ({ id: c.id, position: { ...c.position } }));
}
// 2단계: 정상 컴포넌트 vs 초과 컴포넌트 분리
const normalComponents = converted.filter(c => c.originalCol <= targetColumns);
const overflowComponents = converted.filter(c => c.originalCol > targetColumns);
const ratio = targetColumns / designColumns;
const MIN_COL_SPAN = 2;
const MIN_ROW_SPAN = 2;
// 3단계: 정상 컴포넌트의 최대 row 계산
const maxRow = normalComponents.length > 0
? Math.max(...normalComponents.map(c => c.position.row + c.position.rowSpan - 1))
: 0;
// 4단계: 초과 컴포넌트들을 맨 아래에 순차 배치
let currentRow = maxRow + 1;
const wrappedComponents = overflowComponents.map(comp => {
const wrappedPosition: PopGridPosition = {
col: 1, // 왼쪽 끝부터 시작
row: currentRow,
colSpan: Math.min(comp.position.colSpan, targetColumns), // 최대 칸 수 제한
rowSpan: comp.position.rowSpan,
};
currentRow += comp.position.rowSpan; // 다음 행으로 이동
return {
id: comp.id,
position: wrappedPosition,
};
const rowGroups: Record<number, Array<{ id: string; position: PopGridPosition }>> = {};
components.forEach(comp => {
const r = comp.position.row;
if (!rowGroups[r]) rowGroups[r] = [];
rowGroups[r].push(comp);
});
// 5단계: 정상 + 줄바꿈 컴포넌트 병합
const adjusted = [
...normalComponents.map(c => ({ id: c.id, position: c.position })),
...wrappedComponents,
];
const placed: Array<{ id: string; position: PopGridPosition }> = [];
let outputRow = 1;
// 6단계: 겹침 해결 (아래로 밀기)
return resolveOverlaps(adjusted, targetColumns);
}
// ========================================
// 검토 필요 판별
// ========================================
/**
* "검토 필요"
*
* v5.1 :
* - 12 ( )
* - ( )
*
* @param currentMode
* @param hasOverride
* @returns true = , false =
*/
export function needsReview(
currentMode: GridMode,
hasOverride: boolean
): boolean {
const targetColumns = GRID_BREAKPOINTS[currentMode].columns;
const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b);
// 12칸 모드는 기본 모드이므로 검토 불필요
if (targetColumns === 12) {
return false;
for (const rowKey of sortedRows) {
const group = rowGroups[rowKey].sort((a, b) => a.position.col - b.position.col);
let currentCol = 1;
let maxRowSpanInLine = 0;
for (const comp of group) {
const pos = comp.position;
const isMainContent = pos.colSpan >= designColumns * 0.5;
let scaledSpan = isMainContent
? targetColumns
: Math.max(MIN_COL_SPAN, Math.round(pos.colSpan * ratio));
scaledSpan = Math.min(scaledSpan, targetColumns);
const scaledRowSpan = Math.max(MIN_ROW_SPAN, pos.rowSpan);
if (currentCol + scaledSpan - 1 > targetColumns) {
outputRow += Math.max(1, maxRowSpanInLine);
currentCol = 1;
maxRowSpanInLine = 0;
}
placed.push({
id: comp.id,
position: {
col: currentCol,
row: outputRow,
colSpan: scaledSpan,
rowSpan: scaledRowSpan,
},
});
maxRowSpanInLine = Math.max(maxRowSpanInLine, scaledRowSpan);
currentCol += scaledSpan;
}
outputRow += Math.max(1, maxRowSpanInLine);
}
// 오버라이드가 있으면 이미 편집함 → 검토 완료
if (hasOverride) {
return false;
}
// 오버라이드 없으면 → 검토 필요
return true;
}
/**
* @deprecated v5.1 needsReview()
*
* isOutOfBounds는 "화면 밖" ,
* v5.1 .
* needsReview() "검토 필요" .
*/
export function isOutOfBounds(
originalPosition: PopGridPosition,
currentMode: GridMode,
overridePosition?: PopGridPosition | null
): boolean {
const targetColumns = GRID_BREAKPOINTS[currentMode].columns;
// 12칸 모드면 초과 불가
if (targetColumns === 12) {
return false;
}
// 오버라이드가 있으면 오버라이드 위치로 판단
if (overridePosition) {
return overridePosition.col > targetColumns;
}
// 오버라이드 없으면 원본 col로 판단
return originalPosition.col > targetColumns;
return resolveOverlaps(placed, targetColumns);
}
// ========================================
// 겹침 감지 및 해결
// ========================================
/**
*
*/
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
// 열 겹침 체크
const aColEnd = a.col + a.colSpan - 1;
const bColEnd = b.col + b.colSpan - 1;
const colOverlap = !(aColEnd < b.col || bColEnd < a.col);
// 행 겹침 체크
const aRowEnd = a.row + a.rowSpan - 1;
const bRowEnd = b.row + b.rowSpan - 1;
const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row);
@ -219,14 +108,10 @@ export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
return colOverlap && rowOverlap;
}
/**
* ( )
*/
export function resolveOverlaps(
positions: Array<{ id: string; position: PopGridPosition }>,
columns: number
): Array<{ id: string; position: PopGridPosition }> {
// row, col 순으로 정렬
const sorted = [...positions].sort((a, b) =>
a.position.row - b.position.row || a.position.col - b.position.col
);
@ -236,21 +121,15 @@ export function resolveOverlaps(
sorted.forEach((item) => {
let { row, col, colSpan, rowSpan } = item.position;
// 열이 범위를 초과하면 조정
if (col + colSpan - 1 > columns) {
colSpan = columns - col + 1;
}
// 기존 배치와 겹치면 아래로 이동
let attempts = 0;
const maxAttempts = 100;
while (attempts < maxAttempts) {
while (attempts < 100) {
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
if (!hasOverlap) break;
row++;
attempts++;
}
@ -265,124 +144,9 @@ export function resolveOverlaps(
}
// ========================================
// 좌표 변환
// 자동 배치 (새 컴포넌트 드롭 시)
// ========================================
/**
*
*
* CSS Grid :
* - = - *2 - gap*(columns-1)
* - = / columns
* - N의 X = padding + (N-1) * ( + gap)
*/
export function mouseToGridPosition(
mouseX: number,
mouseY: number,
canvasRect: DOMRect,
columns: number,
rowHeight: number,
gap: number,
padding: number
): { col: number; row: number } {
// 캔버스 내 상대 위치 (패딩 영역 포함)
const relX = mouseX - canvasRect.left - padding;
const relY = mouseY - canvasRect.top - padding;
// CSS Grid 1fr 계산과 동일하게
// 사용 가능 너비 = 전체 너비 - 양쪽 패딩 - (칸 사이 gap)
const availableWidth = canvasRect.width - padding * 2 - gap * (columns - 1);
const colWidth = availableWidth / columns;
// 각 셀의 실제 간격 (셀 너비 + gap)
const cellStride = colWidth + gap;
// 그리드 좌표 계산 (1부터 시작)
// relX를 cellStride로 나누면 몇 번째 칸인지 알 수 있음
const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1));
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
return { col, row };
}
/**
*
*/
export function gridToPixelPosition(
col: number,
row: number,
colSpan: number,
rowSpan: number,
canvasWidth: number,
columns: number,
rowHeight: number,
gap: number,
padding: number
): { x: number; y: number; width: number; height: number } {
const totalGap = gap * (columns - 1);
const colWidth = (canvasWidth - padding * 2 - totalGap) / columns;
return {
x: padding + (col - 1) * (colWidth + gap),
y: padding + (row - 1) * (rowHeight + gap),
width: colWidth * colSpan + gap * (colSpan - 1),
height: rowHeight * rowSpan + gap * (rowSpan - 1),
};
}
// ========================================
// 위치 검증
// ========================================
/**
*
*/
export function isValidPosition(
position: PopGridPosition,
columns: number
): boolean {
return (
position.col >= 1 &&
position.row >= 1 &&
position.colSpan >= 1 &&
position.rowSpan >= 1 &&
position.col + position.colSpan - 1 <= columns
);
}
/**
*
*/
export function clampPosition(
position: PopGridPosition,
columns: number
): PopGridPosition {
let { col, row, colSpan, rowSpan } = position;
// 최소값 보장
col = Math.max(1, col);
row = Math.max(1, row);
colSpan = Math.max(1, colSpan);
rowSpan = Math.max(1, rowSpan);
// 열 범위 초과 방지
if (col + colSpan - 1 > columns) {
if (col > columns) {
col = 1;
}
colSpan = columns - col + 1;
}
return { col, row, colSpan, rowSpan };
}
// ========================================
// 자동 배치
// ========================================
/**
*
*/
export function findNextEmptyPosition(
existingPositions: PopGridPosition[],
colSpan: number,
@ -391,168 +155,94 @@ export function findNextEmptyPosition(
): PopGridPosition {
let row = 1;
let col = 1;
const maxAttempts = 1000;
let attempts = 0;
while (attempts < maxAttempts) {
while (attempts < 1000) {
const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan };
// 범위 체크
if (col + colSpan - 1 > columns) {
col = 1;
row++;
continue;
}
// 겹침 체크
const hasOverlap = existingPositions.some(pos =>
isOverlapping(candidatePos, pos)
);
const hasOverlap = existingPositions.some(pos => isOverlapping(candidatePos, pos));
if (!hasOverlap) return candidatePos;
if (!hasOverlap) {
return candidatePos;
}
// 다음 위치로 이동
col++;
if (col + colSpan - 1 > columns) {
col = 1;
row++;
}
attempts++;
}
// 실패 시 마지막 행에 배치
return { col: 1, row: row + 1, colSpan, rowSpan };
}
/**
*
*/
export function autoLayoutComponents(
components: Array<{ id: string; colSpan: number; rowSpan: number }>,
columns: number
): Array<{ id: string; position: PopGridPosition }> {
const result: Array<{ id: string; position: PopGridPosition }> = [];
let currentRow = 1;
let currentCol = 1;
components.forEach(comp => {
// 현재 행에 공간이 부족하면 다음 행으로
if (currentCol + comp.colSpan - 1 > columns) {
currentRow++;
currentCol = 1;
}
result.push({
id: comp.id,
position: {
col: currentCol,
row: currentRow,
colSpan: comp.colSpan,
rowSpan: comp.rowSpan,
},
});
currentCol += comp.colSpan;
});
return result;
}
// ========================================
// 유효 위치 계산 (통합 함수)
// 유효 위치 계산
// ========================================
/**
* .
* .
* 우선순위: 1. 2. 3.
*
* @param componentId ID
* @param layout
* @param mode
* @param autoResolvedPositions ()
*/
export function getEffectiveComponentPosition(
function getEffectiveComponentPosition(
componentId: string,
layout: PopLayoutDataV5,
layout: PopLayoutData,
mode: GridMode,
autoResolvedPositions?: Array<{ id: string; position: PopGridPosition }>
): PopGridPosition | null {
const component = layout.components[componentId];
if (!component) return null;
// 1순위: 오버라이드가 있으면 사용
const override = layout.overrides?.[mode]?.positions?.[componentId];
if (override) {
return { ...component.position, ...override };
}
// 2순위: 자동 재배치된 위치 사용
if (autoResolvedPositions) {
const autoResolved = autoResolvedPositions.find(p => p.id === componentId);
if (autoResolved) {
return autoResolved.position;
}
if (autoResolved) return autoResolved.position;
} else {
// 자동 재배치 직접 계산
const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({
id,
position: comp.position,
}));
const resolved = convertAndResolvePositions(componentsArray, mode);
const autoResolved = resolved.find(p => p.id === componentId);
if (autoResolved) {
return autoResolved.position;
}
if (autoResolved) return autoResolved.position;
}
// 3순위: 원본 위치 (12칸 모드)
return component.position;
}
/**
* .
* .
*
* v5.1: 자동
* "화면 밖" .
* .
* .
*/
export function getAllEffectivePositions(
layout: PopLayoutDataV5,
layout: PopLayoutData,
mode: GridMode
): Map<string, PopGridPosition> {
const result = new Map<string, PopGridPosition>();
// 숨김 처리된 컴포넌트 ID 목록
const hiddenIds = layout.overrides?.[mode]?.hidden || [];
// 자동 재배치 위치 미리 계산
const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({
id,
position: comp.position,
}));
const autoResolvedPositions = convertAndResolvePositions(componentsArray, mode);
// 각 컴포넌트의 유효 위치 계산
Object.keys(layout.components).forEach(componentId => {
// 숨김 처리된 컴포넌트는 제외
if (hiddenIds.includes(componentId)) {
return;
}
if (hiddenIds.includes(componentId)) return;
const position = getEffectiveComponentPosition(
componentId,
layout,
mode,
autoResolvedPositions
componentId, layout, mode, autoResolvedPositions
);
// v5.1: 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 있음
// 따라서 추가 필터링 불필요
if (position) {
result.set(componentId, position);
}

View File

@ -0,0 +1,128 @@
// 레거시 레이아웃 로더
// DB에 저장된 V5(12칸) 좌표를 현재 블록 좌표로 변환한다.
// DB 데이터는 건드리지 않고, 로드 시 메모리에서만 변환.
import {
PopGridPosition,
PopLayoutData,
BLOCK_SIZE,
BLOCK_GAP,
BLOCK_PADDING,
getBlockColumns,
} from "../types/pop-layout";
const LEGACY_COLUMNS = 12;
const LEGACY_ROW_HEIGHT = 48;
const LEGACY_GAP = 16;
const DESIGN_WIDTH = 1024;
function isLegacyGridConfig(layout: PopLayoutData): boolean {
if (layout.gridConfig?.rowHeight === BLOCK_SIZE) return false;
const maxCol = Object.values(layout.components).reduce((max, comp) => {
const end = comp.position.col + comp.position.colSpan - 1;
return Math.max(max, end);
}, 0);
return maxCol <= LEGACY_COLUMNS;
}
function convertLegacyPosition(
pos: PopGridPosition,
targetColumns: number,
): PopGridPosition {
const colRatio = targetColumns / LEGACY_COLUMNS;
const rowRatio = (LEGACY_ROW_HEIGHT + LEGACY_GAP) / (BLOCK_SIZE + BLOCK_GAP);
const newCol = Math.max(1, Math.round((pos.col - 1) * colRatio) + 1);
let newColSpan = Math.max(1, Math.round(pos.colSpan * colRatio));
const newRowSpan = Math.max(1, Math.round(pos.rowSpan * rowRatio));
if (newCol + newColSpan - 1 > targetColumns) {
newColSpan = targetColumns - newCol + 1;
}
return { col: newCol, row: pos.row, colSpan: newColSpan, rowSpan: newRowSpan };
}
const BLOCK_GRID_CONFIG = {
rowHeight: BLOCK_SIZE,
gap: BLOCK_GAP,
padding: BLOCK_PADDING,
};
/**
* DB에서 .
*
* - 12
* - gridConfig만
* - overrides는 ( )
*/
export function loadLegacyLayout(layout: PopLayoutData): PopLayoutData {
if (!isLegacyGridConfig(layout)) {
return {
...layout,
gridConfig: BLOCK_GRID_CONFIG,
overrides: undefined,
};
}
const blockColumns = getBlockColumns(DESIGN_WIDTH);
const rowGroups: Record<number, string[]> = {};
Object.entries(layout.components).forEach(([id, comp]) => {
const r = comp.position.row;
if (!rowGroups[r]) rowGroups[r] = [];
rowGroups[r].push(id);
});
const convertedPositions: Record<string, PopGridPosition> = {};
Object.entries(layout.components).forEach(([id, comp]) => {
convertedPositions[id] = convertLegacyPosition(comp.position, blockColumns);
});
const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b);
const rowMapping: Record<number, number> = {};
let currentRow = 1;
for (const legacyRow of sortedRows) {
rowMapping[legacyRow] = currentRow;
const maxSpan = Math.max(
...rowGroups[legacyRow].map(id => convertedPositions[id].rowSpan)
);
currentRow += maxSpan;
}
const newComponents = { ...layout.components };
Object.entries(newComponents).forEach(([id, comp]) => {
const converted = convertedPositions[id];
const mappedRow = rowMapping[comp.position.row] ?? converted.row;
newComponents[id] = {
...comp,
position: { ...converted, row: mappedRow },
};
});
const newModals = layout.modals?.map(modal => {
const modalComps = { ...modal.components };
Object.entries(modalComps).forEach(([id, comp]) => {
modalComps[id] = {
...comp,
position: convertLegacyPosition(comp.position, blockColumns),
};
});
return {
...modal,
gridConfig: BLOCK_GRID_CONFIG,
components: modalComps,
overrides: undefined,
};
});
return {
...layout,
gridConfig: BLOCK_GRID_CONFIG,
components: newComponents,
overrides: undefined,
modals: newModals,
};
}

View File

@ -20,7 +20,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import PopRenderer from "../designer/renderers/PopRenderer";
import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout";
import type { PopLayoutData, PopModalDefinition, GridMode } from "../designer/types/pop-layout";
import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver";
@ -31,7 +31,7 @@ import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver";
interface PopViewerWithModalsProps {
/** 전체 레이아웃 (모달 정의 포함) */
layout: PopLayoutDataV5;
layout: PopLayoutData;
/** 뷰포트 너비 */
viewportWidth: number;
/** 화면 ID (이벤트 버스용) */
@ -42,12 +42,15 @@ interface PopViewerWithModalsProps {
overrideGap?: number;
/** Padding 오버라이드 */
overridePadding?: number;
/** 부모 화면에서 선택된 행 데이터 (모달 내부 컴포넌트가 sharedData로 조회) */
parentRow?: Record<string, unknown>;
}
/** 열린 모달 상태 */
interface OpenModal {
definition: PopModalDefinition;
returnTo?: string;
fullscreen?: boolean;
}
// ========================================
@ -61,10 +64,17 @@ export default function PopViewerWithModals({
currentMode,
overrideGap,
overridePadding,
parentRow,
}: PopViewerWithModalsProps) {
const router = useRouter();
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
const { subscribe, publish } = usePopEvent(screenId);
const { subscribe, publish, setSharedData } = usePopEvent(screenId);
useEffect(() => {
if (parentRow) {
setSharedData("parentRow", parentRow);
}
}, [parentRow, setSharedData]);
// 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환
const stableConnections = useMemo(
@ -96,6 +106,7 @@ export default function PopViewerWithModals({
title?: string;
mode?: string;
returnTo?: string;
fullscreen?: boolean;
};
if (data?.modalId) {
@ -104,6 +115,7 @@ export default function PopViewerWithModals({
setModalStack(prev => [...prev, {
definition: modalDef,
returnTo: data.returnTo,
fullscreen: data.fullscreen,
}]);
}
}
@ -173,22 +185,27 @@ export default function PopViewerWithModals({
{/* 모달 스택 렌더링 */}
{modalStack.map((modal, index) => {
const { definition } = modal;
const { definition, fullscreen } = modal;
const isTopModal = index === modalStack.length - 1;
const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false;
const closeOnEsc = definition.frameConfig?.closeOnEsc !== false;
const modalLayout: PopLayoutDataV5 = {
const modalLayout: PopLayoutData = {
...layout,
gridConfig: definition.gridConfig,
components: definition.components,
overrides: definition.overrides,
};
const detectedMode = currentMode || detectGridMode(viewportWidth);
const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth);
const isFull = modalWidth >= viewportWidth;
const rendererWidth = isFull ? viewportWidth : modalWidth - 32;
const isFull = fullscreen || (() => {
const detectedMode = currentMode || detectGridMode(viewportWidth);
const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth);
return modalWidth >= viewportWidth;
})();
const rendererWidth = isFull
? viewportWidth
: resolveModalWidth(definition.sizeConfig, currentMode || detectGridMode(viewportWidth), viewportWidth) - 32;
const modalWidth = isFull ? viewportWidth : resolveModalWidth(definition.sizeConfig, currentMode || detectGridMode(viewportWidth), viewportWidth);
return (
<Dialog
@ -200,7 +217,7 @@ export default function PopViewerWithModals({
>
<DialogContent
className={isFull
? "h-dvh max-h-dvh w-screen max-w-[100vw] overflow-auto rounded-none border-none p-0"
? "flex h-dvh max-h-dvh w-screen max-w-[100vw] flex-col gap-0 overflow-hidden rounded-none border-none p-0"
: "max-h-[90vh] overflow-auto p-0"
}
style={isFull ? undefined : {
@ -208,14 +225,13 @@ export default function PopViewerWithModals({
width: `${modalWidth}px`,
}}
onInteractOutside={(e) => {
// 최상위 모달이 아니면 overlay 클릭 무시 (하위 모달이 먼저 닫히는 것 방지)
if (!isTopModal || !closeOnOverlay) e.preventDefault();
}}
onEscapeKeyDown={(e) => {
if (!isTopModal || !closeOnEsc) e.preventDefault();
}}
>
<DialogHeader className={isFull ? "px-4 pt-3 pb-2" : "px-4 pt-4 pb-2"}>
<DialogHeader className={isFull ? "shrink-0 border-b px-4 py-2" : "px-4 pt-4 pb-2"}>
<DialogTitle className="text-base">
{definition.title}
</DialogTitle>

View File

@ -764,7 +764,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달)
const currentFormData = formDataRef.current;
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData);
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData, manualInputValue || undefined);
if (previewResponse.success && previewResponse.data?.generatedCode) {
const generatedCode = previewResponse.data.generatedCode;
@ -852,6 +852,49 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
};
}, [columnName, manualInputValue, propsInputType, config.inputType, config.type]);
// 수동 입력값 변경 시 디바운스로 순번 미리보기 갱신
useEffect(() => {
const inputType = propsInputType || config.inputType || config.type || "text";
if (inputType !== "numbering") return;
if (!numberingTemplateRef.current?.includes("____")) return;
const ruleId = numberingRuleIdRef.current;
if (!ruleId) return;
// 사용자가 한 번도 입력하지 않은 초기 상태면 스킵
if (!userEditedNumberingRef.current) return;
const debounceTimer = setTimeout(async () => {
try {
const currentFormData = formDataRef.current;
const resp = await previewNumberingCode(ruleId, currentFormData, manualInputValue || undefined);
if (resp.success && resp.data?.generatedCode) {
const newTemplate = resp.data.generatedCode;
if (newTemplate.includes("____")) {
numberingTemplateRef.current = newTemplate;
const parts = newTemplate.split("____");
const prefix = parts[0] || "";
const suffix = parts.length > 1 ? parts.slice(1).join("") : "";
const combined = prefix + manualInputValue + suffix;
setAutoGeneratedValue(combined);
onChange?.(combined);
if (onFormDataChange && columnName) {
onFormDataChange(columnName, combined);
}
}
}
} catch {
/* 미리보기 실패 시 기존 suffix 유지 */
}
}, 300);
return () => clearTimeout(debounceTimer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 실제 표시할 값 (자동생성 값 또는 props value)
const displayValue = autoGeneratedValue ?? value;

View File

@ -15,11 +15,11 @@ import { Badge } from "@/components/ui/badge";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers } from "lucide-react";
import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers, Filter, Link, Zap, Trash2, Plus, GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel } from "@/lib/registry/components/v2-timeline-scheduler/types";
import { zoomLevelOptions, scheduleTypeOptions } from "@/lib/registry/components/v2-timeline-scheduler/config";
import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel, ToolbarAction } from "@/lib/registry/components/v2-timeline-scheduler/types";
import { zoomLevelOptions, scheduleTypeOptions, viewModeOptions, dataSourceOptions, toolbarIconOptions } from "@/lib/registry/components/v2-timeline-scheduler/config";
interface V2TimelineSchedulerConfigPanelProps {
config: TimelineSchedulerConfig;
@ -49,10 +49,16 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
const [resourceTableOpen, setResourceTableOpen] = useState(false);
const [customTableOpen, setCustomTableOpen] = useState(false);
const [scheduleDataOpen, setScheduleDataOpen] = useState(true);
const [filterLinkOpen, setFilterLinkOpen] = useState(false);
const [sourceDataOpen, setSourceDataOpen] = useState(true);
const [resourceOpen, setResourceOpen] = useState(true);
const [displayOpen, setDisplayOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [actionsOpen, setActionsOpen] = useState(false);
const [newFilterKey, setNewFilterKey] = useState("");
const [newFilterValue, setNewFilterValue] = useState("");
const [linkedFilterTableOpen, setLinkedFilterTableOpen] = useState(false);
const [expandedActionId, setExpandedActionId] = useState<string | null>(null);
useEffect(() => {
const loadTables = async () => {
@ -225,6 +231,31 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
</Select>
</div>
{/* 뷰 모드 */}
<div className="flex items-center justify-between py-1">
<div>
<p className="text-xs text-muted-foreground truncate"> </p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{viewModeOptions.find((o) => o.value === (config.viewMode || "resource"))?.description}
</p>
</div>
<Select
value={config.viewMode || "resource"}
onValueChange={(v) => updateConfig({ viewMode: v as any })}
>
<SelectTrigger className="h-7 w-[140px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{viewModeOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 커스텀 테이블 사용 여부 */}
<div className="flex items-center justify-between py-1">
<div>
@ -470,6 +501,210 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
</CollapsibleContent>
</Collapsible>
{/* ─── 필터 & 연동 설정 ─── */}
<Collapsible open={filterLinkOpen} onOpenChange={setFilterLinkOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> & </span>
<Badge variant="secondary" className="text-[10px] h-5">
{Object.keys(config.staticFilters || {}).length + (config.linkedFilter ? 1 : 0)}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", filterLinkOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-4">
{/* 정적 필터 */}
<div className="space-y-2">
<p className="text-xs font-medium text-primary"> (staticFilters)</p>
<p className="text-[10px] text-muted-foreground"> </p>
{Object.entries(config.staticFilters || {}).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<Input value={key} disabled className="h-7 flex-1 text-xs bg-muted/30" />
<span className="text-xs text-muted-foreground">=</span>
<Input value={value} disabled className="h-7 flex-1 text-xs bg-muted/30" />
<Button
variant="ghost"
size="sm"
onClick={() => {
const updated = { ...config.staticFilters };
delete updated[key];
updateConfig({ staticFilters: Object.keys(updated).length > 0 ? updated : undefined });
}}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<div className="flex items-center gap-2">
<Input
value={newFilterKey}
onChange={(e) => setNewFilterKey(e.target.value)}
placeholder="필드명 (예: product_type)"
className="h-7 flex-1 text-xs"
/>
<span className="text-xs text-muted-foreground">=</span>
<Input
value={newFilterValue}
onChange={(e) => setNewFilterValue(e.target.value)}
placeholder="값 (예: 완제품)"
className="h-7 flex-1 text-xs"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
if (!newFilterKey.trim()) return;
updateConfig({
staticFilters: {
...(config.staticFilters || {}),
[newFilterKey.trim()]: newFilterValue.trim(),
},
});
setNewFilterKey("");
setNewFilterValue("");
}}
disabled={!newFilterKey.trim()}
className="h-7 w-7 p-0"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
{/* 구분선 */}
<div className="border-t" />
{/* 연결 필터 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-primary flex items-center gap-1">
<Link className="h-3 w-3" />
(linkedFilter)
</p>
<p className="text-[10px] text-muted-foreground mt-0.5"> </p>
</div>
<Switch
checked={!!config.linkedFilter}
onCheckedChange={(v) => {
if (v) {
updateConfig({
linkedFilter: {
sourceField: "",
targetField: "",
showEmptyWhenNoSelection: true,
emptyMessage: "좌측 목록에서 항목을 선택하세요",
},
});
} else {
updateConfig({ linkedFilter: undefined });
}
}}
/>
</div>
{config.linkedFilter && (
<div className="ml-1 border-l-2 border-primary/20 pl-3 space-y-2 pt-1">
<div className="flex items-center justify-between py-1">
<div>
<span className="text-xs text-muted-foreground"> </span>
<p className="text-[10px] text-muted-foreground mt-0.5"> tableName </p>
</div>
<Popover open={linkedFilterTableOpen} onOpenChange={setLinkedFilterTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-[140px] justify-between text-xs"
disabled={loading}
>
{config.linkedFilter.sourceTableName || "선택..."}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="end">
<Command filter={(value, search) => value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs p-2"></CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName} ${table.tableName}`}
onSelect={() => {
updateConfig({
linkedFilter: { ...config.linkedFilter!, sourceTableName: table.tableName },
});
setLinkedFilterTableOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", config.linkedFilter?.sourceTableName === table.tableName ? "opacity-100" : "opacity-0")} />
{table.displayName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> (sourceField) *</span>
<Input
value={config.linkedFilter.sourceField || ""}
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, sourceField: e.target.value } })}
placeholder="예: part_code"
className="h-7 w-[140px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> (targetField) *</span>
<Input
value={config.linkedFilter.targetField || ""}
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, targetField: e.target.value } })}
placeholder="예: item_code"
className="h-7 w-[140px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.linkedFilter.emptyMessage || ""}
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, emptyMessage: e.target.value } })}
placeholder="선택 안내 문구"
className="h-7 w-[180px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Switch
checked={config.linkedFilter.showEmptyWhenNoSelection ?? true}
onCheckedChange={(v) => updateConfig({ linkedFilter: { ...config.linkedFilter!, showEmptyWhenNoSelection: v } })}
/>
</div>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 2단계: 소스 데이터 설정 ─── */}
<Collapsible open={sourceDataOpen} onOpenChange={setSourceDataOpen}>
<CollapsibleTrigger asChild>
@ -1038,6 +1273,17 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
onCheckedChange={(v) => updateConfig({ showAddButton: v })}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.showLegend ?? true}
onCheckedChange={(v) => updateConfig({ showLegend: v })}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
@ -1114,6 +1360,405 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 6단계: 툴바 액션 설정 ─── */}
<Collapsible open={actionsOpen} onOpenChange={setActionsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{(config.toolbarActions || []).length}
</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", actionsOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<p className="text-[10px] text-muted-foreground">
API ( )
</p>
{/* 기존 액션 목록 */}
{(config.toolbarActions || []).map((action, index) => (
<Collapsible
key={action.id}
open={expandedActionId === action.id}
onOpenChange={(open) => setExpandedActionId(open ? action.id : null)}
>
<div className="rounded-lg border">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-muted/30"
>
<div className="flex items-center gap-2">
<GripVertical className="h-3 w-3 text-muted-foreground/50" />
<div className={cn("h-3 w-3 rounded-sm", action.color?.split(" ")[0] || "bg-primary")} />
<span className="text-xs font-medium">{action.label || "새 액션"}</span>
<Badge variant="outline" className="text-[9px] h-4">
{action.dataSource === "linkedSelection" ? "연결선택" : "스케줄"}
</Badge>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
const updated = (config.toolbarActions || []).filter((_, i) => i !== index);
updateConfig({ toolbarActions: updated.length > 0 ? updated : undefined });
}}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
<ChevronDown className={cn("h-3 w-3 text-muted-foreground transition-transform", expandedActionId === action.id && "rotate-180")} />
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t px-3 py-3 space-y-2.5">
{/* 기본 설정 */}
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={action.label}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], label: e.target.value };
updateConfig({ toolbarActions: updated });
}}
className="h-7 text-xs"
/>
</div>
<div className="w-[110px]">
<span className="text-[10px] text-muted-foreground"></span>
<Select
value={action.icon || "Zap"}
onValueChange={(v) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], icon: v as any };
updateConfig({ toolbarActions: updated });
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{toolbarIconOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<span className="text-[10px] text-muted-foreground"> (Tailwind )</span>
<Input
value={action.color || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], color: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="예: bg-emerald-600 hover:bg-emerald-700"
className="h-7 text-xs"
/>
</div>
{/* API 설정 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5">API </p>
<div className="space-y-1.5">
<div>
<span className="text-[10px] text-muted-foreground"> API *</span>
<Input
value={action.previewApi}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], previewApi: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="/production/generate-schedule/preview"
className="h-7 text-xs"
/>
</div>
<div>
<span className="text-[10px] text-muted-foreground"> API *</span>
<Input
value={action.applyApi}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], applyApi: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="/production/generate-schedule"
className="h-7 text-xs"
/>
</div>
</div>
</div>
{/* 다이얼로그 설정 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5"></p>
<div className="space-y-1.5">
<div>
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={action.dialogTitle || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], dialogTitle: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="자동 생성"
className="h-7 text-xs"
/>
</div>
<div>
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={action.dialogDescription || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], dialogDescription: e.target.value };
updateConfig({ toolbarActions: updated });
}}
placeholder="미리보기 후 확인하여 적용합니다"
className="h-7 text-xs"
/>
</div>
</div>
</div>
{/* 데이터 소스 설정 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5"> </p>
<div className="space-y-1.5">
<div>
<span className="text-[10px] text-muted-foreground"> *</span>
<Select
value={action.dataSource}
onValueChange={(v) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], dataSource: v as any };
updateConfig({ toolbarActions: updated });
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{dataSourceOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
<div>
<span>{opt.label}</span>
<span className="ml-1 text-[10px] text-muted-foreground">({opt.description})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{action.dataSource === "linkedSelection" && (
<div className="ml-2 border-l-2 border-blue-200 pl-2 space-y-1.5">
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.groupByField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, groupByField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="linkedFilter.sourceField 사용"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.quantityField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, quantityField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="balance_qty"
className="h-7 text-xs"
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.dueDateField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, dueDateField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="due_date"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.nameField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, nameField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="part_name"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
{action.dataSource === "currentSchedules" && (
<div className="ml-2 border-l-2 border-amber-200 pl-2 space-y-1.5">
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.scheduleFilterField || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterField: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="product_type"
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Input
value={action.payloadConfig?.scheduleFilterValue || ""}
onChange={(e) => {
const updated = [...(config.toolbarActions || [])];
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterValue: e.target.value || undefined } };
updateConfig({ toolbarActions: updated });
}}
placeholder="완제품"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
</div>
</div>
{/* 표시 조건 */}
<div className="border-t pt-2">
<p className="text-[10px] font-medium text-primary mb-1.5"> (showWhen)</p>
<p className="text-[9px] text-muted-foreground mb-1">staticFilters </p>
{Object.entries(action.showWhen || {}).map(([key, value]) => (
<div key={key} className="flex items-center gap-1 mb-1">
<Input value={key} disabled className="h-6 flex-1 text-[10px] bg-muted/30" />
<span className="text-[10px]">=</span>
<Input value={value} disabled className="h-6 flex-1 text-[10px] bg-muted/30" />
<Button
variant="ghost"
size="sm"
onClick={() => {
const updated = [...(config.toolbarActions || [])];
const newShowWhen = { ...updated[index].showWhen };
delete newShowWhen[key];
updated[index] = { ...updated[index], showWhen: Object.keys(newShowWhen).length > 0 ? newShowWhen : undefined };
updateConfig({ toolbarActions: updated });
}}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-2.5 w-2.5" />
</Button>
</div>
))}
<div className="flex items-center gap-1">
<Input
id={`showWhen-key-${index}`}
placeholder="필드명"
className="h-6 flex-1 text-[10px]"
/>
<span className="text-[10px]">=</span>
<Input
id={`showWhen-val-${index}`}
placeholder="값"
className="h-6 flex-1 text-[10px]"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
const keyEl = document.getElementById(`showWhen-key-${index}`) as HTMLInputElement;
const valEl = document.getElementById(`showWhen-val-${index}`) as HTMLInputElement;
if (!keyEl?.value?.trim()) return;
const updated = [...(config.toolbarActions || [])];
updated[index] = {
...updated[index],
showWhen: { ...(updated[index].showWhen || {}), [keyEl.value.trim()]: valEl?.value?.trim() || "" },
};
updateConfig({ toolbarActions: updated });
keyEl.value = "";
if (valEl) valEl.value = "";
}}
className="h-6 w-6 p-0"
>
<Plus className="h-2.5 w-2.5" />
</Button>
</div>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
))}
{/* 액션 추가 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => {
const newAction: ToolbarAction = {
id: `action_${Date.now()}`,
label: "새 액션",
icon: "Zap",
color: "bg-primary hover:bg-primary/90",
previewApi: "",
applyApi: "",
dataSource: "linkedSelection",
};
updateConfig({
toolbarActions: [...(config.toolbarActions || []), newAction],
});
setExpandedActionId(newAction.id);
}}
className="w-full h-8 text-xs gap-1"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};

View File

@ -105,6 +105,7 @@ export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<v
export async function previewNumberingCode(
ruleId: string,
formData?: Record<string, unknown>,
manualInputValue?: string,
): Promise<ApiResponse<{ generatedCode: string }>> {
// ruleId 유효성 검사
if (!ruleId || ruleId === "undefined" || ruleId === "null") {
@ -114,6 +115,7 @@ export async function previewNumberingCode(
try {
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`, {
formData: formData || {},
manualInputValue,
});
if (!response.data) {
return { success: false, error: "서버 응답이 비어있습니다" };

View File

@ -19,11 +19,12 @@ import {
Send, Radio, Megaphone, Podcast, BellRing,
Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard,
SquareMousePointer,
icons as allLucideIcons,
type LucideIcon,
} from "lucide-react";
// ---------------------------------------------------------------------------
// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘만 명시적 import)
// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘은 명시적 import, 나머지는 동적 조회)
// ---------------------------------------------------------------------------
export const iconMap: Record<string, LucideIcon> = {
Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck,
@ -109,15 +110,27 @@ export function getIconSizeStyle(size: string | number): React.CSSProperties {
// ---------------------------------------------------------------------------
// 아이콘 조회 / 동적 등록
// iconMap에 없으면 lucide-react 전체 아이콘에서 동적 조회 후 캐싱
// ---------------------------------------------------------------------------
export function getLucideIcon(name: string): LucideIcon | undefined {
return iconMap[name];
if (iconMap[name]) return iconMap[name];
const found = allLucideIcons[name as keyof typeof allLucideIcons];
if (found) {
iconMap[name] = found;
return found;
}
return undefined;
}
export function addToIconMap(name: string, component: LucideIcon): void {
iconMap[name] = component;
}
// ButtonConfigPanel 등에서 전체 아이콘 검색용으로 사용
export { allLucideIcons };
// ---------------------------------------------------------------------------
// SVG 정화
// ---------------------------------------------------------------------------

View File

@ -502,15 +502,22 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
if (component.style?.backgroundColor) {
return component.style.backgroundColor;
}
// 4순위: style.labelColor (레거시)
// 4순위: componentConfig.style.backgroundColor
if (componentConfig.style?.backgroundColor) {
return componentConfig.style.backgroundColor;
}
// 5순위: style.labelColor (레거시 호환)
if (component.style?.labelColor) {
return component.style.labelColor;
}
// 기본값: 삭제 버튼이면 빨강, 아니면 파랑
if (isDeleteAction()) {
return "#ef4444"; // 빨간색 (Tailwind red-500)
}
return "#3b82f6"; // 파란색 (Tailwind blue-500)
// 6순위: 액션별 기본 배경색
const excelActions = ["excel_download", "excel_upload", "multi_table_excel_upload"];
const actionType = typeof componentConfig.action === "string"
? componentConfig.action
: componentConfig.action?.type || "";
if (actionType === "delete") return "#F04544";
if (excelActions.includes(actionType)) return "#212121";
return "#3B83F6";
};
const getButtonTextColor = () => {

View File

@ -6,16 +6,29 @@ import { ButtonPrimaryConfig } from "./types";
* ButtonPrimary
*/
export const ButtonPrimaryDefaultConfig: ButtonPrimaryConfig = {
text: "버튼",
text: "저장",
actionType: "button",
variant: "primary",
// 공통 기본값
variant: "default",
size: "md",
disabled: false,
required: false,
readonly: false,
variant: "default",
size: "md",
displayMode: "icon-text",
icon: {
name: "Check",
type: "lucide",
size: "보통",
},
iconTextPosition: "right",
iconGap: 6,
style: {
borderRadius: "8px",
labelColor: "#FFFFFF",
fontSize: "12px",
fontWeight: "normal",
labelTextAlign: "left",
backgroundColor: "#3B83F6",
},
};
/**

View File

@ -26,8 +26,24 @@ export const V2ButtonPrimaryDefinition = createComponentDefinition({
successMessage: "저장되었습니다.",
errorMessage: "저장 중 오류가 발생했습니다.",
},
displayMode: "icon-text",
icon: {
name: "Check",
type: "lucide",
size: "보통",
},
iconTextPosition: "right",
iconGap: 6,
style: {
borderRadius: "8px",
labelColor: "#FFFFFF",
fontSize: "12px",
fontWeight: "normal",
labelTextAlign: "left",
backgroundColor: "#3B83F6",
},
},
defaultSize: { width: 120, height: 40 },
defaultSize: { width: 100, height: 40 },
configPanel: V2ButtonConfigPanel,
icon: "MousePointer",
tags: ["버튼", "액션", "클릭"],

View File

@ -12,7 +12,15 @@ import {
Package,
Zap,
RefreshCw,
Download,
Upload,
Play,
FileText,
Send,
Sparkles,
Wand2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useVirtualizer } from "@tanstack/react-virtual";
@ -20,6 +28,7 @@ import {
TimelineSchedulerComponentProps,
ScheduleItem,
ZoomLevel,
ToolbarAction,
} from "./types";
import { useTimelineData } from "./hooks/useTimelineData";
import { TimelineHeader, ResourceRow, TimelineLegend, ItemTimelineCard, groupSchedulesByItem, SchedulePreviewDialog } from "./components";
@ -53,24 +62,24 @@ export function TimelineSchedulerComponent({
}: TimelineSchedulerComponentProps) {
const containerRef = useRef<HTMLDivElement>(null);
// ────────── 자동 스케줄 생성 상태 ──────────
const [showPreviewDialog, setShowPreviewDialog] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewApplying, setPreviewApplying] = useState(false);
const [previewSummary, setPreviewSummary] = useState<any>(null);
const [previewItems, setPreviewItems] = useState<any[]>([]);
const [previewDeleted, setPreviewDeleted] = useState<any[]>([]);
const [previewKept, setPreviewKept] = useState<any[]>([]);
// ────────── 툴바 액션 다이얼로그 상태 (통합) ──────────
const [actionDialog, setActionDialog] = useState<{
actionId: string;
action: ToolbarAction;
isLoading: boolean;
isApplying: boolean;
summary: any;
previews: any[];
deletedSchedules: any[];
keptSchedules: any[];
preparedPayload: any;
} | null>(null);
const linkedFilterValuesRef = useRef<any[]>([]);
// ────────── 반제품 계획 생성 상태 ──────────
const [showSemiPreviewDialog, setShowSemiPreviewDialog] = useState(false);
const [semiPreviewLoading, setSemiPreviewLoading] = useState(false);
const [semiPreviewApplying, setSemiPreviewApplying] = useState(false);
const [semiPreviewSummary, setSemiPreviewSummary] = useState<any>(null);
const [semiPreviewItems, setSemiPreviewItems] = useState<any[]>([]);
const [semiPreviewDeleted, setSemiPreviewDeleted] = useState<any[]>([]);
const [semiPreviewKept, setSemiPreviewKept] = useState<any[]>([]);
// ────────── 아이콘 맵 ──────────
const TOOLBAR_ICONS: Record<string, React.ComponentType<{ className?: string }>> = useMemo(() => ({
Zap, Package, Plus, Download, Upload, RefreshCw, Play, FileText, Send, Sparkles, Wand2,
}), []);
// ────────── linkedFilter 상태 ──────────
const linkedFilter = config.linkedFilter;
@ -339,197 +348,153 @@ export function TimelineSchedulerComponent({
}
}, [onAddSchedule, effectiveResources]);
// ────────── 자동 스케줄 생성: 미리보기 요청 ──────────
const handleAutoSchedulePreview = useCallback(async () => {
const selectedRows = linkedFilterValuesRef.current;
if (!selectedRows || selectedRows.length === 0) {
toast.warning("좌측에서 품목을 선택해주세요");
return;
// ────────── 유효 툴바 액션 (config 기반 또는 하위호환 자동생성) ──────────
const effectiveToolbarActions: ToolbarAction[] = useMemo(() => {
if (config.toolbarActions && config.toolbarActions.length > 0) {
return config.toolbarActions;
}
return [];
}, [config.toolbarActions]);
const sourceField = config.linkedFilter?.sourceField || "part_code";
const grouped = new Map<string, any[]>();
selectedRows.forEach((row: any) => {
const key = row[sourceField] || "";
if (!key) return;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(row);
});
// ────────── 범용 액션: 미리보기 요청 ──────────
const handleActionPreview = useCallback(async (action: ToolbarAction) => {
let payload: any;
const items = Array.from(grouped.entries()).map(([itemCode, rows]) => {
const totalBalanceQty = rows.reduce((sum: number, r: any) => sum + (Number(r.balance_qty) || 0), 0);
const earliestDueDate = rows
.map((r: any) => r.due_date)
.filter(Boolean)
.sort()[0] || new Date().toISOString().split("T")[0];
const first = rows[0];
if (action.dataSource === "linkedSelection") {
const selectedRows = linkedFilterValuesRef.current;
if (!selectedRows || selectedRows.length === 0) {
toast.warning("좌측에서 항목을 선택해주세요");
return;
}
return {
item_code: itemCode,
item_name: first.part_name || first.item_name || itemCode,
required_qty: totalBalanceQty,
earliest_due_date: typeof earliestDueDate === "string" ? earliestDueDate.split("T")[0] : earliestDueDate,
hourly_capacity: Number(first.hourly_capacity) || undefined,
daily_capacity: Number(first.daily_capacity) || undefined,
};
}).filter((item) => item.required_qty > 0);
const groupField = action.payloadConfig?.groupByField || config.linkedFilter?.sourceField || "part_code";
const qtyField = action.payloadConfig?.quantityField || config.sourceConfig?.quantityField || "balance_qty";
const dateField = action.payloadConfig?.dueDateField || config.sourceConfig?.dueDateField || "due_date";
const nameField = action.payloadConfig?.nameField || config.sourceConfig?.groupNameField || "part_name";
if (items.length === 0) {
toast.warning("선택된 품목의 잔량이 없습니다");
return;
}
setShowPreviewDialog(true);
setPreviewLoading(true);
try {
const response = await apiClient.post("/production/generate-schedule/preview", {
items,
options: {
product_type: config.staticFilters?.product_type || "완제품",
safety_lead_time: 1,
recalculate_unstarted: true,
},
const grouped = new Map<string, any[]>();
selectedRows.forEach((row: any) => {
const key = row[groupField] || "";
if (!key) return;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(row);
});
const items = Array.from(grouped.entries()).map(([code, rows]) => {
const totalQty = rows.reduce((sum: number, r: any) => sum + (Number(r[qtyField]) || 0), 0);
const dates = rows.map((r: any) => r[dateField]).filter(Boolean).sort();
const earliestDate = dates[0] || new Date().toISOString().split("T")[0];
const first = rows[0];
return {
item_code: code,
item_name: first[nameField] || first.item_name || code,
required_qty: totalQty,
earliest_due_date: typeof earliestDate === "string" ? earliestDate.split("T")[0] : earliestDate,
hourly_capacity: Number(first.hourly_capacity) || undefined,
daily_capacity: Number(first.daily_capacity) || undefined,
};
}).filter((item) => item.required_qty > 0);
if (items.length === 0) {
toast.warning("선택된 항목의 잔량이 없습니다");
return;
}
payload = {
items,
options: {
...(config.staticFilters || {}),
...(action.payloadConfig?.extraOptions || {}),
},
};
} else if (action.dataSource === "currentSchedules") {
let targetSchedules = schedules;
const filterField = action.payloadConfig?.scheduleFilterField;
const filterValue = action.payloadConfig?.scheduleFilterValue;
if (filterField && filterValue) {
targetSchedules = schedules.filter((s) => {
const val = (s.data as any)?.[filterField] || "";
return val === filterValue;
});
}
if (targetSchedules.length === 0) {
toast.warning("대상 스케줄이 없습니다");
return;
}
const planIds = targetSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id));
if (planIds.length === 0) {
toast.warning("유효한 스케줄 ID가 없습니다");
return;
}
payload = {
plan_ids: planIds,
options: action.payloadConfig?.extraOptions || {},
};
}
setActionDialog({
actionId: action.id,
action,
isLoading: true,
isApplying: false,
summary: null,
previews: [],
deletedSchedules: [],
keptSchedules: [],
preparedPayload: payload,
});
try {
const response = await apiClient.post(action.previewApi, payload);
if (response.data?.success) {
setPreviewSummary(response.data.data.summary);
setPreviewItems(response.data.data.previews);
setPreviewDeleted(response.data.data.deletedSchedules || []);
setPreviewKept(response.data.data.keptSchedules || []);
setActionDialog((prev) => prev ? {
...prev,
isLoading: false,
summary: response.data.data.summary,
previews: response.data.data.previews || [],
deletedSchedules: response.data.data.deletedSchedules || [],
keptSchedules: response.data.data.keptSchedules || [],
} : null);
} else {
toast.error("미리보기 생성 실패");
setShowPreviewDialog(false);
toast.error("미리보기 생성 실패", { description: response.data?.message });
setActionDialog(null);
}
} catch (err: any) {
toast.error("미리보기 요청 실패", { description: err.message });
setShowPreviewDialog(false);
} finally {
setPreviewLoading(false);
setActionDialog(null);
}
}, [config.linkedFilter, config.staticFilters]);
}, [config.linkedFilter, config.staticFilters, config.sourceConfig, schedules]);
// ────────── 자동 스케줄 생성: 확인 및 적용 ──────────
const handleAutoScheduleApply = useCallback(async () => {
if (!previewItems || previewItems.length === 0) return;
// ────────── 범용 액션: 확인 및 적용 ──────────
const handleActionApply = useCallback(async () => {
if (!actionDialog) return;
const { action, preparedPayload } = actionDialog;
setPreviewApplying(true);
const items = previewItems.map((p: any) => ({
item_code: p.item_code,
item_name: p.item_name,
required_qty: p.required_qty,
earliest_due_date: p.due_date,
hourly_capacity: p.hourly_capacity,
daily_capacity: p.daily_capacity,
}));
setActionDialog((prev) => prev ? { ...prev, isApplying: true } : null);
try {
const response = await apiClient.post("/production/generate-schedule", {
items,
options: {
product_type: config.staticFilters?.product_type || "완제품",
safety_lead_time: 1,
recalculate_unstarted: true,
},
});
if (response.data?.success) {
const summary = response.data.data.summary;
toast.success("생산계획 업데이트 완료", {
description: `신규: ${summary.new_count}건, 유지: ${summary.kept_count}건, 삭제: ${summary.deleted_count}`,
});
setShowPreviewDialog(false);
refreshTimeline();
} else {
toast.error("생산계획 생성 실패");
}
} catch (err: any) {
toast.error("생산계획 생성 실패", { description: err.message });
} finally {
setPreviewApplying(false);
}
}, [previewItems, config.staticFilters, refreshTimeline]);
// ────────── 반제품 계획 생성: 미리보기 요청 ──────────
const handleSemiSchedulePreview = useCallback(async () => {
// 현재 타임라인에 표시된 완제품 스케줄의 plan ID 수집
const finishedSchedules = schedules.filter((s) => {
const productType = (s.data as any)?.product_type || "";
return productType === "완제품";
});
if (finishedSchedules.length === 0) {
toast.warning("완제품 스케줄이 없습니다. 먼저 완제품 계획을 생성해주세요.");
return;
}
const planIds = finishedSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id));
if (planIds.length === 0) {
toast.warning("유효한 완제품 계획 ID가 없습니다");
return;
}
setShowSemiPreviewDialog(true);
setSemiPreviewLoading(true);
try {
const response = await apiClient.post("/production/generate-semi-schedule/preview", {
plan_ids: planIds,
options: { considerStock: true },
});
if (response.data?.success) {
setSemiPreviewSummary(response.data.data.summary);
setSemiPreviewItems(response.data.data.previews || []);
setSemiPreviewDeleted(response.data.data.deletedSchedules || []);
setSemiPreviewKept(response.data.data.keptSchedules || []);
} else {
toast.error("반제품 미리보기 실패", { description: response.data?.message });
setShowSemiPreviewDialog(false);
}
} catch (err: any) {
toast.error("반제품 미리보기 요청 실패", { description: err.message });
setShowSemiPreviewDialog(false);
} finally {
setSemiPreviewLoading(false);
}
}, [schedules]);
// ────────── 반제품 계획 생성: 확인 및 적용 ──────────
const handleSemiScheduleApply = useCallback(async () => {
const finishedSchedules = schedules.filter((s) => {
const productType = (s.data as any)?.product_type || "";
return productType === "완제품";
});
const planIds = finishedSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id));
if (planIds.length === 0) return;
setSemiPreviewApplying(true);
try {
const response = await apiClient.post("/production/generate-semi-schedule", {
plan_ids: planIds,
options: { considerStock: true },
});
const response = await apiClient.post(action.applyApi, preparedPayload);
if (response.data?.success) {
const data = response.data.data;
toast.success("반제품 계획 생성 완료", {
description: `${data.count}건의 반제품 계획이 생성되었습니다`,
const summary = data.summary || data;
toast.success(action.dialogTitle || "완료", {
description: `신규: ${summary.new_count || summary.count || 0}${summary.kept_count ? `, 유지: ${summary.kept_count}` : ""}${summary.deleted_count ? `, 삭제: ${summary.deleted_count}` : ""}`,
});
setShowSemiPreviewDialog(false);
setActionDialog(null);
refreshTimeline();
} else {
toast.error("반제품 계획 생성 실패");
toast.error("실행 실패", { description: response.data?.message });
}
} catch (err: any) {
toast.error("반제품 계획 생성 실패", { description: err.message });
toast.error("실행 실패", { description: err.message });
} finally {
setSemiPreviewApplying(false);
setActionDialog((prev) => prev ? { ...prev, isApplying: false } : null);
}
}, [schedules, refreshTimeline]);
}, [actionDialog, refreshTimeline]);
// ────────── 하단 영역 높이 계산 (툴바 + 범례) ──────────
const showToolbar = config.showToolbar !== false;
@ -713,18 +678,26 @@ export function TimelineSchedulerComponent({
<RefreshCw className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
</Button>
{config.staticFilters?.product_type === "완제품" && (
<>
<Button size="sm" onClick={handleAutoSchedulePreview} className="h-6 gap-1 bg-emerald-600 px-2 text-[10px] hover:bg-emerald-700 sm:h-7 sm:px-3 sm:text-xs">
<Zap className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
{effectiveToolbarActions.map((action) => {
if (action.showWhen) {
const matches = Object.entries(action.showWhen).every(
([key, value]) => config.staticFilters?.[key] === value
);
if (!matches) return null;
}
const IconComp = TOOLBAR_ICONS[action.icon || "Zap"] || Zap;
return (
<Button
key={action.id}
size="sm"
onClick={() => handleActionPreview(action)}
className={cn("h-6 gap-1 px-2 text-[10px] sm:h-7 sm:px-3 sm:text-xs", action.color || "bg-primary hover:bg-primary/90")}
>
<IconComp className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
{action.label}
</Button>
<Button size="sm" onClick={handleSemiSchedulePreview} className="h-6 gap-1 bg-blue-600 px-2 text-[10px] hover:bg-blue-700 sm:h-7 sm:px-3 sm:text-xs">
<Package className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
</Button>
</>
)}
);
})}
</div>
</div>
)}
@ -796,33 +769,22 @@ export function TimelineSchedulerComponent({
</div>
)}
{/* 완제품 스케줄 생성 미리보기 다이얼로그 */}
<SchedulePreviewDialog
open={showPreviewDialog}
onOpenChange={setShowPreviewDialog}
isLoading={previewLoading}
summary={previewSummary}
previews={previewItems}
deletedSchedules={previewDeleted}
keptSchedules={previewKept}
onConfirm={handleAutoScheduleApply}
isApplying={previewApplying}
/>
{/* 반제품 계획 생성 미리보기 다이얼로그 */}
<SchedulePreviewDialog
open={showSemiPreviewDialog}
onOpenChange={setShowSemiPreviewDialog}
isLoading={semiPreviewLoading}
summary={semiPreviewSummary}
previews={semiPreviewItems}
deletedSchedules={semiPreviewDeleted}
keptSchedules={semiPreviewKept}
onConfirm={handleSemiScheduleApply}
isApplying={semiPreviewApplying}
title="반제품 계획 자동 생성"
description="BOM 기반으로 완제품 계획에 필요한 반제품 생산계획을 생성합니다"
/>
{/* 범용 액션 미리보기 다이얼로그 */}
{actionDialog && (
<SchedulePreviewDialog
open={true}
onOpenChange={(open) => { if (!open) setActionDialog(null); }}
isLoading={actionDialog.isLoading}
summary={actionDialog.summary}
previews={actionDialog.previews}
deletedSchedules={actionDialog.deletedSchedules}
keptSchedules={actionDialog.keptSchedules}
onConfirm={handleActionApply}
isApplying={actionDialog.isApplying}
title={actionDialog.action.dialogTitle}
description={actionDialog.action.dialogDescription}
/>
)}
</div>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { TimelineSchedulerConfig, ZoomLevel, ScheduleType } from "./types";
import { TimelineSchedulerConfig, ZoomLevel, ScheduleType, ToolbarAction } from "./types";
/**
*
@ -94,6 +94,39 @@ export const scheduleTypeOptions: { value: ScheduleType; label: string }[] = [
{ value: "WORK_ASSIGN", label: "작업배정" },
];
/**
*
*/
export const viewModeOptions: { value: string; label: string; description: string }[] = [
{ value: "resource", label: "리소스 기반", description: "설비/작업자 행 기반 간트차트" },
{ value: "itemGrouped", label: "품목별 그룹", description: "품목별 카드형 타임라인" },
];
/**
*
*/
export const dataSourceOptions: { value: string; label: string; description: string }[] = [
{ value: "linkedSelection", label: "연결 필터 선택값", description: "좌측 테이블에서 선택된 행 데이터 사용" },
{ value: "currentSchedules", label: "현재 스케줄", description: "타임라인에 표시 중인 스케줄 ID 사용" },
];
/**
*
*/
export const toolbarIconOptions: { value: string; label: string }[] = [
{ value: "Zap", label: "Zap (번개)" },
{ value: "Package", label: "Package (박스)" },
{ value: "Plus", label: "Plus (추가)" },
{ value: "Download", label: "Download (다운로드)" },
{ value: "Upload", label: "Upload (업로드)" },
{ value: "RefreshCw", label: "RefreshCw (새로고침)" },
{ value: "Play", label: "Play (재생)" },
{ value: "FileText", label: "FileText (문서)" },
{ value: "Send", label: "Send (전송)" },
{ value: "Sparkles", label: "Sparkles (반짝)" },
{ value: "Wand2", label: "Wand2 (마법봉)" },
];
/**
*
*/

View File

@ -128,6 +128,58 @@ export interface SourceDataConfig {
groupNameField?: string;
}
/**
* ( )
*
* preview -> confirm -> apply
*/
export interface ToolbarAction {
/** 고유 ID */
id: string;
/** 버튼 텍스트 */
label: string;
/** lucide-react 아이콘명 */
icon?: "Zap" | "Package" | "Plus" | "Download" | "Upload" | "RefreshCw" | "Play" | "FileText" | "Send" | "Sparkles" | "Wand2";
/** 버튼 색상 클래스 (예: "bg-emerald-600 hover:bg-emerald-700") */
color?: string;
/** 미리보기 API 엔드포인트 (예: "/production/generate-schedule/preview") */
previewApi: string;
/** 적용 API 엔드포인트 (예: "/production/generate-schedule") */
applyApi: string;
/** 다이얼로그 제목 */
dialogTitle?: string;
/** 다이얼로그 설명 */
dialogDescription?: string;
/**
*
* - linkedSelection: 연결 ( )
* - currentSchedules: 현재 ID
*/
dataSource: "linkedSelection" | "currentSchedules";
/** 페이로드 구성 설정 */
payloadConfig?: {
/** linkedSelection: 선택된 행을 그룹화할 필드 (기본: linkedFilter.sourceField) */
groupByField?: string;
/** linkedSelection: 수량 합계 필드 (예: "balance_qty") */
quantityField?: string;
/** linkedSelection: 기준일 필드 (예: "due_date") */
dueDateField?: string;
/** linkedSelection: 표시명 필드 (예: "part_name") */
nameField?: string;
/** currentSchedules: 스케줄 필터 조건 필드명 (예: "product_type") */
scheduleFilterField?: string;
/** currentSchedules: 스케줄 필터 값 (예: "완제품") */
scheduleFilterValue?: string;
/** API 호출 시 추가 옵션 (예: { "safety_lead_time": 1 }) */
extraOptions?: Record<string, any>;
};
/**
* 조건: staticFilters와
* : { "product_type": "완제품" } staticFilters.product_type === "완제품"
*/
showWhen?: Record<string, string>;
}
/**
*
*/
@ -254,6 +306,9 @@ export interface TimelineSchedulerConfig extends ComponentConfig {
/** 빈 상태 메시지 */
emptyMessage?: string;
};
/** 툴바 커스텀 액션 버튼 설정 */
toolbarActions?: ToolbarAction[];
}
/**

View File

@ -26,3 +26,4 @@ import "./pop-status-bar";
import "./pop-field";
import "./pop-scanner";
import "./pop-profile";
import "./pop-work-detail";

View File

@ -34,6 +34,7 @@ import type {
TimelineDataSource,
ActionButtonUpdate,
ActionButtonClickAction,
QuantityInputConfig,
StatusValueMapping,
SelectModeConfig,
SelectModeButtonConfig,
@ -47,15 +48,42 @@ import { screenApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useCartSync } from "@/hooks/pop/useCartSync";
import { useAuth } from "@/hooks/useAuth";
import { NumberInputModal } from "../pop-card-list/NumberInputModal";
import { renderCellV2 } from "./cell-renderers";
import type { PopLayoutDataV5 } from "@/components/pop/designer/types/pop-layout";
import { isV5Layout, detectGridMode } from "@/components/pop/designer/types/pop-layout";
import type { PopLayoutData } from "@/components/pop/designer/types/pop-layout";
import { isPopLayout, detectGridMode } from "@/components/pop/designer/types/pop-layout";
import dynamic from "next/dynamic";
const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopViewerWithModals"), { ssr: false });
type RowData = Record<string, unknown>;
function calculateMaxQty(
row: RowData,
processId: string | number | undefined,
cfg?: QuantityInputConfig,
): number {
if (!cfg) return 999999;
const maxVal = cfg.maxColumn ? Number(row[cfg.maxColumn]) || 999999 : 999999;
if (!cfg.currentColumn) return maxVal;
const processFlow = row.__processFlow__ as Array<{
isCurrent: boolean;
processId?: string | number;
rawData?: Record<string, unknown>;
}> | undefined;
const currentProcess = processId
? processFlow?.find((p) => String(p.processId) === String(processId))
: processFlow?.find((p) => p.isCurrent);
if (currentProcess?.rawData) {
const currentVal = Number(currentProcess.rawData[cfg.currentColumn]) || 0;
return Math.max(0, maxVal - currentVal);
}
return maxVal;
}
// cart_items 행 파싱 (pop-card-list에서 그대로 차용)
function parseCartRow(dbRow: Record<string, unknown>): Record<string, unknown> {
let rowData: Record<string, unknown> = {};
@ -111,8 +139,9 @@ export function PopCardListV2Component({
currentColSpan,
onRequestResize,
}: PopCardListV2ComponentProps) {
const { subscribe, publish } = usePopEvent(screenId || "default");
const { subscribe, publish, setSharedData } = usePopEvent(screenId || "default");
const router = useRouter();
const { userId: currentUserId } = useAuth();
const isCartListMode = config?.cartListMode?.enabled === true;
const [inheritedConfig, setInheritedConfig] = useState<Partial<PopCardListV2Config> | null>(null);
@ -216,11 +245,18 @@ export function PopCardListV2Component({
// ===== 모달 열기 (POP 화면) =====
const [popModalOpen, setPopModalOpen] = useState(false);
const [popModalLayout, setPopModalLayout] = useState<PopLayoutDataV5 | null>(null);
const [popModalLayout, setPopModalLayout] = useState<PopLayoutData | null>(null);
const [popModalScreenId, setPopModalScreenId] = useState<string>("");
const [popModalRow, setPopModalRow] = useState<RowData | null>(null);
const openPopModal = useCallback(async (screenIdStr: string, row: RowData) => {
// 내부 모달 캔버스 (디자이너에서 생성한 modal-*)인 경우 이벤트 발행
if (screenIdStr.startsWith("modal-")) {
setSharedData("parentRow", row);
publish("__pop_modal_open__", { modalId: screenIdStr, fullscreen: true });
return;
}
// 외부 POP 화면 ID인 경우 기존 fetch 방식
try {
const sid = parseInt(screenIdStr, 10);
if (isNaN(sid)) {
@ -228,7 +264,7 @@ export function PopCardListV2Component({
return;
}
const popLayout = await screenApi.getLayoutPop(sid);
if (popLayout && isV5Layout(popLayout)) {
if (popLayout && isPopLayout(popLayout)) {
setPopModalLayout(popLayout);
setPopModalScreenId(String(sid));
setPopModalRow(row);
@ -239,7 +275,7 @@ export function PopCardListV2Component({
} catch {
toast.error("POP 화면을 불러오는데 실패했습니다.");
}
}, []);
}, [publish, setSharedData]);
const handleCardSelect = useCallback((row: RowData) => {
@ -469,7 +505,7 @@ export function PopCardListV2Component({
type: "data-update" as const,
targetTable: btnConfig.targetTable!,
targetColumn: u.column,
operationType: "assign" as const,
operationType: (u.operationType || "assign") as "assign" | "add" | "subtract",
valueSource: "fixed" as const,
fixedValue: u.valueType === "static" ? (u.value ?? "") :
u.valueType === "currentUser" ? "__CURRENT_USER__" :
@ -619,11 +655,28 @@ export function PopCardListV2Component({
const scrollAreaRef = useRef<HTMLDivElement>(null);
const ownerSortColumn = config?.ownerSortColumn;
const displayCards = useMemo(() => {
if (!isExpanded) return filteredRows.slice(0, visibleCardCount);
let source = filteredRows;
if (ownerSortColumn && currentUserId) {
const mine: RowData[] = [];
const others: RowData[] = [];
for (const row of source) {
if (String(row[ownerSortColumn] ?? "") === currentUserId) {
mine.push(row);
} else {
others.push(row);
}
}
source = [...mine, ...others];
}
if (!isExpanded) return source.slice(0, visibleCardCount);
const start = (currentPage - 1) * expandedCardsPerPage;
return filteredRows.slice(start, start + expandedCardsPerPage);
}, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]);
return source.slice(start, start + expandedCardsPerPage);
}, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage, ownerSortColumn, currentUserId]);
const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1;
const needsPagination = isExpanded && totalPages > 1;
@ -756,10 +809,17 @@ export function PopCardListV2Component({
if (firstPending) { firstPending.isCurrent = true; }
}
return fetchedRows.map((row) => ({
...row,
__processFlow__: processMap.get(String(row.id)) || [],
}));
return fetchedRows.map((row) => {
const steps = processMap.get(String(row.id)) || [];
const current = steps.find((s) => s.isCurrent);
const processFields: Record<string, unknown> = {};
if (current?.rawData) {
for (const [key, val] of Object.entries(current.rawData)) {
processFields[`__process_${key}`] = val;
}
}
return { ...row, __processFlow__: steps, ...processFields };
});
}, []);
const fetchData = useCallback(async () => {
@ -1014,35 +1074,42 @@ export function PopCardListV2Component({
className={`min-h-0 flex-1 grid ${scrollClassName}`}
style={{ ...cardAreaStyle, alignContent: "start", justifyContent: isHorizontalMode ? "start" : "center" }}
>
{displayCards.map((row, index) => (
<CardV2
key={`card-${index}`}
row={row}
cardGrid={cardGrid}
spec={spec}
config={effectiveConfig}
onSelect={handleCardSelect}
cart={cart}
publish={publish}
parentComponentId={componentId}
isCartListMode={isCartListMode}
isSelected={selectedKeys.has(String(row.__cart_id ?? ""))}
onToggleSelect={() => {
const cartId = row.__cart_id != null ? String(row.__cart_id) : "";
if (!cartId) return;
setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; });
}}
onDeleteItem={handleDeleteItem}
onUpdateQuantity={handleUpdateQuantity}
onRefresh={fetchData}
selectMode={selectMode}
isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))}
isSelectable={isRowSelectable(row)}
onToggleRowSelect={() => toggleRowSelection(row)}
onEnterSelectMode={enterSelectMode}
onOpenPopModal={openPopModal}
/>
))}
{displayCards.map((row, index) => {
const locked = !!ownerSortColumn
&& !!String(row[ownerSortColumn] ?? "")
&& String(row[ownerSortColumn] ?? "") !== (currentUserId ?? "");
return (
<CardV2
key={`card-${index}`}
row={row}
cardGrid={cardGrid}
spec={spec}
config={effectiveConfig}
onSelect={handleCardSelect}
cart={cart}
publish={publish}
parentComponentId={componentId}
isCartListMode={isCartListMode}
isSelected={selectedKeys.has(String(row.__cart_id ?? ""))}
onToggleSelect={() => {
const cartId = row.__cart_id != null ? String(row.__cart_id) : "";
if (!cartId) return;
setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; });
}}
onDeleteItem={handleDeleteItem}
onUpdateQuantity={handleUpdateQuantity}
onRefresh={fetchData}
selectMode={selectMode}
isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))}
isSelectable={isRowSelectable(row)}
onToggleRowSelect={() => toggleRowSelection(row)}
onEnterSelectMode={enterSelectMode}
onOpenPopModal={openPopModal}
currentUserId={currentUserId}
isLockedByOther={locked}
/>
);
})}
</div>
{/* 선택 모드 하단 액션 바 */}
@ -1116,6 +1183,7 @@ export function PopCardListV2Component({
viewportWidth={typeof window !== "undefined" ? window.innerWidth : 1024}
screenId={popModalScreenId}
currentMode={detectGridMode(typeof window !== "undefined" ? window.innerWidth : 1024)}
parentRow={popModalRow ?? undefined}
/>
)}
</div>
@ -1148,6 +1216,8 @@ interface CardV2Props {
onToggleRowSelect?: () => void;
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
onOpenPopModal?: (screenId: string, row: RowData) => void;
currentUserId?: string;
isLockedByOther?: boolean;
}
function CardV2({
@ -1155,7 +1225,7 @@ function CardV2({
parentComponentId, isCartListMode, isSelected, onToggleSelect,
onDeleteItem, onUpdateQuantity, onRefresh,
selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode,
onOpenPopModal,
onOpenPopModal, currentUserId, isLockedByOther,
}: CardV2Props) {
const inputField = config?.inputField;
const cartAction = config?.cartAction;
@ -1167,6 +1237,72 @@ function CardV2({
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [qtyModalState, setQtyModalState] = useState<{
open: boolean;
row: RowData;
processId?: string | number;
action: ActionButtonClickAction;
} | null>(null);
const handleQtyConfirm = useCallback(async (value: number) => {
if (!qtyModalState) return;
const { row: actionRow, processId: qtyProcessId, action } = qtyModalState;
setQtyModalState(null);
if (!action.targetTable || !action.updates) return;
const rowId = qtyProcessId ?? actionRow.id ?? actionRow.pk;
if (!rowId) { toast.error("대상 레코드 ID를 찾을 수 없습니다."); return; }
const lookupValue = action.joinConfig
? String(actionRow[action.joinConfig.sourceColumn] ?? rowId)
: rowId;
const lookupColumn = action.joinConfig?.targetColumn || "id";
const tasks = action.updates.map((u, idx) => ({
id: `qty-update-${idx}`,
type: "data-update" as const,
targetTable: action.targetTable!,
targetColumn: u.column,
operationType: (u.operationType || "assign") as "assign" | "add" | "subtract",
valueSource: "fixed" as const,
fixedValue: u.valueType === "userInput" ? String(value) :
u.valueType === "static" ? (u.value ?? "") :
u.valueType === "currentUser" ? "__CURRENT_USER__" :
u.valueType === "currentTime" ? "__CURRENT_TIME__" :
u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") :
(u.value ?? ""),
lookupMode: "manual" as const,
manualItemField: lookupColumn,
manualPkColumn: lookupColumn,
...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}),
}));
const targetRow = action.joinConfig
? { ...actionRow, [lookupColumn]: lookupValue }
: qtyProcessId ? { ...actionRow, id: qtyProcessId } : actionRow;
try {
const result = await apiClient.post("/pop/execute-action", {
tasks,
data: { items: [targetRow], fieldValues: {} },
mappings: {},
});
if (result.data?.success) {
toast.success(result.data.message || "처리 완료");
onRefresh?.();
} else {
toast.error(result.data?.message || "처리 실패");
}
} catch (err: unknown) {
if ((err as any)?.response?.status === 409) {
toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다.");
onRefresh?.();
} else {
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
}
}
}, [qtyModalState, onRefresh]);
const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : "";
const isCarted = cart.isItemInCart(rowKey);
const existingCartItem = cart.getCartItem(rowKey);
@ -1272,16 +1408,24 @@ function CardV2({
return (
<div
className={`relative flex cursor-pointer flex-col rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${borderClass}`}
className={cn(
"relative flex flex-col rounded-lg border bg-card shadow-sm transition-all duration-150",
isLockedByOther
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:shadow-md",
borderClass,
)}
style={{ minHeight: `${spec.height}px` }}
onClick={() => {
if (isLockedByOther) return;
if (selectMode && isSelectable) { onToggleRowSelect?.(); return; }
if (!selectMode) onSelect?.(row);
}}
role="button"
tabIndex={0}
tabIndex={isLockedByOther ? -1 : 0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (isLockedByOther) return;
if (selectMode && isSelectable) { onToggleRowSelect?.(); return; }
if (!selectMode) onSelect?.(row);
}
@ -1365,7 +1509,11 @@ function CardV2({
}
for (const action of actionsToRun) {
if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) {
if (action.type === "quantity-input" && action.targetTable && action.updates) {
if (action.confirmMessage && !window.confirm(action.confirmMessage)) return;
setQtyModalState({ open: true, row: actionRow, processId, action });
return;
} else if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) {
if (action.confirmMessage) {
if (!window.confirm(action.confirmMessage)) return;
}
@ -1381,7 +1529,7 @@ function CardV2({
type: "data-update" as const,
targetTable: action.targetTable!,
targetColumn: u.column,
operationType: "assign" as const,
operationType: (u.operationType || "assign") as "assign" | "add" | "subtract",
valueSource: "fixed" as const,
fixedValue: u.valueType === "static" ? (u.value ?? "") :
u.valueType === "currentUser" ? "__CURRENT_USER__" :
@ -1391,6 +1539,7 @@ function CardV2({
lookupMode: "manual" as const,
manualItemField: lookupColumn,
manualPkColumn: lookupColumn,
...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}),
}));
const targetRow = action.joinConfig
? { ...actionRow, [lookupColumn]: lookupValue }
@ -1408,7 +1557,12 @@ function CardV2({
return;
}
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
if ((err as any)?.response?.status === 409) {
toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다.");
onRefresh?.();
} else {
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
}
return;
}
} else if (action.type === "modal-open" && action.modalScreenId) {
@ -1418,6 +1572,7 @@ function CardV2({
},
packageEntries,
inputUnit: inputField?.unit,
currentUserId,
})}
</div>
))}
@ -1437,6 +1592,17 @@ function CardV2({
/>
)}
{qtyModalState?.open && (
<NumberInputModal
open={true}
onOpenChange={(open) => { if (!open) setQtyModalState(null); }}
unit={qtyModalState.action.quantityInput?.unit || "EA"}
maxValue={calculateMaxQty(qtyModalState.row, qtyModalState.processId, qtyModalState.action.quantityInput)}
showPackageUnit={qtyModalState.action.quantityInput?.enablePackage ?? false}
onConfirm={(value) => handleQtyConfirm(value)}
/>
)}
</div>
);
}

View File

@ -12,6 +12,7 @@ import { useState, useEffect, useRef, useCallback, useMemo, Fragment } from "rea
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext";
import { Switch } from "@/components/ui/switch";
import {
Select,
@ -65,6 +66,33 @@ import {
type ColumnInfo,
} from "../pop-dashboard/utils/dataFetcher";
// ===== 컬럼 옵션 그룹 =====
interface ColumnOptionGroup {
groupLabel: string;
options: { value: string; label: string }[];
}
function renderColumnOptionGroups(groups: ColumnOptionGroup[]) {
if (groups.length <= 1) {
return groups.flatMap((g) =>
g.options.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
))
);
}
return groups
.filter((g) => g.options.length > 0)
.map((g) => (
<SelectGroup key={g.groupLabel}>
<SelectLabel className="text-[9px] font-semibold text-muted-foreground px-2 py-1">{g.groupLabel}</SelectLabel>
{g.options.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
))}
</SelectGroup>
));
}
// ===== Props =====
interface ConfigPanelProps {
@ -271,6 +299,7 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps)
<TabActions
cfg={cfg}
onUpdate={update}
columns={columns}
/>
)}
</div>
@ -759,10 +788,36 @@ function TabCardDesign({
sourceTable: j.targetTable,
}))
);
const allColumnOptions = [
...availableColumns.map((c) => ({ value: c.name, label: c.name })),
...joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })),
const [processColumns, setProcessColumns] = useState<ColumnInfo[]>([]);
const timelineCell = cfg.cardGrid.cells.find((c) => c.type === "timeline" && c.timelineSource?.processTable);
const processTableName = timelineCell?.timelineSource?.processTable || "";
useEffect(() => {
if (!processTableName) { setProcessColumns([]); return; }
fetchTableColumns(processTableName)
.then(setProcessColumns)
.catch(() => setProcessColumns([]));
}, [processTableName]);
const columnOptionGroups: ColumnOptionGroup[] = [
{
groupLabel: `메인 (${cfg.dataSource.tableName || "테이블"})`,
options: availableColumns.map((c) => ({ value: c.name, label: c.name })),
},
...(joinedColumns.length > 0
? [{
groupLabel: "조인",
options: joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })),
}]
: []),
...(processColumns.length > 0
? [{
groupLabel: `공정 (${processTableName})`,
options: processColumns.map((c) => ({ value: `__process_${c.name}`, label: c.name })),
}]
: []),
];
const allColumnOptions = columnOptionGroups.flatMap((g) => g.options);
const [selectedCellId, setSelectedCellId] = useState<string | null>(null);
const [mergeMode, setMergeMode] = useState(false);
@ -1273,6 +1328,7 @@ function TabCardDesign({
cell={selectedCell}
allCells={grid.cells}
allColumnOptions={allColumnOptions}
columnOptionGroups={columnOptionGroups}
columns={columns}
selectedColumns={selectedColumns}
tables={tables}
@ -1291,6 +1347,7 @@ function CellDetailEditor({
cell,
allCells,
allColumnOptions,
columnOptionGroups,
columns,
selectedColumns,
tables,
@ -1301,6 +1358,7 @@ function CellDetailEditor({
cell: CardCellDefinitionV2;
allCells: CardCellDefinitionV2[];
allColumnOptions: { value: string; label: string }[];
columnOptionGroups: ColumnOptionGroup[];
columns: ColumnInfo[];
selectedColumns: string[];
tables: TableInfo[];
@ -1348,9 +1406,7 @@ function CellDetailEditor({
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-[10px]"></SelectItem>
{allColumnOptions.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
))}
{renderColumnOptionGroups(columnOptionGroups)}
</SelectContent>
</Select>
)}
@ -1417,9 +1473,9 @@ function CellDetailEditor({
{/* 타입별 상세 설정 */}
{cell.type === "status-badge" && <StatusMappingEditor cell={cell} allCells={allCells} onUpdate={onUpdate} />}
{cell.type === "timeline" && <TimelineConfigEditor cell={cell} allColumnOptions={allColumnOptions} tables={tables} onUpdate={onUpdate} />}
{cell.type === "action-buttons" && <ActionButtonsEditor cell={cell} allCells={allCells} allColumnOptions={allColumnOptions} availableTableOptions={availableTableOptions} onUpdate={onUpdate} />}
{cell.type === "footer-status" && <FooterStatusEditor cell={cell} allColumnOptions={allColumnOptions} onUpdate={onUpdate} />}
{cell.type === "field" && <FieldConfigEditor cell={cell} allColumnOptions={allColumnOptions} onUpdate={onUpdate} />}
{cell.type === "action-buttons" && <ActionButtonsEditor cell={cell} allCells={allCells} allColumnOptions={allColumnOptions} columnOptionGroups={columnOptionGroups} availableTableOptions={availableTableOptions} onUpdate={onUpdate} />}
{cell.type === "footer-status" && <FooterStatusEditor cell={cell} allColumnOptions={allColumnOptions} columnOptionGroups={columnOptionGroups} onUpdate={onUpdate} />}
{cell.type === "field" && <FieldConfigEditor cell={cell} allColumnOptions={allColumnOptions} columnOptionGroups={columnOptionGroups} onUpdate={onUpdate} />}
{cell.type === "number-input" && (
<div className="space-y-1">
<span className="text-[9px] font-medium text-muted-foreground"> </span>
@ -1429,7 +1485,7 @@ function CellDetailEditor({
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="제한 컬럼" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"></SelectItem>
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
{renderColumnOptionGroups(columnOptionGroups)}
</SelectContent>
</Select>
</div>
@ -1809,12 +1865,14 @@ function ActionButtonsEditor({
cell,
allCells,
allColumnOptions,
columnOptionGroups,
availableTableOptions,
onUpdate,
}: {
cell: CardCellDefinitionV2;
allCells: CardCellDefinitionV2[];
allColumnOptions: { value: string; label: string }[];
columnOptionGroups: ColumnOptionGroup[];
availableTableOptions: { value: string; label: string }[];
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
}) {
@ -1975,7 +2033,7 @@ function ActionButtonsEditor({
const isSectionOpen = (key: string) => expandedSections[key] !== false;
const ACTION_TYPE_LABELS: Record<string, string> = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기" };
const ACTION_TYPE_LABELS: Record<string, string> = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기", "quantity-input": "수량 입력" };
const getCondSummary = (btn: ActionButtonDef) => {
const c = btn.showCondition;
@ -1985,6 +2043,7 @@ function ActionButtonsEditor({
return opt ? opt.label : (c.value || "미설정");
}
if (c.type === "column-value") return `${c.column || "?"} = ${c.value || "?"}`;
if (c.type === "owner-match") return `소유자(${c.column || "?"})`;
return "항상";
};
@ -2081,8 +2140,21 @@ function ActionButtonsEditor({
<SelectItem value="always" className="text-[10px]"></SelectItem>
<SelectItem value="timeline-status" className="text-[10px]"></SelectItem>
<SelectItem value="column-value" className="text-[10px]"> </SelectItem>
<SelectItem value="owner-match" className="text-[10px]"> </SelectItem>
</SelectContent>
</Select>
{condType === "owner-match" && (
<Select
value={btn.showCondition?.column || "__none__"}
onValueChange={(v) => updateCondition(bi, { column: v === "__none__" ? "" : v })}
>
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"></SelectItem>
{renderColumnOptionGroups(columnOptionGroups)}
</SelectContent>
</Select>
)}
{condType === "timeline-status" && (
<Select
value={btn.showCondition?.value || "__none__"}
@ -2106,9 +2178,7 @@ function ActionButtonsEditor({
<SelectTrigger className="h-6 w-24 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"></SelectItem>
{allColumnOptions.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
))}
{renderColumnOptionGroups(columnOptionGroups)}
</SelectContent>
</Select>
<Input
@ -2168,6 +2238,7 @@ function ActionButtonsEditor({
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="immediate" className="text-[10px]"> </SelectItem>
<SelectItem value="quantity-input" className="text-[10px]"> </SelectItem>
<SelectItem value="select-mode" className="text-[10px]"> </SelectItem>
<SelectItem value="modal-open" className="text-[10px]"> </SelectItem>
</SelectContent>
@ -2191,6 +2262,50 @@ function ActionButtonsEditor({
/>
)}
{aType === "quantity-input" && (
<div className="space-y-1.5">
<ImmediateActionEditor
action={action}
allColumnOptions={allColumnOptions}
availableTableOptions={availableTableOptions}
onAddUpdate={() => addActionUpdate(bi, ai)}
onUpdateUpdate={(ui, p) => updateActionUpdate(bi, ai, ui, p)}
onRemoveUpdate={(ui) => removeActionUpdate(bi, ai, ui)}
onUpdateAction={(p) => updateAction(bi, ai, p)}
/>
<div className="rounded border bg-background/50 p-1.5 space-y-1">
<span className="text-[8px] font-medium text-muted-foreground"> </span>
<div className="flex items-center gap-1">
<span className="w-14 shrink-0 text-[8px] text-muted-foreground"> </span>
<Input
value={action.quantityInput?.maxColumn || ""}
onChange={(e) => updateAction(bi, ai, { quantityInput: { ...action.quantityInput, maxColumn: e.target.value } })}
placeholder="예: qty"
className="h-6 flex-1 text-[10px]"
/>
</div>
<div className="flex items-center gap-1">
<span className="w-14 shrink-0 text-[8px] text-muted-foreground"> </span>
<Input
value={action.quantityInput?.currentColumn || ""}
onChange={(e) => updateAction(bi, ai, { quantityInput: { ...action.quantityInput, currentColumn: e.target.value } })}
placeholder="예: input_qty"
className="h-6 flex-1 text-[10px]"
/>
</div>
<div className="flex items-center gap-1">
<span className="w-14 shrink-0 text-[8px] text-muted-foreground"></span>
<Input
value={action.quantityInput?.unit || ""}
onChange={(e) => updateAction(bi, ai, { quantityInput: { ...action.quantityInput, unit: e.target.value } })}
placeholder="예: EA"
className="h-6 w-20 text-[10px]"
/>
</div>
</div>
</div>
)}
{aType === "select-mode" && (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
@ -2455,6 +2570,70 @@ function ImmediateActionEditor({
className="h-6 flex-1 text-[10px]"
/>
</div>
{/* 사전 조건 (중복 방지) */}
<div className="rounded border bg-background/50 p-1.5 space-y-1">
<div className="flex items-center justify-between">
<span className="text-[8px] font-medium text-muted-foreground"> ( )</span>
<Switch
checked={!!action.preCondition}
onCheckedChange={(checked) => {
if (checked) {
onUpdateAction({ preCondition: { column: "", expectedValue: "", failMessage: "" } });
} else {
onUpdateAction({ preCondition: undefined });
}
}}
className="h-3.5 w-7 [&>span]:h-2.5 [&>span]:w-2.5"
/>
</div>
{action.preCondition && (
<div className="space-y-1">
<div className="flex items-center gap-1">
<span className="w-14 shrink-0 text-[8px] text-muted-foreground"> </span>
<Select
value={action.preCondition.column || "__none__"}
onValueChange={(v) => onUpdateAction({ preCondition: { ...action.preCondition!, column: v === "__none__" ? "" : v } })}
>
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"></SelectItem>
{businessCols.length > 0 && (
<SelectGroup>
<SelectLabel className="text-[8px] text-muted-foreground">{tableName}</SelectLabel>
{businessCols.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1">
<span className="w-14 shrink-0 text-[8px] text-muted-foreground"></span>
<Input
value={action.preCondition.expectedValue || ""}
onChange={(e) => onUpdateAction({ preCondition: { ...action.preCondition!, expectedValue: e.target.value } })}
placeholder="예: waiting"
className="h-6 flex-1 text-[10px]"
/>
</div>
<div className="flex items-center gap-1">
<span className="w-14 shrink-0 text-[8px] text-muted-foreground"> </span>
<Input
value={action.preCondition.failMessage || ""}
onChange={(e) => onUpdateAction({ preCondition: { ...action.preCondition!, failMessage: e.target.value } })}
placeholder="이미 다른 사용자가 처리했습니다"
className="h-6 flex-1 text-[10px]"
/>
</div>
<p className="text-[7px] text-muted-foreground/70 pl-0.5">
DB
</p>
</div>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-[8px] font-medium text-muted-foreground">
{tableName ? ` (${tableName})` : ""}
@ -2491,11 +2670,22 @@ function ImmediateActionEditor({
<SelectTrigger className="h-6 w-20 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="static" className="text-[10px]"></SelectItem>
<SelectItem value="userInput" className="text-[10px]"> </SelectItem>
<SelectItem value="currentUser" className="text-[10px]"> </SelectItem>
<SelectItem value="currentTime" className="text-[10px]"> </SelectItem>
<SelectItem value="columnRef" className="text-[10px]"> </SelectItem>
</SelectContent>
</Select>
{u.valueType === "userInput" && (
<Select value={u.operationType || "assign"} onValueChange={(v) => onUpdateUpdate(ui, { operationType: v as ActionButtonUpdate["operationType"] })}>
<SelectTrigger className="h-6 w-16 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="assign" className="text-[10px]"></SelectItem>
<SelectItem value="add" className="text-[10px]"></SelectItem>
<SelectItem value="subtract" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
)}
{(u.valueType === "static" || u.valueType === "columnRef") && (
<Input
value={u.value || ""}
@ -2608,10 +2798,12 @@ function DbTableCombobox({
function FooterStatusEditor({
cell,
allColumnOptions,
columnOptionGroups,
onUpdate,
}: {
cell: CardCellDefinitionV2;
allColumnOptions: { value: string; label: string }[];
columnOptionGroups: ColumnOptionGroup[];
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
}) {
const footerStatusMap = cell.footerStatusMap || [];
@ -2644,7 +2836,7 @@ function FooterStatusEditor({
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="상태 컬럼" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"></SelectItem>
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
{renderColumnOptionGroups(columnOptionGroups)}
</SelectContent>
</Select>
</div>
@ -2680,10 +2872,12 @@ function FooterStatusEditor({
function FieldConfigEditor({
cell,
allColumnOptions,
columnOptionGroups,
onUpdate,
}: {
cell: CardCellDefinitionV2;
allColumnOptions: { value: string; label: string }[];
columnOptionGroups: ColumnOptionGroup[];
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
}) {
const valueType = cell.valueType || "column";
@ -2706,7 +2900,7 @@ function FieldConfigEditor({
<Select value={cell.formulaLeft || ""} onValueChange={(v) => onUpdate({ formulaLeft: v })}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="좌항" /></SelectTrigger>
<SelectContent>
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
{renderColumnOptionGroups(columnOptionGroups)}
</SelectContent>
</Select>
<Select value={cell.formulaOperator || "+"} onValueChange={(v) => onUpdate({ formulaOperator: v as "+" | "-" | "*" | "/" })}>
@ -2726,7 +2920,7 @@ function FieldConfigEditor({
<Select value={cell.formulaRight || ""} onValueChange={(v) => onUpdate({ formulaRight: v })}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="우항" /></SelectTrigger>
<SelectContent>
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
{renderColumnOptionGroups(columnOptionGroups)}
</SelectContent>
</Select>
)}
@ -2741,16 +2935,62 @@ function FieldConfigEditor({
function TabActions({
cfg,
onUpdate,
columns,
}: {
cfg: PopCardListV2Config;
onUpdate: (partial: Partial<PopCardListV2Config>) => void;
columns: ColumnInfo[];
}) {
const designerCtx = usePopDesignerContext();
const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 };
const clickAction = cfg.cardClickAction || "none";
const modalConfig = cfg.cardClickModalConfig || { screenId: "" };
const [processColumns, setProcessColumns] = useState<ColumnInfo[]>([]);
const timelineCell = cfg.cardGrid?.cells?.find((c) => c.type === "timeline" && c.timelineSource?.processTable);
const processTableName = timelineCell?.timelineSource?.processTable || "";
useEffect(() => {
if (!processTableName) { setProcessColumns([]); return; }
fetchTableColumns(processTableName)
.then(setProcessColumns)
.catch(() => setProcessColumns([]));
}, [processTableName]);
const ownerColumnGroups: ColumnOptionGroup[] = useMemo(() => [
{
groupLabel: `메인 (${cfg.dataSource?.tableName || "테이블"})`,
options: columns.map((c) => ({ value: c.name, label: c.name })),
},
...(processColumns.length > 0
? [{
groupLabel: `공정 (${processTableName})`,
options: processColumns.map((c) => ({ value: `__process_${c.name}`, label: c.name })),
}]
: []),
], [columns, processColumns, processTableName, cfg.dataSource?.tableName]);
return (
<div className="space-y-3">
{/* 소유자 우선 정렬 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex items-center gap-1">
<Select
value={cfg.ownerSortColumn || "__none__"}
onValueChange={(v) => onUpdate({ ownerSortColumn: v === "__none__" ? undefined : v })}
>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="사용 안 함" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"> </SelectItem>
{renderColumnOptionGroups(ownerColumnGroups)}
</SelectContent>
</Select>
</div>
<p className="mt-0.5 text-[9px] text-muted-foreground">
</p>
</div>
{/* 카드 선택 시 */}
<div>
<Label className="text-xs"> </Label>
@ -2775,15 +3015,52 @@ function TabActions({
</div>
{clickAction === "modal-open" && (
<div className="mt-2 space-y-1.5 rounded border bg-muted/20 p-2">
<div className="flex items-center gap-1">
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">POP ID</span>
<Input
value={modalConfig.screenId || ""}
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })}
placeholder="화면 ID (예: 4481)"
className="h-7 flex-1 text-[10px]"
/>
</div>
{/* 모달 캔버스 (디자이너 모드) */}
{designerCtx && (
<div>
{modalConfig.screenId?.startsWith("modal-") ? (
<Button
variant="outline"
size="sm"
className="h-7 w-full text-[10px]"
onClick={() => designerCtx.navigateToCanvas(modalConfig.screenId)}
>
</Button>
) : (
<Button
variant="outline"
size="sm"
className="h-7 w-full text-[10px]"
onClick={() => {
const selectedId = designerCtx.selectedComponentId;
if (!selectedId) return;
const modalId = designerCtx.createModalCanvas(
selectedId,
modalConfig.modalTitle || "카드 상세"
);
onUpdate({
cardClickModalConfig: { ...modalConfig, screenId: modalId },
});
}}
>
</Button>
)}
</div>
)}
{/* 뷰어 모드 또는 직접 입력 폴백 */}
{!designerCtx && (
<div className="flex items-center gap-1">
<span className="w-16 shrink-0 text-[9px] text-muted-foreground"> ID</span>
<Input
value={modalConfig.screenId || ""}
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })}
placeholder="모달 ID"
className="h-7 flex-1 text-[10px]"
/>
</div>
)}
<div className="flex items-center gap-1">
<span className="w-16 shrink-0 text-[9px] text-muted-foreground"> </span>
<Input

View File

@ -70,6 +70,7 @@ export interface CellRendererProps {
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
packageEntries?: PackageEntry[];
inputUnit?: string;
currentUserId?: string;
}
// ===== 메인 디스패치 =====
@ -592,7 +593,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
// ===== 11. action-buttons =====
function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" {
function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId?: string): "visible" | "disabled" | "hidden" {
const cond = btn.showCondition;
if (!cond || cond.type === "always") return "visible";
@ -603,6 +604,9 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" |
matched = subStatus !== undefined && String(subStatus) === cond.value;
} else if (cond.type === "column-value" && cond.column) {
matched = String(row[cond.column] ?? "") === (cond.value ?? "");
} else if (cond.type === "owner-match" && cond.column) {
const ownerValue = String(row[cond.column] ?? "");
matched = !!currentUserId && ownerValue === currentUserId;
} else {
return "visible";
}
@ -611,7 +615,7 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" |
return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden";
}
function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) {
function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, currentUserId }: CellRendererProps) {
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
const currentProcess = processFlow?.find((s) => s.isCurrent);
const currentProcessId = currentProcess?.processId;
@ -619,7 +623,7 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }
if (cell.actionButtons && cell.actionButtons.length > 0) {
const evaluated = cell.actionButtons.map((btn) => ({
btn,
state: evaluateShowCondition(btn, row),
state: evaluateShowCondition(btn, row, currentUserId),
}));
const activeBtn = evaluated.find((e) => e.state === "visible");

View File

@ -295,8 +295,8 @@ function BasicSettingsTab({
const recommendation = useMemo(() => {
if (!currentMode) return null;
const cols = GRID_BREAKPOINTS[currentMode].columns;
if (cols >= 8) return { rows: 3, cols: 2 };
if (cols >= 6) return { rows: 3, cols: 1 };
if (cols >= 25) return { rows: 3, cols: 2 };
if (cols >= 18) return { rows: 3, cols: 1 };
return { rows: 2, cols: 1 };
}, [currentMode]);

View File

@ -19,7 +19,7 @@ import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { BarcodeScanModal } from "@/components/common/BarcodeScanModal";
import type {
PopDataConnection,
PopComponentDefinitionV5,
PopComponentDefinition,
} from "@/components/pop/designer/types/pop-layout";
// ========================================
@ -99,7 +99,7 @@ function parseScanResult(
function getConnectedFields(
componentId?: string,
connections?: PopDataConnection[],
allComponents?: PopComponentDefinitionV5[],
allComponents?: PopComponentDefinition[],
): ConnectedFieldInfo[] {
if (!componentId || !connections || !allComponents) return [];
@ -308,7 +308,7 @@ const PARSE_MODE_LABELS: Record<string, string> = {
interface PopScannerConfigPanelProps {
config: PopScannerConfig;
onUpdate: (config: PopScannerConfig) => void;
allComponents?: PopComponentDefinitionV5[];
allComponents?: PopComponentDefinition[];
connections?: PopDataConnection[];
componentId?: string;
}

View File

@ -72,7 +72,7 @@ const DEFAULT_CONFIG: PopSearchConfig = {
interface ConfigPanelProps {
config: PopSearchConfig | undefined;
onUpdate: (config: PopSearchConfig) => void;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
@ -151,7 +151,7 @@ export function PopSearchConfigPanel({ config, onUpdate, allComponents, connecti
interface StepProps {
cfg: PopSearchConfig;
update: (partial: Partial<PopSearchConfig>) => void;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
@ -268,7 +268,7 @@ interface FilterConnectionSectionProps {
update: (partial: Partial<PopSearchConfig>) => void;
showFieldName: boolean;
fixedFilterMode?: SearchFilterMode;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
@ -284,7 +284,7 @@ interface ConnectedComponentInfo {
function getConnectedComponentInfo(
componentId?: string,
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[],
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[],
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[],
): ConnectedComponentInfo {
const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() };
if (!componentId || !connections || !allComponents) return empty;

View File

@ -22,7 +22,7 @@ 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[];
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}

View File

@ -0,0 +1,832 @@
"use client";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import {
Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { dataApi } from "@/lib/api/data";
import { apiClient } from "@/lib/api/client";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useAuth } from "@/hooks/useAuth";
import type { PopWorkDetailConfig } from "../types";
import type { TimelineProcessStep } from "../types";
// ========================================
// 타입
// ========================================
type RowData = Record<string, unknown>;
interface WorkResultRow {
id: string;
work_order_process_id: string;
source_work_item_id: string;
source_detail_id: string;
work_phase: string;
item_title: string;
item_sort_order: string;
detail_type: string;
detail_label: string;
detail_sort_order: string;
spec_value: string | null;
lower_limit: string | null;
upper_limit: string | null;
input_type: string | null;
result_value: string | null;
status: string;
is_passed: string | null;
recorded_by: string | null;
recorded_at: string | null;
}
interface WorkGroup {
phase: string;
title: string;
itemId: string;
sortOrder: number;
total: number;
completed: number;
}
type WorkPhase = "PRE" | "IN" | "POST";
const PHASE_ORDER: Record<string, number> = { PRE: 1, IN: 2, POST: 3 };
interface ProcessTimerData {
started_at: string | null;
paused_at: string | null;
total_paused_time: string | null;
status: string;
good_qty: string | null;
defect_qty: string | null;
}
// ========================================
// Props
// ========================================
interface PopWorkDetailComponentProps {
config?: PopWorkDetailConfig;
screenId?: string;
componentId?: string;
currentRowSpan?: number;
currentColSpan?: number;
}
// ========================================
// 메인 컴포넌트
// ========================================
export function PopWorkDetailComponent({
config,
screenId,
componentId,
}: PopWorkDetailComponentProps) {
const { getSharedData } = usePopEvent(screenId || "default");
const { user } = useAuth();
const cfg: PopWorkDetailConfig = {
showTimer: config?.showTimer ?? true,
showQuantityInput: config?.showQuantityInput ?? true,
phaseLabels: config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" },
};
// parentRow에서 현재 공정 정보 추출
const parentRow = getSharedData<RowData>("parentRow");
const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined;
const currentProcess = processFlow?.find((p) => p.isCurrent);
const workOrderProcessId = currentProcess?.processId
? String(currentProcess.processId)
: undefined;
const processName = currentProcess?.processName ?? "공정 상세";
// ========================================
// 상태
// ========================================
const [allResults, setAllResults] = useState<WorkResultRow[]>([]);
const [processData, setProcessData] = useState<ProcessTimerData | null>(null);
const [loading, setLoading] = useState(true);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [tick, setTick] = useState(Date.now());
const [savingIds, setSavingIds] = useState<Set<string>>(new Set());
// 수량 입력 로컬 상태
const [goodQty, setGoodQty] = useState("");
const [defectQty, setDefectQty] = useState("");
// ========================================
// D-FE1: 데이터 로드
// ========================================
const fetchData = useCallback(async () => {
if (!workOrderProcessId) {
setLoading(false);
return;
}
try {
setLoading(true);
const [resultRes, processRes] = await Promise.all([
dataApi.getTableData("process_work_result", {
size: 500,
filters: { work_order_process_id: workOrderProcessId },
}),
dataApi.getTableData("work_order_process", {
size: 1,
filters: { id: workOrderProcessId },
}),
]);
setAllResults((resultRes.data ?? []) as unknown as WorkResultRow[]);
const proc = (processRes.data?.[0] ?? null) as ProcessTimerData | null;
setProcessData(proc);
if (proc) {
setGoodQty(proc.good_qty ?? "");
setDefectQty(proc.defect_qty ?? "");
}
} catch {
toast.error("데이터를 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [workOrderProcessId]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ========================================
// D-FE2: 좌측 사이드바 - 작업항목 그룹핑
// ========================================
const groups = useMemo<WorkGroup[]>(() => {
const map = new Map<string, WorkGroup>();
for (const row of allResults) {
const key = row.source_work_item_id;
if (!map.has(key)) {
map.set(key, {
phase: row.work_phase,
title: row.item_title,
itemId: key,
sortOrder: parseInt(row.item_sort_order || "0", 10),
total: 0,
completed: 0,
});
}
const g = map.get(key)!;
g.total++;
if (row.status === "completed") g.completed++;
}
return Array.from(map.values()).sort(
(a, b) =>
(PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) ||
a.sortOrder - b.sortOrder
);
}, [allResults]);
// phase별로 그룹핑
const groupsByPhase = useMemo(() => {
const result: Record<string, WorkGroup[]> = {};
for (const g of groups) {
if (!result[g.phase]) result[g.phase] = [];
result[g.phase].push(g);
}
return result;
}, [groups]);
// 첫 그룹 자동 선택
useEffect(() => {
if (groups.length > 0 && !selectedGroupId) {
setSelectedGroupId(groups[0].itemId);
}
}, [groups, selectedGroupId]);
// ========================================
// D-FE3: 우측 체크리스트
// ========================================
const currentItems = useMemo(
() =>
allResults
.filter((r) => r.source_work_item_id === selectedGroupId)
.sort((a, b) => parseInt(a.detail_sort_order || "0", 10) - parseInt(b.detail_sort_order || "0", 10)),
[allResults, selectedGroupId]
);
const saveResultValue = useCallback(
async (
rowId: string,
resultValue: string,
isPassed: string | null,
newStatus: string
) => {
setSavingIds((prev) => new Set(prev).add(rowId));
try {
await apiClient.post("/pop/execute-action", {
tasks: [
{ type: "data-update", targetTable: "process_work_result", targetColumn: "result_value", value: resultValue, items: [{ id: rowId }] },
{ type: "data-update", targetTable: "process_work_result", targetColumn: "status", value: newStatus, items: [{ id: rowId }] },
...(isPassed !== null
? [{ type: "data-update", targetTable: "process_work_result", targetColumn: "is_passed", value: isPassed, items: [{ id: rowId }] }]
: []),
{ type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_by", value: user?.userId ?? "", items: [{ id: rowId }] },
{ type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_at", value: new Date().toISOString(), items: [{ id: rowId }] },
],
data: { items: [{ id: rowId }], fieldValues: {} },
});
setAllResults((prev) =>
prev.map((r) =>
r.id === rowId
? {
...r,
result_value: resultValue,
status: newStatus,
is_passed: isPassed,
recorded_by: user?.userId ?? null,
recorded_at: new Date().toISOString(),
}
: r
)
);
} catch {
toast.error("저장에 실패했습니다.");
} finally {
setSavingIds((prev) => {
const next = new Set(prev);
next.delete(rowId);
return next;
});
}
},
[user?.userId]
);
// ========================================
// D-FE4: 타이머
// ========================================
useEffect(() => {
if (!cfg.showTimer || !processData?.started_at) return;
const id = setInterval(() => setTick(Date.now()), 1000);
return () => clearInterval(id);
}, [cfg.showTimer, processData?.started_at]);
const elapsedMs = useMemo(() => {
if (!processData?.started_at) return 0;
const now = tick;
const totalMs = now - new Date(processData.started_at).getTime();
const pausedSec = parseInt(processData.total_paused_time || "0", 10);
const currentPauseMs = processData.paused_at
? now - new Date(processData.paused_at).getTime()
: 0;
return Math.max(0, totalMs - pausedSec * 1000 - currentPauseMs);
}, [processData?.started_at, processData?.paused_at, processData?.total_paused_time, tick]);
const formattedTime = useMemo(() => {
const totalSec = Math.floor(elapsedMs / 1000);
const h = String(Math.floor(totalSec / 3600)).padStart(2, "0");
const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, "0");
const s = String(totalSec % 60).padStart(2, "0");
return `${h}:${m}:${s}`;
}, [elapsedMs]);
const isPaused = !!processData?.paused_at;
const isStarted = !!processData?.started_at;
const handleTimerAction = useCallback(
async (action: "start" | "pause" | "resume") => {
if (!workOrderProcessId) return;
try {
await apiClient.post("/api/pop/production/timer", {
workOrderProcessId,
action,
});
// 타이머 상태 새로고침
const res = await dataApi.getTableData("work_order_process", {
size: 1,
filters: { id: workOrderProcessId },
});
const proc = (res.data?.[0] ?? null) as ProcessTimerData | null;
if (proc) setProcessData(proc);
} catch {
toast.error("타이머 제어에 실패했습니다.");
}
},
[workOrderProcessId]
);
// ========================================
// D-FE5: 수량 등록 + 완료
// ========================================
const handleQuantityRegister = useCallback(async () => {
if (!workOrderProcessId) return;
try {
await apiClient.post("/pop/execute-action", {
tasks: [
{ type: "data-update", targetTable: "work_order_process", targetColumn: "good_qty", value: goodQty || "0", items: [{ id: workOrderProcessId }] },
{ type: "data-update", targetTable: "work_order_process", targetColumn: "defect_qty", value: defectQty || "0", items: [{ id: workOrderProcessId }] },
],
data: { items: [{ id: workOrderProcessId }], fieldValues: {} },
});
toast.success("수량이 등록되었습니다.");
} catch {
toast.error("수량 등록에 실패했습니다.");
}
}, [workOrderProcessId, goodQty, defectQty]);
const handleProcessComplete = useCallback(async () => {
if (!workOrderProcessId) return;
try {
await apiClient.post("/pop/execute-action", {
tasks: [
{ type: "data-update", targetTable: "work_order_process", targetColumn: "status", value: "completed", items: [{ id: workOrderProcessId }] },
{ type: "data-update", targetTable: "work_order_process", targetColumn: "completed_at", value: new Date().toISOString(), items: [{ id: workOrderProcessId }] },
],
data: { items: [{ id: workOrderProcessId }], fieldValues: {} },
});
toast.success("공정이 완료되었습니다.");
setProcessData((prev) =>
prev ? { ...prev, status: "completed" } : prev
);
} catch {
toast.error("공정 완료 처리에 실패했습니다.");
}
}, [workOrderProcessId]);
// ========================================
// 안전 장치
// ========================================
if (!parentRow) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<AlertCircle className="mr-2 h-4 w-4" />
</div>
);
}
if (!workOrderProcessId) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<AlertCircle className="mr-2 h-4 w-4" />
</div>
);
}
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
);
}
if (allResults.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<AlertCircle className="mr-2 h-4 w-4" />
</div>
);
}
const isProcessCompleted = processData?.status === "completed";
// ========================================
// 렌더링
// ========================================
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b px-3 py-2">
<h3 className="text-sm font-semibold">{processName}</h3>
{cfg.showTimer && (
<div className="flex items-center gap-2">
<Timer className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-sm font-medium tabular-nums">
{formattedTime}
</span>
{!isProcessCompleted && (
<>
{!isStarted && (
<Button size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => handleTimerAction("start")}>
<Play className="mr-1 h-3 w-3" />
</Button>
)}
{isStarted && !isPaused && (
<Button size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => handleTimerAction("pause")}>
<Pause className="mr-1 h-3 w-3" />
</Button>
)}
{isStarted && isPaused && (
<Button size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => handleTimerAction("resume")}>
<Play className="mr-1 h-3 w-3" />
</Button>
)}
</>
)}
</div>
)}
</div>
{/* 본문: 좌측 사이드바 + 우측 체크리스트 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측 사이드바 */}
<div className="w-40 shrink-0 overflow-y-auto border-r bg-muted/30">
{(["PRE", "IN", "POST"] as WorkPhase[]).map((phase) => {
const phaseGroups = groupsByPhase[phase];
if (!phaseGroups || phaseGroups.length === 0) return null;
return (
<div key={phase}>
<div className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase text-muted-foreground">
{cfg.phaseLabels[phase] ?? phase}
</div>
{phaseGroups.map((g) => (
<button
key={g.itemId}
className={cn(
"flex w-full flex-col px-3 py-1.5 text-left transition-colors",
selectedGroupId === g.itemId
? "bg-primary/10 text-primary"
: "hover:bg-muted/60"
)}
onClick={() => setSelectedGroupId(g.itemId)}
>
<span className="text-xs font-medium leading-tight">
{g.title}
</span>
<span className="text-[10px] text-muted-foreground">
{g.completed}/{g.total}
</span>
</button>
))}
</div>
);
})}
</div>
{/* 우측 체크리스트 */}
<div className="flex-1 overflow-y-auto p-3">
{selectedGroupId && (
<div className="space-y-2">
{currentItems.map((item) => (
<ChecklistItem
key={item.id}
item={item}
saving={savingIds.has(item.id)}
disabled={isProcessCompleted}
onSave={saveResultValue}
/>
))}
</div>
)}
</div>
</div>
{/* 하단: 수량 입력 + 완료 */}
{cfg.showQuantityInput && (
<div className="flex items-center gap-2 border-t px-3 py-2">
<Package className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground"></span>
<Input
type="number"
className="h-7 w-20 text-xs"
value={goodQty}
onChange={(e) => setGoodQty(e.target.value)}
disabled={isProcessCompleted}
/>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground"></span>
<Input
type="number"
className="h-7 w-20 text-xs"
value={defectQty}
onChange={(e) => setDefectQty(e.target.value)}
disabled={isProcessCompleted}
/>
</div>
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={handleQuantityRegister}
disabled={isProcessCompleted}
>
</Button>
<div className="flex-1" />
{!isProcessCompleted && (
<Button
size="sm"
variant="default"
className="h-7 text-xs"
onClick={handleProcessComplete}
>
<CheckCircle2 className="mr-1 h-3 w-3" />
</Button>
)}
{isProcessCompleted && (
<Badge variant="outline" className="text-xs text-green-600">
</Badge>
)}
</div>
)}
</div>
);
}
// ========================================
// 체크리스트 개별 항목
// ========================================
interface ChecklistItemProps {
item: WorkResultRow;
saving: boolean;
disabled: boolean;
onSave: (
rowId: string,
resultValue: string,
isPassed: string | null,
newStatus: string
) => void;
}
function ChecklistItem({ item, saving, disabled, onSave }: ChecklistItemProps) {
const isSaving = saving;
const isDisabled = disabled || isSaving;
switch (item.detail_type) {
case "check":
return <CheckItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
case "inspect":
return <InspectItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
case "input":
return <InputItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
case "procedure":
return <ProcedureItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
case "material":
return <MaterialItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
default:
return (
<div className="rounded border p-2 text-xs text-muted-foreground">
: {item.detail_type}
</div>
);
}
}
// ===== check: 체크박스 =====
function CheckItem({
item,
disabled,
saving,
onSave,
}: {
item: WorkResultRow;
disabled: boolean;
saving: boolean;
onSave: ChecklistItemProps["onSave"];
}) {
const checked = item.result_value === "Y";
return (
<div
className={cn(
"flex items-center gap-2 rounded border px-3 py-2",
item.status === "completed" && "bg-green-50 border-green-200"
)}
>
<Checkbox
checked={checked}
disabled={disabled}
onCheckedChange={(v) => {
const val = v ? "Y" : "N";
onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending");
}}
/>
<span className="flex-1 text-xs">{item.detail_label}</span>
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
{item.status === "completed" && !saving && (
<Badge variant="outline" className="text-[10px] text-green-600">
</Badge>
)}
</div>
);
}
// ===== inspect: 측정값 입력 (범위 판정) =====
function InspectItem({
item,
disabled,
saving,
onSave,
}: {
item: WorkResultRow;
disabled: boolean;
saving: boolean;
onSave: ChecklistItemProps["onSave"];
}) {
const [inputVal, setInputVal] = useState(item.result_value ?? "");
const lower = parseFloat(item.lower_limit ?? "");
const upper = parseFloat(item.upper_limit ?? "");
const hasRange = !isNaN(lower) && !isNaN(upper);
const handleBlur = () => {
if (!inputVal || disabled) return;
const numVal = parseFloat(inputVal);
let passed: string | null = null;
if (hasRange) {
passed = numVal >= lower && numVal <= upper ? "Y" : "N";
}
onSave(item.id, inputVal, passed, "completed");
};
const isPassed = item.is_passed;
return (
<div
className={cn(
"rounded border px-3 py-2",
isPassed === "Y" && "bg-green-50 border-green-200",
isPassed === "N" && "bg-red-50 border-red-200"
)}
>
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium">{item.detail_label}</span>
{hasRange && (
<span className="text-[10px] text-muted-foreground">
: {item.lower_limit} ~ {item.upper_limit}
{item.spec_value ? ` (표준: ${item.spec_value})` : ""}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Input
type="number"
className="h-7 w-28 text-xs"
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
onBlur={handleBlur}
disabled={disabled}
placeholder="측정값 입력"
/>
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
{isPassed === "Y" && !saving && (
<Badge variant="outline" className="text-[10px] text-green-600"></Badge>
)}
{isPassed === "N" && !saving && (
<Badge variant="outline" className="text-[10px] text-red-600"></Badge>
)}
</div>
</div>
);
}
// ===== input: 자유 입력 =====
function InputItem({
item,
disabled,
saving,
onSave,
}: {
item: WorkResultRow;
disabled: boolean;
saving: boolean;
onSave: ChecklistItemProps["onSave"];
}) {
const [inputVal, setInputVal] = useState(item.result_value ?? "");
const inputType = item.input_type === "number" ? "number" : "text";
const handleBlur = () => {
if (!inputVal || disabled) return;
onSave(item.id, inputVal, null, "completed");
};
return (
<div
className={cn(
"rounded border px-3 py-2",
item.status === "completed" && "bg-green-50 border-green-200"
)}
>
<div className="mb-1 text-xs font-medium">{item.detail_label}</div>
<div className="flex items-center gap-2">
<Input
type={inputType}
className="h-7 flex-1 text-xs"
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
onBlur={handleBlur}
disabled={disabled}
placeholder="값 입력"
/>
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
</div>
</div>
);
}
// ===== procedure: 절차 확인 (읽기 전용 + 체크) =====
function ProcedureItem({
item,
disabled,
saving,
onSave,
}: {
item: WorkResultRow;
disabled: boolean;
saving: boolean;
onSave: ChecklistItemProps["onSave"];
}) {
const checked = item.result_value === "Y";
return (
<div
className={cn(
"rounded border px-3 py-2",
item.status === "completed" && "bg-green-50 border-green-200"
)}
>
<div className="mb-1 text-xs text-muted-foreground">
{item.spec_value || item.detail_label}
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={checked}
disabled={disabled}
onCheckedChange={(v) => {
onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending");
}}
/>
<span className="text-xs"></span>
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
</div>
</div>
);
}
// ===== material: 자재/LOT 입력 =====
function MaterialItem({
item,
disabled,
saving,
onSave,
}: {
item: WorkResultRow;
disabled: boolean;
saving: boolean;
onSave: ChecklistItemProps["onSave"];
}) {
const [inputVal, setInputVal] = useState(item.result_value ?? "");
const handleBlur = () => {
if (!inputVal || disabled) return;
onSave(item.id, inputVal, null, "completed");
};
return (
<div
className={cn(
"rounded border px-3 py-2",
item.status === "completed" && "bg-green-50 border-green-200"
)}
>
<div className="mb-1 text-xs font-medium">{item.detail_label}</div>
<div className="flex items-center gap-2">
<Input
className="h-7 flex-1 text-xs"
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
onBlur={handleBlur}
disabled={disabled}
placeholder="LOT 번호 입력"
/>
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
</div>
</div>
);
}

View File

@ -0,0 +1,72 @@
"use client";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import type { PopWorkDetailConfig } from "../types";
interface PopWorkDetailConfigPanelProps {
config?: PopWorkDetailConfig;
onChange?: (config: PopWorkDetailConfig) => void;
}
const DEFAULT_PHASE_LABELS: Record<string, string> = {
PRE: "작업 전",
IN: "작업 중",
POST: "작업 후",
};
export function PopWorkDetailConfigPanel({
config,
onChange,
}: PopWorkDetailConfigPanelProps) {
const cfg: PopWorkDetailConfig = {
showTimer: config?.showTimer ?? true,
showQuantityInput: config?.showQuantityInput ?? true,
phaseLabels: config?.phaseLabels ?? { ...DEFAULT_PHASE_LABELS },
};
const update = (partial: Partial<PopWorkDetailConfig>) => {
onChange?.({ ...cfg, ...partial });
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={cfg.showTimer}
onCheckedChange={(v) => update({ showTimer: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={cfg.showQuantityInput}
onCheckedChange={(v) => update({ showQuantityInput: v })}
/>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
{(["PRE", "IN", "POST"] as const).map((phase) => (
<div key={phase} className="flex items-center gap-2">
<span className="w-12 text-xs font-medium text-muted-foreground">
{phase}
</span>
<Input
className="h-8 text-xs"
value={cfg.phaseLabels[phase] ?? DEFAULT_PHASE_LABELS[phase]}
onChange={(e) =>
update({
phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value },
})
}
/>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
"use client";
import { ClipboardCheck } from "lucide-react";
import type { PopWorkDetailConfig } from "../types";
interface PopWorkDetailPreviewProps {
config?: PopWorkDetailConfig;
}
export function PopWorkDetailPreviewComponent({ config }: PopWorkDetailPreviewProps) {
const labels = config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" };
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
<ClipboardCheck className="h-6 w-6 text-muted-foreground" />
<span className="text-[10px] font-medium text-muted-foreground">
</span>
<div className="flex gap-1">
{Object.values(labels).map((l) => (
<span
key={l}
className="rounded bg-muted/60 px-1.5 py-0.5 text-[8px] text-muted-foreground"
>
{l}
</span>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,39 @@
"use client";
import { PopComponentRegistry } from "../../PopComponentRegistry";
import { PopWorkDetailComponent } from "./PopWorkDetailComponent";
import { PopWorkDetailConfigPanel } from "./PopWorkDetailConfig";
import { PopWorkDetailPreviewComponent } from "./PopWorkDetailPreview";
import type { PopWorkDetailConfig } from "../types";
const defaultConfig: PopWorkDetailConfig = {
showTimer: true,
showQuantityInput: true,
phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" },
};
PopComponentRegistry.registerComponent({
id: "pop-work-detail",
name: "작업 상세",
description: "공정별 체크리스트/검사/실적 상세 작업 화면",
category: "display",
icon: "ClipboardCheck",
component: PopWorkDetailComponent,
configPanel: PopWorkDetailConfigPanel,
preview: PopWorkDetailPreviewComponent,
defaultProps: defaultConfig,
connectionMeta: {
sendable: [
{
key: "process_completed",
label: "공정 완료",
type: "event",
category: "event",
description: "공정 작업 전체 완료 이벤트",
},
],
receivable: [],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -851,7 +851,8 @@ export interface CardCellDefinitionV2 {
export interface ActionButtonUpdate {
column: string;
value?: string;
valueType: "static" | "currentUser" | "currentTime" | "columnRef";
valueType: "static" | "currentUser" | "currentTime" | "columnRef" | "userInput";
operationType?: "assign" | "add" | "subtract";
}
// 액션 버튼 클릭 시 동작 모드
@ -881,34 +882,49 @@ export interface SelectModeConfig {
export interface SelectModeButtonConfig {
label: string;
variant: ButtonVariant;
clickMode: "status-change" | "modal-open" | "cancel-select";
clickMode: "status-change" | "modal-open" | "cancel-select" | "quantity-input";
targetTable?: string;
updates?: ActionButtonUpdate[];
confirmMessage?: string;
modalScreenId?: string;
quantityInput?: QuantityInputConfig;
}
// ===== 버튼 중심 구조 (신규) =====
export interface ActionButtonShowCondition {
type: "timeline-status" | "column-value" | "always";
type: "timeline-status" | "column-value" | "always" | "owner-match";
value?: string;
column?: string;
unmatchBehavior?: "hidden" | "disabled";
}
export interface ActionButtonClickAction {
type: "immediate" | "select-mode" | "modal-open";
type: "immediate" | "select-mode" | "modal-open" | "quantity-input";
targetTable?: string;
updates?: ActionButtonUpdate[];
confirmMessage?: string;
selectModeButtons?: SelectModeButtonConfig[];
modalScreenId?: string;
// 외부 테이블 조인 설정 (DB 직접 선택 시)
joinConfig?: {
sourceColumn: string; // 메인 테이블의 FK 컬럼
targetColumn: string; // 외부 테이블의 매칭 컬럼
sourceColumn: string;
targetColumn: string;
};
quantityInput?: QuantityInputConfig;
preCondition?: ActionPreCondition;
}
export interface QuantityInputConfig {
maxColumn?: string;
currentColumn?: string;
unit?: string;
enablePackage?: boolean;
}
export interface ActionPreCondition {
column: string;
expectedValue: string;
failMessage?: string;
}
export interface ActionButtonDef {
@ -976,6 +992,7 @@ export interface PopCardListV2Config {
cartAction?: CardCartActionConfig;
cartListMode?: CartListModeConfig;
saveMapping?: CardListSaveMapping;
ownerSortColumn?: string;
}
/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */
@ -983,3 +1000,14 @@ 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;
// =============================================
// pop-work-detail 전용 타입
// =============================================
export interface PopWorkDetailConfig {
showTimer: boolean;
showQuantityInput: boolean;
phaseLabels: Record<string, string>;
}