diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index d25c6bdc..669cc960 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -104,6 +104,11 @@ interface TaskBody { manualItemField?: string; manualPkColumn?: string; cartScreenId?: string; + preCondition?: { + column: string; + expectedValue: string; + failMessage?: string; + }; } function resolveStatusValue( @@ -334,14 +339,30 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp const item = items[i] ?? {}; const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item); const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; - await client.query( - `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`, - [resolved, companyCode, lookupValues[i]], + let condWhere = `WHERE company_code = $2 AND "${pkColumn}" = $3`; + const condParams: unknown[] = [resolved, companyCode, lookupValues[i]]; + if (task.preCondition?.column && task.preCondition?.expectedValue) { + if (!isSafeIdentifier(task.preCondition.column)) throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`); + condWhere += ` AND "${task.preCondition.column}" = $4`; + condParams.push(task.preCondition.expectedValue); + } + const condResult = await client.query( + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} ${condWhere}`, + condParams, ); + if (task.preCondition && condResult.rowCount === 0) { + const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다."); + (err as any).isPreConditionFail = true; + throw err; + } processedCount++; } } else if (opType === "db-conditional") { - // DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중') + if (task.preCondition) { + logger.warn("[pop/execute-action] db-conditional에는 preCondition 미지원, 무시됨", { + taskId: task.id, preCondition: task.preCondition, + }); + } if (!task.compareColumn || !task.compareOperator || !task.compareWith) break; if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break; @@ -392,10 +413,24 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; - await client.query( - `UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`, - [value, companyCode, lookupValues[i]], + let whereSql = `WHERE company_code = $2 AND "${pkColumn}" = $3`; + const queryParams: unknown[] = [value, companyCode, lookupValues[i]]; + if (task.preCondition?.column && task.preCondition?.expectedValue) { + if (!isSafeIdentifier(task.preCondition.column)) { + throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`); + } + whereSql += ` AND "${task.preCondition.column}" = $4`; + queryParams.push(task.preCondition.expectedValue); + } + const updateResult = await client.query( + `UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} ${whereSql}`, + queryParams, ); + if (task.preCondition && updateResult.rowCount === 0) { + const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다."); + (err as any).isPreConditionFail = true; + throw err; + } processedCount++; } } @@ -746,6 +781,16 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp }); } catch (error: any) { await client.query("ROLLBACK"); + + if (error.isPreConditionFail) { + logger.warn("[pop/execute-action] preCondition 실패", { message: error.message }); + return res.status(409).json({ + success: false, + message: error.message, + errorCode: "PRE_CONDITION_FAIL", + }); + } + logger.error("[pop/execute-action] 오류:", error); return res.status(500).json({ success: false, 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 55829efb..3a52a36e 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 @@ -34,6 +34,7 @@ import type { TimelineDataSource, ActionButtonUpdate, ActionButtonClickAction, + QuantityInputConfig, StatusValueMapping, SelectModeConfig, SelectModeButtonConfig, @@ -47,6 +48,7 @@ import { screenApi } from "@/lib/api/screen"; import { apiClient } from "@/lib/api/client"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useCartSync } from "@/hooks/pop/useCartSync"; +import { useAuth } from "@/hooks/useAuth"; import { NumberInputModal } from "../pop-card-list/NumberInputModal"; import { renderCellV2 } from "./cell-renderers"; import type { PopLayoutDataV5 } from "@/components/pop/designer/types/pop-layout"; @@ -56,6 +58,32 @@ const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopVie type RowData = Record; +function calculateMaxQty( + row: RowData, + processId: string | number | undefined, + cfg?: QuantityInputConfig, +): number { + if (!cfg) return 999999; + const maxVal = cfg.maxColumn ? Number(row[cfg.maxColumn]) || 999999 : 999999; + if (!cfg.currentColumn) return maxVal; + + const processFlow = row.__processFlow__ as Array<{ + isCurrent: boolean; + processId?: string | number; + rawData?: Record; + }> | undefined; + + const currentProcess = processId + ? processFlow?.find((p) => String(p.processId) === String(processId)) + : processFlow?.find((p) => p.isCurrent); + + if (currentProcess?.rawData) { + const currentVal = Number(currentProcess.rawData[cfg.currentColumn]) || 0; + return Math.max(0, maxVal - currentVal); + } + return maxVal; +} + // cart_items 행 파싱 (pop-card-list에서 그대로 차용) function parseCartRow(dbRow: Record): Record { let rowData: Record = {}; @@ -113,6 +141,7 @@ export function PopCardListV2Component({ }: PopCardListV2ComponentProps) { const { subscribe, publish } = usePopEvent(screenId || "default"); const router = useRouter(); + const { userId: currentUserId } = useAuth(); const isCartListMode = config?.cartListMode?.enabled === true; const [inheritedConfig, setInheritedConfig] = useState | null>(null); @@ -469,7 +498,7 @@ export function PopCardListV2Component({ type: "data-update" as const, targetTable: btnConfig.targetTable!, targetColumn: u.column, - operationType: "assign" as const, + operationType: (u.operationType || "assign") as "assign" | "add" | "subtract", valueSource: "fixed" as const, fixedValue: u.valueType === "static" ? (u.value ?? "") : u.valueType === "currentUser" ? "__CURRENT_USER__" : @@ -619,11 +648,28 @@ export function PopCardListV2Component({ const scrollAreaRef = useRef(null); + const ownerSortColumn = config?.ownerSortColumn; + const displayCards = useMemo(() => { - if (!isExpanded) return filteredRows.slice(0, visibleCardCount); + let source = filteredRows; + + if (ownerSortColumn && currentUserId) { + const mine: RowData[] = []; + const others: RowData[] = []; + for (const row of source) { + if (String(row[ownerSortColumn] ?? "") === currentUserId) { + mine.push(row); + } else { + others.push(row); + } + } + source = [...mine, ...others]; + } + + if (!isExpanded) return source.slice(0, visibleCardCount); const start = (currentPage - 1) * expandedCardsPerPage; - return filteredRows.slice(start, start + expandedCardsPerPage); - }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]); + return source.slice(start, start + expandedCardsPerPage); + }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage, ownerSortColumn, currentUserId]); const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1; const needsPagination = isExpanded && totalPages > 1; @@ -756,10 +802,17 @@ export function PopCardListV2Component({ if (firstPending) { firstPending.isCurrent = true; } } - return fetchedRows.map((row) => ({ - ...row, - __processFlow__: processMap.get(String(row.id)) || [], - })); + return fetchedRows.map((row) => { + const steps = processMap.get(String(row.id)) || []; + const current = steps.find((s) => s.isCurrent); + const processFields: Record = {}; + if (current?.rawData) { + for (const [key, val] of Object.entries(current.rawData)) { + processFields[`__process_${key}`] = val; + } + } + return { ...row, __processFlow__: steps, ...processFields }; + }); }, []); const fetchData = useCallback(async () => { @@ -1041,6 +1094,7 @@ export function PopCardListV2Component({ onToggleRowSelect={() => toggleRowSelection(row)} onEnterSelectMode={enterSelectMode} onOpenPopModal={openPopModal} + currentUserId={currentUserId} /> ))} @@ -1148,6 +1202,8 @@ interface CardV2Props { onToggleRowSelect?: () => void; onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; onOpenPopModal?: (screenId: string, row: RowData) => void; + currentUserId?: string; + isLockedByOther?: boolean; } function CardV2({ @@ -1155,7 +1211,7 @@ function CardV2({ parentComponentId, isCartListMode, isSelected, onToggleSelect, onDeleteItem, onUpdateQuantity, onRefresh, selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode, - onOpenPopModal, + onOpenPopModal, currentUserId, isLockedByOther, }: CardV2Props) { const inputField = config?.inputField; const cartAction = config?.cartAction; @@ -1167,6 +1223,72 @@ function CardV2({ const [packageEntries, setPackageEntries] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); + const [qtyModalState, setQtyModalState] = useState<{ + open: boolean; + row: RowData; + processId?: string | number; + action: ActionButtonClickAction; + } | null>(null); + + const handleQtyConfirm = useCallback(async (value: number) => { + if (!qtyModalState) return; + const { row: actionRow, processId: qtyProcessId, action } = qtyModalState; + setQtyModalState(null); + if (!action.targetTable || !action.updates) return; + + const rowId = qtyProcessId ?? actionRow.id ?? actionRow.pk; + if (!rowId) { toast.error("대상 레코드 ID를 찾을 수 없습니다."); return; } + + const lookupValue = action.joinConfig + ? String(actionRow[action.joinConfig.sourceColumn] ?? rowId) + : rowId; + const lookupColumn = action.joinConfig?.targetColumn || "id"; + + const tasks = action.updates.map((u, idx) => ({ + id: `qty-update-${idx}`, + type: "data-update" as const, + targetTable: action.targetTable!, + targetColumn: u.column, + operationType: (u.operationType || "assign") as "assign" | "add" | "subtract", + valueSource: "fixed" as const, + fixedValue: u.valueType === "userInput" ? String(value) : + u.valueType === "static" ? (u.value ?? "") : + u.valueType === "currentUser" ? "__CURRENT_USER__" : + u.valueType === "currentTime" ? "__CURRENT_TIME__" : + u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") : + (u.value ?? ""), + lookupMode: "manual" as const, + manualItemField: lookupColumn, + manualPkColumn: lookupColumn, + ...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}), + })); + + const targetRow = action.joinConfig + ? { ...actionRow, [lookupColumn]: lookupValue } + : qtyProcessId ? { ...actionRow, id: qtyProcessId } : actionRow; + + try { + const result = await apiClient.post("/pop/execute-action", { + tasks, + data: { items: [targetRow], fieldValues: {} }, + mappings: {}, + }); + if (result.data?.success) { + toast.success(result.data.message || "처리 완료"); + onRefresh?.(); + } else { + toast.error(result.data?.message || "처리 실패"); + } + } catch (err: unknown) { + if ((err as any)?.response?.status === 409) { + toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다."); + onRefresh?.(); + } else { + toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + } + } + }, [qtyModalState, onRefresh]); + const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : ""; const isCarted = cart.isItemInCart(rowKey); const existingCartItem = cart.getCartItem(rowKey); @@ -1365,7 +1487,11 @@ function CardV2({ } for (const action of actionsToRun) { - if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) { + if (action.type === "quantity-input" && action.targetTable && action.updates) { + if (action.confirmMessage && !window.confirm(action.confirmMessage)) return; + setQtyModalState({ open: true, row: actionRow, processId, action }); + return; + } else if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) { if (action.confirmMessage) { if (!window.confirm(action.confirmMessage)) return; } @@ -1381,7 +1507,7 @@ function CardV2({ type: "data-update" as const, targetTable: action.targetTable!, targetColumn: u.column, - operationType: "assign" as const, + operationType: (u.operationType || "assign") as "assign" | "add" | "subtract", valueSource: "fixed" as const, fixedValue: u.valueType === "static" ? (u.value ?? "") : u.valueType === "currentUser" ? "__CURRENT_USER__" : @@ -1391,6 +1517,7 @@ function CardV2({ lookupMode: "manual" as const, manualItemField: lookupColumn, manualPkColumn: lookupColumn, + ...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}), })); const targetRow = action.joinConfig ? { ...actionRow, [lookupColumn]: lookupValue } @@ -1408,7 +1535,12 @@ function CardV2({ return; } } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + if ((err as any)?.response?.status === 409) { + toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다."); + onRefresh?.(); + } else { + toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); + } return; } } else if (action.type === "modal-open" && action.modalScreenId) { @@ -1418,6 +1550,7 @@ function CardV2({ }, packageEntries, inputUnit: inputField?.unit, + currentUserId, })} ))} @@ -1437,6 +1570,17 @@ function CardV2({ /> )} + {qtyModalState?.open && ( + { if (!open) setQtyModalState(null); }} + unit={qtyModalState.action.quantityInput?.unit || "EA"} + maxValue={calculateMaxQty(qtyModalState.row, qtyModalState.processId, qtyModalState.action.quantityInput)} + showPackageUnit={qtyModalState.action.quantityInput?.enablePackage ?? false} + onConfirm={(value) => handleQtyConfirm(value)} + /> + )} + ); } 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 79d8a31e..04ba0622 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 @@ -65,6 +65,33 @@ import { type ColumnInfo, } from "../pop-dashboard/utils/dataFetcher"; +// ===== 컬럼 옵션 그룹 ===== + +interface ColumnOptionGroup { + groupLabel: string; + options: { value: string; label: string }[]; +} + +function renderColumnOptionGroups(groups: ColumnOptionGroup[]) { + if (groups.length <= 1) { + return groups.flatMap((g) => + g.options.map((o) => ( + {o.label} + )) + ); + } + return groups + .filter((g) => g.options.length > 0) + .map((g) => ( + + {g.groupLabel} + {g.options.map((o) => ( + {o.label} + ))} + + )); +} + // ===== Props ===== interface ConfigPanelProps { @@ -271,6 +298,7 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) )} @@ -759,10 +787,36 @@ function TabCardDesign({ sourceTable: j.targetTable, })) ); - const allColumnOptions = [ - ...availableColumns.map((c) => ({ value: c.name, label: c.name })), - ...joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })), + + const [processColumns, setProcessColumns] = useState([]); + const timelineCell = cfg.cardGrid.cells.find((c) => c.type === "timeline" && c.timelineSource?.processTable); + const processTableName = timelineCell?.timelineSource?.processTable || ""; + useEffect(() => { + if (!processTableName) { setProcessColumns([]); return; } + fetchTableColumns(processTableName) + .then(setProcessColumns) + .catch(() => setProcessColumns([])); + }, [processTableName]); + + const columnOptionGroups: ColumnOptionGroup[] = [ + { + groupLabel: `메인 (${cfg.dataSource.tableName || "테이블"})`, + options: availableColumns.map((c) => ({ value: c.name, label: c.name })), + }, + ...(joinedColumns.length > 0 + ? [{ + groupLabel: "조인", + options: joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })), + }] + : []), + ...(processColumns.length > 0 + ? [{ + groupLabel: `공정 (${processTableName})`, + options: processColumns.map((c) => ({ value: `__process_${c.name}`, label: c.name })), + }] + : []), ]; + const allColumnOptions = columnOptionGroups.flatMap((g) => g.options); const [selectedCellId, setSelectedCellId] = useState(null); const [mergeMode, setMergeMode] = useState(false); @@ -1273,6 +1327,7 @@ function TabCardDesign({ cell={selectedCell} allCells={grid.cells} allColumnOptions={allColumnOptions} + columnOptionGroups={columnOptionGroups} columns={columns} selectedColumns={selectedColumns} tables={tables} @@ -1291,6 +1346,7 @@ function CellDetailEditor({ cell, allCells, allColumnOptions, + columnOptionGroups, columns, selectedColumns, tables, @@ -1301,6 +1357,7 @@ function CellDetailEditor({ cell: CardCellDefinitionV2; allCells: CardCellDefinitionV2[]; allColumnOptions: { value: string; label: string }[]; + columnOptionGroups: ColumnOptionGroup[]; columns: ColumnInfo[]; selectedColumns: string[]; tables: TableInfo[]; @@ -1348,9 +1405,7 @@ function CellDetailEditor({ 미지정 - {allColumnOptions.map((o) => ( - {o.label} - ))} + {renderColumnOptionGroups(columnOptionGroups)} )} @@ -1417,9 +1472,9 @@ function CellDetailEditor({ {/* 타입별 상세 설정 */} {cell.type === "status-badge" && } {cell.type === "timeline" && } - {cell.type === "action-buttons" && } - {cell.type === "footer-status" && } - {cell.type === "field" && } + {cell.type === "action-buttons" && } + {cell.type === "footer-status" && } + {cell.type === "field" && } {cell.type === "number-input" && (
숫자 입력 설정 @@ -1429,7 +1484,7 @@ function CellDetailEditor({ 없음 - {allColumnOptions.map((o) => {o.label})} + {renderColumnOptionGroups(columnOptionGroups)}
@@ -1809,12 +1864,14 @@ function ActionButtonsEditor({ cell, allCells, allColumnOptions, + columnOptionGroups, availableTableOptions, onUpdate, }: { cell: CardCellDefinitionV2; allCells: CardCellDefinitionV2[]; allColumnOptions: { value: string; label: string }[]; + columnOptionGroups: ColumnOptionGroup[]; availableTableOptions: { value: string; label: string }[]; onUpdate: (partial: Partial) => void; }) { @@ -1975,7 +2032,7 @@ function ActionButtonsEditor({ const isSectionOpen = (key: string) => expandedSections[key] !== false; - const ACTION_TYPE_LABELS: Record = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기" }; + const ACTION_TYPE_LABELS: Record = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기", "quantity-input": "수량 입력" }; const getCondSummary = (btn: ActionButtonDef) => { const c = btn.showCondition; @@ -1985,6 +2042,7 @@ function ActionButtonsEditor({ return opt ? opt.label : (c.value || "미설정"); } if (c.type === "column-value") return `${c.column || "?"} = ${c.value || "?"}`; + if (c.type === "owner-match") return `소유자(${c.column || "?"})`; return "항상"; }; @@ -2081,8 +2139,21 @@ function ActionButtonsEditor({ 항상 타임라인 카드 컬럼 + 소유자 일치 + {condType === "owner-match" && ( + + )} {condType === "timeline-status" && ( 즉시 실행 + 수량 입력 선택 후 실행 모달 열기 @@ -2191,6 +2261,50 @@ function ActionButtonsEditor({ /> )} + {aType === "quantity-input" && ( +
+ addActionUpdate(bi, ai)} + onUpdateUpdate={(ui, p) => updateActionUpdate(bi, ai, ui, p)} + onRemoveUpdate={(ui) => removeActionUpdate(bi, ai, ui)} + onUpdateAction={(p) => updateAction(bi, ai, p)} + /> +
+ 수량 모달 설정 +
+ 최대값 컬럼 + updateAction(bi, ai, { quantityInput: { ...action.quantityInput, maxColumn: e.target.value } })} + placeholder="예: qty" + className="h-6 flex-1 text-[10px]" + /> +
+
+ 현재값 컬럼 + updateAction(bi, ai, { quantityInput: { ...action.quantityInput, currentColumn: e.target.value } })} + placeholder="예: input_qty" + className="h-6 flex-1 text-[10px]" + /> +
+
+ 단위 + updateAction(bi, ai, { quantityInput: { ...action.quantityInput, unit: e.target.value } })} + placeholder="예: EA" + className="h-6 w-20 text-[10px]" + /> +
+
+
+ )} + {aType === "select-mode" && (
@@ -2455,6 +2569,70 @@ function ImmediateActionEditor({ className="h-6 flex-1 text-[10px]" />
+ + {/* 사전 조건 (중복 방지) */} +
+
+ 사전 조건 (중복 방지) + { + if (checked) { + onUpdateAction({ preCondition: { column: "", expectedValue: "", failMessage: "" } }); + } else { + onUpdateAction({ preCondition: undefined }); + } + }} + className="h-3.5 w-7 [&>span]:h-2.5 [&>span]:w-2.5" + /> +
+ {action.preCondition && ( +
+
+ 검증 컬럼 + +
+
+ 기대값 + onUpdateAction({ preCondition: { ...action.preCondition!, expectedValue: e.target.value } })} + placeholder="예: waiting" + className="h-6 flex-1 text-[10px]" + /> +
+
+ 실패 메시지 + onUpdateAction({ preCondition: { ...action.preCondition!, failMessage: e.target.value } })} + placeholder="이미 다른 사용자가 처리했습니다" + className="h-6 flex-1 text-[10px]" + /> +
+

+ 실행 시 해당 컬럼의 현재 DB 값이 기대값과 일치할 때만 처리됩니다 +

+
+ )} +
+
변경할 컬럼{tableName ? ` (${tableName})` : ""} @@ -2491,11 +2669,22 @@ function ImmediateActionEditor({ 직접입력 + 사용자 입력 현재 사용자 현재 시간 컬럼 참조 + {u.valueType === "userInput" && ( + + )} {(u.valueType === "static" || u.valueType === "columnRef") && ( ) => void; }) { const footerStatusMap = cell.footerStatusMap || []; @@ -2644,7 +2835,7 @@ function FooterStatusEditor({ 없음 - {allColumnOptions.map((o) => {o.label})} + {renderColumnOptionGroups(columnOptionGroups)}
@@ -2680,10 +2871,12 @@ function FooterStatusEditor({ function FieldConfigEditor({ cell, allColumnOptions, + columnOptionGroups, onUpdate, }: { cell: CardCellDefinitionV2; allColumnOptions: { value: string; label: string }[]; + columnOptionGroups: ColumnOptionGroup[]; onUpdate: (partial: Partial) => void; }) { const valueType = cell.valueType || "column"; @@ -2706,7 +2899,7 @@ function FieldConfigEditor({ onUpdate({ formulaRight: v })}> - {allColumnOptions.map((o) => {o.label})} + {renderColumnOptionGroups(columnOptionGroups)} )} @@ -2741,16 +2934,61 @@ function FieldConfigEditor({ function TabActions({ cfg, onUpdate, + columns, }: { cfg: PopCardListV2Config; onUpdate: (partial: Partial) => void; + columns: ColumnInfo[]; }) { const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }; const clickAction = cfg.cardClickAction || "none"; const modalConfig = cfg.cardClickModalConfig || { screenId: "" }; + const [processColumns, setProcessColumns] = useState([]); + const timelineCell = cfg.cardGrid?.cells?.find((c) => c.type === "timeline" && c.timelineSource?.processTable); + const processTableName = timelineCell?.timelineSource?.processTable || ""; + useEffect(() => { + if (!processTableName) { setProcessColumns([]); return; } + fetchTableColumns(processTableName) + .then(setProcessColumns) + .catch(() => setProcessColumns([])); + }, [processTableName]); + + const ownerColumnGroups: ColumnOptionGroup[] = useMemo(() => [ + { + groupLabel: `메인 (${cfg.dataSource?.tableName || "테이블"})`, + options: columns.map((c) => ({ value: c.name, label: c.name })), + }, + ...(processColumns.length > 0 + ? [{ + groupLabel: `공정 (${processTableName})`, + options: processColumns.map((c) => ({ value: `__process_${c.name}`, label: c.name })), + }] + : []), + ], [columns, processColumns, processTableName, cfg.dataSource?.tableName]); + return (
+ {/* 소유자 우선 정렬 */} +
+ +
+ +
+

+ 선택한 컬럼 값이 현재 로그인 사용자와 일치하는 카드가 맨 위에 표시됩니다 +

+
+ {/* 카드 선택 시 */}
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 f1863b13..180dc219 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 @@ -70,6 +70,7 @@ export interface CellRendererProps { onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; packageEntries?: PackageEntry[]; inputUnit?: string; + currentUserId?: string; } // ===== 메인 디스패치 ===== @@ -592,7 +593,7 @@ function TimelineCell({ cell, row }: CellRendererProps) { // ===== 11. action-buttons ===== -function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" { +function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId?: string): "visible" | "disabled" | "hidden" { const cond = btn.showCondition; if (!cond || cond.type === "always") return "visible"; @@ -603,6 +604,9 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | matched = subStatus !== undefined && String(subStatus) === cond.value; } else if (cond.type === "column-value" && cond.column) { matched = String(row[cond.column] ?? "") === (cond.value ?? ""); + } else if (cond.type === "owner-match" && cond.column) { + const ownerValue = String(row[cond.column] ?? ""); + matched = !!currentUserId && ownerValue === currentUserId; } else { return "visible"; } @@ -611,7 +615,7 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden"; } -function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) { +function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, currentUserId }: CellRendererProps) { const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; const currentProcess = processFlow?.find((s) => s.isCurrent); const currentProcessId = currentProcess?.processId; @@ -619,7 +623,7 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode } if (cell.actionButtons && cell.actionButtons.length > 0) { const evaluated = cell.actionButtons.map((btn) => ({ btn, - state: evaluateShowCondition(btn, row), + state: evaluateShowCondition(btn, row, currentUserId), })); const activeBtn = evaluated.find((e) => e.state === "visible"); diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 3b7ff73e..3680578e 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -851,7 +851,8 @@ export interface CardCellDefinitionV2 { export interface ActionButtonUpdate { column: string; value?: string; - valueType: "static" | "currentUser" | "currentTime" | "columnRef"; + valueType: "static" | "currentUser" | "currentTime" | "columnRef" | "userInput"; + operationType?: "assign" | "add" | "subtract"; } // 액션 버튼 클릭 시 동작 모드 @@ -881,34 +882,49 @@ export interface SelectModeConfig { export interface SelectModeButtonConfig { label: string; variant: ButtonVariant; - clickMode: "status-change" | "modal-open" | "cancel-select"; + clickMode: "status-change" | "modal-open" | "cancel-select" | "quantity-input"; targetTable?: string; updates?: ActionButtonUpdate[]; confirmMessage?: string; modalScreenId?: string; + quantityInput?: QuantityInputConfig; } // ===== 버튼 중심 구조 (신규) ===== export interface ActionButtonShowCondition { - type: "timeline-status" | "column-value" | "always"; + type: "timeline-status" | "column-value" | "always" | "owner-match"; value?: string; column?: string; unmatchBehavior?: "hidden" | "disabled"; } export interface ActionButtonClickAction { - type: "immediate" | "select-mode" | "modal-open"; + type: "immediate" | "select-mode" | "modal-open" | "quantity-input"; targetTable?: string; updates?: ActionButtonUpdate[]; confirmMessage?: string; selectModeButtons?: SelectModeButtonConfig[]; modalScreenId?: string; - // 외부 테이블 조인 설정 (DB 직접 선택 시) joinConfig?: { - sourceColumn: string; // 메인 테이블의 FK 컬럼 - targetColumn: string; // 외부 테이블의 매칭 컬럼 + sourceColumn: string; + targetColumn: string; }; + quantityInput?: QuantityInputConfig; + preCondition?: ActionPreCondition; +} + +export interface QuantityInputConfig { + maxColumn?: string; + currentColumn?: string; + unit?: string; + enablePackage?: boolean; +} + +export interface ActionPreCondition { + column: string; + expectedValue: string; + failMessage?: string; } export interface ActionButtonDef { @@ -976,6 +992,7 @@ export interface PopCardListV2Config { cartAction?: CardCartActionConfig; cartListMode?: CartListModeConfig; saveMapping?: CardListSaveMapping; + ownerSortColumn?: string; } /** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */