2026-02-12 14:23:44 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
import React, { useCallback, useState, useEffect, useMemo } from "react";
|
2026-02-12 14:23:44 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "@/components/ui/select";
|
|
|
|
|
import {
|
|
|
|
|
AlertDialog,
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
} from "@/components/ui/alert-dialog";
|
|
|
|
|
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
2026-02-26 16:00:07 +09:00
|
|
|
import { DataFlowAPI } from "@/lib/api/dataflow";
|
2026-02-23 13:54:49 +09:00
|
|
|
import { usePopAction } from "@/hooks/pop/usePopAction";
|
|
|
|
|
import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext";
|
2026-02-26 16:00:07 +09:00
|
|
|
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
2026-02-23 13:54:49 +09:00
|
|
|
import {
|
|
|
|
|
Save,
|
|
|
|
|
Trash2,
|
|
|
|
|
LogOut,
|
|
|
|
|
Menu,
|
|
|
|
|
ExternalLink,
|
|
|
|
|
Plus,
|
|
|
|
|
Check,
|
|
|
|
|
X,
|
|
|
|
|
Edit,
|
|
|
|
|
Search,
|
|
|
|
|
RefreshCw,
|
|
|
|
|
Download,
|
|
|
|
|
Upload,
|
|
|
|
|
Send,
|
|
|
|
|
Copy,
|
|
|
|
|
Settings,
|
|
|
|
|
ChevronDown,
|
2026-02-26 16:00:07 +09:00
|
|
|
ShoppingCart,
|
|
|
|
|
ShoppingBag,
|
2026-03-03 15:30:07 +09:00
|
|
|
PackageCheck,
|
2026-02-23 13:54:49 +09:00
|
|
|
type LucideIcon,
|
|
|
|
|
} from "lucide-react";
|
2026-02-12 14:23:44 +09:00
|
|
|
import { toast } from "sonner";
|
2026-03-03 15:30:07 +09:00
|
|
|
import type { CollectedDataResponse, StatusChangeRule } from "./types";
|
|
|
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
|
import { TableCombobox } from "./pop-shared/TableCombobox";
|
|
|
|
|
import { ColumnCombobox } from "./pop-shared/ColumnCombobox";
|
|
|
|
|
import {
|
|
|
|
|
fetchTableList,
|
|
|
|
|
fetchTableColumns,
|
|
|
|
|
type TableInfo,
|
|
|
|
|
type ColumnInfo,
|
|
|
|
|
} from "./pop-dashboard/utils/dataFetcher";
|
2026-02-12 14:23:44 +09:00
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// STEP 1: 타입 정의
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
/** 메인 액션 타입 (5종) */
|
|
|
|
|
export type ButtonActionType = "save" | "delete" | "api" | "modal" | "event";
|
|
|
|
|
|
2026-02-23 13:54:49 +09:00
|
|
|
/** 후속 액션 타입 (4종) */
|
|
|
|
|
export type FollowUpActionType = "event" | "refresh" | "navigate" | "close-modal";
|
2026-02-12 14:23:44 +09:00
|
|
|
|
|
|
|
|
/** 버튼 variant (shadcn 기반 4종) */
|
|
|
|
|
export type ButtonVariant = "default" | "secondary" | "outline" | "destructive";
|
|
|
|
|
|
|
|
|
|
/** 모달 열기 방식 */
|
|
|
|
|
export type ModalMode = "dropdown" | "fullscreen" | "screen-ref";
|
|
|
|
|
|
|
|
|
|
/** 확인 다이얼로그 설정 */
|
|
|
|
|
export interface ConfirmConfig {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
message?: string; // 빈값이면 기본 메시지
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 후속 액션 1건 */
|
|
|
|
|
export interface FollowUpAction {
|
|
|
|
|
type: FollowUpActionType;
|
|
|
|
|
// event
|
|
|
|
|
eventName?: string;
|
|
|
|
|
eventPayload?: Record<string, unknown>;
|
|
|
|
|
// navigate
|
|
|
|
|
targetScreenId?: string;
|
|
|
|
|
params?: Record<string, string>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 드롭다운 모달 메뉴 항목 */
|
|
|
|
|
export interface ModalMenuItem {
|
|
|
|
|
label: string;
|
|
|
|
|
screenId?: string;
|
|
|
|
|
action?: string; // 커스텀 이벤트명
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 메인 액션 설정 */
|
|
|
|
|
export interface ButtonMainAction {
|
|
|
|
|
type: ButtonActionType;
|
|
|
|
|
// save/delete 공통
|
|
|
|
|
targetTable?: string;
|
|
|
|
|
// api
|
|
|
|
|
apiEndpoint?: string;
|
|
|
|
|
apiMethod?: "GET" | "POST" | "PUT" | "DELETE";
|
|
|
|
|
// modal
|
|
|
|
|
modalMode?: ModalMode;
|
|
|
|
|
modalScreenId?: string;
|
|
|
|
|
modalTitle?: string;
|
|
|
|
|
modalItems?: ModalMenuItem[];
|
|
|
|
|
// event
|
|
|
|
|
eventName?: string;
|
|
|
|
|
eventPayload?: Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 프리셋 이름 */
|
|
|
|
|
export type ButtonPreset =
|
|
|
|
|
| "save"
|
|
|
|
|
| "delete"
|
|
|
|
|
| "logout"
|
|
|
|
|
| "menu"
|
|
|
|
|
| "modal-open"
|
2026-02-26 16:00:07 +09:00
|
|
|
| "cart"
|
2026-03-03 15:30:07 +09:00
|
|
|
| "inbound-confirm"
|
2026-02-12 14:23:44 +09:00
|
|
|
| "custom";
|
|
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
/** row_data 저장 모드 */
|
|
|
|
|
export type RowDataMode = "all" | "selected";
|
|
|
|
|
|
|
|
|
|
/** 장바구니 버튼 전용 설정 */
|
|
|
|
|
export interface CartButtonConfig {
|
|
|
|
|
cartScreenId?: string;
|
|
|
|
|
rowDataMode?: RowDataMode;
|
|
|
|
|
selectedColumns?: string[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 14:23:44 +09:00
|
|
|
/** pop-button 전체 설정 */
|
|
|
|
|
export interface PopButtonConfig {
|
|
|
|
|
label: string;
|
|
|
|
|
variant: ButtonVariant;
|
2026-02-26 16:00:07 +09:00
|
|
|
icon?: string;
|
2026-02-12 14:23:44 +09:00
|
|
|
iconOnly?: boolean;
|
|
|
|
|
preset: ButtonPreset;
|
|
|
|
|
confirm?: ConfirmConfig;
|
|
|
|
|
action: ButtonMainAction;
|
|
|
|
|
followUpActions?: FollowUpAction[];
|
2026-02-26 16:00:07 +09:00
|
|
|
cart?: CartButtonConfig;
|
2026-03-03 15:30:07 +09:00
|
|
|
statusChangeRules?: StatusChangeRule[];
|
|
|
|
|
/** @deprecated inboundConfirm.statusChangeRules -> statusChangeRules로 이동 */
|
|
|
|
|
inboundConfirm?: { statusChangeRules?: StatusChangeRule[] };
|
2026-02-12 14:23:44 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// 상수
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
/** 메인 액션 타입 라벨 */
|
|
|
|
|
const ACTION_TYPE_LABELS: Record<ButtonActionType, string> = {
|
|
|
|
|
save: "저장",
|
|
|
|
|
delete: "삭제",
|
|
|
|
|
api: "API 호출",
|
|
|
|
|
modal: "모달 열기",
|
|
|
|
|
event: "이벤트 발행",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** 후속 액션 타입 라벨 */
|
|
|
|
|
const FOLLOWUP_TYPE_LABELS: Record<FollowUpActionType, string> = {
|
|
|
|
|
event: "이벤트 발행",
|
|
|
|
|
refresh: "새로고침",
|
|
|
|
|
navigate: "화면 이동",
|
2026-02-23 13:54:49 +09:00
|
|
|
"close-modal": "모달 닫기",
|
2026-02-12 14:23:44 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** variant 라벨 */
|
|
|
|
|
const VARIANT_LABELS: Record<ButtonVariant, string> = {
|
|
|
|
|
default: "기본 (Primary)",
|
|
|
|
|
secondary: "보조 (Secondary)",
|
|
|
|
|
outline: "외곽선 (Outline)",
|
|
|
|
|
destructive: "위험 (Destructive)",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** 프리셋 라벨 */
|
|
|
|
|
const PRESET_LABELS: Record<ButtonPreset, string> = {
|
|
|
|
|
save: "저장",
|
|
|
|
|
delete: "삭제",
|
|
|
|
|
logout: "로그아웃",
|
|
|
|
|
menu: "메뉴 (드롭다운)",
|
|
|
|
|
"modal-open": "모달 열기",
|
2026-02-26 16:00:07 +09:00
|
|
|
cart: "장바구니 저장",
|
2026-03-03 15:30:07 +09:00
|
|
|
"inbound-confirm": "입고 확정",
|
2026-02-12 14:23:44 +09:00
|
|
|
custom: "직접 설정",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** 모달 모드 라벨 */
|
|
|
|
|
const MODAL_MODE_LABELS: Record<ModalMode, string> = {
|
|
|
|
|
dropdown: "드롭다운",
|
|
|
|
|
fullscreen: "전체 모달",
|
|
|
|
|
"screen-ref": "화면 선택",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** API 메서드 라벨 */
|
|
|
|
|
const API_METHOD_LABELS: Record<string, string> = {
|
|
|
|
|
GET: "GET",
|
|
|
|
|
POST: "POST",
|
|
|
|
|
PUT: "PUT",
|
|
|
|
|
DELETE: "DELETE",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** 주요 Lucide 아이콘 목록 (설정 패널용) */
|
|
|
|
|
const ICON_OPTIONS: { value: string; label: string }[] = [
|
|
|
|
|
{ value: "none", label: "없음" },
|
|
|
|
|
{ value: "Save", label: "저장 (Save)" },
|
|
|
|
|
{ value: "Trash2", label: "삭제 (Trash)" },
|
|
|
|
|
{ value: "LogOut", label: "로그아웃 (LogOut)" },
|
|
|
|
|
{ value: "Menu", label: "메뉴 (Menu)" },
|
|
|
|
|
{ value: "ExternalLink", label: "외부링크 (ExternalLink)" },
|
|
|
|
|
{ value: "Plus", label: "추가 (Plus)" },
|
|
|
|
|
{ value: "Check", label: "확인 (Check)" },
|
|
|
|
|
{ value: "X", label: "취소 (X)" },
|
|
|
|
|
{ value: "Edit", label: "수정 (Edit)" },
|
|
|
|
|
{ value: "Search", label: "검색 (Search)" },
|
|
|
|
|
{ value: "RefreshCw", label: "새로고침 (RefreshCw)" },
|
|
|
|
|
{ value: "Download", label: "다운로드 (Download)" },
|
|
|
|
|
{ value: "Upload", label: "업로드 (Upload)" },
|
|
|
|
|
{ value: "Send", label: "전송 (Send)" },
|
|
|
|
|
{ value: "Copy", label: "복사 (Copy)" },
|
|
|
|
|
{ value: "Settings", label: "설정 (Settings)" },
|
|
|
|
|
{ value: "ChevronDown", label: "아래 화살표 (ChevronDown)" },
|
2026-02-26 16:00:07 +09:00
|
|
|
{ value: "ShoppingCart", label: "장바구니 (ShoppingCart)" },
|
|
|
|
|
{ value: "ShoppingBag", label: "장바구니 담김 (ShoppingBag)" },
|
2026-02-12 14:23:44 +09:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/** 프리셋별 기본 설정 */
|
|
|
|
|
const PRESET_DEFAULTS: Record<ButtonPreset, Partial<PopButtonConfig>> = {
|
|
|
|
|
save: {
|
|
|
|
|
label: "저장",
|
|
|
|
|
variant: "default",
|
|
|
|
|
icon: "Save",
|
|
|
|
|
confirm: { enabled: false },
|
|
|
|
|
action: { type: "save" },
|
|
|
|
|
},
|
|
|
|
|
delete: {
|
|
|
|
|
label: "삭제",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
icon: "Trash2",
|
|
|
|
|
confirm: { enabled: true, message: "" },
|
|
|
|
|
action: { type: "delete" },
|
|
|
|
|
},
|
|
|
|
|
logout: {
|
|
|
|
|
label: "로그아웃",
|
|
|
|
|
variant: "outline",
|
|
|
|
|
icon: "LogOut",
|
|
|
|
|
confirm: { enabled: true, message: "로그아웃 하시겠습니까?" },
|
|
|
|
|
action: {
|
|
|
|
|
type: "api",
|
|
|
|
|
apiEndpoint: "/api/auth/logout",
|
|
|
|
|
apiMethod: "POST",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
menu: {
|
|
|
|
|
label: "메뉴",
|
|
|
|
|
variant: "secondary",
|
|
|
|
|
icon: "Menu",
|
|
|
|
|
confirm: { enabled: false },
|
|
|
|
|
action: { type: "modal", modalMode: "dropdown" },
|
|
|
|
|
},
|
|
|
|
|
"modal-open": {
|
|
|
|
|
label: "열기",
|
|
|
|
|
variant: "outline",
|
|
|
|
|
icon: "ExternalLink",
|
|
|
|
|
confirm: { enabled: false },
|
|
|
|
|
action: { type: "modal", modalMode: "fullscreen" },
|
|
|
|
|
},
|
2026-02-26 16:00:07 +09:00
|
|
|
cart: {
|
|
|
|
|
label: "장바구니 저장",
|
|
|
|
|
variant: "default",
|
|
|
|
|
icon: "ShoppingCart",
|
|
|
|
|
confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" },
|
|
|
|
|
action: { type: "event" },
|
|
|
|
|
},
|
2026-03-03 15:30:07 +09:00
|
|
|
"inbound-confirm": {
|
|
|
|
|
label: "입고 확정",
|
|
|
|
|
variant: "default",
|
|
|
|
|
icon: "PackageCheck",
|
|
|
|
|
confirm: { enabled: true, message: "선택한 품목을 입고 확정하시겠습니까?" },
|
|
|
|
|
action: { type: "event" },
|
|
|
|
|
},
|
2026-02-12 14:23:44 +09:00
|
|
|
custom: {
|
|
|
|
|
label: "버튼",
|
|
|
|
|
variant: "default",
|
|
|
|
|
icon: "none",
|
|
|
|
|
confirm: { enabled: false },
|
|
|
|
|
action: { type: "save" },
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** 확인 다이얼로그 기본 메시지 (액션별) */
|
|
|
|
|
const DEFAULT_CONFIRM_MESSAGES: Record<ButtonActionType, string> = {
|
|
|
|
|
save: "저장하시겠습니까?",
|
|
|
|
|
delete: "삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
|
|
|
|
api: "실행하시겠습니까?",
|
|
|
|
|
modal: "열기하시겠습니까?",
|
|
|
|
|
event: "실행하시겠습니까?",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// 헬퍼 함수
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
/** 섹션 구분선 */
|
|
|
|
|
function SectionDivider({ label }: { label: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="pt-3 pb-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="h-px flex-1 bg-gray-300" />
|
|
|
|
|
<span className="text-xs font-medium text-gray-500">{label}</span>
|
|
|
|
|
<div className="h-px flex-1 bg-gray-300" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
/** 장바구니 데이터 매핑 행 (읽기 전용) */
|
|
|
|
|
function CartMappingRow({
|
|
|
|
|
source,
|
|
|
|
|
target,
|
|
|
|
|
desc,
|
|
|
|
|
auto,
|
|
|
|
|
}: {
|
|
|
|
|
source: string;
|
|
|
|
|
target: string;
|
|
|
|
|
desc?: string;
|
|
|
|
|
auto?: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-start gap-1 py-0.5">
|
|
|
|
|
<span className={cn("min-w-0 flex-1 text-[10px]", auto ? "text-muted-foreground" : "text-foreground")}>
|
|
|
|
|
{source}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="shrink-0 text-[10px] text-muted-foreground">→</span>
|
|
|
|
|
<div className="shrink-0 text-right">
|
|
|
|
|
<code className="rounded bg-muted px-1 font-mono text-[10px] text-foreground">
|
|
|
|
|
{target}
|
|
|
|
|
</code>
|
|
|
|
|
{desc && (
|
|
|
|
|
<p className="mt-0.5 text-[9px] text-muted-foreground leading-tight">{desc}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 13:54:49 +09:00
|
|
|
/** 허용된 아이콘 맵 (개별 import로 트리 쉐이킹 적용) */
|
|
|
|
|
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
|
|
|
|
Save, Trash2, LogOut, Menu, ExternalLink, Plus, Check, X,
|
|
|
|
|
Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown,
|
2026-02-26 16:00:07 +09:00
|
|
|
ShoppingCart,
|
|
|
|
|
ShoppingBag,
|
2026-03-03 15:30:07 +09:00
|
|
|
PackageCheck,
|
2026-02-23 13:54:49 +09:00
|
|
|
};
|
|
|
|
|
|
2026-02-12 14:23:44 +09:00
|
|
|
/** Lucide 아이콘 동적 렌더링 */
|
|
|
|
|
function DynamicLucideIcon({
|
|
|
|
|
name,
|
|
|
|
|
size = 16,
|
|
|
|
|
className,
|
|
|
|
|
}: {
|
|
|
|
|
name: string;
|
|
|
|
|
size?: number;
|
|
|
|
|
className?: string;
|
|
|
|
|
}) {
|
2026-02-23 13:54:49 +09:00
|
|
|
const IconComponent = LUCIDE_ICON_MAP[name];
|
2026-02-12 14:23:44 +09:00
|
|
|
if (!IconComponent) return null;
|
|
|
|
|
return <IconComponent size={size} className={className} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// STEP 2: 메인 컴포넌트
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
interface PopButtonComponentProps {
|
|
|
|
|
config?: PopButtonConfig;
|
|
|
|
|
label?: string;
|
|
|
|
|
isDesignMode?: boolean;
|
2026-02-23 13:54:49 +09:00
|
|
|
screenId?: string;
|
2026-02-26 16:00:07 +09:00
|
|
|
componentId?: string;
|
2026-02-12 14:23:44 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function PopButtonComponent({
|
|
|
|
|
config,
|
|
|
|
|
label,
|
|
|
|
|
isDesignMode,
|
2026-02-23 13:54:49 +09:00
|
|
|
screenId,
|
2026-02-26 16:00:07 +09:00
|
|
|
componentId,
|
2026-02-12 14:23:44 +09:00
|
|
|
}: PopButtonComponentProps) {
|
2026-02-23 13:54:49 +09:00
|
|
|
const {
|
|
|
|
|
execute,
|
|
|
|
|
isLoading,
|
|
|
|
|
pendingConfirm,
|
|
|
|
|
confirmExecute,
|
|
|
|
|
cancelConfirm,
|
|
|
|
|
} = usePopAction(screenId || "");
|
2026-02-12 14:23:44 +09:00
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
const { subscribe, publish } = usePopEvent(screenId || "default");
|
|
|
|
|
|
|
|
|
|
// 장바구니 모드 상태
|
|
|
|
|
const isCartMode = config?.preset === "cart";
|
2026-03-03 15:30:07 +09:00
|
|
|
const isInboundConfirmMode = config?.preset === "inbound-confirm";
|
2026-02-26 16:00:07 +09:00
|
|
|
const [cartCount, setCartCount] = useState(0);
|
|
|
|
|
const [cartIsDirty, setCartIsDirty] = useState(false);
|
|
|
|
|
const [cartSaving, setCartSaving] = useState(false);
|
|
|
|
|
const [showCartConfirm, setShowCartConfirm] = useState(false);
|
2026-03-03 15:30:07 +09:00
|
|
|
const [confirmProcessing, setConfirmProcessing] = useState(false);
|
|
|
|
|
const [showInboundConfirm, setShowInboundConfirm] = useState(false);
|
2026-03-03 17:13:01 +09:00
|
|
|
const [inboundSelectedCount, setInboundSelectedCount] = useState(0);
|
|
|
|
|
|
|
|
|
|
// 입고 확정 모드: 선택 항목 수 수신
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isInboundConfirmMode || !componentId) return;
|
|
|
|
|
const unsub = subscribe(
|
|
|
|
|
`__comp_input__${componentId}__selected_items`,
|
|
|
|
|
(payload: unknown) => {
|
|
|
|
|
const data = payload as { value?: unknown[] } | undefined;
|
|
|
|
|
const items = Array.isArray(data?.value) ? data.value : [];
|
|
|
|
|
setInboundSelectedCount(items.length);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
return unsub;
|
|
|
|
|
}, [isInboundConfirmMode, componentId, subscribe]);
|
2026-02-26 16:00:07 +09:00
|
|
|
|
|
|
|
|
// 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isCartMode || !componentId) return;
|
|
|
|
|
const unsub = subscribe(
|
|
|
|
|
`__comp_input__${componentId}__cart_updated`,
|
|
|
|
|
(payload: unknown) => {
|
|
|
|
|
const data = payload as { value?: { count?: number; isDirty?: boolean } } | undefined;
|
|
|
|
|
const inner = data?.value;
|
|
|
|
|
if (inner?.count !== undefined) setCartCount(inner.count);
|
|
|
|
|
if (inner?.isDirty !== undefined) setCartIsDirty(inner.isDirty);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
return unsub;
|
|
|
|
|
}, [isCartMode, componentId, subscribe]);
|
|
|
|
|
|
|
|
|
|
// 저장 완료 수신 (카드 목록에서 saveToDb 완료 후 전달)
|
|
|
|
|
const cartScreenIdRef = React.useRef(config?.cart?.cartScreenId);
|
|
|
|
|
cartScreenIdRef.current = config?.cart?.cartScreenId;
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isCartMode || !componentId) return;
|
|
|
|
|
const unsub = subscribe(
|
|
|
|
|
`__comp_input__${componentId}__cart_save_completed`,
|
|
|
|
|
(payload: unknown) => {
|
|
|
|
|
const data = payload as { value?: { success?: boolean } } | undefined;
|
|
|
|
|
setCartSaving(false);
|
|
|
|
|
if (data?.value?.success) {
|
|
|
|
|
setCartIsDirty(false);
|
|
|
|
|
const targetScreenId = cartScreenIdRef.current;
|
|
|
|
|
if (targetScreenId) {
|
|
|
|
|
const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim();
|
|
|
|
|
window.location.href = `/pop/screens/${cleanId}`;
|
|
|
|
|
} else {
|
|
|
|
|
toast.success("장바구니가 저장되었습니다.");
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
toast.error("장바구니 저장에 실패했습니다.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
return unsub;
|
|
|
|
|
}, [isCartMode, componentId, subscribe]);
|
|
|
|
|
|
2026-02-12 14:23:44 +09:00
|
|
|
const getConfirmMessage = useCallback((): string => {
|
2026-02-23 13:54:49 +09:00
|
|
|
if (pendingConfirm?.confirm?.message) return pendingConfirm.confirm.message;
|
2026-02-12 14:23:44 +09:00
|
|
|
if (config?.confirm?.message) return config.confirm.message;
|
|
|
|
|
return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"];
|
2026-02-23 13:54:49 +09:00
|
|
|
}, [pendingConfirm?.confirm?.message, config?.confirm?.message, config?.action?.type]);
|
2026-02-12 14:23:44 +09:00
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
// 장바구니 저장 트리거 (연결 미설정 시 10초 타임아웃으로 복구)
|
|
|
|
|
const cartSaveTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
|
|
|
|
|
const handleCartSave = useCallback(() => {
|
|
|
|
|
if (!componentId) return;
|
|
|
|
|
setCartSaving(true);
|
|
|
|
|
const selectedCols =
|
|
|
|
|
config?.cart?.rowDataMode === "selected" ? config.cart.selectedColumns : undefined;
|
|
|
|
|
publish(`__comp_output__${componentId}__cart_save_trigger`, {
|
|
|
|
|
selectedColumns: selectedCols,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (cartSaveTimeoutRef.current) clearTimeout(cartSaveTimeoutRef.current);
|
|
|
|
|
cartSaveTimeoutRef.current = setTimeout(() => {
|
|
|
|
|
setCartSaving((prev) => {
|
|
|
|
|
if (prev) {
|
|
|
|
|
toast.error("장바구니 저장 응답이 없습니다. 연결 설정을 확인하세요.");
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
});
|
|
|
|
|
}, 10_000);
|
|
|
|
|
}, [componentId, publish, config?.cart?.rowDataMode, config?.cart?.selectedColumns]);
|
|
|
|
|
|
|
|
|
|
// 저장 완료 시 타임아웃 정리
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!cartSaving && cartSaveTimeoutRef.current) {
|
|
|
|
|
clearTimeout(cartSaveTimeoutRef.current);
|
|
|
|
|
cartSaveTimeoutRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}, [cartSaving]);
|
|
|
|
|
|
2026-03-03 15:30:07 +09:00
|
|
|
// 입고 확정: 데이터 수집 → API 호출
|
|
|
|
|
const handleInboundConfirm = useCallback(async () => {
|
|
|
|
|
if (!componentId) return;
|
|
|
|
|
setConfirmProcessing(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 동기적 이벤트 수집 (connectionResolver가 동기 중계)
|
|
|
|
|
const responses: CollectedDataResponse[] = [];
|
|
|
|
|
const unsub = subscribe(
|
|
|
|
|
`__comp_input__${componentId}__collected_data`,
|
|
|
|
|
(payload: unknown) => {
|
|
|
|
|
const enriched = payload as { value?: CollectedDataResponse };
|
|
|
|
|
if (enriched?.value) {
|
|
|
|
|
responses.push(enriched.value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
publish(`__comp_output__${componentId}__collect_data`, {
|
|
|
|
|
requestId: crypto.randomUUID(),
|
|
|
|
|
action: "inbound-confirm",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
unsub();
|
|
|
|
|
|
|
|
|
|
if (responses.length === 0) {
|
|
|
|
|
toast.error("연결된 컴포넌트에서 데이터를 수집할 수 없습니다. 연결 설정을 확인하세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cardListData = responses.find(r => r.componentType === "pop-card-list");
|
|
|
|
|
const fieldData = responses.find(r => r.componentType === "pop-field");
|
|
|
|
|
|
|
|
|
|
const selectedItems = cardListData?.data?.items ?? [];
|
|
|
|
|
if (selectedItems.length === 0) {
|
|
|
|
|
toast.error("확정할 항목을 선택해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fieldValues = fieldData?.data?.values ?? {};
|
|
|
|
|
|
|
|
|
|
const statusChangeRules = config?.statusChangeRules ?? config?.inboundConfirm?.statusChangeRules ?? [];
|
|
|
|
|
|
|
|
|
|
const cardListMapping = cardListData?.mapping ?? null;
|
|
|
|
|
const fieldMapping = fieldData?.mapping ?? null;
|
|
|
|
|
|
2026-03-03 16:19:22 +09:00
|
|
|
const result = await apiClient.post("/pop/execute-action", {
|
2026-03-03 15:30:07 +09:00
|
|
|
action: "inbound-confirm",
|
|
|
|
|
data: {
|
|
|
|
|
items: selectedItems,
|
|
|
|
|
fieldValues,
|
|
|
|
|
},
|
|
|
|
|
mappings: {
|
|
|
|
|
cardList: cardListMapping,
|
|
|
|
|
field: fieldMapping,
|
|
|
|
|
},
|
|
|
|
|
statusChanges: statusChangeRules,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (result.data?.success) {
|
|
|
|
|
toast.success(`${selectedItems.length}건 입고 확정 완료`);
|
|
|
|
|
publish(`__comp_output__${componentId}__action_completed`, {
|
|
|
|
|
action: "inbound-confirm",
|
|
|
|
|
success: true,
|
|
|
|
|
count: selectedItems.length,
|
|
|
|
|
});
|
2026-03-03 16:19:22 +09:00
|
|
|
|
|
|
|
|
// 후속 액션 실행 (navigate, refresh 등)
|
|
|
|
|
const followUps = config?.followUpActions ?? [];
|
|
|
|
|
for (const fa of followUps) {
|
|
|
|
|
switch (fa.type) {
|
|
|
|
|
case "navigate":
|
|
|
|
|
if (fa.targetScreenId) {
|
|
|
|
|
publish("__pop_navigate__", { screenId: fa.targetScreenId, params: fa.params });
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "refresh":
|
|
|
|
|
publish("__pop_refresh__");
|
|
|
|
|
break;
|
|
|
|
|
case "event":
|
|
|
|
|
if (fa.eventName) publish(fa.eventName, fa.eventPayload);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-03 15:30:07 +09:00
|
|
|
} else {
|
|
|
|
|
toast.error(result.data?.message || "입고 확정에 실패했습니다.");
|
|
|
|
|
}
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
const message = err instanceof Error ? err.message : "입고 확정 중 오류가 발생했습니다.";
|
|
|
|
|
toast.error(message);
|
|
|
|
|
} finally {
|
|
|
|
|
setConfirmProcessing(false);
|
|
|
|
|
setShowInboundConfirm(false);
|
|
|
|
|
}
|
2026-03-03 16:19:22 +09:00
|
|
|
}, [componentId, subscribe, publish, config?.statusChangeRules, config?.inboundConfirm?.statusChangeRules, config?.followUpActions]);
|
2026-03-03 15:30:07 +09:00
|
|
|
|
2026-02-12 14:23:44 +09:00
|
|
|
// 클릭 핸들러
|
|
|
|
|
const handleClick = useCallback(async () => {
|
|
|
|
|
if (isDesignMode) {
|
2026-03-03 15:30:07 +09:00
|
|
|
const modeLabel = isCartMode ? "장바구니 저장" : isInboundConfirmMode ? "입고 확정" : ACTION_TYPE_LABELS[config?.action?.type || "save"];
|
|
|
|
|
toast.info(`[디자인 모드] ${modeLabel} 액션`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 입고 확정 모드: confirm 다이얼로그 후 데이터 수집 → API 호출
|
|
|
|
|
if (isInboundConfirmMode) {
|
|
|
|
|
if (config?.confirm?.enabled !== false) {
|
|
|
|
|
setShowInboundConfirm(true);
|
|
|
|
|
} else {
|
|
|
|
|
await handleInboundConfirm();
|
|
|
|
|
}
|
2026-02-12 14:23:44 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
// 장바구니 모드: isDirty 여부에 따라 분기
|
|
|
|
|
if (isCartMode) {
|
|
|
|
|
if (cartCount === 0 && !cartIsDirty) {
|
|
|
|
|
toast.info("장바구니가 비어 있습니다.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (cartIsDirty) {
|
|
|
|
|
// 새로 담은 항목이 있음 → 확인 후 저장
|
|
|
|
|
setShowCartConfirm(true);
|
|
|
|
|
} else {
|
|
|
|
|
// 이미 저장된 상태 → 바로 장바구니 화면 이동
|
|
|
|
|
const targetScreenId = config?.cart?.cartScreenId;
|
|
|
|
|
if (targetScreenId) {
|
|
|
|
|
const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim();
|
|
|
|
|
window.location.href = `/pop/screens/${cleanId}`;
|
|
|
|
|
} else {
|
|
|
|
|
toast.info("장바구니 화면이 설정되지 않았습니다.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 13:54:49 +09:00
|
|
|
const action = config?.action;
|
|
|
|
|
if (!action) return;
|
2026-02-12 14:23:44 +09:00
|
|
|
|
2026-02-23 13:54:49 +09:00
|
|
|
await execute(action, {
|
|
|
|
|
confirm: config?.confirm,
|
|
|
|
|
followUpActions: config?.followUpActions,
|
|
|
|
|
});
|
2026-03-03 15:30:07 +09:00
|
|
|
}, [isDesignMode, isCartMode, isInboundConfirmMode, config, cartCount, cartIsDirty, execute, handleCartSave, handleInboundConfirm]);
|
2026-02-12 14:23:44 +09:00
|
|
|
|
|
|
|
|
// 외형
|
|
|
|
|
const buttonLabel = config?.label || label || "버튼";
|
|
|
|
|
const variant = config?.variant || "default";
|
|
|
|
|
const iconName = config?.icon || "";
|
|
|
|
|
const isIconOnly = config?.iconOnly || false;
|
|
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
// 장바구니 3상태 아이콘: 빈 장바구니 / 저장 완료 / 변경사항 있음
|
|
|
|
|
const cartIconName = useMemo(() => {
|
|
|
|
|
if (!isCartMode) return iconName;
|
|
|
|
|
if (cartCount === 0 && !cartIsDirty) return "ShoppingCart";
|
|
|
|
|
if (cartCount > 0 && !cartIsDirty) return "ShoppingBag";
|
|
|
|
|
return "ShoppingCart";
|
|
|
|
|
}, [isCartMode, cartCount, cartIsDirty, iconName]);
|
|
|
|
|
|
|
|
|
|
// 장바구니 3상태 버튼 색상
|
|
|
|
|
const cartButtonClass = useMemo(() => {
|
|
|
|
|
if (!isCartMode) return "";
|
|
|
|
|
if (cartCount > 0 && !cartIsDirty) {
|
|
|
|
|
return "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600";
|
|
|
|
|
}
|
|
|
|
|
if (cartIsDirty) {
|
|
|
|
|
return "bg-orange-500 hover:bg-orange-600 text-white border-orange-500 animate-pulse";
|
|
|
|
|
}
|
|
|
|
|
return "";
|
|
|
|
|
}, [isCartMode, cartCount, cartIsDirty]);
|
|
|
|
|
|
2026-03-03 17:13:01 +09:00
|
|
|
// 입고 확정 2상태 아이콘: 미선택(기본 아이콘) / 선택됨(체크 아이콘)
|
|
|
|
|
const inboundIconName = useMemo(() => {
|
|
|
|
|
if (!isInboundConfirmMode) return iconName;
|
|
|
|
|
return inboundSelectedCount > 0 ? (config?.icon || "PackageCheck") : (config?.icon || "PackageCheck");
|
|
|
|
|
}, [isInboundConfirmMode, inboundSelectedCount, config?.icon, iconName]);
|
|
|
|
|
|
|
|
|
|
// 입고 확정 2상태 버튼 색상: 미선택(기본) / 선택됨(초록)
|
|
|
|
|
const inboundButtonClass = useMemo(() => {
|
|
|
|
|
if (!isInboundConfirmMode) return "";
|
|
|
|
|
return inboundSelectedCount > 0
|
|
|
|
|
? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600"
|
|
|
|
|
: "";
|
|
|
|
|
}, [isInboundConfirmMode, inboundSelectedCount]);
|
|
|
|
|
|
2026-02-12 14:23:44 +09:00
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center">
|
2026-02-26 16:00:07 +09:00
|
|
|
<div className="relative">
|
|
|
|
|
<Button
|
|
|
|
|
variant={variant}
|
|
|
|
|
onClick={handleClick}
|
2026-03-03 15:30:07 +09:00
|
|
|
disabled={isLoading || cartSaving || confirmProcessing}
|
2026-02-26 16:00:07 +09:00
|
|
|
className={cn(
|
|
|
|
|
"transition-transform active:scale-95",
|
|
|
|
|
isIconOnly && "px-2",
|
|
|
|
|
cartButtonClass,
|
2026-03-03 17:13:01 +09:00
|
|
|
inboundButtonClass,
|
2026-02-26 16:00:07 +09:00
|
|
|
)}
|
|
|
|
|
>
|
2026-03-03 17:13:01 +09:00
|
|
|
{(isCartMode ? cartIconName : isInboundConfirmMode ? inboundIconName : iconName) && (
|
2026-02-26 16:00:07 +09:00
|
|
|
<DynamicLucideIcon
|
2026-03-03 17:13:01 +09:00
|
|
|
name={isCartMode ? cartIconName : isInboundConfirmMode ? inboundIconName : iconName}
|
2026-02-26 16:00:07 +09:00
|
|
|
size={16}
|
|
|
|
|
className={isIconOnly ? "" : "mr-1.5"}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{!isIconOnly && <span>{buttonLabel}</span>}
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
{/* 장바구니 배지 */}
|
|
|
|
|
{isCartMode && cartCount > 0 && (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"absolute -top-2 -right-2 flex items-center justify-center rounded-full text-[10px] font-bold",
|
|
|
|
|
cartIsDirty
|
|
|
|
|
? "bg-orange-500 text-white"
|
|
|
|
|
: "bg-emerald-600 text-white",
|
|
|
|
|
)}
|
|
|
|
|
style={{ minWidth: 18, height: 18, padding: "0 4px" }}
|
|
|
|
|
>
|
|
|
|
|
{cartCount}
|
|
|
|
|
</div>
|
2026-02-12 14:23:44 +09:00
|
|
|
)}
|
2026-03-03 17:13:01 +09:00
|
|
|
|
|
|
|
|
{/* 입고 확정 선택 개수 배지 */}
|
|
|
|
|
{isInboundConfirmMode && inboundSelectedCount > 0 && (
|
|
|
|
|
<div
|
|
|
|
|
className="absolute -top-2 -right-2 flex items-center justify-center rounded-full bg-emerald-600 text-white text-[10px] font-bold"
|
|
|
|
|
style={{ minWidth: 18, height: 18, padding: "0 4px" }}
|
|
|
|
|
>
|
|
|
|
|
{inboundSelectedCount}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-26 16:00:07 +09:00
|
|
|
</div>
|
2026-02-12 14:23:44 +09:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
{/* 장바구니 확인 다이얼로그 */}
|
|
|
|
|
<AlertDialog open={showCartConfirm} onOpenChange={setShowCartConfirm}>
|
|
|
|
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
<AlertDialogTitle className="text-base sm:text-lg">
|
|
|
|
|
장바구니 저장
|
|
|
|
|
</AlertDialogTitle>
|
|
|
|
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
|
|
|
|
{config?.confirm?.message || `${cartCount}개 항목을 장바구니에 저장하시겠습니까?`}
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
|
|
|
|
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
|
|
|
취소
|
|
|
|
|
</AlertDialogCancel>
|
|
|
|
|
<AlertDialogAction
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setShowCartConfirm(false);
|
|
|
|
|
handleCartSave();
|
|
|
|
|
}}
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
저장
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
2026-03-03 15:30:07 +09:00
|
|
|
{/* 입고 확정 확인 다이얼로그 */}
|
|
|
|
|
<AlertDialog open={showInboundConfirm} onOpenChange={setShowInboundConfirm}>
|
|
|
|
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
<AlertDialogTitle className="text-base sm:text-lg">
|
|
|
|
|
입고 확정
|
|
|
|
|
</AlertDialogTitle>
|
|
|
|
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
|
|
|
|
{config?.confirm?.message || "선택한 품목을 입고 확정하시겠습니까?"}
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
|
|
|
|
<AlertDialogCancel
|
|
|
|
|
disabled={confirmProcessing}
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</AlertDialogCancel>
|
|
|
|
|
<AlertDialogAction
|
|
|
|
|
onClick={() => { handleInboundConfirm(); }}
|
|
|
|
|
disabled={confirmProcessing}
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
{confirmProcessing ? "처리 중..." : "확정"}
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
{/* 일반 확인 다이얼로그 */}
|
2026-02-23 13:54:49 +09:00
|
|
|
<AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}>
|
2026-02-12 14:23:44 +09:00
|
|
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
<AlertDialogTitle className="text-base sm:text-lg">
|
|
|
|
|
실행 확인
|
|
|
|
|
</AlertDialogTitle>
|
|
|
|
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
|
|
|
|
{getConfirmMessage()}
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
|
|
|
|
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
|
|
|
취소
|
|
|
|
|
</AlertDialogCancel>
|
|
|
|
|
<AlertDialogAction
|
2026-02-23 13:54:49 +09:00
|
|
|
onClick={confirmExecute}
|
2026-02-12 14:23:44 +09:00
|
|
|
className={cn(
|
|
|
|
|
"h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm",
|
|
|
|
|
config?.action?.type === "delete" &&
|
|
|
|
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
확인
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// STEP 3: 설정 패널
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
interface PopButtonConfigPanelProps {
|
|
|
|
|
config: PopButtonConfig;
|
|
|
|
|
onUpdate: (config: PopButtonConfig) => void;
|
2026-02-26 16:00:07 +09:00
|
|
|
allComponents?: { id: string; type: string; config?: Record<string, unknown> }[];
|
|
|
|
|
connections?: { sourceComponent: string; targetComponent: string; sourceOutput?: string; targetInput?: string }[];
|
|
|
|
|
componentId?: string;
|
2026-02-12 14:23:44 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function PopButtonConfigPanel({
|
|
|
|
|
config,
|
|
|
|
|
onUpdate,
|
2026-02-26 16:00:07 +09:00
|
|
|
allComponents,
|
|
|
|
|
connections,
|
|
|
|
|
componentId,
|
2026-02-12 14:23:44 +09:00
|
|
|
}: PopButtonConfigPanelProps) {
|
|
|
|
|
const isCustom = config?.preset === "custom";
|
|
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
// 컬럼 불러오기용 상태
|
|
|
|
|
const [loadedColumns, setLoadedColumns] = useState<{ name: string; label: string }[]>([]);
|
|
|
|
|
const [colLoading, setColLoading] = useState(false);
|
|
|
|
|
const [connectedTableName, setConnectedTableName] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
// 연결된 카드 목록의 테이블명 자동 탐색
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (config?.preset !== "cart" || !componentId || !connections || !allComponents) {
|
|
|
|
|
setConnectedTableName(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 방법 1: 버튼(source) -> 카드목록(target) cart_save_trigger 연결
|
|
|
|
|
let cardListId: string | undefined;
|
|
|
|
|
const outConn = connections.find(
|
|
|
|
|
(c) =>
|
|
|
|
|
c.sourceComponent === componentId &&
|
|
|
|
|
c.sourceOutput === "cart_save_trigger",
|
|
|
|
|
);
|
|
|
|
|
if (outConn) {
|
|
|
|
|
cardListId = outConn.targetComponent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 방법 2: 카드목록(source) -> 버튼(target) cart_updated 연결 (역방향)
|
|
|
|
|
if (!cardListId) {
|
|
|
|
|
const inConn = connections.find(
|
|
|
|
|
(c) =>
|
|
|
|
|
c.targetComponent === componentId &&
|
|
|
|
|
(c.sourceOutput === "cart_updated" || c.sourceOutput === "cart_save_completed"),
|
|
|
|
|
);
|
|
|
|
|
if (inConn) {
|
|
|
|
|
cardListId = inConn.sourceComponent;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 방법 3: 버튼과 연결된 pop-card-list 타입 컴포넌트 탐색
|
|
|
|
|
if (!cardListId) {
|
|
|
|
|
const anyConn = connections.find(
|
|
|
|
|
(c) =>
|
|
|
|
|
(c.sourceComponent === componentId || c.targetComponent === componentId),
|
|
|
|
|
);
|
|
|
|
|
if (anyConn) {
|
|
|
|
|
const otherId = anyConn.sourceComponent === componentId
|
|
|
|
|
? anyConn.targetComponent
|
|
|
|
|
: anyConn.sourceComponent;
|
|
|
|
|
const otherComp = allComponents.find((c) => c.id === otherId);
|
|
|
|
|
if (otherComp?.type === "pop-card-list") {
|
|
|
|
|
cardListId = otherId;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!cardListId) {
|
|
|
|
|
setConnectedTableName(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cardList = allComponents.find((c) => c.id === cardListId);
|
|
|
|
|
const cfg = cardList?.config as Record<string, unknown> | undefined;
|
|
|
|
|
const dataSource = cfg?.dataSource as Record<string, unknown> | undefined;
|
|
|
|
|
const tableName = (dataSource?.tableName as string) || (cfg?.tableName as string) || undefined;
|
|
|
|
|
setConnectedTableName(tableName || null);
|
|
|
|
|
}, [config?.preset, componentId, connections, allComponents]);
|
|
|
|
|
|
|
|
|
|
// 선택 저장 모드 + 연결 테이블명이 있으면 컬럼 자동 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (config?.cart?.rowDataMode !== "selected" || !connectedTableName) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// 이미 같은 테이블의 컬럼이 로드되어 있으면 스킵
|
|
|
|
|
if (loadedColumns.length > 0) return;
|
|
|
|
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
setColLoading(true);
|
|
|
|
|
DataFlowAPI.getTableColumns(connectedTableName)
|
|
|
|
|
.then((cols) => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setLoadedColumns(
|
|
|
|
|
cols
|
|
|
|
|
.filter((c: { columnName: string }) =>
|
|
|
|
|
!["id", "created_at", "updated_at", "created_by", "updated_by"].includes(c.columnName),
|
|
|
|
|
)
|
|
|
|
|
.map((c: { columnName: string; displayName?: string }) => ({
|
|
|
|
|
name: c.columnName,
|
|
|
|
|
label: c.displayName || c.columnName,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
if (!cancelled) setLoadedColumns([]);
|
|
|
|
|
})
|
|
|
|
|
.finally(() => {
|
|
|
|
|
if (!cancelled) setColLoading(false);
|
|
|
|
|
});
|
|
|
|
|
return () => { cancelled = true; };
|
|
|
|
|
}, [config?.cart?.rowDataMode, connectedTableName, loadedColumns.length]);
|
|
|
|
|
|
2026-02-12 14:23:44 +09:00
|
|
|
// 프리셋 변경 핸들러
|
|
|
|
|
const handlePresetChange = (preset: ButtonPreset) => {
|
|
|
|
|
const defaults = PRESET_DEFAULTS[preset];
|
|
|
|
|
onUpdate({
|
|
|
|
|
...config,
|
|
|
|
|
preset,
|
|
|
|
|
label: defaults.label || config.label,
|
|
|
|
|
variant: defaults.variant || config.variant,
|
|
|
|
|
icon: defaults.icon ?? config.icon,
|
|
|
|
|
confirm: defaults.confirm || config.confirm,
|
|
|
|
|
action: (defaults.action as ButtonMainAction) || config.action,
|
|
|
|
|
// 후속 액션은 프리셋 변경 시 유지
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 메인 액션 업데이트 헬퍼
|
|
|
|
|
const updateAction = (updates: Partial<ButtonMainAction>) => {
|
|
|
|
|
onUpdate({
|
|
|
|
|
...config,
|
|
|
|
|
action: { ...config.action, ...updates },
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2 overflow-y-auto pr-1 pb-32">
|
|
|
|
|
{/* 프리셋 선택 */}
|
|
|
|
|
<SectionDivider label="프리셋" />
|
|
|
|
|
<Select
|
|
|
|
|
value={config?.preset || "custom"}
|
|
|
|
|
onValueChange={(v) => handlePresetChange(v as ButtonPreset)}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{Object.entries(PRESET_LABELS).map(([key, label]) => (
|
|
|
|
|
<SelectItem key={key} value={key} className="text-xs">
|
|
|
|
|
{label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
{!isCustom && (
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
|
|
|
프리셋 변경 시 외형과 액션이 자동 설정됩니다
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 외형 설정 */}
|
|
|
|
|
<SectionDivider label="외형" />
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{/* 라벨 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">라벨</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={config?.label || ""}
|
|
|
|
|
onChange={(e) => onUpdate({ ...config, label: e.target.value })}
|
|
|
|
|
placeholder="버튼 텍스트"
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* variant */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">스타일</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={config?.variant || "default"}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
onUpdate({ ...config, variant: v as ButtonVariant })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{Object.entries(VARIANT_LABELS).map(([key, label]) => (
|
|
|
|
|
<SelectItem key={key} value={key} className="text-xs">
|
|
|
|
|
{label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 아이콘 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">아이콘</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={config?.icon || "none"}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
onUpdate({ ...config, icon: v === "none" ? "" : v })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs">
|
|
|
|
|
<SelectValue placeholder="없음" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{ICON_OPTIONS.map((opt) => (
|
|
|
|
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{opt.value && (
|
|
|
|
|
<DynamicLucideIcon name={opt.value} size={14} />
|
|
|
|
|
)}
|
|
|
|
|
<span>{opt.label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 아이콘 전용 모드 */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Checkbox
|
|
|
|
|
id="iconOnly"
|
|
|
|
|
checked={config?.iconOnly || false}
|
|
|
|
|
onCheckedChange={(checked) =>
|
|
|
|
|
onUpdate({ ...config, iconOnly: checked === true })
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<Label htmlFor="iconOnly" className="text-xs">
|
|
|
|
|
아이콘만 표시 (라벨 숨김)
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
{/* 장바구니 설정 (cart 프리셋 전용) */}
|
|
|
|
|
{config?.preset === "cart" && (
|
|
|
|
|
<>
|
|
|
|
|
<SectionDivider label="장바구니 설정" />
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">장바구니 화면 ID</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={config?.cart?.cartScreenId || ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdate({
|
|
|
|
|
...config,
|
|
|
|
|
cart: { ...config.cart, cartScreenId: e.target.value },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
placeholder="저장 후 이동할 POP 화면 ID"
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
|
|
|
|
저장 완료 후 이동할 장바구니 리스트 화면 ID입니다.
|
|
|
|
|
비어있으면 이동 없이 저장만 합니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 데이터 저장 흐름 시각화 */}
|
|
|
|
|
<SectionDivider label="데이터 저장 흐름" />
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<p className="text-muted-foreground text-[10px]">
|
|
|
|
|
카드 목록에서 "담기" 클릭 시 아래와 같이 <code className="rounded bg-muted px-1 font-mono text-foreground">cart_items</code> 테이블에 저장됩니다.
|
2026-02-12 14:23:44 +09:00
|
|
|
</p>
|
|
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
{/* 사용자 입력 데이터 */}
|
|
|
|
|
<div className="rounded-md border bg-amber-50/50 px-2.5 py-1.5 dark:bg-amber-950/20">
|
|
|
|
|
<p className="mb-1 text-[10px] font-medium text-amber-700 dark:text-amber-400">사용자 입력</p>
|
|
|
|
|
<CartMappingRow source="입력한 수량" target="quantity" />
|
|
|
|
|
<CartMappingRow source="포장 단위" target="package_unit" />
|
|
|
|
|
<CartMappingRow source="포장 내역 (JSON)" target="package_entries" />
|
|
|
|
|
<CartMappingRow source="메모" target="memo" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 원본 데이터 */}
|
|
|
|
|
<div className="rounded-md border bg-blue-50/50 px-2.5 py-1.5 dark:bg-blue-950/20">
|
|
|
|
|
<p className="mb-1 text-[10px] font-medium text-blue-700 dark:text-blue-400">원본 행 데이터</p>
|
|
|
|
|
|
|
|
|
|
{/* 저장 모드 선택 */}
|
|
|
|
|
<div className="mb-1.5 flex items-center gap-1.5">
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">저장 모드:</span>
|
|
|
|
|
<Select
|
|
|
|
|
value={config?.cart?.rowDataMode || "all"}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
onUpdate({
|
|
|
|
|
...config,
|
|
|
|
|
cart: { ...config.cart, rowDataMode: v as RowDataMode },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-6 w-[100px] text-[10px]">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="all" className="text-xs">전체 저장</SelectItem>
|
|
|
|
|
<SelectItem value="selected" className="text-xs">선택 저장</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{config?.cart?.rowDataMode === "selected" ? (
|
|
|
|
|
<>
|
|
|
|
|
{/* 선택 저장 모드: 컬럼 목록 관리 */}
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
{connectedTableName ? (
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
|
|
|
연결: <code className="rounded bg-muted px-1 font-mono text-foreground">{connectedTableName}</code>
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<p className="text-[9px] text-amber-600 dark:text-amber-400">
|
|
|
|
|
카드 목록과 연결(cart_save_trigger)하면 컬럼이 자동으로 표시됩니다.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{colLoading && (
|
|
|
|
|
<p className="text-[9px] text-muted-foreground">컬럼 불러오는 중...</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 불러온 컬럼 체크박스 */}
|
|
|
|
|
{loadedColumns.length > 0 && (
|
|
|
|
|
<div className="max-h-[160px] space-y-0.5 overflow-y-auto rounded border bg-background p-1.5">
|
|
|
|
|
{loadedColumns.map((col) => {
|
|
|
|
|
const isChecked = (config?.cart?.selectedColumns || []).includes(col.name);
|
|
|
|
|
return (
|
|
|
|
|
<label key={col.name} className="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 hover:bg-muted/50">
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={isChecked}
|
|
|
|
|
onCheckedChange={(checked) => {
|
|
|
|
|
const prev = config?.cart?.selectedColumns || [];
|
|
|
|
|
const next = checked
|
|
|
|
|
? [...prev, col.name]
|
|
|
|
|
: prev.filter((c) => c !== col.name);
|
|
|
|
|
onUpdate({
|
|
|
|
|
...config,
|
|
|
|
|
cart: { ...config.cart, selectedColumns: next },
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
className="h-3 w-3"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-[10px]">{col.label}</span>
|
|
|
|
|
{col.label !== col.name && (
|
|
|
|
|
<span className="text-[9px] text-muted-foreground">({col.name})</span>
|
|
|
|
|
)}
|
|
|
|
|
</label>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 선택된 컬럼 요약 */}
|
|
|
|
|
{(config?.cart?.selectedColumns?.length ?? 0) > 0 ? (
|
|
|
|
|
<CartMappingRow
|
|
|
|
|
source={`선택된 ${config!.cart!.selectedColumns!.length}개 컬럼 (JSON)`}
|
|
|
|
|
target="row_data"
|
|
|
|
|
desc={config!.cart!.selectedColumns!.join(", ")}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<p className="text-[9px] text-amber-600 dark:text-amber-400">
|
|
|
|
|
저장할 컬럼을 선택하세요. 미선택 시 전체 저장됩니다.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<CartMappingRow source="행 전체 (JSON)" target="row_data" desc="원본 테이블의 모든 컬럼이 JSON으로 저장" />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<CartMappingRow source="행 식별키 (PK)" target="row_key" />
|
|
|
|
|
<CartMappingRow source="원본 테이블명" target="source_table" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 시스템 자동 */}
|
|
|
|
|
<div className="rounded-md border bg-muted/30 px-2.5 py-1.5">
|
|
|
|
|
<p className="mb-1 text-[10px] font-medium text-muted-foreground">자동 설정</p>
|
|
|
|
|
<CartMappingRow source="현재 화면 ID" target="screen_id" auto />
|
2026-03-03 15:30:07 +09:00
|
|
|
<CartMappingRow source='장바구니 타입 (미사용)' target="cart_type" auto />
|
2026-02-26 16:00:07 +09:00
|
|
|
<CartMappingRow source='상태 ("in_cart")' target="status" auto />
|
|
|
|
|
<CartMappingRow source="회사 코드" target="company_code" auto />
|
|
|
|
|
<CartMappingRow source="사용자 ID" target="user_id" auto />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p className="text-muted-foreground text-[10px] leading-relaxed">
|
|
|
|
|
장바구니 목록 화면에서 <code className="rounded bg-muted px-1 font-mono text-foreground">row_data</code>의 JSON을 풀어서
|
|
|
|
|
최종 대상 테이블로 매핑할 수 있습니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 메인 액션 (cart 프리셋에서는 숨김) */}
|
|
|
|
|
{config?.preset !== "cart" && (
|
|
|
|
|
<>
|
|
|
|
|
<SectionDivider label="메인 액션" />
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">액션 유형</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={config?.action?.type || "save"}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
updateAction({ type: v as ButtonActionType })
|
|
|
|
|
}
|
|
|
|
|
disabled={!isCustom}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{Object.entries(ACTION_TYPE_LABELS).map(([key, label]) => (
|
|
|
|
|
<SelectItem key={key} value={key} className="text-xs">
|
|
|
|
|
{label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
{!isCustom && (
|
|
|
|
|
<p className="text-[10px] text-muted-foreground mt-1">
|
|
|
|
|
프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<ActionDetailFields
|
|
|
|
|
action={config?.action}
|
|
|
|
|
onUpdate={updateAction}
|
|
|
|
|
disabled={!isCustom}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-02-12 14:23:44 +09:00
|
|
|
|
|
|
|
|
{/* 확인 다이얼로그 */}
|
|
|
|
|
<SectionDivider label="확인 메시지" />
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Checkbox
|
|
|
|
|
id="confirmEnabled"
|
|
|
|
|
checked={config?.confirm?.enabled || false}
|
|
|
|
|
onCheckedChange={(checked) =>
|
|
|
|
|
onUpdate({
|
|
|
|
|
...config,
|
|
|
|
|
confirm: {
|
|
|
|
|
...config?.confirm,
|
|
|
|
|
enabled: checked === true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<Label htmlFor="confirmEnabled" className="text-xs">
|
|
|
|
|
실행 전 확인 메시지 표시
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
{config?.confirm?.enabled && (
|
|
|
|
|
<div>
|
|
|
|
|
<Input
|
|
|
|
|
value={config?.confirm?.message || ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdate({
|
|
|
|
|
...config,
|
|
|
|
|
confirm: {
|
|
|
|
|
...config?.confirm,
|
|
|
|
|
enabled: true,
|
|
|
|
|
message: e.target.value,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
placeholder="비워두면 기본 메시지 사용"
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-[10px] text-muted-foreground mt-1">
|
|
|
|
|
기본:{" "}
|
|
|
|
|
{DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"]}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-03 15:30:07 +09:00
|
|
|
{/* 상태 변경 규칙 (cart 프리셋 제외 모두 표시) */}
|
|
|
|
|
{config?.preset !== "cart" && (
|
|
|
|
|
<>
|
|
|
|
|
<SectionDivider label="상태 변경 규칙" />
|
|
|
|
|
<StatusChangeRuleEditor
|
|
|
|
|
rules={config?.statusChangeRules ?? config?.inboundConfirm?.statusChangeRules ?? []}
|
|
|
|
|
onUpdate={(rules) => onUpdate({ ...config, statusChangeRules: rules })}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-12 14:23:44 +09:00
|
|
|
{/* 후속 액션 */}
|
|
|
|
|
<SectionDivider label="후속 액션" />
|
|
|
|
|
<FollowUpActionsEditor
|
|
|
|
|
actions={config?.followUpActions || []}
|
|
|
|
|
onUpdate={(actions) =>
|
|
|
|
|
onUpdate({ ...config, followUpActions: actions })
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// 액션 세부 필드 (타입별)
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
function ActionDetailFields({
|
|
|
|
|
action,
|
|
|
|
|
onUpdate,
|
|
|
|
|
disabled,
|
|
|
|
|
}: {
|
|
|
|
|
action?: ButtonMainAction;
|
|
|
|
|
onUpdate: (updates: Partial<ButtonMainAction>) => void;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
}) {
|
2026-02-23 13:54:49 +09:00
|
|
|
// 디자이너 컨텍스트 (뷰어에서는 null)
|
|
|
|
|
const designerCtx = usePopDesignerContext();
|
2026-02-12 14:23:44 +09:00
|
|
|
const actionType = action?.type || "save";
|
|
|
|
|
|
|
|
|
|
switch (actionType) {
|
|
|
|
|
case "save":
|
|
|
|
|
case "delete":
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">대상 테이블</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={action?.targetTable || ""}
|
|
|
|
|
onChange={(e) => onUpdate({ targetTable: e.target.value })}
|
|
|
|
|
placeholder="테이블명 입력"
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "api":
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">엔드포인트</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={action?.apiEndpoint || ""}
|
|
|
|
|
onChange={(e) => onUpdate({ apiEndpoint: e.target.value })}
|
|
|
|
|
placeholder="/api/..."
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">HTTP 메서드</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={action?.apiMethod || "POST"}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
onUpdate({
|
|
|
|
|
apiMethod: v as "GET" | "POST" | "PUT" | "DELETE",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{Object.entries(API_METHOD_LABELS).map(([key, label]) => (
|
|
|
|
|
<SelectItem key={key} value={key} className="text-xs">
|
|
|
|
|
{label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "modal":
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">모달 방식</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={action?.modalMode || "fullscreen"}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
onUpdate({ modalMode: v as ModalMode })
|
|
|
|
|
}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{Object.entries(MODAL_MODE_LABELS).map(([key, label]) => (
|
|
|
|
|
<SelectItem key={key} value={key} className="text-xs">
|
|
|
|
|
{label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
{action?.modalMode === "screen-ref" && (
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">화면 ID</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={action?.modalScreenId || ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdate({ modalScreenId: e.target.value })
|
|
|
|
|
}
|
|
|
|
|
placeholder="화면 ID"
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">모달 제목</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={action?.modalTitle || ""}
|
|
|
|
|
onChange={(e) => onUpdate({ modalTitle: e.target.value })}
|
|
|
|
|
placeholder="모달 제목 (선택)"
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-02-23 13:54:49 +09:00
|
|
|
{/* 모달 캔버스 생성/열기 (fullscreen 모드 + 디자이너 내부) */}
|
|
|
|
|
{action?.modalMode === "fullscreen" && designerCtx && (
|
|
|
|
|
<div>
|
|
|
|
|
{action?.modalScreenId ? (
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-8 w-full text-xs"
|
|
|
|
|
onClick={() => designerCtx.navigateToCanvas(action.modalScreenId!)}
|
|
|
|
|
>
|
|
|
|
|
모달 캔버스 열기
|
|
|
|
|
</Button>
|
|
|
|
|
) : (
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-8 w-full text-xs"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const selectedId = designerCtx.selectedComponentId;
|
|
|
|
|
if (!selectedId) return;
|
|
|
|
|
const modalId = designerCtx.createModalCanvas(
|
|
|
|
|
selectedId,
|
|
|
|
|
action?.modalTitle || "새 모달"
|
|
|
|
|
);
|
|
|
|
|
onUpdate({ modalScreenId: modalId });
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
모달 캔버스 생성
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-12 14:23:44 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "event":
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">이벤트명</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={action?.eventName || ""}
|
|
|
|
|
onChange={(e) => onUpdate({ eventName: e.target.value })}
|
|
|
|
|
placeholder="예: data-saved, item-selected"
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// 후속 액션 편집기
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
function FollowUpActionsEditor({
|
|
|
|
|
actions,
|
|
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
|
|
|
|
actions: FollowUpAction[];
|
|
|
|
|
onUpdate: (actions: FollowUpAction[]) => void;
|
|
|
|
|
}) {
|
|
|
|
|
// 추가
|
|
|
|
|
const handleAdd = () => {
|
|
|
|
|
onUpdate([...actions, { type: "event" }]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 삭제
|
|
|
|
|
const handleRemove = (index: number) => {
|
|
|
|
|
onUpdate(actions.filter((_, i) => i !== index));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 수정
|
|
|
|
|
const handleUpdate = (index: number, updates: Partial<FollowUpAction>) => {
|
|
|
|
|
const newActions = [...actions];
|
|
|
|
|
newActions[index] = { ...newActions[index], ...updates };
|
|
|
|
|
onUpdate(newActions);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{actions.length === 0 && (
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
|
|
|
메인 액션 성공 후 순차 실행할 후속 동작
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{actions.map((fa, idx) => (
|
|
|
|
|
<div
|
|
|
|
|
key={idx}
|
|
|
|
|
className="space-y-1.5 rounded border p-2"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span className="text-[10px] font-medium text-muted-foreground">
|
|
|
|
|
후속 {idx + 1}
|
|
|
|
|
</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleRemove(idx)}
|
|
|
|
|
className="h-5 px-1 text-[10px] text-destructive"
|
|
|
|
|
>
|
|
|
|
|
삭제
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 타입 선택 */}
|
|
|
|
|
<Select
|
|
|
|
|
value={fa.type}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
handleUpdate(idx, { type: v as FollowUpActionType })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{Object.entries(FOLLOWUP_TYPE_LABELS).map(([key, label]) => (
|
|
|
|
|
<SelectItem key={key} value={key} className="text-xs">
|
|
|
|
|
{label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
|
|
|
{/* 타입별 추가 입력 */}
|
|
|
|
|
{fa.type === "event" && (
|
|
|
|
|
<Input
|
|
|
|
|
value={fa.eventName || ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
handleUpdate(idx, { eventName: e.target.value })
|
|
|
|
|
}
|
|
|
|
|
placeholder="이벤트명"
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{fa.type === "navigate" && (
|
|
|
|
|
<Input
|
|
|
|
|
value={fa.targetScreenId || ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
handleUpdate(idx, { targetScreenId: e.target.value })
|
|
|
|
|
}
|
|
|
|
|
placeholder="화면 ID"
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleAdd}
|
|
|
|
|
className="w-full h-7 text-xs"
|
|
|
|
|
>
|
|
|
|
|
후속 액션 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// STEP 4: 미리보기 + 레지스트리 등록
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
function PopButtonPreviewComponent({
|
|
|
|
|
config,
|
|
|
|
|
}: {
|
|
|
|
|
config?: PopButtonConfig;
|
|
|
|
|
}) {
|
|
|
|
|
const buttonLabel = config?.label || "버튼";
|
|
|
|
|
const variant = config?.variant || "default";
|
|
|
|
|
const iconName = config?.icon || "";
|
|
|
|
|
const isIconOnly = config?.iconOnly || false;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center overflow-hidden">
|
|
|
|
|
<Button
|
|
|
|
|
variant={variant}
|
|
|
|
|
className={cn(
|
|
|
|
|
"pointer-events-none",
|
|
|
|
|
isIconOnly && "px-2"
|
|
|
|
|
)}
|
|
|
|
|
tabIndex={-1}
|
|
|
|
|
>
|
|
|
|
|
{iconName && (
|
|
|
|
|
<DynamicLucideIcon
|
|
|
|
|
name={iconName}
|
|
|
|
|
size={16}
|
|
|
|
|
className={isIconOnly ? "" : "mr-1.5"}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{!isIconOnly && <span>{buttonLabel}</span>}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 15:30:07 +09:00
|
|
|
// ========================================
|
|
|
|
|
// 상태 변경 규칙 편집기
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
const KNOWN_ITEM_FIELDS = [
|
|
|
|
|
{ value: "__cart_id", label: "__cart_id (카드 항목 ID)" },
|
|
|
|
|
{ value: "__cart_row_key", label: "__cart_row_key (원본 PK 값)" },
|
|
|
|
|
{ value: "id", label: "id" },
|
|
|
|
|
{ value: "row_key", label: "row_key" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function StatusChangeRuleEditor({
|
|
|
|
|
rules,
|
|
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
|
|
|
|
rules: StatusChangeRule[];
|
|
|
|
|
onUpdate: (rules: StatusChangeRule[]) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
|
|
|
const [columnsMap, setColumnsMap] = useState<Record<string, ColumnInfo[]>>({});
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchTableList().then(setTables);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const loadColumns = (tableName: string) => {
|
|
|
|
|
if (!tableName || columnsMap[tableName]) return;
|
|
|
|
|
fetchTableColumns(tableName).then((cols) => {
|
|
|
|
|
setColumnsMap((prev) => ({ ...prev, [tableName]: cols }));
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateRule = (idx: number, partial: Partial<StatusChangeRule>) => {
|
|
|
|
|
const next = [...rules];
|
|
|
|
|
next[idx] = { ...next[idx], ...partial };
|
|
|
|
|
onUpdate(next);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removeRule = (idx: number) => {
|
|
|
|
|
const next = [...rules];
|
|
|
|
|
next.splice(idx, 1);
|
|
|
|
|
onUpdate(next);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const addRule = () => {
|
|
|
|
|
onUpdate([
|
|
|
|
|
...rules,
|
|
|
|
|
{
|
|
|
|
|
targetTable: "",
|
|
|
|
|
targetColumn: "",
|
|
|
|
|
valueType: "fixed",
|
|
|
|
|
fixedValue: "",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2 px-1">
|
|
|
|
|
{rules.map((rule, idx) => (
|
|
|
|
|
<SingleRuleEditor
|
|
|
|
|
key={idx}
|
|
|
|
|
rule={rule}
|
|
|
|
|
idx={idx}
|
|
|
|
|
tables={tables}
|
|
|
|
|
columns={columnsMap[rule.targetTable] ?? []}
|
|
|
|
|
onLoadColumns={loadColumns}
|
|
|
|
|
onUpdate={(partial) => updateRule(idx, partial)}
|
|
|
|
|
onRemove={() => removeRule(idx)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
<Button variant="outline" size="sm" className="w-full text-xs" onClick={addRule}>
|
|
|
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
|
|
|
규칙 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SingleRuleEditor({
|
|
|
|
|
rule,
|
|
|
|
|
idx,
|
|
|
|
|
tables,
|
|
|
|
|
columns,
|
|
|
|
|
onLoadColumns,
|
|
|
|
|
onUpdate,
|
|
|
|
|
onRemove,
|
|
|
|
|
}: {
|
|
|
|
|
rule: StatusChangeRule;
|
|
|
|
|
idx: number;
|
|
|
|
|
tables: TableInfo[];
|
|
|
|
|
columns: ColumnInfo[];
|
|
|
|
|
onLoadColumns: (tableName: string) => void;
|
|
|
|
|
onUpdate: (partial: Partial<StatusChangeRule>) => void;
|
|
|
|
|
onRemove: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (rule.targetTable) onLoadColumns(rule.targetTable);
|
|
|
|
|
}, [rule.targetTable]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
|
|
const conditions = rule.conditionalValue?.conditions ?? [];
|
|
|
|
|
const defaultValue = rule.conditionalValue?.defaultValue ?? "";
|
|
|
|
|
|
|
|
|
|
const updateCondition = (cIdx: number, partial: Partial<(typeof conditions)[0]>) => {
|
|
|
|
|
const next = [...conditions];
|
|
|
|
|
next[cIdx] = { ...next[cIdx], ...partial };
|
|
|
|
|
onUpdate({
|
|
|
|
|
conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue },
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removeCondition = (cIdx: number) => {
|
|
|
|
|
const next = [...conditions];
|
|
|
|
|
next.splice(cIdx, 1);
|
|
|
|
|
onUpdate({
|
|
|
|
|
conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue },
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const addCondition = () => {
|
|
|
|
|
onUpdate({
|
|
|
|
|
conditionalValue: {
|
|
|
|
|
...rule.conditionalValue,
|
|
|
|
|
conditions: [...conditions, { whenColumn: "", operator: "=" as const, whenValue: "", thenValue: "" }],
|
|
|
|
|
defaultValue,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2 rounded border border-border p-2">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span className="text-[10px] font-medium text-muted-foreground">규칙 {idx + 1}</span>
|
|
|
|
|
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onRemove}>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 대상 테이블 */}
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label className="text-[10px]">대상 테이블</Label>
|
|
|
|
|
<TableCombobox
|
|
|
|
|
tables={tables}
|
|
|
|
|
value={rule.targetTable}
|
|
|
|
|
onSelect={(v) => onUpdate({ targetTable: v, targetColumn: "" })}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 변경 컬럼 */}
|
|
|
|
|
{rule.targetTable && (
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label className="text-[10px]">변경 컬럼</Label>
|
|
|
|
|
<ColumnCombobox
|
|
|
|
|
columns={columns}
|
|
|
|
|
value={rule.targetColumn}
|
|
|
|
|
onSelect={(v) => onUpdate({ targetColumn: v })}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 조회 키 */}
|
|
|
|
|
{rule.targetColumn && (
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Label className="text-[10px]">조회 키</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={rule.lookupMode ?? "auto"}
|
|
|
|
|
onValueChange={(v) => onUpdate({ lookupMode: v as "auto" | "manual" })}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-6 w-16 text-[10px]">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="auto" className="text-[10px]">자동</SelectItem>
|
|
|
|
|
<SelectItem value="manual" className="text-[10px]">수동</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
{(rule.lookupMode ?? "auto") === "auto" ? (
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
|
|
|
{rule.targetTable === "cart_items"
|
|
|
|
|
? `카드 항목.__cart_id → ${rule.targetTable}.id`
|
|
|
|
|
: `카드 항목.row_key → ${rule.targetTable}.${columns.find(c => c.isPrimaryKey)?.name ?? "PK(조회중)"}`}
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Select
|
|
|
|
|
value={rule.manualItemField ?? ""}
|
|
|
|
|
onValueChange={(v) => onUpdate({ manualItemField: v })}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]">
|
|
|
|
|
<SelectValue placeholder="카드 항목 필드" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{KNOWN_ITEM_FIELDS.map((f) => (
|
|
|
|
|
<SelectItem key={f.value} value={f.value} className="text-[10px]">{f.label}</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<span className="shrink-0 text-[10px] text-muted-foreground">→</span>
|
|
|
|
|
<ColumnCombobox
|
|
|
|
|
columns={columns}
|
|
|
|
|
value={rule.manualPkColumn ?? ""}
|
|
|
|
|
onSelect={(v) => onUpdate({ manualPkColumn: v })}
|
|
|
|
|
placeholder="대상 PK 컬럼"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 변경 값 타입 */}
|
|
|
|
|
{rule.targetColumn && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label className="text-[10px]">변경 값</Label>
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<label className="flex items-center gap-1 text-[10px]">
|
|
|
|
|
<input
|
|
|
|
|
type="radio"
|
|
|
|
|
name={`valueType-${idx}`}
|
|
|
|
|
checked={rule.valueType === "fixed"}
|
|
|
|
|
onChange={() => onUpdate({ valueType: "fixed" })}
|
|
|
|
|
className="h-3 w-3"
|
|
|
|
|
/>
|
|
|
|
|
고정값
|
|
|
|
|
</label>
|
|
|
|
|
<label className="flex items-center gap-1 text-[10px]">
|
|
|
|
|
<input
|
|
|
|
|
type="radio"
|
|
|
|
|
name={`valueType-${idx}`}
|
|
|
|
|
checked={rule.valueType === "conditional"}
|
|
|
|
|
onChange={() => onUpdate({ valueType: "conditional" })}
|
|
|
|
|
className="h-3 w-3"
|
|
|
|
|
/>
|
|
|
|
|
조건부
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 고정값 */}
|
|
|
|
|
{rule.valueType === "fixed" && (
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Input
|
|
|
|
|
value={rule.fixedValue ?? ""}
|
|
|
|
|
onChange={(e) => onUpdate({ fixedValue: e.target.value })}
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
placeholder="변경할 값 입력"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 조건부 */}
|
|
|
|
|
{rule.valueType === "conditional" && (
|
|
|
|
|
<div className="space-y-2 rounded border border-dashed border-muted-foreground/30 p-2">
|
|
|
|
|
{conditions.map((cond, cIdx) => (
|
|
|
|
|
<div key={cIdx} className="space-y-1">
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<span className="shrink-0 text-[10px] text-muted-foreground">만약</span>
|
|
|
|
|
<ColumnCombobox
|
|
|
|
|
columns={columns}
|
|
|
|
|
value={cond.whenColumn}
|
|
|
|
|
onSelect={(v) => updateCondition(cIdx, { whenColumn: v })}
|
|
|
|
|
placeholder="컬럼"
|
|
|
|
|
/>
|
|
|
|
|
<Select
|
|
|
|
|
value={cond.operator}
|
|
|
|
|
onValueChange={(v) => updateCondition(cIdx, { operator: v as "=" | "!=" | ">" | "<" | ">=" | "<=" })}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 w-14 text-[10px]">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{["=", "!=", ">", "<", ">=", "<="].map((op) => (
|
|
|
|
|
<SelectItem key={op} value={op} className="text-xs">{op}</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<Input
|
|
|
|
|
value={cond.whenValue}
|
|
|
|
|
onChange={(e) => updateCondition(cIdx, { whenValue: e.target.value })}
|
|
|
|
|
className="h-7 w-16 text-[10px]"
|
|
|
|
|
placeholder="값"
|
|
|
|
|
/>
|
|
|
|
|
<Button variant="ghost" size="icon" className="h-5 w-5 shrink-0" onClick={() => removeCondition(cIdx)}>
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1 pl-4">
|
|
|
|
|
<span className="shrink-0 text-[10px] text-muted-foreground">이면 -></span>
|
|
|
|
|
<Input
|
|
|
|
|
value={cond.thenValue}
|
|
|
|
|
onChange={(e) => updateCondition(cIdx, { thenValue: e.target.value })}
|
|
|
|
|
className="h-7 text-[10px]"
|
|
|
|
|
placeholder="변경할 값"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
<Button variant="ghost" size="sm" className="w-full text-[10px]" onClick={addCondition}>
|
|
|
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
|
|
|
조건 추가
|
|
|
|
|
</Button>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<span className="shrink-0 text-[10px] text-muted-foreground">그 외 -></span>
|
|
|
|
|
<Input
|
|
|
|
|
value={defaultValue}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdate({
|
|
|
|
|
conditionalValue: { ...rule.conditionalValue, conditions, defaultValue: e.target.value },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
className="h-7 text-[10px]"
|
|
|
|
|
placeholder="기본값"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 14:23:44 +09:00
|
|
|
// 레지스트리 등록
|
|
|
|
|
PopComponentRegistry.registerComponent({
|
|
|
|
|
id: "pop-button",
|
|
|
|
|
name: "버튼",
|
2026-02-26 16:00:07 +09:00
|
|
|
description: "액션 버튼 (저장/삭제/API/모달/이벤트/장바구니)",
|
2026-02-12 14:23:44 +09:00
|
|
|
category: "action",
|
|
|
|
|
icon: "MousePointerClick",
|
|
|
|
|
component: PopButtonComponent,
|
|
|
|
|
configPanel: PopButtonConfigPanel,
|
|
|
|
|
preview: PopButtonPreviewComponent,
|
|
|
|
|
defaultProps: {
|
|
|
|
|
label: "버튼",
|
|
|
|
|
variant: "default",
|
|
|
|
|
preset: "custom",
|
|
|
|
|
confirm: { enabled: false },
|
|
|
|
|
action: { type: "save" },
|
|
|
|
|
} as PopButtonConfig,
|
2026-02-26 16:00:07 +09:00
|
|
|
connectionMeta: {
|
|
|
|
|
sendable: [
|
2026-03-03 15:30:07 +09:00
|
|
|
{ key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" },
|
|
|
|
|
{ key: "collect_data", label: "데이터 수집 요청", type: "event", category: "event", description: "연결된 컴포넌트에 데이터+매핑 수집 요청" },
|
|
|
|
|
{ key: "action_completed", label: "액션 완료", type: "event", category: "event", description: "확정/저장 완료 후 결과 전달" },
|
2026-02-26 16:00:07 +09:00
|
|
|
],
|
|
|
|
|
receivable: [
|
2026-03-03 15:30:07 +09:00
|
|
|
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" },
|
|
|
|
|
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "카드 목록에서 DB 저장 완료 시 수신" },
|
|
|
|
|
{ key: "collected_data", label: "수집된 데이터", type: "event", category: "event", description: "컴포넌트에서 수집한 데이터+매핑 응답" },
|
2026-03-03 17:13:01 +09:00
|
|
|
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "카드 목록에서 체크박스로 선택된 항목 수 수신" },
|
2026-02-26 16:00:07 +09:00
|
|
|
],
|
|
|
|
|
},
|
2026-02-12 14:23:44 +09:00
|
|
|
touchOptimized: true,
|
|
|
|
|
supportedDevices: ["mobile", "tablet"],
|
|
|
|
|
});
|