Merge pull request 'flowExecutionService 트랜잭션 처리 개선 및 데이터 변경 추적 로직 수정' (#259) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/259
This commit is contained in:
@@ -72,6 +72,11 @@ export class FlowDataMoveService {
|
|||||||
// 내부 DB 처리 (기존 로직)
|
// 내부 DB 처리 (기존 로직)
|
||||||
return await db.transaction(async (client) => {
|
return await db.transaction(async (client) => {
|
||||||
try {
|
try {
|
||||||
|
// 트랜잭션 세션 변수 설정 (트리거에서 changed_by 기록용)
|
||||||
|
await client.query("SELECT set_config('app.user_id', $1, true)", [
|
||||||
|
userId || "system",
|
||||||
|
]);
|
||||||
|
|
||||||
// 1. 단계 정보 조회
|
// 1. 단계 정보 조회
|
||||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||||
const toStep = await this.flowStepService.findById(toStepId);
|
const toStep = await this.flowStepService.findById(toStepId);
|
||||||
@@ -684,6 +689,14 @@ export class FlowDataMoveService {
|
|||||||
dbConnectionId,
|
dbConnectionId,
|
||||||
async (externalClient, dbType) => {
|
async (externalClient, dbType) => {
|
||||||
try {
|
try {
|
||||||
|
// 외부 DB가 PostgreSQL인 경우에만 세션 변수 설정 시도
|
||||||
|
if (dbType.toLowerCase() === "postgresql") {
|
||||||
|
await externalClient.query(
|
||||||
|
"SELECT set_config('app.user_id', $1, true)",
|
||||||
|
[userId || "system"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 단계 정보 조회 (내부 DB에서)
|
// 1. 단계 정보 조회 (내부 DB에서)
|
||||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||||
const toStep = await this.flowStepService.findById(toStepId);
|
const toStep = await this.flowStepService.findById(toStepId);
|
||||||
|
|||||||
@@ -298,7 +298,9 @@ export class FlowExecutionService {
|
|||||||
// 4. Primary Key 컬럼 결정 (기본값: id)
|
// 4. Primary Key 컬럼 결정 (기본값: id)
|
||||||
const primaryKeyColumn = flowDef.primaryKey || "id";
|
const primaryKeyColumn = flowDef.primaryKey || "id";
|
||||||
|
|
||||||
console.log(`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`);
|
console.log(
|
||||||
|
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
|
||||||
|
);
|
||||||
|
|
||||||
// 5. SET 절 생성
|
// 5. SET 절 생성
|
||||||
const updateColumns = Object.keys(updateData);
|
const updateColumns = Object.keys(updateData);
|
||||||
@@ -309,74 +311,86 @@ export class FlowExecutionService {
|
|||||||
// 6. 외부 DB vs 내부 DB 구분
|
// 6. 외부 DB vs 내부 DB 구분
|
||||||
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
|
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
|
||||||
// 외부 DB 업데이트
|
// 외부 DB 업데이트
|
||||||
console.log("✅ [updateStepData] Using EXTERNAL DB:", flowDef.dbConnectionId);
|
console.log(
|
||||||
|
"✅ [updateStepData] Using EXTERNAL DB:",
|
||||||
|
flowDef.dbConnectionId
|
||||||
|
);
|
||||||
|
|
||||||
// 외부 DB 연결 정보 조회
|
// 외부 DB 연결 정보 조회
|
||||||
const connectionResult = await db.query(
|
const connectionResult = await db.query(
|
||||||
"SELECT * FROM external_db_connection WHERE id = $1",
|
"SELECT * FROM external_db_connection WHERE id = $1",
|
||||||
[flowDef.dbConnectionId]
|
[flowDef.dbConnectionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (connectionResult.length === 0) {
|
if (connectionResult.length === 0) {
|
||||||
throw new Error(`External DB connection not found: ${flowDef.dbConnectionId}`);
|
throw new Error(
|
||||||
|
`External DB connection not found: ${flowDef.dbConnectionId}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const connection = connectionResult[0];
|
const connection = connectionResult[0];
|
||||||
const dbType = connection.db_type?.toLowerCase();
|
const dbType = connection.db_type?.toLowerCase();
|
||||||
|
|
||||||
// DB 타입에 따른 placeholder 및 쿼리 생성
|
// DB 타입에 따른 placeholder 및 쿼리 생성
|
||||||
let setClause: string;
|
let setClause: string;
|
||||||
let params: any[];
|
let params: any[];
|
||||||
|
|
||||||
if (dbType === "mysql" || dbType === "mariadb") {
|
if (dbType === "mysql" || dbType === "mariadb") {
|
||||||
// MySQL/MariaDB: ? placeholder
|
// MySQL/MariaDB: ? placeholder
|
||||||
setClause = updateColumns.map((col) => `\`${col}\` = ?`).join(", ");
|
setClause = updateColumns.map((col) => `\`${col}\` = ?`).join(", ");
|
||||||
params = [...Object.values(updateData), recordId];
|
params = [...Object.values(updateData), recordId];
|
||||||
} else if (dbType === "mssql") {
|
} else if (dbType === "mssql") {
|
||||||
// MSSQL: @p1, @p2 placeholder
|
// MSSQL: @p1, @p2 placeholder
|
||||||
setClause = updateColumns.map((col, idx) => `[${col}] = @p${idx + 1}`).join(", ");
|
setClause = updateColumns
|
||||||
|
.map((col, idx) => `[${col}] = @p${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
params = [...Object.values(updateData), recordId];
|
params = [...Object.values(updateData), recordId];
|
||||||
} else {
|
} else {
|
||||||
// PostgreSQL: $1, $2 placeholder
|
// PostgreSQL: $1, $2 placeholder
|
||||||
setClause = updateColumns.map((col, idx) => `"${col}" = $${idx + 1}`).join(", ");
|
setClause = updateColumns
|
||||||
|
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
params = [...Object.values(updateData), recordId];
|
params = [...Object.values(updateData), recordId];
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = ${dbType === "mysql" || dbType === "mariadb" ? "?" : dbType === "mssql" ? `@p${params.length}` : `$${params.length}`}`;
|
const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = ${dbType === "mysql" || dbType === "mariadb" ? "?" : dbType === "mssql" ? `@p${params.length}` : `$${params.length}`}`;
|
||||||
|
|
||||||
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
|
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
|
||||||
console.log(`📝 [updateStepData] Params:`, params);
|
console.log(`📝 [updateStepData] Params:`, params);
|
||||||
|
|
||||||
await executeExternalQuery(flowDef.dbConnectionId, updateQuery, params);
|
await executeExternalQuery(flowDef.dbConnectionId, updateQuery, params);
|
||||||
} else {
|
} else {
|
||||||
// 내부 DB 업데이트
|
// 내부 DB 업데이트
|
||||||
console.log("✅ [updateStepData] Using INTERNAL DB");
|
console.log("✅ [updateStepData] Using INTERNAL DB");
|
||||||
|
|
||||||
const setClause = updateColumns.map((col, idx) => `"${col}" = $${idx + 1}`).join(", ");
|
const setClause = updateColumns
|
||||||
|
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
const params = [...Object.values(updateData), recordId];
|
const params = [...Object.values(updateData), recordId];
|
||||||
|
|
||||||
const updateQuery = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKeyColumn}" = $${params.length}`;
|
const updateQuery = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKeyColumn}" = $${params.length}`;
|
||||||
|
|
||||||
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
|
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
|
||||||
console.log(`📝 [updateStepData] Params:`, params);
|
console.log(`📝 [updateStepData] Params:`, params);
|
||||||
|
|
||||||
// 트랜잭션으로 감싸서 사용자 ID 세션 변수 설정 후 업데이트 실행
|
// 트랜잭션으로 감싸서 사용자 ID 세션 변수 설정 후 업데이트 실행
|
||||||
// (트리거에서 changed_by를 기록하기 위함)
|
// (트리거에서 changed_by를 기록하기 위함)
|
||||||
await db.query("BEGIN");
|
await db.transaction(async (client) => {
|
||||||
try {
|
// 안전한 파라미터 바인딩 방식 사용
|
||||||
await db.query(`SET LOCAL app.user_id = '${userId}'`);
|
await client.query("SELECT set_config('app.user_id', $1, true)", [
|
||||||
await db.query(updateQuery, params);
|
userId,
|
||||||
await db.query("COMMIT");
|
]);
|
||||||
} catch (txError) {
|
await client.query(updateQuery, params);
|
||||||
await db.query("ROLLBACK");
|
});
|
||||||
throw txError;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`, {
|
console.log(
|
||||||
updatedFields: updateColumns,
|
`✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`,
|
||||||
userId,
|
{
|
||||||
});
|
updatedFields: updateColumns,
|
||||||
|
userId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -175,6 +175,12 @@ export class NodeFlowExecutionService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
result = await transaction(async (client) => {
|
result = await transaction(async (client) => {
|
||||||
|
// 🔥 사용자 ID 세션 변수 설정 (트리거용)
|
||||||
|
const userId = context.buttonContext?.userId || "system";
|
||||||
|
await client.query("SELECT set_config('app.user_id', $1, true)", [
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
// 트랜잭션 내에서 레벨별 실행
|
// 트랜잭션 내에서 레벨별 실행
|
||||||
for (const level of levels) {
|
for (const level of levels) {
|
||||||
await this.executeLevel(level, nodes, edges, context, client);
|
await this.executeLevel(level, nodes, edges, context, client);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog";
|
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogDescription } from "@/components/ui/resizable-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||||
@@ -130,11 +130,11 @@ export function FlowDataListModal({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="flex max-h-[80vh] max-w-4xl flex-col overflow-hidden">
|
<DialogContent className="flex max-h-[80vh] max-w-4xl flex-col overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<ResizableDialogTitle className="flex items-center gap-2">
|
||||||
{stepName}
|
{stepName}
|
||||||
<Badge variant="secondary">{data.length}건</Badge>
|
<Badge variant="secondary">{data.length}건</Badge>
|
||||||
</DialogTitle>
|
</ResizableDialogTitle>
|
||||||
<DialogDescription>이 단계에 해당하는 데이터 목록입니다</DialogDescription>
|
<DialogDescription>이 단계에 해당하는 데이터 목록입니다</ResizableDialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
|
|||||||
@@ -1669,53 +1669,53 @@ export function FlowWidget({
|
|||||||
>
|
>
|
||||||
<Filter className="mr-1 h-3 w-3" />
|
<Filter className="mr-1 h-3 w-3" />
|
||||||
검색
|
검색
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필터/그룹 설정 버튼 */}
|
{/* 필터/그룹 설정 버튼 */}
|
||||||
<div className="flex items-center gap-1 border-r border-border pr-2">
|
<div className="flex items-center gap-1 border-r border-border pr-2">
|
||||||
<Button
|
<Button
|
||||||
variant={searchFilterColumns.size > 0 ? "default" : "ghost"}
|
variant={searchFilterColumns.size > 0 ? "default" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isPreviewMode) {
|
if (!isPreviewMode) {
|
||||||
setIsFilterSettingOpen(true);
|
setIsFilterSettingOpen(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isPreviewMode}
|
disabled={isPreviewMode}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
title="검색 필터 설정"
|
title="검색 필터 설정"
|
||||||
>
|
>
|
||||||
<Filter className="mr-1 h-3 w-3" />
|
<Filter className="mr-1 h-3 w-3" />
|
||||||
필터
|
필터
|
||||||
{searchFilterColumns.size > 0 && (
|
{searchFilterColumns.size > 0 && (
|
||||||
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
|
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
|
||||||
{searchFilterColumns.size}
|
{searchFilterColumns.size}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={groupByColumns.length > 0 ? "default" : "ghost"}
|
variant={groupByColumns.length > 0 ? "default" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isPreviewMode) {
|
if (!isPreviewMode) {
|
||||||
setIsGroupSettingOpen(true);
|
setIsGroupSettingOpen(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isPreviewMode}
|
disabled={isPreviewMode}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
title="그룹 설정"
|
title="그룹 설정"
|
||||||
>
|
>
|
||||||
<Layers className="mr-1 h-3 w-3" />
|
<Layers className="mr-1 h-3 w-3" />
|
||||||
그룹
|
그룹
|
||||||
{groupByColumns.length > 0 && (
|
{groupByColumns.length > 0 && (
|
||||||
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
|
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
|
||||||
{groupByColumns.length}
|
{groupByColumns.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 새로고침 */}
|
{/* 새로고침 */}
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
@@ -1731,7 +1731,7 @@ export function FlowWidget({
|
|||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 필터 입력 영역 */}
|
{/* 검색 필터 입력 영역 */}
|
||||||
{searchFilterColumns.size > 0 && (
|
{searchFilterColumns.size > 0 && (
|
||||||
@@ -1859,20 +1859,20 @@ export function FlowWidget({
|
|||||||
{groupByColumns.length > 0 && groupedData.length > 0 ? (
|
{groupByColumns.length > 0 && groupedData.length > 0 ? (
|
||||||
// 그룹화된 렌더링 (기존 방식 유지)
|
// 그룹화된 렌더링 (기존 방식 유지)
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table noWrapper>
|
<Table noWrapper>
|
||||||
<TableHeader className="sticky top-0 z-30 bg-background shadow-sm">
|
<TableHeader className="sticky top-0 z-30 bg-background shadow-sm">
|
||||||
<TableRow className="hover:bg-muted/50">
|
<TableRow className="hover:bg-muted/50">
|
||||||
{allowDataMove && (
|
{allowDataMove && (
|
||||||
<TableHead className="bg-background sticky left-0 z-40 w-12 border-b px-6 py-3 text-center">
|
<TableHead className="bg-background sticky left-0 z-40 w-12 border-b px-6 py-3 text-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedRows.size === stepData.length && stepData.length > 0}
|
checked={selectedRows.size === stepData.length && stepData.length > 0}
|
||||||
onCheckedChange={toggleAllRows}
|
onCheckedChange={toggleAllRows}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
{stepDataColumns.map((col) => (
|
{stepDataColumns.map((col) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={col}
|
key={col}
|
||||||
className="bg-background border-b px-6 py-3 text-sm font-semibold whitespace-nowrap cursor-pointer hover:bg-muted/50"
|
className="bg-background border-b px-6 py-3 text-sm font-semibold whitespace-nowrap cursor-pointer hover:bg-muted/50"
|
||||||
onClick={() => handleSort(col)}
|
onClick={() => handleSort(col)}
|
||||||
>
|
>
|
||||||
@@ -1884,68 +1884,68 @@ export function FlowWidget({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{groupedData.flatMap((group) => {
|
{groupedData.flatMap((group) => {
|
||||||
const isCollapsed = collapsedGroups.has(group.groupKey);
|
const isCollapsed = collapsedGroups.has(group.groupKey);
|
||||||
const groupRows = [
|
const groupRows = [
|
||||||
<TableRow key={`group-${group.groupKey}`}>
|
<TableRow key={`group-${group.groupKey}`}>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={stepDataColumns.length + (allowDataMove ? 1 : 0)}
|
colSpan={stepDataColumns.length + (allowDataMove ? 1 : 0)}
|
||||||
className="bg-muted/50 border-b"
|
className="bg-muted/50 border-b"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 p-2 cursor-pointer hover:bg-muted"
|
||||||
|
onClick={() => toggleGroupCollapse(group.groupKey)}
|
||||||
>
|
>
|
||||||
<div
|
{isCollapsed ? (
|
||||||
className="flex items-center gap-3 p-2 cursor-pointer hover:bg-muted"
|
<ChevronRight className="h-4 w-4 flex-shrink-0" />
|
||||||
onClick={() => toggleGroupCollapse(group.groupKey)}
|
) : (
|
||||||
>
|
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||||
{isCollapsed ? (
|
)}
|
||||||
<ChevronRight className="h-4 w-4 flex-shrink-0" />
|
<span className="font-medium text-sm flex-1">{group.groupKey}</span>
|
||||||
) : (
|
<span className="text-muted-foreground text-xs">({group.count}건)</span>
|
||||||
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
</div>
|
||||||
)}
|
</TableCell>
|
||||||
<span className="font-medium text-sm flex-1">{group.groupKey}</span>
|
</TableRow>,
|
||||||
<span className="text-muted-foreground text-xs">({group.count}건)</span>
|
];
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!isCollapsed) {
|
if (!isCollapsed) {
|
||||||
const dataRows = group.items.map((row, itemIndex) => {
|
const dataRows = group.items.map((row, itemIndex) => {
|
||||||
const actualIndex = sortedDisplayData.indexOf(row);
|
const actualIndex = sortedDisplayData.indexOf(row);
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={`${group.groupKey}-${itemIndex}`}
|
key={`${group.groupKey}-${itemIndex}`}
|
||||||
className={`h-16 transition-colors hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
|
className={`h-16 transition-colors hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
|
||||||
>
|
>
|
||||||
{allowDataMove && (
|
{allowDataMove && (
|
||||||
<TableCell className="bg-background sticky left-0 z-10 border-b px-6 py-3 text-center">
|
<TableCell className="bg-background sticky left-0 z-10 border-b px-6 py-3 text-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedRows.has(actualIndex)}
|
checked={selectedRows.has(actualIndex)}
|
||||||
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{stepDataColumns.map((col) => (
|
{stepDataColumns.map((col) => (
|
||||||
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
||||||
{formatValue(row[col])}
|
{formatValue(row[col])}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
groupRows.push(...dataRows);
|
groupRows.push(...dataRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
return groupRows;
|
return groupRows;
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 일반 렌더링 - SingleTableWithSticky 사용
|
// 일반 렌더링 - SingleTableWithSticky 사용
|
||||||
<SingleTableWithSticky
|
<SingleTableWithSticky
|
||||||
visibleColumns={tableColumns}
|
visibleColumns={tableColumns}
|
||||||
|
|||||||
Reference in New Issue
Block a user