diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index 4ff425e3..d25c6bdc 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -273,6 +273,19 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } + if (!columns.includes('"created_date"')) { + columns.push('"created_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"updated_date"')) { + columns.push('"updated_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"writer"') && userId) { + columns.push('"writer"'); + values.push(userId); + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); await client.query( @@ -320,8 +333,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp for (let i = 0; i < lookupValues.length; i++) { const item = items[i] ?? {}; const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item); + const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; await client.query( - `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`, + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`, [resolved, companyCode, lookupValues[i]], ); processedCount++; @@ -339,9 +353,10 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`; + const autoUpdatedDb = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", "); await client.query( - `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`, + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql}${autoUpdatedDb} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`, [thenVal, elseVal, companyCode, ...lookupValues], ); processedCount += lookupValues.length; @@ -376,8 +391,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp setSql = `"${task.targetColumn}" = $1`; } + const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; await client.query( - `UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`, + `UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`, [value, companyCode, lookupValues[i]], ); processedCount++; @@ -578,6 +594,19 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } + if (!columns.includes('"created_date"')) { + columns.push('"created_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"updated_date"')) { + columns.push('"updated_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"writer"') && userId) { + columns.push('"writer"'); + values.push(userId); + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; @@ -611,6 +640,19 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp values.push(fieldValues[sourceField] ?? null); } + if (!columns.includes('"created_date"')) { + columns.push('"created_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"updated_date"')) { + columns.push('"updated_date"'); + values.push(new Date().toISOString()); + } + if (!columns.includes('"writer"') && userId) { + columns.push('"writer"'); + values.push(userId); + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; @@ -662,16 +704,18 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } if (valueType === "fixed") { + const autoUpd = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", "); - const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`; + const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd} WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`; await client.query(sql, [fixedValue, companyCode, ...lookupValues]); processedCount += lookupValues.length; } else { for (let i = 0; i < lookupValues.length; i++) { const item = items[i] ?? {}; const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item); + const autoUpd2 = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; await client.query( - `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`, + `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd2} WHERE company_code = $2 AND "${pkColumn}" = $3`, [resolvedValue, companyCode, lookupValues[i]] ); processedCount++; diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index 5a424d4e..55829efb 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -33,6 +33,7 @@ import type { TimelineProcessStep, TimelineDataSource, ActionButtonUpdate, + ActionButtonClickAction, StatusValueMapping, SelectModeConfig, SelectModeButtonConfig, @@ -206,11 +207,6 @@ export function PopCardListV2Component({ }); }, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish, isCartListMode]); - const handleCardSelect = useCallback((row: RowData) => { - if (!componentId) return; - publish(`__comp_output__${componentId}__selected_row`, row); - }, [componentId, publish]); - // ===== 선택 모드 ===== const [selectMode, setSelectMode] = useState(false); const [selectModeStatus, setSelectModeStatus] = useState(""); @@ -245,6 +241,26 @@ export function PopCardListV2Component({ } }, []); + const handleCardSelect = useCallback((row: RowData) => { + + if (effectiveConfig?.cardClickAction === "modal-open" && effectiveConfig?.cardClickModalConfig?.screenId) { + const mc = effectiveConfig.cardClickModalConfig; + if (mc.condition && mc.condition.type !== "always") { + const processFlow = row.__processFlow__ as { isCurrent: boolean; status?: string }[] | undefined; + const currentProcess = processFlow?.find((s) => s.isCurrent); + if (mc.condition.type === "timeline-status") { + if (currentProcess?.status !== mc.condition.value) return; + } else if (mc.condition.type === "column-value") { + if (String(row[mc.condition.column || ""] ?? "") !== mc.condition.value) return; + } + } + openPopModal(mc.screenId, row); + return; + } + if (!componentId) return; + publish(`__comp_output__${componentId}__selected_row`, row); + }, [componentId, publish, effectiveConfig, openPopModal]); + const enterSelectMode = useCallback((whenStatus: string, buttonConfig: Record) => { const smConfig = buttonConfig.selectModeConfig as SelectModeConfig | undefined; if (!smConfig) return; @@ -931,6 +947,10 @@ export function PopCardListV2Component({

데이터 소스를 설정해주세요.

+ ) : effectiveConfig?.hideUntilFiltered && externalFilters.size === 0 ? ( +
+

필터를 선택하면 데이터가 표시됩니다.

+
) : loading ? (
@@ -1077,7 +1097,7 @@ export function PopCardListV2Component({ )} - {/* POP 화면 모달 */} + {/* POP 화면 모달 (풀스크린) */} { setPopModalOpen(open); if (!open) { @@ -1085,17 +1105,17 @@ export function PopCardListV2Component({ setPopModalRow(null); } }}> - - - 상세 작업 + + + {effectiveConfig?.cardClickModalConfig?.modalTitle || "상세 작업"} -
+
{popModalLayout && ( )}
@@ -1326,71 +1346,74 @@ function CardV2({ onCartCancel: handleCartCancel, onEnterSelectMode, onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => { - const cfg = buttonConfig as { - updates?: ActionButtonUpdate[]; - targetTable?: string; - confirmMessage?: string; - __processId?: string | number; - } | undefined; + const cfg = buttonConfig as Record | undefined; + const allActions = (cfg?.__allActions as ActionButtonClickAction[] | undefined) || []; + const processId = cfg?.__processId as string | number | undefined; - if (cfg?.updates && cfg.updates.length > 0 && cfg.targetTable) { - if (cfg.confirmMessage) { - if (!window.confirm(cfg.confirmMessage)) return; + // 단일 액션 폴백 (기존 구조 호환) + const actionsToRun = allActions.length > 0 + ? allActions + : cfg?.type + ? [cfg as unknown as ActionButtonClickAction] + : []; + + if (actionsToRun.length === 0) { + if (parentComponentId) { + publish(`__comp_output__${parentComponentId}__action`, { taskPreset, row: actionRow }); } - try { - // 공정 테이블 대상이면 processId 우선 사용 - const rowId = cfg.__processId ?? actionRow.id ?? actionRow.pk; - if (!rowId) { - toast.error("대상 레코드의 ID를 찾을 수 없습니다."); + return; + } + + for (const action of actionsToRun) { + if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) { + if (action.confirmMessage) { + if (!window.confirm(action.confirmMessage)) return; + } + try { + const rowId = processId ?? 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: `btn-update-${idx}`, + type: "data-update" as const, + targetTable: action.targetTable!, + targetColumn: u.column, + operationType: "assign" as const, + valueSource: "fixed" as const, + fixedValue: 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, + })); + const targetRow = action.joinConfig + ? { ...actionRow, [lookupColumn]: lookupValue } + : processId ? { ...actionRow, id: processId } : actionRow; + 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 || "처리 실패"); + return; + } + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); return; } - const tasks = cfg.updates.map((u, idx) => ({ - id: `btn-update-${idx}`, - type: "data-update" as const, - targetTable: cfg.targetTable!, - targetColumn: u.column, - operationType: "assign" as const, - valueSource: "fixed" as const, - fixedValue: 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: "id", - manualPkColumn: "id", - })); - const targetRow = cfg.__processId - ? { ...actionRow, id: cfg.__processId } - : actionRow; - 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) { - toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + } else if (action.type === "modal-open" && action.modalScreenId) { + onOpenPopModal?.(action.modalScreenId, actionRow); } - return; - } - - const actionCfg = buttonConfig as { type?: string; modalScreenId?: string } | undefined; - if (actionCfg?.type === "modal-open" && actionCfg.modalScreenId) { - onOpenPopModal?.(actionCfg.modalScreenId, actionRow); - return; - } - - if (parentComponentId) { - publish(`__comp_output__${parentComponentId}__action`, { - taskPreset, - row: actionRow, - }); } }, packageEntries, diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx index 4fee23ba..79d8a31e 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx @@ -8,7 +8,7 @@ * 탭 3: 동작 — 카드 선택 동작, 오버플로우, 카트 */ -import { useState, useEffect, useRef, useCallback, Fragment } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo, Fragment } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -16,11 +16,13 @@ import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Check, ChevronsUpDown, Plus, Minus, Trash2 } from "lucide-react"; +import { Check, ChevronsUpDown, Plus, Minus, Trash2, ChevronDown, ChevronRight } from "lucide-react"; import { Popover, PopoverContent, @@ -45,10 +47,15 @@ import type { CardSortConfig, V2OverflowConfig, V2CardClickAction, + V2CardClickModalConfig, ActionButtonUpdate, TimelineDataSource, StatusValueMapping, TimelineStatusSemantic, + SelectModeButtonConfig, + ActionButtonDef, + ActionButtonShowCondition, + ActionButtonClickAction, } from "../types"; import type { ButtonVariant } from "../pop-button"; import { @@ -1269,6 +1276,7 @@ function TabCardDesign({ columns={columns} selectedColumns={selectedColumns} tables={tables} + dataSource={cfg.dataSource} onUpdate={(partial) => updateCell(selectedCell.id, partial)} onRemove={() => removeCell(selectedCell.id)} /> @@ -1286,6 +1294,7 @@ function CellDetailEditor({ columns, selectedColumns, tables, + dataSource, onUpdate, onRemove, }: { @@ -1295,9 +1304,31 @@ function CellDetailEditor({ columns: ColumnInfo[]; selectedColumns: string[]; tables: TableInfo[]; + dataSource: CardListDataSource; onUpdate: (partial: Partial) => void; onRemove: () => void; }) { + const availableTableOptions = useMemo(() => { + const opts: { value: string; label: string }[] = []; + if (dataSource.tableName) { + opts.push({ value: dataSource.tableName, label: `${dataSource.tableName} (메인)` }); + } + for (const j of dataSource.joins || []) { + if (j.targetTable) { + opts.push({ value: j.targetTable, label: `${j.targetTable} (조인)` }); + } + } + const added = new Set(opts.map((o) => o.value)); + for (const c of allCells) { + const pt = c.timelineSource?.processTable; + if (pt && !added.has(pt)) { + opts.push({ value: pt, label: `${pt} (타임라인)` }); + added.add(pt); + } + } + return opts; + }, [dataSource, allCells]); + return (
@@ -1312,17 +1343,19 @@ function CellDetailEditor({ {/* 컬럼 + 타입 */}
- + {cell.type !== "action-buttons" && ( + + )} updateRule(ri, { whenStatus: e.target.value })} placeholder="상태값 (예: waiting)" className="h-6 flex-1 text-[10px]" /> - -
- {rule.buttons.map((btn, bi) => { - const btnKey = `${ri}-${bi}`; - const isExpanded = expandedBtn === btnKey; + {buttons.length === 0 && ( +

버튼 규칙을 추가하세요. 상태별로 다른 버튼을 설정할 수 있습니다.

+ )} - return ( -
-
- updateButton(ri, bi, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" /> - updateBtn(bi, { label: e.target.value })} + onClick={(e) => e.stopPropagation()} + placeholder="라벨" + className="h-6 flex-1 text-[10px]" + /> + - - + + ) : ( + + {btn.label || "(미입력)"} | {getCondSummary(btn)} | {actionSummary} + + )} + +
+ + {/* 펼쳐진 상세 */} + {isExpanded && ( +
+ {/* === 조건 섹션 === */} +
toggleSection(`${bi}-cond`)} + > + {isSectionOpen(`${bi}-cond`) + ? + : } + 조건 + {!isSectionOpen(`${bi}-cond`) && ( + {getCondSummary(btn)} + )}
- - {isExpanded && ( -
- 클릭 시 동작 - + {isSectionOpen(`${bi}-cond`) && ( +
- 대상 테이블 - updateButton(ri, bi, { targetTable: e.target.value })} - placeholder="work_order_process" - className="h-6 flex-1 text-[10px]" - /> -
- -
- 확인 메시지 - updateButton(ri, bi, { confirmMessage: e.target.value })} - placeholder="접수하시겠습니까?" - className="h-6 flex-1 text-[10px]" - /> -
- -
- 변경할 컬럼 - -
- - {(btn.updates || []).map((u, ui) => ( -
- updateCondition(bi, { type: v as ActionButtonShowCondition["type"] })}> + + + 항상 + 타임라인 + 카드 컬럼 + + + {condType === "timeline-status" && ( + - updateCondition(bi, { column: v === "__none__" ? "" : v })} + > + + + 선택 + {allColumnOptions.map((o) => ( + {o.label} + ))} + + + updateCondition(bi, { value: e.target.value })} + placeholder="값" + className="h-6 w-20 text-[10px]" + /> + + )} +
+ {condType !== "always" && ( +
+ 그 외 + - {(u.valueType === "static" || u.valueType === "columnRef") && ( - updateUpdateEntry(ri, bi, ui, { value: e.target.value })} - placeholder={u.valueType === "static" ? "값" : "컬럼명"} - className="h-6 flex-1 text-[10px]" - /> - )} - + + {(btn.showCondition?.unmatchBehavior || "hidden") === "disabled" + ? "보이지만 클릭 불가" + : "버튼 안 보임"} +
- ))} - - {(!btn.updates || btn.updates.length === 0) && ( -

변경 항목을 추가하면 버튼 클릭 시 DB가 변경됩니다.

)}
)} -
- ); - })} - + )} +
+ + {aType === "immediate" && ( + addActionUpdate(bi, ai)} + onUpdateUpdate={(ui, p) => updateActionUpdate(bi, ai, ui, p)} + onRemoveUpdate={(ui) => removeActionUpdate(bi, ai, ui)} + onUpdateAction={(p) => updateAction(bi, ai, p)} + /> + )} + + {aType === "select-mode" && ( +
+
+ 로직 순서 + +
+ {(action.selectModeButtons || []).map((smBtn, si) => ( +
+
+ updateSelectModeBtn(bi, ai, si, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" /> + + +
+
+ 동작 + +
+ {smBtn.clickMode === "status-change" && ( + addSmBtnUpdate(bi, ai, si)} + onUpdateUpdate={(ui, p) => updateSmBtnUpdate(bi, ai, si, ui, p)} + onRemoveUpdate={(ui) => removeSmBtnUpdate(bi, ai, si, ui)} + onUpdateAction={(p) => updateSelectModeBtn(bi, ai, si, { targetTable: p.targetTable ?? smBtn.targetTable, confirmMessage: p.confirmMessage ?? smBtn.confirmMessage })} + /> + )} + {smBtn.clickMode === "modal-open" && ( +
+ POP 화면 + updateSelectModeBtn(bi, ai, si, { modalScreenId: e.target.value })} + placeholder="화면 ID (예: 4481)" + className="h-6 flex-1 text-[10px]" + /> +
+ )} +
+ ))} + {(!action.selectModeButtons || action.selectModeButtons.length === 0) && ( +

로직 순서를 추가하세요.

+ )} +
+ )} + + {aType === "modal-open" && ( +
+ POP 화면 + updateAction(bi, ai, { modalScreenId: e.target.value })} + placeholder="화면 ID (예: 4481)" + className="h-6 flex-1 text-[10px]" + /> +
+ )} +
+ ); + })} + +
+ )} +
+ )} +
+ ); + })} +
+ ); +} + +const SYSTEM_COLUMNS = new Set([ + "id", "company_code", "created_date", "updated_date", "writer", +]); + +function ImmediateActionEditor({ + action, + allColumnOptions, + availableTableOptions, + onAddUpdate, + onUpdateUpdate, + onRemoveUpdate, + onUpdateAction, +}: { + action: ActionButtonClickAction; + allColumnOptions: { value: string; label: string }[]; + availableTableOptions: { value: string; label: string }[]; + onAddUpdate: () => void; + onUpdateUpdate: (uIdx: number, partial: Partial) => void; + onRemoveUpdate: (uIdx: number) => void; + onUpdateAction: (partial: Partial) => void; +}) { + const isExternalTable = action.targetTable && !availableTableOptions.some((t) => t.value === action.targetTable); + const [dbSelectMode, setDbSelectMode] = useState(!!isExternalTable); + const [allTables, setAllTables] = useState([]); + + const [tableColumnGroups, setTableColumnGroups] = useState< + { table: string; label: string; business: { value: string; label: string }[]; system: { value: string; label: string }[] }[] + >([]); + + // 외부 DB 모드 시 전체 테이블 로드 + useEffect(() => { + if (dbSelectMode && allTables.length === 0) { + fetchTableList().then(setAllTables).catch(() => setAllTables([])); + } + }, [dbSelectMode, allTables.length]); + + // 선택된 테이블 컬럼 로드 (카드 소스 + 외부 공통) + const effectiveTableOptions = useMemo(() => { + if (dbSelectMode && action.targetTable) { + const existing = availableTableOptions.find((t) => t.value === action.targetTable); + if (!existing) return [...availableTableOptions, { value: action.targetTable, label: `${action.targetTable} (외부)` }]; + } + return availableTableOptions; + }, [availableTableOptions, dbSelectMode, action.targetTable]); + + useEffect(() => { + let cancelled = false; + const loadAll = async () => { + const groups: typeof tableColumnGroups = []; + for (const t of effectiveTableOptions) { + try { + const cols = await fetchTableColumns(t.value); + const mapped = cols.map((c) => ({ value: c.name, label: c.name })); + groups.push({ + table: t.value, + label: t.label, + business: mapped.filter((c) => !SYSTEM_COLUMNS.has(c.value)), + system: mapped.filter((c) => SYSTEM_COLUMNS.has(c.value)), + }); + } catch { + groups.push({ table: t.value, label: t.label, business: [], system: [] }); + } + } + if (!cancelled) setTableColumnGroups(groups); + }; + if (effectiveTableOptions.length > 0) loadAll(); + else setTableColumnGroups([]); + return () => { cancelled = true; }; + }, [effectiveTableOptions]); + + const selectedGroup = tableColumnGroups.find((g) => g.table === action.targetTable); + const businessCols = selectedGroup?.business || []; + const systemCols = selectedGroup?.system || []; + const tableName = action.targetTable?.trim() || ""; + + // 메인 테이블 컬럼 (조인키 소스 컬럼 선택 용도) + const mainTableGroup = tableColumnGroups.find((g) => availableTableOptions[0]?.value === g.table); + const mainCols = mainTableGroup ? [...mainTableGroup.business, ...mainTableGroup.system] : []; + + return ( +
+ {/* 대상 테이블 */} +
+ 대상 테이블 + {!dbSelectMode ? ( + + ) : ( +
+ onUpdateAction({ targetTable: v })} + /> + +
+ )} +
+ + {/* 외부 DB 선택 시 조인키 설정 */} + {dbSelectMode && action.targetTable && ( + <> +
+ 기준 컬럼 + +
+
+ 매칭 컬럼 + +
+

+ 메인.기준컬럼 = 외부.매칭컬럼 으로 연결하여 업데이트 +

+ + )} + +
+ 확인 메시지 + onUpdateAction({ confirmMessage: e.target.value })} + placeholder="처리하시겠습니까?" + className="h-6 flex-1 text-[10px]" + /> +
+
+ + 변경할 컬럼{tableName ? ` (${tableName})` : ""} + + +
+ {(action.updates || []).map((u, ui) => ( +
+ + + {(u.valueType === "static" || u.valueType === "columnRef") && ( + onUpdateUpdate(ui, { value: e.target.value })} + placeholder={u.valueType === "static" ? "값" : "컬럼명"} + className="h-6 flex-1 text-[10px]" + /> + )} +
))} + {(!action.updates || action.updates.length === 0) && ( +

변경 항목을 추가하면 클릭 시 DB가 변경됩니다.

+ )}
); } + +// ===== DB 테이블 검색 Combobox ===== + +function DbTableCombobox({ + value, + tables, + onSelect, +}: { + value: string; + tables: TableInfo[]; + onSelect: (tableName: string) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + if (!search) return tables; + const q = search.toLowerCase(); + return tables.filter( + (t) => + t.tableName.toLowerCase().includes(q) || + (t.tableComment || "").toLowerCase().includes(q), + ); + }, [tables, search]); + + const selectedLabel = useMemo(() => { + if (!value) return "DB 테이블 검색..."; + const found = tables.find((t) => t.tableName === value); + return found ? `${found.tableName}${found.tableComment ? ` (${found.tableComment})` : ""}` : value; + }, [value, tables]); + + return ( + + + + + + + + + + 검색 결과가 없습니다. + + + {filtered.map((t) => ( + { + onSelect(t.tableName); + setOpen(false); + setSearch(""); + }} + className="text-[10px]" + > + + {t.tableName} + {t.tableComment && ( + ({t.tableComment}) + )} + + ))} + + + + + + ); +} + // ===== 하단 상태 에디터 ===== function FooterStatusEditor({ @@ -2119,6 +2747,7 @@ function TabActions({ }) { const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }; const clickAction = cfg.cardClickAction || "none"; + const modalConfig = cfg.cardClickModalConfig || { screenId: "" }; return (
@@ -2126,7 +2755,7 @@ function TabActions({
- {(["none", "publish", "navigate"] as V2CardClickAction[]).map((action) => ( + {(["none", "publish", "navigate", "modal-open"] as V2CardClickAction[]).map((action) => ( ))}
+ {clickAction === "modal-open" && ( +
+
+ POP 화면 ID + onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })} + placeholder="화면 ID (예: 4481)" + className="h-7 flex-1 text-[10px]" + /> +
+
+ 모달 제목 + onUpdate({ cardClickModalConfig: { ...modalConfig, modalTitle: e.target.value } })} + placeholder="비우면 '상세 작업' 표시" + className="h-7 flex-1 text-[10px]" + /> +
+
+ 조건 + +
+ {modalConfig.condition?.type === "timeline-status" && ( +
+ 상태 값 + onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, value: e.target.value } } })} + placeholder="예: in_progress" + className="h-7 flex-1 text-[10px]" + /> +
+ )} + {modalConfig.condition?.type === "column-value" && ( + <> +
+ 컬럼 + onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, column: e.target.value } } })} + placeholder="컬럼명" + className="h-7 flex-1 text-[10px]" + /> +
+
+ + onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, value: e.target.value } } })} + placeholder="값" + className="h-7 flex-1 text-[10px]" + /> +
+ + )} +
+ )}
+ {/* 필터 전 비표시 */} +
+ + onUpdate({ hideUntilFiltered: checked })} + /> +
+ {cfg.hideUntilFiltered && ( +

+ 연결된 컴포넌트에서 필터 값이 전달되기 전까지 데이터를 표시하지 않습니다. +

+ )} + {/* 스크롤 방향 */}
diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx index 5cc9afb3..f1863b13 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx @@ -18,7 +18,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; -import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep } from "../types"; +import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep, ActionButtonDef } from "../types"; import { DEFAULT_CARD_IMAGE, VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC } from "../types"; import type { ButtonVariant } from "../pop-button"; @@ -67,6 +67,7 @@ export interface CellRendererProps { onCartCancel?: () => void; onButtonClick?: (cell: CardCellDefinitionV2, row: RowData) => void; onActionButtonClick?: (taskPreset: string, row: RowData, buttonConfig?: Record) => void; + onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; packageEntries?: PackageEntry[]; inputUnit?: string; } @@ -591,23 +592,86 @@ function TimelineCell({ cell, row }: CellRendererProps) { // ===== 11. action-buttons ===== -function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps) { +function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" { + const cond = btn.showCondition; + if (!cond || cond.type === "always") return "visible"; + + let matched = false; + + if (cond.type === "timeline-status") { + const subStatus = row[VIRTUAL_SUB_STATUS]; + matched = subStatus !== undefined && String(subStatus) === cond.value; + } else if (cond.type === "column-value" && cond.column) { + matched = String(row[cond.column] ?? "") === (cond.value ?? ""); + } else { + return "visible"; + } + + if (matched) return "visible"; + return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden"; +} + +function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) { + const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; + const currentProcess = processFlow?.find((s) => s.isCurrent); + const currentProcessId = currentProcess?.processId; + + if (cell.actionButtons && cell.actionButtons.length > 0) { + const evaluated = cell.actionButtons.map((btn) => ({ + btn, + state: evaluateShowCondition(btn, row), + })); + + const activeBtn = evaluated.find((e) => e.state === "visible"); + const disabledBtn = activeBtn ? null : evaluated.find((e) => e.state === "disabled"); + const pick = activeBtn || disabledBtn; + if (!pick) return null; + + const { btn, state } = pick; + + return ( +
+ +
+ ); + } + + // 기존 구조 (actionRules) 폴백 const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined; const statusValue = hasSubStatus ? String(row[VIRTUAL_SUB_STATUS] || "") : (cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : "")); const rules = cell.actionRules || []; - const matchedRule = rules.find((r) => r.whenStatus === statusValue); - - if (!matchedRule) { - return null; - } - - // __processFlow__에서 isCurrent 공정의 processId 추출 - const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; - const currentProcess = processFlow?.find((s) => s.isCurrent); - const currentProcessId = currentProcess?.processId; + if (!matchedRule) return null; return (
@@ -620,8 +684,10 @@ function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps onClick={(e) => { e.stopPropagation(); const config = { ...(btn as Record) }; - if (currentProcessId !== undefined) { - config.__processId = currentProcessId; + if (currentProcessId !== undefined) config.__processId = currentProcessId; + if (btn.clickMode === "select-mode" && onEnterSelectMode) { + onEnterSelectMode(matchedRule.whenStatus, config); + return; } onActionButtonClick?.(btn.taskPreset, row, config); }} diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index e883202f..3b7ff73e 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -833,19 +833,12 @@ export interface CardCellDefinitionV2 { timelinePriority?: "before" | "after"; showDetailModal?: boolean; - // action-buttons 타입 전용 + // action-buttons 타입 전용 (신규: 버튼 중심 구조) + actionButtons?: ActionButtonDef[]; + // action-buttons 타입 전용 (구: 조건 중심 구조, 하위호환) actionRules?: Array<{ whenStatus: string; - buttons: Array<{ - label: string; - variant: ButtonVariant; - taskPreset: string; - confirm?: ConfirmConfig; - targetTable?: string; - confirmMessage?: string; - allowMultiSelect?: boolean; - updates?: ActionButtonUpdate[]; - }>; + buttons: Array; }>; // footer-status 타입 전용 @@ -861,6 +854,72 @@ export interface ActionButtonUpdate { valueType: "static" | "currentUser" | "currentTime" | "columnRef"; } +// 액션 버튼 클릭 시 동작 모드 +export type ActionButtonClickMode = "status-change" | "modal-open" | "select-mode"; + +// 액션 버튼 개별 설정 +export interface ActionButtonConfig { + label: string; + variant: ButtonVariant; + taskPreset: string; + confirm?: ConfirmConfig; + targetTable?: string; + confirmMessage?: string; + allowMultiSelect?: boolean; + updates?: ActionButtonUpdate[]; + clickMode?: ActionButtonClickMode; + selectModeConfig?: SelectModeConfig; +} + +// 선택 모드 설정 +export interface SelectModeConfig { + filterStatus?: string; + buttons: Array; +} + +// 선택 모드 하단 버튼 설정 +export interface SelectModeButtonConfig { + label: string; + variant: ButtonVariant; + clickMode: "status-change" | "modal-open" | "cancel-select"; + targetTable?: string; + updates?: ActionButtonUpdate[]; + confirmMessage?: string; + modalScreenId?: string; +} + +// ===== 버튼 중심 구조 (신규) ===== + +export interface ActionButtonShowCondition { + type: "timeline-status" | "column-value" | "always"; + value?: string; + column?: string; + unmatchBehavior?: "hidden" | "disabled"; +} + +export interface ActionButtonClickAction { + type: "immediate" | "select-mode" | "modal-open"; + targetTable?: string; + updates?: ActionButtonUpdate[]; + confirmMessage?: string; + selectModeButtons?: SelectModeButtonConfig[]; + modalScreenId?: string; + // 외부 테이블 조인 설정 (DB 직접 선택 시) + joinConfig?: { + sourceColumn: string; // 메인 테이블의 FK 컬럼 + targetColumn: string; // 외부 테이블의 매칭 컬럼 + }; +} + +export interface ActionButtonDef { + label: string; + variant: ButtonVariant; + showCondition?: ActionButtonShowCondition; + /** 단일 액션 (하위호환) 또는 다중 액션 체이닝 */ + clickAction: ActionButtonClickAction; + clickActions?: ActionButtonClickAction[]; +} + export interface CardGridConfigV2 { rows: number; cols: number; @@ -873,7 +932,17 @@ export interface CardGridConfigV2 { // ----- V2 카드 선택 동작 ----- -export type V2CardClickAction = "none" | "publish" | "navigate"; +export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open"; + +export interface V2CardClickModalConfig { + screenId: string; + modalTitle?: string; + condition?: { + type: "timeline-status" | "column-value" | "always"; + value?: string; + column?: string; + }; +} // ----- V2 오버플로우 설정 ----- @@ -898,6 +967,9 @@ export interface PopCardListV2Config { cardGap?: number; overflow?: V2OverflowConfig; cardClickAction?: V2CardClickAction; + cardClickModalConfig?: V2CardClickModalConfig; + /** 연결된 필터 값이 전달되기 전까지 데이터 비표시 */ + hideUntilFiltered?: boolean; responsiveDisplay?: CardResponsiveConfig; inputField?: CardInputFieldConfig; packageConfig?: CardPackageConfig;