flow-widgdt 인라인 편집 및 검색 하이라이트 기능 추가

This commit is contained in:
dohyeons 2025-12-08 16:06:43 +09:00
parent 2cc0a7b309
commit 11a99a5c2e
6 changed files with 1293 additions and 193 deletions

View File

@ -837,4 +837,53 @@ export class FlowController {
});
}
};
/**
* ( )
*/
updateStepData = async (req: Request, res: Response): Promise<void> => {
try {
const { flowId, stepId, recordId } = req.params;
const updateData = req.body;
const userId = (req as any).user?.userId || "system";
const userCompanyCode = (req as any).user?.companyCode;
if (!flowId || !stepId || !recordId) {
res.status(400).json({
success: false,
message: "flowId, stepId, and recordId are required",
});
return;
}
if (!updateData || Object.keys(updateData).length === 0) {
res.status(400).json({
success: false,
message: "Update data is required",
});
return;
}
const result = await this.flowExecutionService.updateStepData(
parseInt(flowId),
parseInt(stepId),
recordId,
updateData,
userId,
userCompanyCode
);
res.json({
success: true,
message: "Data updated successfully",
data: result,
});
} catch (error: any) {
console.error("Error updating step data:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to update step data",
});
}
};
}

View File

@ -43,6 +43,9 @@ router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
router.post("/move", flowController.moveData);
router.post("/move-batch", flowController.moveBatchData);
// ==================== 스텝 데이터 수정 (인라인 편집) ====================
router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData);
// ==================== 오딧 로그 ====================
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
router.get("/audit/:flowId", flowController.getFlowAuditLogs);

View File

@ -263,4 +263,115 @@ export class FlowExecutionService {
tableName: result[0].table_name,
};
}
/**
* ( )
* .
*/
async updateStepData(
flowId: number,
stepId: number,
recordId: string,
updateData: Record<string, any>,
userId: string,
companyCode?: string
): Promise<{ success: boolean }> {
try {
// 1. 플로우 정의 조회
const flowDef = await this.flowDefinitionService.findById(flowId);
if (!flowDef) {
throw new Error(`Flow definition not found: ${flowId}`);
}
// 2. 스텝 조회
const step = await this.flowStepService.findById(stepId);
if (!step) {
throw new Error(`Flow step not found: ${stepId}`);
}
// 3. 테이블명 결정
const tableName = step.tableName || flowDef.tableName;
if (!tableName) {
throw new Error("Table name not found");
}
// 4. Primary Key 컬럼 결정 (기본값: id)
const primaryKeyColumn = flowDef.primaryKey || "id";
console.log(`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`);
// 5. SET 절 생성
const updateColumns = Object.keys(updateData);
if (updateColumns.length === 0) {
throw new Error("No columns to update");
}
// 6. 외부 DB vs 내부 DB 구분
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
// 외부 DB 업데이트
console.log("✅ [updateStepData] Using EXTERNAL DB:", flowDef.dbConnectionId);
// 외부 DB 연결 정보 조회
const connectionResult = await db.query(
"SELECT * FROM external_db_connection WHERE id = $1",
[flowDef.dbConnectionId]
);
if (connectionResult.length === 0) {
throw new Error(`External DB connection not found: ${flowDef.dbConnectionId}`);
}
const connection = connectionResult[0];
const dbType = connection.db_type?.toLowerCase();
// DB 타입에 따른 placeholder 및 쿼리 생성
let setClause: string;
let params: any[];
if (dbType === "mysql" || dbType === "mariadb") {
// MySQL/MariaDB: ? placeholder
setClause = updateColumns.map((col) => `\`${col}\` = ?`).join(", ");
params = [...Object.values(updateData), recordId];
} else if (dbType === "mssql") {
// MSSQL: @p1, @p2 placeholder
setClause = updateColumns.map((col, idx) => `[${col}] = @p${idx + 1}`).join(", ");
params = [...Object.values(updateData), recordId];
} else {
// PostgreSQL: $1, $2 placeholder
setClause = updateColumns.map((col, idx) => `"${col}" = $${idx + 1}`).join(", ");
params = [...Object.values(updateData), recordId];
}
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] Params:`, params);
await executeExternalQuery(flowDef.dbConnectionId, updateQuery, params);
} else {
// 내부 DB 업데이트
console.log("✅ [updateStepData] Using INTERNAL DB");
const setClause = updateColumns.map((col, idx) => `"${col}" = $${idx + 1}`).join(", ");
const params = [...Object.values(updateData), recordId];
const updateQuery = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKeyColumn}" = $${params.length}`;
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
console.log(`📝 [updateStepData] Params:`, params);
await db.query(updateQuery, params);
}
console.log(`✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`, {
updatedFields: updateColumns,
userId,
});
return { success: true };
} catch (error: any) {
console.error("❌ [updateStepData] Error:", error);
throw error;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -525,3 +525,37 @@ export async function getFlowAuditLogs(flowId: number, limit: number = 100): Pro
};
}
}
// ============================================
// 플로우 스텝 데이터 수정 API
// ============================================
/**
* ( )
* @param flowId ID
* @param stepId ID
* @param recordId primary key
* @param updateData
*/
export async function updateFlowStepData(
flowId: number,
stepId: number,
recordId: string | number,
updateData: Record<string, any>,
): Promise<ApiResponse<{ success: boolean }>> {
try {
const response = await fetch(`${API_BASE}/flow/${flowId}/step/${stepId}/data/${recordId}`, {
method: "PUT",
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(updateData),
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}

View File

@ -28,6 +28,17 @@ interface SingleTableWithStickyProps {
containerWidth?: string; // 컨테이너 너비 설정
loading?: boolean;
error?: string | null;
// 인라인 편집 관련 props
onCellDoubleClick?: (rowIndex: number, colIndex: number, columnName: string, value: any) => void;
editingCell?: { rowIndex: number; colIndex: number; columnName: string; originalValue: any } | null;
editingValue?: string;
onEditingValueChange?: (value: string) => void;
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
editInputRef?: React.RefObject<HTMLInputElement>;
// 검색 하이라이트 관련 props
searchHighlights?: Set<string>;
currentSearchIndex?: number;
searchTerm?: string;
}
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
@ -51,6 +62,17 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
containerWidth,
loading = false,
error = null,
// 인라인 편집 관련 props
onCellDoubleClick,
editingCell,
editingValue,
onEditingValueChange,
onEditKeyDown,
editInputRef,
// 검색 하이라이트 관련 props
searchHighlights,
currentSearchIndex = 0,
searchTerm = "",
}) => {
const checkboxConfig = tableConfig?.checkbox || {};
const actualColumns = visibleColumns || columns || [];
@ -58,14 +80,13 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
return (
<div
className="relative flex h-full flex-col overflow-hidden bg-background shadow-sm"
className="relative flex flex-col bg-background shadow-sm"
style={{
width: "100%",
height: "100%",
boxSizing: "border-box",
}}
>
<div className="relative flex-1 overflow-auto">
<div className="relative overflow-x-auto">
<Table
className="w-full"
style={{
@ -75,17 +96,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
}}
>
<TableHeader
className={
tableConfig.stickyHeader
? "sticky top-0 border-b shadow-md"
: "border-b"
}
style={{
position: "sticky",
top: 0,
zIndex: 50,
backgroundColor: "hsl(var(--background))",
}}
className={cn(
"border-b bg-background",
tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm"
)}
>
<TableRow className="border-b">
{actualColumns.map((column, colIndex) => {
@ -215,9 +229,65 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
// 현재 셀이 편집 중인지 확인
const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
// 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인
const cellKey = `${index}-${colIndex}`;
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
// 인덱스 기반 하이라이트 + 실제 값 검증
const isHighlighted = column.columnName !== "__checkbox__" &&
hasSearchTerm &&
(searchHighlights?.has(cellKey) ?? false);
// 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
const isCurrentSearchResult = isHighlighted &&
currentSearchIndex >= 0 &&
currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey;
// 셀 값에서 검색어 하이라이트 렌더링
const renderCellContent = () => {
const cellValue = formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
return cellValue;
}
// 검색어 하이라이트 처리
const lowerValue = String(cellValue).toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
const startIndex = lowerValue.indexOf(lowerTerm);
if (startIndex === -1) return cellValue;
const before = String(cellValue).slice(0, startIndex);
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
const after = String(cellValue).slice(startIndex + searchTerm.length);
return (
<>
{before}
<mark className={cn(
"rounded px-0.5",
isCurrentSearchResult
? "bg-orange-400 text-white font-semibold"
: "bg-yellow-200 text-yellow-900"
)}>
{match}
</mark>
{after}
</>
);
};
return (
<TableCell
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cn(
"h-14 px-3 py-2 align-middle text-xs whitespace-nowrap text-foreground transition-colors sm:h-16 sm:px-6 sm:py-3 sm:text-sm",
`text-${column.align}`,
@ -226,6 +296,8 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
"sticky z-10 border-r border-border bg-background/90 backdrop-blur-sm",
column.fixed === "right" &&
"sticky z-10 border-l border-border bg-background/90 backdrop-blur-sm",
// 편집 가능 셀 스타일
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
)}
style={{
width: getColumnWidth(column),
@ -239,10 +311,36 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onDoubleClick={(e) => {
if (onCellDoubleClick && column.columnName !== "__checkbox__") {
e.stopPropagation();
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
}
}}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"}
{column.columnName === "__checkbox__" ? (
renderCheckboxCell(row, index)
) : isEditing ? (
// 인라인 편집 입력 필드
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
// blur 시 저장 (Enter와 동일)
if (onEditKeyDown) {
const fakeEvent = { key: "Enter", preventDefault: () => {} } as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
}}
className="h-8 w-full rounded border border-primary bg-background px-2 text-xs focus:outline-none focus:ring-2 focus:ring-primary sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
) : (
renderCellContent()
)}
</TableCell>
);
})}