feat(pop): 다중 액션 체이닝 + 외부 테이블 선택 + 카드 클릭 모달 + 필터 전 비표시
버튼 규칙 하나에 여러 액션을 순차 실행하는 다중 액션 체이닝, DB 직접 선택으로 외부 테이블에 값을 변경하는 기능, 카드 클릭 시 조건부 모달 열기, 필터 전 데이터 비표시 옵션을 추가한다. [다중 액션 체이닝] - types.ts: ActionButtonDef.clickActions 배열 추가 (하위호환 유지) - PopCardListV2Config: 액션 목록 UI (추가/삭제/순서) - cell-renderers: __allActions 배열로 config 전달 - PopCardListV2Component: actionsToRun 순차 실행, 실패 시 스킵 [외부 테이블 선택] - ActionButtonClickAction.joinConfig (sourceColumn, targetColumn) 추가 - ImmediateActionEditor: "DB에서 직접 선택..." 옵션 + 조인키 설정 UI - DbTableCombobox: 테이블명(영어)+설명(한글) 검색 가능 - Component: joinConfig 기반 lookupValue/lookupColumn 처리 [카드 클릭 모달] - types.ts: V2CardClickAction에 "modal-open", V2CardClickModalConfig 추가 - PopCardListV2Config: 동작 탭에 모달 설정 (화면 ID, 조건, 제목) - PopCardListV2Component: handleCardSelect 조건 체크 후 openPopModal [필터 전 데이터 비표시] - PopCardListV2Config.hideUntilFiltered Switch - Component: externalFilters 없을 때 안내 메시지 [버그 수정] - availableTableOptions: dataSource.table -> dataSource.tableName 수정 - popActionRoutes: INSERT 시 created_date/updated_date/writer 자동 추가, UPDATE 시 updated_date 자동 갱신 [액션 버튼 구조 개선] - evaluateShowCondition: 버튼별 조건 평가 (visible/disabled/hidden) - ActionButtonsEditor: 아코디언 UI + sessionStorage 상태 유지 - 1셀 1버튼 렌더링: 조건 맞는 버튼 1개만 표시
This commit is contained in:
parent
cae1622ac2
commit
e1188027ed
|
|
@ -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) {
|
if (columns.length > 1) {
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
await client.query(
|
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++) {
|
for (let i = 0; i < lookupValues.length; i++) {
|
||||||
const item = items[i] ?? {};
|
const item = items[i] ?? {};
|
||||||
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
||||||
|
const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
await client.query(
|
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]],
|
[resolved, companyCode, lookupValues[i]],
|
||||||
);
|
);
|
||||||
processedCount++;
|
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 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(", ");
|
const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", ");
|
||||||
await client.query(
|
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],
|
[thenVal, elseVal, companyCode, ...lookupValues],
|
||||||
);
|
);
|
||||||
processedCount += lookupValues.length;
|
processedCount += lookupValues.length;
|
||||||
|
|
@ -376,8 +391,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
setSql = `"${task.targetColumn}" = $1`;
|
setSql = `"${task.targetColumn}" = $1`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
await client.query(
|
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]],
|
[value, companyCode, lookupValues[i]],
|
||||||
);
|
);
|
||||||
processedCount++;
|
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) {
|
if (columns.length > 1) {
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
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);
|
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) {
|
if (columns.length > 1) {
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
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") {
|
if (valueType === "fixed") {
|
||||||
|
const autoUpd = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", ");
|
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]);
|
await client.query(sql, [fixedValue, companyCode, ...lookupValues]);
|
||||||
processedCount += lookupValues.length;
|
processedCount += lookupValues.length;
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < lookupValues.length; i++) {
|
for (let i = 0; i < lookupValues.length; i++) {
|
||||||
const item = items[i] ?? {};
|
const item = items[i] ?? {};
|
||||||
const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item);
|
const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item);
|
||||||
|
const autoUpd2 = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
await client.query(
|
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]]
|
[resolvedValue, companyCode, lookupValues[i]]
|
||||||
);
|
);
|
||||||
processedCount++;
|
processedCount++;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import type {
|
||||||
TimelineProcessStep,
|
TimelineProcessStep,
|
||||||
TimelineDataSource,
|
TimelineDataSource,
|
||||||
ActionButtonUpdate,
|
ActionButtonUpdate,
|
||||||
|
ActionButtonClickAction,
|
||||||
StatusValueMapping,
|
StatusValueMapping,
|
||||||
SelectModeConfig,
|
SelectModeConfig,
|
||||||
SelectModeButtonConfig,
|
SelectModeButtonConfig,
|
||||||
|
|
@ -206,11 +207,6 @@ export function PopCardListV2Component({
|
||||||
});
|
});
|
||||||
}, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish, isCartListMode]);
|
}, [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 [selectMode, setSelectMode] = useState(false);
|
||||||
const [selectModeStatus, setSelectModeStatus] = useState<string>("");
|
const [selectModeStatus, setSelectModeStatus] = useState<string>("");
|
||||||
|
|
@ -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<string, unknown>) => {
|
const enterSelectMode = useCallback((whenStatus: string, buttonConfig: Record<string, unknown>) => {
|
||||||
const smConfig = buttonConfig.selectModeConfig as SelectModeConfig | undefined;
|
const smConfig = buttonConfig.selectModeConfig as SelectModeConfig | undefined;
|
||||||
if (!smConfig) return;
|
if (!smConfig) return;
|
||||||
|
|
@ -931,6 +947,10 @@ export function PopCardListV2Component({
|
||||||
<div className="flex flex-1 items-center justify-center rounded-md border border-dashed bg-muted/30 p-4">
|
<div className="flex flex-1 items-center justify-center rounded-md border border-dashed bg-muted/30 p-4">
|
||||||
<p className="text-sm text-muted-foreground">데이터 소스를 설정해주세요.</p>
|
<p className="text-sm text-muted-foreground">데이터 소스를 설정해주세요.</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : effectiveConfig?.hideUntilFiltered && externalFilters.size === 0 ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded-md border border-dashed bg-muted/30 p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">필터를 선택하면 데이터가 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<div className="flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4">
|
<div className="flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
|
@ -1077,7 +1097,7 @@ export function PopCardListV2Component({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* POP 화면 모달 */}
|
{/* POP 화면 모달 (풀스크린) */}
|
||||||
<Dialog open={popModalOpen} onOpenChange={(open) => {
|
<Dialog open={popModalOpen} onOpenChange={(open) => {
|
||||||
setPopModalOpen(open);
|
setPopModalOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
|
@ -1085,17 +1105,17 @@ export function PopCardListV2Component({
|
||||||
setPopModalRow(null);
|
setPopModalRow(null);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-auto p-0 sm:max-w-[800px]">
|
<DialogContent className="flex h-dvh w-screen max-w-none flex-col gap-0 rounded-none border-none p-0 [&>button]:z-50">
|
||||||
<DialogHeader className="px-4 pt-4">
|
<DialogHeader className="flex shrink-0 flex-row items-center justify-between border-b px-4 py-2">
|
||||||
<DialogTitle className="text-base">상세 작업</DialogTitle>
|
<DialogTitle className="text-base">{effectiveConfig?.cardClickModalConfig?.modalTitle || "상세 작업"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="min-h-[300px] px-2 pb-4">
|
<div className="flex-1 overflow-auto">
|
||||||
{popModalLayout && (
|
{popModalLayout && (
|
||||||
<PopViewerWithModals
|
<PopViewerWithModals
|
||||||
layout={popModalLayout}
|
layout={popModalLayout}
|
||||||
viewportWidth={760}
|
viewportWidth={typeof window !== "undefined" ? window.innerWidth : 1024}
|
||||||
screenId={popModalScreenId}
|
screenId={popModalScreenId}
|
||||||
currentMode={detectGridMode(760)}
|
currentMode={detectGridMode(typeof window !== "undefined" ? window.innerWidth : 1024)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1326,71 +1346,74 @@ function CardV2({
|
||||||
onCartCancel: handleCartCancel,
|
onCartCancel: handleCartCancel,
|
||||||
onEnterSelectMode,
|
onEnterSelectMode,
|
||||||
onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => {
|
onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => {
|
||||||
const cfg = buttonConfig as {
|
const cfg = buttonConfig as Record<string, unknown> | undefined;
|
||||||
updates?: ActionButtonUpdate[];
|
const allActions = (cfg?.__allActions as ActionButtonClickAction[] | undefined) || [];
|
||||||
targetTable?: string;
|
const processId = cfg?.__processId as string | number | undefined;
|
||||||
confirmMessage?: string;
|
|
||||||
__processId?: string | number;
|
|
||||||
} | undefined;
|
|
||||||
|
|
||||||
if (cfg?.updates && cfg.updates.length > 0 && cfg.targetTable) {
|
// 단일 액션 폴백 (기존 구조 호환)
|
||||||
if (cfg.confirmMessage) {
|
const actionsToRun = allActions.length > 0
|
||||||
if (!window.confirm(cfg.confirmMessage)) return;
|
? allActions
|
||||||
|
: cfg?.type
|
||||||
|
? [cfg as unknown as ActionButtonClickAction]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (actionsToRun.length === 0) {
|
||||||
|
if (parentComponentId) {
|
||||||
|
publish(`__comp_output__${parentComponentId}__action`, { taskPreset, row: actionRow });
|
||||||
}
|
}
|
||||||
try {
|
return;
|
||||||
// 공정 테이블 대상이면 processId 우선 사용
|
}
|
||||||
const rowId = cfg.__processId ?? actionRow.id ?? actionRow.pk;
|
|
||||||
if (!rowId) {
|
for (const action of actionsToRun) {
|
||||||
toast.error("대상 레코드의 ID를 찾을 수 없습니다.");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const tasks = cfg.updates.map((u, idx) => ({
|
} else if (action.type === "modal-open" && action.modalScreenId) {
|
||||||
id: `btn-update-${idx}`,
|
onOpenPopModal?.(action.modalScreenId, actionRow);
|
||||||
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 : "처리 중 오류 발생");
|
|
||||||
}
|
}
|
||||||
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,
|
packageEntries,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -18,7 +18,7 @@ import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { cn } from "@/lib/utils";
|
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 { DEFAULT_CARD_IMAGE, VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC } from "../types";
|
||||||
import type { ButtonVariant } from "../pop-button";
|
import type { ButtonVariant } from "../pop-button";
|
||||||
|
|
||||||
|
|
@ -67,6 +67,7 @@ export interface CellRendererProps {
|
||||||
onCartCancel?: () => void;
|
onCartCancel?: () => void;
|
||||||
onButtonClick?: (cell: CardCellDefinitionV2, row: RowData) => void;
|
onButtonClick?: (cell: CardCellDefinitionV2, row: RowData) => void;
|
||||||
onActionButtonClick?: (taskPreset: string, row: RowData, buttonConfig?: Record<string, unknown>) => void;
|
onActionButtonClick?: (taskPreset: string, row: RowData, buttonConfig?: Record<string, unknown>) => void;
|
||||||
|
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
|
||||||
packageEntries?: PackageEntry[];
|
packageEntries?: PackageEntry[];
|
||||||
inputUnit?: string;
|
inputUnit?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -591,23 +592,86 @@ function TimelineCell({ cell, row }: CellRendererProps) {
|
||||||
|
|
||||||
// ===== 11. action-buttons =====
|
// ===== 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 (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant={btn.variant || "outline"}
|
||||||
|
size="sm"
|
||||||
|
disabled={state === "disabled"}
|
||||||
|
className="h-7 text-[10px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const actions = (btn.clickActions && btn.clickActions.length > 0) ? btn.clickActions : [btn.clickAction];
|
||||||
|
const firstAction = actions[0];
|
||||||
|
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
...firstAction,
|
||||||
|
__allActions: actions,
|
||||||
|
selectModeConfig: firstAction.selectModeButtons
|
||||||
|
? { filterStatus: btn.showCondition?.value || "", buttons: firstAction.selectModeButtons }
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
if (currentProcessId !== undefined) config.__processId = currentProcessId;
|
||||||
|
|
||||||
|
if (firstAction.type === "select-mode" && onEnterSelectMode) {
|
||||||
|
onEnterSelectMode(btn.showCondition?.value || "", config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onActionButtonClick?.(btn.label, row, config);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{btn.label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 구조 (actionRules) 폴백
|
||||||
const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined;
|
const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined;
|
||||||
const statusValue = hasSubStatus
|
const statusValue = hasSubStatus
|
||||||
? String(row[VIRTUAL_SUB_STATUS] || "")
|
? String(row[VIRTUAL_SUB_STATUS] || "")
|
||||||
: (cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : ""));
|
: (cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : ""));
|
||||||
const rules = cell.actionRules || [];
|
const rules = cell.actionRules || [];
|
||||||
|
|
||||||
const matchedRule = rules.find((r) => r.whenStatus === statusValue);
|
const matchedRule = rules.find((r) => r.whenStatus === statusValue);
|
||||||
|
if (!matchedRule) return null;
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|
@ -620,8 +684,10 @@ function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const config = { ...(btn as Record<string, unknown>) };
|
const config = { ...(btn as Record<string, unknown>) };
|
||||||
if (currentProcessId !== undefined) {
|
if (currentProcessId !== undefined) config.__processId = currentProcessId;
|
||||||
config.__processId = currentProcessId;
|
if (btn.clickMode === "select-mode" && onEnterSelectMode) {
|
||||||
|
onEnterSelectMode(matchedRule.whenStatus, config);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
onActionButtonClick?.(btn.taskPreset, row, config);
|
onActionButtonClick?.(btn.taskPreset, row, config);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -833,19 +833,12 @@ export interface CardCellDefinitionV2 {
|
||||||
timelinePriority?: "before" | "after";
|
timelinePriority?: "before" | "after";
|
||||||
showDetailModal?: boolean;
|
showDetailModal?: boolean;
|
||||||
|
|
||||||
// action-buttons 타입 전용
|
// action-buttons 타입 전용 (신규: 버튼 중심 구조)
|
||||||
|
actionButtons?: ActionButtonDef[];
|
||||||
|
// action-buttons 타입 전용 (구: 조건 중심 구조, 하위호환)
|
||||||
actionRules?: Array<{
|
actionRules?: Array<{
|
||||||
whenStatus: string;
|
whenStatus: string;
|
||||||
buttons: Array<{
|
buttons: Array<ActionButtonConfig>;
|
||||||
label: string;
|
|
||||||
variant: ButtonVariant;
|
|
||||||
taskPreset: string;
|
|
||||||
confirm?: ConfirmConfig;
|
|
||||||
targetTable?: string;
|
|
||||||
confirmMessage?: string;
|
|
||||||
allowMultiSelect?: boolean;
|
|
||||||
updates?: ActionButtonUpdate[];
|
|
||||||
}>;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// footer-status 타입 전용
|
// footer-status 타입 전용
|
||||||
|
|
@ -861,6 +854,72 @@ export interface ActionButtonUpdate {
|
||||||
valueType: "static" | "currentUser" | "currentTime" | "columnRef";
|
valueType: "static" | "currentUser" | "currentTime" | "columnRef";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 액션 버튼 클릭 시 동작 모드
|
||||||
|
export type ActionButtonClickMode = "status-change" | "modal-open" | "select-mode";
|
||||||
|
|
||||||
|
// 액션 버튼 개별 설정
|
||||||
|
export interface ActionButtonConfig {
|
||||||
|
label: string;
|
||||||
|
variant: ButtonVariant;
|
||||||
|
taskPreset: string;
|
||||||
|
confirm?: ConfirmConfig;
|
||||||
|
targetTable?: string;
|
||||||
|
confirmMessage?: string;
|
||||||
|
allowMultiSelect?: boolean;
|
||||||
|
updates?: ActionButtonUpdate[];
|
||||||
|
clickMode?: ActionButtonClickMode;
|
||||||
|
selectModeConfig?: SelectModeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택 모드 설정
|
||||||
|
export interface SelectModeConfig {
|
||||||
|
filterStatus?: string;
|
||||||
|
buttons: Array<SelectModeButtonConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택 모드 하단 버튼 설정
|
||||||
|
export interface SelectModeButtonConfig {
|
||||||
|
label: string;
|
||||||
|
variant: ButtonVariant;
|
||||||
|
clickMode: "status-change" | "modal-open" | "cancel-select";
|
||||||
|
targetTable?: string;
|
||||||
|
updates?: ActionButtonUpdate[];
|
||||||
|
confirmMessage?: string;
|
||||||
|
modalScreenId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 버튼 중심 구조 (신규) =====
|
||||||
|
|
||||||
|
export interface ActionButtonShowCondition {
|
||||||
|
type: "timeline-status" | "column-value" | "always";
|
||||||
|
value?: string;
|
||||||
|
column?: string;
|
||||||
|
unmatchBehavior?: "hidden" | "disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionButtonClickAction {
|
||||||
|
type: "immediate" | "select-mode" | "modal-open";
|
||||||
|
targetTable?: string;
|
||||||
|
updates?: ActionButtonUpdate[];
|
||||||
|
confirmMessage?: string;
|
||||||
|
selectModeButtons?: SelectModeButtonConfig[];
|
||||||
|
modalScreenId?: string;
|
||||||
|
// 외부 테이블 조인 설정 (DB 직접 선택 시)
|
||||||
|
joinConfig?: {
|
||||||
|
sourceColumn: string; // 메인 테이블의 FK 컬럼
|
||||||
|
targetColumn: string; // 외부 테이블의 매칭 컬럼
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionButtonDef {
|
||||||
|
label: string;
|
||||||
|
variant: ButtonVariant;
|
||||||
|
showCondition?: ActionButtonShowCondition;
|
||||||
|
/** 단일 액션 (하위호환) 또는 다중 액션 체이닝 */
|
||||||
|
clickAction: ActionButtonClickAction;
|
||||||
|
clickActions?: ActionButtonClickAction[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CardGridConfigV2 {
|
export interface CardGridConfigV2 {
|
||||||
rows: number;
|
rows: number;
|
||||||
cols: number;
|
cols: number;
|
||||||
|
|
@ -873,7 +932,17 @@ export interface CardGridConfigV2 {
|
||||||
|
|
||||||
// ----- V2 카드 선택 동작 -----
|
// ----- 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 오버플로우 설정 -----
|
// ----- V2 오버플로우 설정 -----
|
||||||
|
|
||||||
|
|
@ -898,6 +967,9 @@ export interface PopCardListV2Config {
|
||||||
cardGap?: number;
|
cardGap?: number;
|
||||||
overflow?: V2OverflowConfig;
|
overflow?: V2OverflowConfig;
|
||||||
cardClickAction?: V2CardClickAction;
|
cardClickAction?: V2CardClickAction;
|
||||||
|
cardClickModalConfig?: V2CardClickModalConfig;
|
||||||
|
/** 연결된 필터 값이 전달되기 전까지 데이터 비표시 */
|
||||||
|
hideUntilFiltered?: boolean;
|
||||||
responsiveDisplay?: CardResponsiveConfig;
|
responsiveDisplay?: CardResponsiveConfig;
|
||||||
inputField?: CardInputFieldConfig;
|
inputField?: CardInputFieldConfig;
|
||||||
packageConfig?: CardPackageConfig;
|
packageConfig?: CardPackageConfig;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue