Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map

This commit is contained in:
dohyeons 2025-12-15 09:47:45 +09:00
commit d21c4acf0f
40 changed files with 1509 additions and 2212 deletions

View File

@ -71,7 +71,6 @@ import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
@ -249,7 +248,6 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
app.use("/api/orders", orderRoutes); // 수주 관리
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리

View File

@ -1,276 +0,0 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
/**
*
* 형식: ORD + YYMMDD + 4 퀀
* : ORD250114001
*/
async function generateOrderNumber(companyCode: string): Promise<string> {
const pool = getPool();
const today = new Date();
const year = today.getFullYear().toString().slice(2); // 25
const month = String(today.getMonth() + 1).padStart(2, "0"); // 01
const day = String(today.getDate()).padStart(2, "0"); // 14
const dateStr = `${year}${month}${day}`; // 250114
// 당일 수주 카운트 조회
const countQuery = `
SELECT COUNT(*) as count
FROM order_mng_master
WHERE objid LIKE $1
AND writer LIKE $2
`;
const pattern = `ORD${dateStr}%`;
const result = await pool.query(countQuery, [pattern, `%${companyCode}%`]);
const count = parseInt(result.rows[0]?.count || "0");
const seq = count + 1;
return `ORD${dateStr}${String(seq).padStart(4, "0")}`; // ORD250114001
}
/**
* API
* POST /api/orders
*/
export async function createOrder(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
try {
const {
inputMode, // 입력 방식
customerCode, // 거래처 코드
deliveryDate, // 납품일
items, // 품목 목록
memo, // 메모
} = req.body;
// 멀티테넌시
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
// 유효성 검사
if (!customerCode) {
return res.status(400).json({
success: false,
message: "거래처 코드는 필수입니다",
});
}
if (!items || items.length === 0) {
return res.status(400).json({
success: false,
message: "품목은 최소 1개 이상 필요합니다",
});
}
// 수주 번호 생성
const orderNo = await generateOrderNumber(companyCode);
// 전체 금액 계산
const totalAmount = items.reduce(
(sum: number, item: any) => sum + (item.amount || 0),
0
);
// 수주 마스터 생성
const masterQuery = `
INSERT INTO order_mng_master (
objid,
partner_objid,
final_delivery_date,
reason,
status,
reg_date,
writer
) VALUES ($1, $2, $3, $4, $5, NOW(), $6)
RETURNING *
`;
const masterResult = await pool.query(masterQuery, [
orderNo,
customerCode,
deliveryDate || null,
memo || null,
"진행중",
`${userId}|${companyCode}`,
]);
const masterObjid = masterResult.rows[0].objid;
// 수주 상세 (품목) 생성
for (let i = 0; i < items.length; i++) {
const item = items[i];
const subObjid = `${orderNo}_${i + 1}`;
const subQuery = `
INSERT INTO order_mng_sub (
objid,
order_mng_master_objid,
part_objid,
partner_objid,
partner_price,
partner_qty,
delivery_date,
status,
regdate,
writer
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
`;
await pool.query(subQuery, [
subObjid,
masterObjid,
item.item_code || item.id, // 품목 코드
customerCode,
item.unit_price || 0,
item.quantity || 0,
item.delivery_date || deliveryDate || null,
"진행중",
`${userId}|${companyCode}`,
]);
}
logger.info("수주 등록 성공", {
companyCode,
orderNo,
masterObjid,
itemCount: items.length,
totalAmount,
});
res.json({
success: true,
data: {
orderNo,
masterObjid,
itemCount: items.length,
totalAmount,
},
message: "수주가 등록되었습니다",
});
} catch (error: any) {
logger.error("수주 등록 오류", {
error: error.message,
stack: error.stack,
});
res.status(500).json({
success: false,
message: error.message || "수주 등록 중 오류가 발생했습니다",
});
}
}
/**
* API ( + JOIN)
* GET /api/orders
*/
export async function getOrders(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
try {
const { page = "1", limit = "20", searchText = "" } = req.query;
const companyCode = req.user!.companyCode;
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
// WHERE 조건
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 멀티테넌시 (writer 필드에 company_code 포함)
if (companyCode !== "*") {
whereConditions.push(`m.writer LIKE $${paramIndex}`);
params.push(`%${companyCode}%`);
paramIndex++;
}
// 검색
if (searchText) {
whereConditions.push(`m.objid LIKE $${paramIndex}`);
params.push(`%${searchText}%`);
paramIndex++;
}
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 카운트 쿼리 (고유한 수주 개수)
const countQuery = `
SELECT COUNT(DISTINCT m.objid) as count
FROM order_mng_master m
${whereClause}
`;
const countResult = await pool.query(countQuery, params);
const total = parseInt(countResult.rows[0]?.count || "0");
// 데이터 쿼리 (마스터 + 품목 JOIN)
const dataQuery = `
SELECT
m.objid as order_no,
m.partner_objid,
m.final_delivery_date,
m.reason,
m.status,
m.reg_date,
m.writer,
COALESCE(
json_agg(
CASE WHEN s.objid IS NOT NULL THEN
json_build_object(
'sub_objid', s.objid,
'part_objid', s.part_objid,
'partner_price', s.partner_price,
'partner_qty', s.partner_qty,
'delivery_date', s.delivery_date,
'status', s.status,
'regdate', s.regdate
)
END
ORDER BY s.regdate
) FILTER (WHERE s.objid IS NOT NULL),
'[]'::json
) as items
FROM order_mng_master m
LEFT JOIN order_mng_sub s ON m.objid = s.order_mng_master_objid
${whereClause}
GROUP BY m.objid, m.partner_objid, m.final_delivery_date, m.reason, m.status, m.reg_date, m.writer
ORDER BY m.reg_date DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(parseInt(limit as string));
params.push(offset);
const dataResult = await pool.query(dataQuery, params);
logger.info("수주 목록 조회 성공", {
companyCode,
total,
page: parseInt(page as string),
itemCount: dataResult.rows.length,
});
res.json({
success: true,
data: dataResult.rows,
pagination: {
total,
page: parseInt(page as string),
limit: parseInt(limit as string),
},
});
} catch (error: any) {
logger.error("수주 목록 조회 오류", { error: error.message });
res.status(500).json({
success: false,
message: error.message,
});
}
}

View File

@ -50,3 +50,4 @@ router.get("/data/:groupCode", getAutoFillData);
export default router;

View File

@ -46,3 +46,4 @@ router.get("/filtered-options/:relationCode", getFilteredOptions);
export default router;

View File

@ -62,3 +62,4 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions);
export default router;

View File

@ -50,3 +50,4 @@ router.get("/options/:exclusionCode", getExcludedOptions);
export default router;

View File

@ -1,20 +0,0 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { createOrder, getOrders } from "../controllers/orderController";
const router = Router();
/**
*
* POST /api/orders
*/
router.post("/", authenticateToken, createOrder);
/**
*
* GET /api/orders
*/
router.get("/", authenticateToken, getOrders);
export default router;

View File

@ -903,7 +903,7 @@ export class DynamicFormService {
return `${key} = $${index + 1}::numeric`;
} else if (dataType === "boolean") {
return `${key} = $${index + 1}::boolean`;
} else if (dataType === 'jsonb' || dataType === 'json') {
} else if (dataType === "jsonb" || dataType === "json") {
// 🆕 JSONB/JSON 타입은 명시적 캐스팅
return `${key} = $${index + 1}::jsonb`;
} else {
@ -917,9 +917,13 @@ export class DynamicFormService {
const values: any[] = Object.keys(changedFields).map((key) => {
const value = changedFields[key];
const dataType = columnTypes[key];
// JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환
if ((dataType === 'jsonb' || dataType === 'json') && (Array.isArray(value) || (typeof value === 'object' && value !== null))) {
if (
(dataType === "jsonb" || dataType === "json") &&
(Array.isArray(value) ||
(typeof value === "object" && value !== null))
) {
return JSON.stringify(value);
}
return value;
@ -1588,6 +1592,7 @@ export class DynamicFormService {
/**
* ( )
*
*/
private async executeDataflowControlIfConfigured(
screenId: number,
@ -1629,105 +1634,67 @@ export class DynamicFormService {
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
hasDiagramId:
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
hasFlowControls:
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
});
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
if (
properties?.componentType === "button-primary" &&
properties?.componentConfig?.action?.type === "save" &&
properties?.webTypeConfig?.enableDataflowControl === true &&
properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId
properties?.webTypeConfig?.enableDataflowControl === true
) {
controlConfigFound = true;
const diagramId =
properties.webTypeConfig.dataflowConfig.selectedDiagramId;
const relationshipId =
properties.webTypeConfig.dataflowConfig.selectedRelationshipId;
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
console.log(`🎯 제어관리 설정 발견:`, {
componentId: layout.component_id,
diagramId,
relationshipId,
triggerType,
});
// 다중 제어 설정 확인 (flowControls 배열)
const flowControls = dataflowConfig?.flowControls || [];
// 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주)
let controlResult: any;
// flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행
if (flowControls.length > 0) {
controlConfigFound = true;
console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}`);
if (!relationshipId) {
// 노드 플로우 실행
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
const { NodeFlowExecutionService } = await import(
"./nodeFlowExecutionService"
// 순서대로 정렬
const sortedControls = [...flowControls].sort(
(a: any, b: any) => (a.order || 0) - (b.order || 0)
);
const executionResult = await NodeFlowExecutionService.executeFlow(
// 다중 제어 순차 실행
await this.executeMultipleFlowControls(
sortedControls,
savedData,
screenId,
tableName,
triggerType,
userId,
companyCode
);
} else if (dataflowConfig?.selectedDiagramId) {
// 기존 단일 제어 실행 (하위 호환성)
controlConfigFound = true;
const diagramId = dataflowConfig.selectedDiagramId;
const relationshipId = dataflowConfig.selectedRelationshipId;
console.log(`🎯 단일 제어관리 설정 발견:`, {
componentId: layout.component_id,
diagramId,
{
sourceData: [savedData],
dataSourceType: "formData",
buttonId: "save-button",
screenId: screenId,
userId: userId,
companyCode: companyCode,
formData: savedData,
}
);
relationshipId,
triggerType,
});
controlResult = {
success: executionResult.success,
message: executionResult.message,
executedActions: executionResult.nodes?.map((node) => ({
nodeId: node.nodeId,
status: node.status,
duration: node.duration,
})),
errors: executionResult.nodes
?.filter((node) => node.status === "failed")
.map((node) => node.error || "실행 실패"),
};
} else {
// 관계 기반 제어관리 실행
console.log(
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
await this.executeSingleFlowControl(
diagramId,
relationshipId,
savedData,
screenId,
tableName,
triggerType,
userId,
companyCode
);
controlResult =
await this.dataflowControlService.executeDataflowControl(
diagramId,
relationshipId,
triggerType,
savedData,
tableName,
userId
);
}
console.log(`🎯 제어관리 실행 결과:`, controlResult);
if (controlResult.success) {
console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`);
if (
controlResult.executedActions &&
controlResult.executedActions.length > 0
) {
console.log(`📊 실행된 액션들:`, controlResult.executedActions);
}
// 오류가 있는 경우 경고 로그 출력 (성공이지만 일부 액션 실패)
if (controlResult.errors && controlResult.errors.length > 0) {
console.warn(
`⚠️ 제어관리 실행 중 일부 오류 발생:`,
controlResult.errors
);
// 오류 정보를 별도로 저장하여 필요시 사용자에게 알림 가능
// 현재는 로그만 출력하고 메인 저장 프로세스는 계속 진행
}
} else {
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
// 제어관리 실패는 메인 저장 프로세스에 영향을 주지 않음
}
// 첫 번째 설정된 제어관리만 실행 (여러 개가 있을 경우)
// 첫 번째 설정된 버튼의 제어관리만 실행
break;
}
}
@ -1741,6 +1708,218 @@ export class DynamicFormService {
}
}
/**
*
*/
private async executeMultipleFlowControls(
flowControls: Array<{
id: string;
flowId: number;
flowName: string;
executionTiming: string;
order: number;
}>,
savedData: Record<string, any>,
screenId: number,
tableName: string,
triggerType: "insert" | "update" | "delete",
userId: string,
companyCode: string
): Promise<void> {
console.log(`🚀 다중 제어 순차 실행 시작: ${flowControls.length}`);
const { NodeFlowExecutionService } = await import(
"./nodeFlowExecutionService"
);
const results: Array<{
order: number;
flowId: number;
flowName: string;
success: boolean;
message: string;
duration: number;
}> = [];
for (let i = 0; i < flowControls.length; i++) {
const control = flowControls[i];
const startTime = Date.now();
console.log(
`\n📍 [${i + 1}/${flowControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})`
);
try {
// 유효하지 않은 flowId 스킵
if (!control.flowId || control.flowId <= 0) {
console.warn(`⚠️ 유효하지 않은 flowId, 스킵: ${control.flowId}`);
results.push({
order: control.order,
flowId: control.flowId,
flowName: control.flowName,
success: false,
message: "유효하지 않은 flowId",
duration: 0,
});
continue;
}
const executionResult = await NodeFlowExecutionService.executeFlow(
control.flowId,
{
sourceData: [savedData],
dataSourceType: "formData",
buttonId: "save-button",
screenId: screenId,
userId: userId,
companyCode: companyCode,
formData: savedData,
}
);
const duration = Date.now() - startTime;
results.push({
order: control.order,
flowId: control.flowId,
flowName: control.flowName,
success: executionResult.success,
message: executionResult.message,
duration,
});
if (executionResult.success) {
console.log(
`✅ [${i + 1}/${flowControls.length}] 제어 성공: ${control.flowName} (${duration}ms)`
);
} else {
console.error(
`❌ [${i + 1}/${flowControls.length}] 제어 실패: ${control.flowName} - ${executionResult.message}`
);
// 이전 제어 실패 시 다음 제어 실행 중단
console.warn(`⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단`);
break;
}
} catch (error: any) {
const duration = Date.now() - startTime;
console.error(
`❌ [${i + 1}/${flowControls.length}] 제어 실행 오류: ${control.flowName}`,
error
);
results.push({
order: control.order,
flowId: control.flowId,
flowName: control.flowName,
success: false,
message: error.message || "실행 오류",
duration,
});
// 오류 발생 시 다음 제어 실행 중단
console.warn(`⚠️ 제어 실행 오류로 인해 나머지 제어 실행 중단`);
break;
}
}
// 실행 결과 요약
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
console.log(`\n📊 다중 제어 실행 완료:`, {
total: flowControls.length,
executed: results.length,
success: successCount,
failed: failCount,
totalDuration: `${totalDuration}ms`,
});
}
/**
* ( , )
*/
private async executeSingleFlowControl(
diagramId: number,
relationshipId: string | null,
savedData: Record<string, any>,
screenId: number,
tableName: string,
triggerType: "insert" | "update" | "delete",
userId: string,
companyCode: string
): Promise<void> {
let controlResult: any;
if (!relationshipId) {
// 노드 플로우 실행
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
const { NodeFlowExecutionService } = await import(
"./nodeFlowExecutionService"
);
const executionResult = await NodeFlowExecutionService.executeFlow(
diagramId,
{
sourceData: [savedData],
dataSourceType: "formData",
buttonId: "save-button",
screenId: screenId,
userId: userId,
companyCode: companyCode,
formData: savedData,
}
);
controlResult = {
success: executionResult.success,
message: executionResult.message,
executedActions: executionResult.nodes?.map((node) => ({
nodeId: node.nodeId,
status: node.status,
duration: node.duration,
})),
errors: executionResult.nodes
?.filter((node) => node.status === "failed")
.map((node) => node.error || "실행 실패"),
};
} else {
// 관계 기반 제어관리 실행
console.log(
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
);
controlResult = await this.dataflowControlService.executeDataflowControl(
diagramId,
relationshipId,
triggerType,
savedData,
tableName,
userId
);
}
console.log(`🎯 제어관리 실행 결과:`, controlResult);
if (controlResult.success) {
console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`);
if (
controlResult.executedActions &&
controlResult.executedActions.length > 0
) {
console.log(`📊 실행된 액션들:`, controlResult.executedActions);
}
if (controlResult.errors && controlResult.errors.length > 0) {
console.warn(
`⚠️ 제어관리 실행 중 일부 오류 발생:`,
controlResult.errors
);
}
} else {
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
}
}
/**
*
* ( )

View File

@ -959,9 +959,10 @@ class NumberingRuleService {
switch (part.partType) {
case "sequence": {
// 순번 (자동 증가 숫자)
// 순번 (자동 증가 숫자 - 다음 번호 사용)
const length = autoConfig.sequenceLength || 3;
return String(rule.currentSequence || 1).padStart(length, "0");
const nextSequence = (rule.currentSequence || 0) + 1;
return String(nextSequence).padStart(length, "0");
}
case "number": {

View File

@ -582,3 +582,4 @@ const result = await executeNodeFlow(flowId, {

View File

@ -355,3 +355,4 @@
- [ ] 부모 화면에서 모달로 데이터가 전달되는가?
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?

View File

@ -390,9 +390,11 @@ export interface RowDetailPopupConfig {
// 추가 데이터 조회 설정
additionalQuery?: {
enabled: boolean;
queryMode?: "table" | "custom"; // 조회 모드: table(테이블 조회), custom(커스텀 쿼리)
tableName: string; // 조회할 테이블명 (예: vehicles)
matchColumn: string; // 매칭할 컬럼 (예: id)
sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일)
customQuery?: string; // 커스텀 쿼리 ({id}, {vehicle_number} 등 파라미터 사용)
// 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시)
displayColumns?: DisplayColumnConfig[];
};

View File

@ -158,7 +158,7 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
checked={popupConfig.additionalQuery?.enabled || false}
onCheckedChange={(enabled) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery, enabled, tableName: "", matchColumn: "" },
additionalQuery: { ...popupConfig.additionalQuery, enabled, queryMode: "table", tableName: "", matchColumn: "" },
})
}
aria-label="추가 데이터 조회 활성화"
@ -167,116 +167,230 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
{popupConfig.additionalQuery?.enabled && (
<div className="space-y-2">
{/* 조회 모드 선택 */}
<div>
<Label className="text-xs"></Label>
<Input
value={popupConfig.additionalQuery?.tableName || ""}
onChange={(e) =>
<Label className="text-xs"> </Label>
<Select
value={popupConfig.additionalQuery?.queryMode || "table"}
onValueChange={(value: "table" | "custom") =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
additionalQuery: { ...popupConfig.additionalQuery!, queryMode: value },
})
}
placeholder="vehicles"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.matchColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
})
}
placeholder="id"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.sourceColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
})
}
placeholder="비워두면 매칭 컬럼과 동일"
className="mt-1 h-8 text-xs"
/>
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="table"> </SelectItem>
<SelectItem value="custom"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 표시할 컬럼 선택 (다중 선택 + 라벨 편집) */}
{/* 테이블 조회 모드 */}
{(popupConfig.additionalQuery?.queryMode || "table") === "table" && (
<>
<div>
<Label className="text-xs"></Label>
<Input
value={popupConfig.additionalQuery?.tableName || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
})
}
placeholder="vehicles"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.matchColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
})
}
placeholder="id"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.sourceColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
})
}
placeholder="비워두면 매칭 컬럼과 동일"
className="mt-1 h-8 text-xs"
/>
</div>
</>
)}
{/* 커스텀 쿼리 모드 */}
{popupConfig.additionalQuery?.queryMode === "custom" && (
<>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.sourceColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
})
}
placeholder="id"
className="mt-1 h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<div>
<Label className="text-xs"> </Label>
<textarea
value={popupConfig.additionalQuery?.customQuery || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, customQuery: e.target.value },
})
}
placeholder={`SELECT
v.vehicle_number AS "차량번호",
ROUND(SUM(ts.loaded_distance_km)::NUMERIC, 2) AS "운행거리"
FROM vehicles v
LEFT JOIN transport_statistics ts ON v.id = ts.vehicle_id
WHERE v.id = {id}
GROUP BY v.id;`}
className="mt-1 h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-muted-foreground mt-1 text-xs">
{"{id}"}, {"{vehicle_number}"}
</p>
</div>
</>
)}
{/* 표시할 컬럼 선택 - 테이블 모드와 커스텀 쿼리 모드 분기 */}
<div>
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
<span className="truncate">
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
: "전체 표시 (클릭하여 선택)"}
</span>
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="start">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium"> </span>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={() =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
})
}
>
</Button>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{/* 쿼리 결과 컬럼 목록 */}
{queryResult?.columns.map((col) => {
const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
const existingConfig = currentColumns.find((c) =>
typeof c === 'object' ? c.column === col : c === col
);
const isSelected = !!existingConfig;
return (
<div
key={col}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
onClick={() => {
const newColumns = isSelected
? currentColumns.filter((c) =>
typeof c === 'object' ? c.column !== col : c !== col
)
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
{/* 테이블 모드: 기존 쿼리 결과에서 선택 */}
{popupConfig.additionalQuery?.queryMode !== "custom" && (
<>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
<span className="truncate">
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
: "전체 표시 (클릭하여 선택)"}
</span>
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="start">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium"> </span>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={() =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
})
}
>
<Checkbox checked={isSelected} className="h-3 w-3" />
<span className="text-xs">{col}</span>
</div>
);
})}
{(!queryResult?.columns || queryResult.columns.length === 0) && (
<p className="text-muted-foreground py-2 text-center text-xs">
</p>
</Button>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{/* 쿼리 결과 컬럼 목록 */}
{queryResult?.columns.map((col) => {
const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
const existingConfig = currentColumns.find((c) =>
typeof c === 'object' ? c.column === col : c === col
);
const isSelected = !!existingConfig;
return (
<div
key={col}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
onClick={() => {
const newColumns = isSelected
? currentColumns.filter((c) =>
typeof c === 'object' ? c.column !== col : c !== col
)
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
>
<Checkbox checked={isSelected} className="h-3 w-3" />
<span className="text-xs">{col}</span>
</div>
);
})}
{(!queryResult?.columns || queryResult.columns.length === 0) && (
<p className="text-muted-foreground py-2 text-center text-xs">
</p>
)}
</div>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</>
)}
{/* 커스텀 쿼리 모드: 직접 입력 방식 */}
{popupConfig.additionalQuery?.queryMode === "custom" && (
<>
<p className="text-muted-foreground mt-1 text-xs">
.
AS "라벨명" alias를 .
</p>
<div className="mt-2 flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => {
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || []), { column: "", label: "" }];
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
>
<Plus className="h-3 w-3" />
()
</Button>
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
})
}
>
</Button>
)}
</div>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</>
)}
{/* 선택된 컬럼 라벨 편집 */}
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
{/* 선택된 컬럼 라벨 편집 (테이블 모드) */}
{popupConfig.additionalQuery?.queryMode !== "custom" && (popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<div className="mt-3 space-y-2">
<Label className="text-xs"> </Label>
<div className="space-y-1.5">
@ -321,6 +435,63 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
</div>
</div>
)}
{/* 커스텀 쿼리 모드: 직접 입력 컬럼 편집 */}
{popupConfig.additionalQuery?.queryMode === "custom" && (popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<div className="mt-3 space-y-2">
<Label className="text-xs"> </Label>
<p className="text-muted-foreground text-xs"> </p>
<div className="space-y-1.5">
{popupConfig.additionalQuery?.displayColumns?.map((colConfig, index) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
return (
<div key={index} className="flex items-center gap-2">
<Input
value={column}
onChange={(e) => {
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
newColumns[index] = { column: e.target.value, label: label || e.target.value };
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
placeholder="컬럼명 (쿼리 결과)"
className="h-7 flex-1 text-xs"
/>
<Input
value={label}
onChange={(e) => {
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
newColumns[index] = { column, label: e.target.value };
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
placeholder="표시 라벨"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => {
const newColumns = (popupConfig.additionalQuery?.displayColumns || []).filter(
(_, i) => i !== index
);
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
)}

View File

@ -64,22 +64,35 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
// 추가 데이터 조회 설정이 있으면 실행
const additionalQuery = config.rowDetailPopup?.additionalQuery;
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
const matchValue = row[sourceColumn];
if (matchValue !== undefined && matchValue !== null) {
if (additionalQuery?.enabled) {
const queryMode = additionalQuery.queryMode || "table";
// 커스텀 쿼리 모드
if (queryMode === "custom" && additionalQuery.customQuery) {
setDetailPopupLoading(true);
try {
const query = `
SELECT *
FROM ${additionalQuery.tableName}
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
LIMIT 1;
`;
// 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
let query = additionalQuery.customQuery;
// console.log("🔍 [ListWidget] 커스텀 쿼리 파라미터 치환 시작");
// console.log("🔍 [ListWidget] 클릭한 행 데이터:", row);
// console.log("🔍 [ListWidget] 행 컬럼 목록:", Object.keys(row));
Object.keys(row).forEach((key) => {
const value = row[key];
const placeholder = new RegExp(`\\{${key}\\}`, "g");
// SQL 인젝션 방지를 위해 값 이스케이프
const safeValue = typeof value === "string"
? value.replace(/'/g, "''")
: value;
query = query.replace(placeholder, String(safeValue ?? ""));
// console.log(`🔍 [ListWidget] 치환: {${key}} → ${safeValue}`);
});
// console.log("🔍 [ListWidget] 최종 쿼리:", query);
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query);
// console.log("🔍 [ListWidget] 쿼리 결과:", result);
if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]);
@ -87,12 +100,43 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
setAdditionalDetailData({});
}
} catch (error) {
console.error("추가 데이터 로드 실패:", error);
console.error("커스텀 쿼리 실행 실패:", error);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
// 테이블 조회 모드
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
const matchValue = row[sourceColumn];
if (matchValue !== undefined && matchValue !== null) {
setDetailPopupLoading(true);
try {
const query = `
SELECT *
FROM ${additionalQuery.tableName}
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
LIMIT 1;
`;
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query);
if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]);
} else {
setAdditionalDetailData({});
}
} catch (error) {
console.error("추가 데이터 로드 실패:", error);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
}
}
},
[config.rowDetailPopup],
@ -190,22 +234,34 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
const groups: FieldGroup[] = [];
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
const queryMode = config.rowDetailPopup?.additionalQuery?.queryMode || "table";
// 커스텀 쿼리 모드일 때는 additional 데이터를 우선 사용
// row와 additional을 병합하되, 커스텀 쿼리 결과(additional)가 우선
const mergedData = queryMode === "custom" && additional && Object.keys(additional).length > 0
? { ...row, ...additional } // additional이 row를 덮어씀
: row;
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
let basicFields: { column: string; label: string }[] = [];
if (displayColumns && displayColumns.length > 0) {
// DisplayColumnConfig 형식 지원
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
basicFields = displayColumns
.map((colConfig) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
return { column, label };
})
.filter((item) => item.column in row);
.filter((item) => item.column in mergedData);
} else {
// 전체 컬럼
basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
// 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
basicFields = Object.keys(additional).map((key) => ({ column: key, label: key }));
} else {
basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
}
}
groups.push({
@ -220,8 +276,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
})),
});
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
if (additional && Object.keys(additional).length > 0) {
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
// 운행 정보
if (additional.last_trip_start || additional.last_trip_end) {
groups.push({

View File

@ -1,13 +1,7 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
@ -183,15 +177,66 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
} else {
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
// 1순위: 이벤트로 전달된 splitPanelParentData (탭 안에서 열린 모달)
// 2순위: splitPanelContext에서 직접 가져온 데이터 (분할 패널 내에서 열린 모달)
const parentData =
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
// 모든 필드를 전달하면 동일한 컬럼명이 있을 때 부모 값이 들어가는 문제 발생
// 예: 설비의 manufacturer가 소모품의 manufacturer로 들어감
// parentDataMapping에서 명시된 필드만 추출
const parentDataMapping = splitPanelContext?.parentDataMapping || [];
// 부모 데이터 소스
const rawParentData =
splitPanelParentData && Object.keys(splitPanelParentData).length > 0
? splitPanelParentData
: splitPanelContext?.getMappedParentData() || {};
: splitPanelContext?.selectedLeftData || {};
// 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달
const parentData: Record<string, any> = {};
// 필수 연결 필드: company_code (멀티테넌시)
if (rawParentData.company_code) {
parentData.company_code = rawParentData.company_code;
}
// parentDataMapping에 정의된 필드만 전달
for (const mapping of parentDataMapping) {
const sourceValue = rawParentData[mapping.sourceColumn];
if (sourceValue !== undefined && sourceValue !== null) {
parentData[mapping.targetColumn] = sourceValue;
console.log(
`🔗 [ScreenModal] 매핑 필드 전달: ${mapping.sourceColumn}${mapping.targetColumn} = ${sourceValue}`,
);
}
}
// parentDataMapping이 비어있으면 연결 필드 자동 감지 (equipment_code, xxx_code, xxx_id 패턴)
if (parentDataMapping.length === 0) {
const linkFieldPatterns = ["_code", "_id"];
const excludeFields = [
"id",
"company_code",
"created_date",
"updated_date",
"created_at",
"updated_at",
"writer",
];
for (const [key, value] of Object.entries(rawParentData)) {
if (excludeFields.includes(key)) continue;
if (value === undefined || value === null) continue;
// 연결 필드 패턴 확인
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
if (isLinkField) {
parentData[key] = value;
console.log(`🔗 [ScreenModal] 연결 필드 자동 감지: ${key} = ${value}`);
}
}
}
if (Object.keys(parentData).length > 0) {
console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정:", parentData);
console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정 (연결 필드만):", parentData);
setFormData(parentData);
} else {
setFormData({});
@ -604,19 +649,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<div className="flex items-center gap-2">
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
{modalState.description && !loading && (
<DialogDescription className="text-muted-foreground text-xs">
{modalState.description}
</DialogDescription>
<DialogDescription className="text-muted-foreground text-xs">{modalState.description}</DialogDescription>
)}
{loading && (
<DialogDescription className="text-xs">
{loading ? "화면을 불러오는 중입니다..." : ""}
</DialogDescription>
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
)}
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">

View File

@ -96,22 +96,35 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
// 추가 데이터 조회 설정이 있으면 실행
const additionalQuery = config.rowDetailPopup?.additionalQuery;
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
const matchValue = row[sourceColumn];
if (matchValue !== undefined && matchValue !== null) {
if (additionalQuery?.enabled) {
const queryMode = additionalQuery.queryMode || "table";
// 커스텀 쿼리 모드
if (queryMode === "custom" && additionalQuery.customQuery) {
setDetailPopupLoading(true);
try {
const query = `
SELECT *
FROM ${additionalQuery.tableName}
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
LIMIT 1;
`;
// 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
let query = additionalQuery.customQuery;
// console.log("🔍 [ListTestWidget] 커스텀 쿼리 파라미터 치환 시작");
// console.log("🔍 [ListTestWidget] 클릭한 행 데이터:", row);
// console.log("🔍 [ListTestWidget] 행 컬럼 목록:", Object.keys(row));
Object.keys(row).forEach((key) => {
const value = row[key];
const placeholder = new RegExp(`\\{${key}\\}`, "g");
// SQL 인젝션 방지를 위해 값 이스케이프
const safeValue = typeof value === "string"
? value.replace(/'/g, "''")
: value;
query = query.replace(placeholder, String(safeValue ?? ""));
// console.log(`🔍 [ListTestWidget] 치환: {${key}} → ${safeValue}`);
});
// console.log("🔍 [ListTestWidget] 최종 쿼리:", query);
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query);
// console.log("🔍 [ListTestWidget] 쿼리 결과:", result);
if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]);
@ -119,12 +132,43 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
setAdditionalDetailData({});
}
} catch (err) {
console.error("추가 데이터 로드 실패:", err);
console.error("커스텀 쿼리 실행 실패:", err);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
// 테이블 조회 모드
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
const matchValue = row[sourceColumn];
if (matchValue !== undefined && matchValue !== null) {
setDetailPopupLoading(true);
try {
const query = `
SELECT *
FROM ${additionalQuery.tableName}
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
LIMIT 1;
`;
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query);
if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]);
} else {
setAdditionalDetailData({});
}
} catch (err) {
console.error("추가 데이터 로드 실패:", err);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
}
}
},
[config.rowDetailPopup],
@ -222,13 +266,21 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
const groups: FieldGroup[] = [];
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
const queryMode = config.rowDetailPopup?.additionalQuery?.queryMode || "table";
// 커스텀 쿼리 모드일 때는 additional 데이터를 우선 사용
// row와 additional을 병합하되, 커스텀 쿼리 결과(additional)가 우선
const mergedData = queryMode === "custom" && additional && Object.keys(additional).length > 0
? { ...row, ...additional } // additional이 row를 덮어씀
: row;
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
const allKeys = Object.keys(row).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
const allKeys = Object.keys(mergedData).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
let basicFields: { column: string; label: string }[] = [];
if (displayColumns && displayColumns.length > 0) {
// DisplayColumnConfig 형식 지원
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
basicFields = displayColumns
.map((colConfig) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
@ -237,8 +289,14 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
})
.filter((item) => allKeys.includes(item.column));
} else {
// 전체 컬럼
basicFields = allKeys.map((key) => ({ column: key, label: key }));
// 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
basicFields = Object.keys(additional)
.filter((key) => !key.startsWith("_"))
.map((key) => ({ column: key, label: key }));
} else {
basicFields = allKeys.map((key) => ({ column: key, label: key }));
}
}
groups.push({
@ -253,8 +311,8 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
})),
});
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
if (additional && Object.keys(additional).length > 0) {
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
// 운행 정보
if (additional.last_trip_start || additional.last_trip_end) {
groups.push({

View File

@ -203,11 +203,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
setTripInfoLoading(identifier);
try {
// user_id 또는 vehicle_number로 조회
// user_id 또는 vehicle_number로 조회 (시간은 KST로 변환)
const query = `SELECT
id, vehicle_number, user_id,
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
(last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start,
(last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end,
last_trip_distance, last_trip_time,
(last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start,
(last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end,
last_empty_distance, last_empty_time,
departure, arrival, status
FROM vehicles
WHERE user_id = '${identifier}'
@ -277,12 +281,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
if (identifiers.length === 0) return;
try {
// 모든 마커의 운행/공차 정보를 한 번에 조회
// 모든 마커의 운행/공차 정보를 한 번에 조회 (시간은 KST로 변환)
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
const query = `SELECT
id, vehicle_number, user_id,
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
(last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start,
(last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end,
last_trip_distance, last_trip_time,
(last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start,
(last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end,
last_empty_distance, last_empty_time,
departure, arrival, status
FROM vehicles
WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")})

View File

@ -1,49 +0,0 @@
"use client";
import React from "react";
import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input";
/**
*
*
* , .
* AutocompleteSearchInput과 customer_mng .
*/
interface OrderCustomerSearchProps {
/** 현재 선택된 거래처 코드 */
value: string;
/** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */
onChange: (customerCode: string | null, fullData?: any) => void;
/** 비활성화 여부 */
disabled?: boolean;
}
export function OrderCustomerSearch({
value,
onChange,
disabled = false,
}: OrderCustomerSearchProps) {
return (
<AutocompleteSearchInputComponent
// 고정 설정 (수주 등록 전용)
tableName="customer_mng"
displayField="customer_name"
valueField="customer_code"
searchFields={[
"customer_name",
"customer_code",
"business_number",
]}
placeholder="거래처명 입력하여 검색"
showAdditionalInfo
additionalFields={["customer_code", "address", "contact_phone"]}
// 외부에서 제어 가능한 prop
value={value}
onChange={onChange}
disabled={disabled}
/>
);
}

View File

@ -1,135 +0,0 @@
"use client";
import React from "react";
import { ModalRepeaterTableComponent } from "@/lib/registry/components/modal-repeater-table";
import type {
RepeaterColumnConfig,
CalculationRule,
} from "@/lib/registry/components/modal-repeater-table";
/**
*
*
* , .
* ModalRepeaterTable과 item_info ,
* .
*/
interface OrderItemRepeaterTableProps {
/** 현재 선택된 품목 목록 */
value: any[];
/** 품목 목록 변경 시 콜백 */
onChange: (items: any[]) => void;
/** 비활성화 여부 */
disabled?: boolean;
}
// 수주 등록 전용 컬럼 설정 (고정)
const ORDER_COLUMNS: RepeaterColumnConfig[] = [
{
field: "item_number",
label: "품번",
editable: false,
width: "120px",
},
{
field: "item_name",
label: "품명",
editable: false,
width: "180px",
},
{
field: "specification",
label: "규격",
editable: false,
width: "150px",
},
{
field: "material",
label: "재질",
editable: false,
width: "120px",
},
{
field: "quantity",
label: "수량",
type: "number",
editable: true,
required: true,
defaultValue: 1,
width: "100px",
},
{
field: "selling_price",
label: "단가",
type: "number",
editable: true,
required: true,
width: "120px",
},
{
field: "amount",
label: "금액",
type: "number",
editable: false,
calculated: true,
width: "120px",
},
{
field: "order_date",
label: "수주일",
type: "date",
editable: true,
width: "130px",
},
{
field: "delivery_date",
label: "납기일",
type: "date",
editable: true,
width: "130px",
},
];
// 수주 등록 전용 계산 공식 (고정)
const ORDER_CALCULATION_RULES: CalculationRule[] = [
{
result: "amount",
formula: "quantity * selling_price",
dependencies: ["quantity", "selling_price"],
},
];
export function OrderItemRepeaterTable({
value,
onChange,
disabled = false,
}: OrderItemRepeaterTableProps) {
return (
<ModalRepeaterTableComponent
// 고정 설정 (수주 등록 전용)
sourceTable="item_info"
sourceColumns={[
"item_number",
"item_name",
"specification",
"material",
"unit",
"selling_price",
]}
sourceSearchFields={["item_name", "item_number", "specification"]}
modalTitle="품목 검색 및 선택"
modalButtonText="품목 검색"
multiSelect={true}
columns={ORDER_COLUMNS}
calculationRules={ORDER_CALCULATION_RULES}
uniqueField="item_number"
// 외부에서 제어 가능한 prop
value={value}
onChange={onChange}
disabled={disabled}
/>
);
}

View File

@ -1,572 +0,0 @@
"use client";
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { OrderCustomerSearch } from "./OrderCustomerSearch";
import { OrderItemRepeaterTable } from "./OrderItemRepeaterTable";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
interface OrderRegistrationModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function OrderRegistrationModal({
open,
onOpenChange,
onSuccess,
}: OrderRegistrationModalProps) {
// 입력 방식
const [inputMode, setInputMode] = useState<string>("customer_first");
// 판매 유형 (국내/해외)
const [salesType, setSalesType] = useState<string>("domestic");
// 단가 기준 (기준단가/거래처별단가)
const [priceType, setPriceType] = useState<string>("standard");
// 폼 데이터
const [formData, setFormData] = useState<any>({
customerCode: "",
customerName: "",
contactPerson: "",
deliveryDestination: "",
deliveryAddress: "",
deliveryDate: "",
memo: "",
// 무역 정보 (해외 판매 시)
incoterms: "",
paymentTerms: "",
currency: "KRW",
portOfLoading: "",
portOfDischarge: "",
hsCode: "",
});
// 선택된 품목 목록
const [selectedItems, setSelectedItems] = useState<any[]>([]);
// 납기일 일괄 적용 플래그 (딱 한 번만 실행)
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
// 저장 중
const [isSaving, setIsSaving] = useState(false);
// 저장 처리
const handleSave = async () => {
try {
// 유효성 검사
if (!formData.customerCode) {
toast.error("거래처를 선택해주세요");
return;
}
if (selectedItems.length === 0) {
toast.error("품목을 추가해주세요");
return;
}
setIsSaving(true);
// 수주 등록 API 호출
const orderData: any = {
inputMode,
salesType,
priceType,
customerCode: formData.customerCode,
contactPerson: formData.contactPerson,
deliveryDestination: formData.deliveryDestination,
deliveryAddress: formData.deliveryAddress,
deliveryDate: formData.deliveryDate,
items: selectedItems,
memo: formData.memo,
};
// 해외 판매 시 무역 정보 추가
if (salesType === "export") {
orderData.tradeInfo = {
incoterms: formData.incoterms,
paymentTerms: formData.paymentTerms,
currency: formData.currency,
portOfLoading: formData.portOfLoading,
portOfDischarge: formData.portOfDischarge,
hsCode: formData.hsCode,
};
}
const response = await apiClient.post("/orders", orderData);
if (response.data.success) {
toast.success("수주가 등록되었습니다");
onOpenChange(false);
onSuccess?.();
// 폼 초기화
resetForm();
} else {
toast.error(response.data.message || "수주 등록에 실패했습니다");
}
} catch (error: any) {
console.error("수주 등록 오류:", error);
toast.error(
error.response?.data?.message || "수주 등록 중 오류가 발생했습니다"
);
} finally {
setIsSaving(false);
}
};
// 취소 처리
const handleCancel = () => {
onOpenChange(false);
resetForm();
};
// 폼 초기화
const resetForm = () => {
setInputMode("customer_first");
setSalesType("domestic");
setPriceType("standard");
setFormData({
customerCode: "",
customerName: "",
contactPerson: "",
deliveryDestination: "",
deliveryAddress: "",
deliveryDate: "",
memo: "",
incoterms: "",
paymentTerms: "",
currency: "KRW",
portOfLoading: "",
portOfDischarge: "",
hsCode: "",
});
setSelectedItems([]);
setIsDeliveryDateApplied(false); // 플래그 초기화
};
// 품목 목록 변경 핸들러 (납기일 일괄 적용 로직 포함)
const handleItemsChange = (newItems: any[]) => {
// 1⃣ 플래그가 이미 true면 그냥 업데이트만 (일괄 적용 완료 상태)
if (isDeliveryDateApplied) {
setSelectedItems(newItems);
return;
}
// 2⃣ 품목이 없으면 그냥 업데이트
if (newItems.length === 0) {
setSelectedItems(newItems);
return;
}
// 3⃣ 현재 상태: 납기일이 있는 행과 없는 행 개수 체크
const itemsWithDate = newItems.filter((item) => item.delivery_date);
const itemsWithoutDate = newItems.filter((item) => !item.delivery_date);
// 4⃣ 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
// 5⃣ 전체 일괄 적용
const selectedDate = itemsWithDate[0].delivery_date;
const updatedItems = newItems.map((item) => ({
...item,
delivery_date: selectedDate, // 모든 행에 동일한 납기일 적용
}));
setSelectedItems(updatedItems);
setIsDeliveryDateApplied(true); // 플래그 활성화 (다음부터는 일괄 적용 안 함)
console.log("✅ 납기일 일괄 적용 완료:", selectedDate);
console.log(` - 대상: ${itemsWithoutDate.length}개 행에 ${selectedDate} 적용`);
} else {
// 그냥 업데이트
setSelectedItems(newItems);
}
};
// 전체 금액 계산
const totalAmount = selectedItems.reduce(
(sum, item) => sum + (item.amount || 0),
0
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] max-h-[90vh] overflow-hidden">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 상단 셀렉트 박스 3개 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* 입력 방식 */}
<div className="space-y-2">
<Label htmlFor="inputMode" className="text-xs sm:text-sm flex items-center gap-1">
<span className="text-amber-500">📝</span>
</Label>
<Select value={inputMode} onValueChange={setInputMode}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="입력 방식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="customer_first"> </SelectItem>
<SelectItem value="quotation"> </SelectItem>
<SelectItem value="unit_price"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 판매 유형 */}
<div className="space-y-2">
<Label htmlFor="salesType" className="text-xs sm:text-sm flex items-center gap-1">
<span className="text-blue-500">🌏</span>
</Label>
<Select value={salesType} onValueChange={setSalesType}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="판매 유형 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="domestic"> </SelectItem>
<SelectItem value="export"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 단가 기준 */}
<div className="space-y-2">
<Label htmlFor="priceType" className="text-xs sm:text-sm flex items-center gap-1">
<span className="text-green-500">💰</span>
</Label>
<Select value={priceType} onValueChange={setPriceType}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="단가 방식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard"> </SelectItem>
<SelectItem value="customer"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 거래처 정보 (항상 표시) */}
{inputMode === "customer_first" && (
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-700">
<span>🏢</span>
<span> </span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 거래처 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<OrderCustomerSearch
value={formData.customerCode}
onChange={(code, fullData) => {
setFormData({
...formData,
customerCode: code || "",
customerName: fullData?.customer_name || "",
});
}}
/>
</div>
{/* 담당자 */}
<div className="space-y-2">
<Label htmlFor="contactPerson" className="text-xs sm:text-sm">
</Label>
<input
type="text"
id="contactPerson"
placeholder="담당자"
value={formData.contactPerson}
onChange={(e) =>
setFormData({ ...formData, contactPerson: e.target.value })
}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
{/* 납품처 */}
<div className="space-y-2">
<Label htmlFor="deliveryDestination" className="text-xs sm:text-sm">
</Label>
<input
type="text"
id="deliveryDestination"
placeholder="납품처"
value={formData.deliveryDestination}
onChange={(e) =>
setFormData({ ...formData, deliveryDestination: e.target.value })
}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
{/* 납품장소 */}
<div className="space-y-2">
<Label htmlFor="deliveryAddress" className="text-xs sm:text-sm">
</Label>
<input
type="text"
id="deliveryAddress"
placeholder="납품장소"
value={formData.deliveryAddress}
onChange={(e) =>
setFormData({ ...formData, deliveryAddress: e.target.value })
}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
</div>
</div>
)}
{inputMode === "quotation" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<input
type="text"
placeholder="견대 번호를 입력하세요"
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
</div>
)}
{inputMode === "unit_price" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<input
type="text"
placeholder="단가 정보 입력"
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
</div>
)}
{/* 추가된 품목 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<OrderItemRepeaterTable
value={selectedItems}
onChange={handleItemsChange}
/>
</div>
{/* 전체 금액 표시 */}
{selectedItems.length > 0 && (
<div className="flex justify-end">
<div className="text-sm sm:text-base font-semibold">
: {totalAmount.toLocaleString()}
</div>
</div>
)}
{/* 무역 정보 (해외 판매 시에만 표시) */}
{salesType === "export" && (
<div className="rounded-lg border border-blue-200 bg-blue-50/50 p-4 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-blue-700">
<span>🌏</span>
<span> </span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* 인코텀즈 */}
<div className="space-y-2">
<Label htmlFor="incoterms" className="text-xs sm:text-sm">
</Label>
<Select
value={formData.incoterms}
onValueChange={(value) =>
setFormData({ ...formData, incoterms: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="EXW">EXW</SelectItem>
<SelectItem value="FOB">FOB</SelectItem>
<SelectItem value="CIF">CIF</SelectItem>
<SelectItem value="DDP">DDP</SelectItem>
<SelectItem value="DAP">DAP</SelectItem>
</SelectContent>
</Select>
</div>
{/* 결제 조건 */}
<div className="space-y-2">
<Label htmlFor="paymentTerms" className="text-xs sm:text-sm">
</Label>
<Select
value={formData.paymentTerms}
onValueChange={(value) =>
setFormData({ ...formData, paymentTerms: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="advance"></SelectItem>
<SelectItem value="cod"></SelectItem>
<SelectItem value="lc">(L/C)</SelectItem>
<SelectItem value="net30">NET 30</SelectItem>
<SelectItem value="net60">NET 60</SelectItem>
</SelectContent>
</Select>
</div>
{/* 통화 */}
<div className="space-y-2">
<Label htmlFor="currency" className="text-xs sm:text-sm">
</Label>
<Select
value={formData.currency}
onValueChange={(value) =>
setFormData({ ...formData, currency: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="통화 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="KRW">KRW ()</SelectItem>
<SelectItem value="USD">USD ()</SelectItem>
<SelectItem value="EUR">EUR ()</SelectItem>
<SelectItem value="JPY">JPY ()</SelectItem>
<SelectItem value="CNY">CNY ()</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* 선적항 */}
<div className="space-y-2">
<Label htmlFor="portOfLoading" className="text-xs sm:text-sm">
</Label>
<input
type="text"
id="portOfLoading"
placeholder="선적항"
value={formData.portOfLoading}
onChange={(e) =>
setFormData({ ...formData, portOfLoading: e.target.value })
}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
{/* 도착항 */}
<div className="space-y-2">
<Label htmlFor="portOfDischarge" className="text-xs sm:text-sm">
</Label>
<input
type="text"
id="portOfDischarge"
placeholder="도착항"
value={formData.portOfDischarge}
onChange={(e) =>
setFormData({ ...formData, portOfDischarge: e.target.value })
}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
{/* HS Code */}
<div className="space-y-2">
<Label htmlFor="hsCode" className="text-xs sm:text-sm">
HS Code
</Label>
<input
type="text"
id="hsCode"
placeholder="HS Code"
value={formData.hsCode}
onChange={(e) =>
setFormData({ ...formData, hsCode: e.target.value })
}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
/>
</div>
</div>
</div>
)}
{/* 메모 */}
<div className="space-y-2">
<Label htmlFor="memo" className="text-xs sm:text-sm">
</Label>
<textarea
id="memo"
placeholder="메모를 입력하세요"
value={formData.memo}
onChange={(e) =>
setFormData({ ...formData, memo: e.target.value })
}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
rows={3}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={handleCancel}
disabled={isSaving}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={isSaving}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isSaving ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,374 +0,0 @@
# 수주 등록 컴포넌트
## 개요
수주 등록 기능을 위한 전용 컴포넌트들입니다. 이 컴포넌트들은 범용 컴포넌트를 래핑하여 수주 등록에 최적화된 고정 설정을 제공합니다.
## 컴포넌트 구조
```
frontend/components/order/
├── OrderRegistrationModal.tsx # 수주 등록 메인 모달
├── OrderCustomerSearch.tsx # 거래처 검색 (전용)
├── OrderItemRepeaterTable.tsx # 품목 반복 테이블 (전용)
└── README.md # 문서 (현재 파일)
```
## 1. OrderRegistrationModal
수주 등록 메인 모달 컴포넌트입니다.
### Props
```typescript
interface OrderRegistrationModalProps {
/** 모달 열림/닫힘 상태 */
open: boolean;
/** 모달 상태 변경 핸들러 */
onOpenChange: (open: boolean) => void;
/** 수주 등록 성공 시 콜백 */
onSuccess?: () => void;
}
```
### 사용 예시
```tsx
import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal";
function MyComponent() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button onClick={() => setIsOpen(true)}>수주 등록</Button>
<OrderRegistrationModal
open={isOpen}
onOpenChange={setIsOpen}
onSuccess={() => {
console.log("수주 등록 완료!");
// 목록 새로고침 등
}}
/>
</>
);
}
```
### 기능
- **입력 방식 선택**: 거래처 우선, 견적 방식, 단가 방식
- **거래처 검색**: 자동완성 드롭다운으로 거래처 검색 및 선택
- **품목 관리**: 모달에서 품목 검색 및 추가, 수량/단가 입력, 금액 자동 계산
- **전체 금액 표시**: 추가된 품목들의 총 금액 계산
- **유효성 검사**: 거래처 및 품목 필수 입력 체크
---
## 2. OrderCustomerSearch
수주 등록 전용 거래처 검색 컴포넌트입니다.
### 특징
- `customer_mng` 테이블만 조회 (고정)
- 거래처명, 거래처코드, 사업자번호로 검색 (고정)
- 추가 정보 표시 (주소, 연락처)
### Props
```typescript
interface OrderCustomerSearchProps {
/** 현재 선택된 거래처 코드 */
value: string;
/** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */
onChange: (customerCode: string | null, fullData?: any) => void;
/** 비활성화 여부 */
disabled?: boolean;
}
```
### 사용 예시
```tsx
import { OrderCustomerSearch } from "@/components/order/OrderCustomerSearch";
function MyForm() {
const [customerCode, setCustomerCode] = useState("");
const [customerName, setCustomerName] = useState("");
return (
<OrderCustomerSearch
value={customerCode}
onChange={(code, fullData) => {
setCustomerCode(code || "");
setCustomerName(fullData?.customer_name || "");
}}
/>
);
}
```
### 고정 설정
| 설정 | 값 | 설명 |
|------|-----|------|
| `tableName` | `customer_mng` | 거래처 테이블 |
| `displayField` | `customer_name` | 표시 필드 |
| `valueField` | `customer_code` | 값 필드 |
| `searchFields` | `["customer_name", "customer_code", "business_number"]` | 검색 대상 필드 |
| `additionalFields` | `["customer_code", "address", "contact_phone"]` | 추가 표시 필드 |
---
## 3. OrderItemRepeaterTable
수주 등록 전용 품목 반복 테이블 컴포넌트입니다.
### 특징
- `item_info` 테이블만 조회 (고정)
- 수주에 필요한 컬럼만 표시 (품번, 품명, 수량, 단가, 금액 등)
- 금액 자동 계산 (`수량 * 단가`)
### Props
```typescript
interface OrderItemRepeaterTableProps {
/** 현재 선택된 품목 목록 */
value: any[];
/** 품목 목록 변경 시 콜백 */
onChange: (items: any[]) => void;
/** 비활성화 여부 */
disabled?: boolean;
}
```
### 사용 예시
```tsx
import { OrderItemRepeaterTable } from "@/components/order/OrderItemRepeaterTable";
function MyForm() {
const [items, setItems] = useState([]);
return (
<OrderItemRepeaterTable
value={items}
onChange={setItems}
/>
);
}
```
### 고정 컬럼 설정
| 필드 | 라벨 | 타입 | 편집 | 필수 | 계산 | 설명 |
|------|------|------|------|------|------|------|
| `id` | 품번 | text | ❌ | - | - | 품목 ID |
| `item_name` | 품명 | text | ❌ | - | - | 품목명 |
| `item_number` | 품목번호 | text | ❌ | - | - | 품목 번호 |
| `quantity` | 수량 | number | ✅ | ✅ | - | 주문 수량 (기본값: 1) |
| `selling_price` | 단가 | number | ✅ | ✅ | - | 판매 단가 |
| `amount` | 금액 | number | ❌ | - | ✅ | 자동 계산 (수량 * 단가) |
| `delivery_date` | 납품일 | date | ✅ | - | - | 납품 예정일 |
| `note` | 비고 | text | ✅ | - | - | 추가 메모 |
### 계산 규칙
```javascript
amount = quantity * selling_price
```
---
## 범용 컴포넌트 vs 전용 컴포넌트
### 왜 전용 컴포넌트를 만들었나?
| 항목 | 범용 컴포넌트 | 전용 컴포넌트 |
|------|--------------|--------------|
| **목적** | 화면 편집기에서 다양한 용도로 사용 | 수주 등록 전용 |
| **설정** | ConfigPanel에서 자유롭게 변경 가능 | 하드코딩으로 고정 |
| **유연성** | 높음 (모든 테이블/필드 지원) | 낮음 (수주에 최적화) |
| **안정성** | 사용자 실수 가능 | 설정 변경 불가로 안전 |
| **위치** | `lib/registry/components/` | `components/order/` |
### 범용 컴포넌트 (화면 편집기용)
```tsx
// ❌ 수주 등록에서 사용 금지
<AutocompleteSearchInputComponent
tableName="???" // ConfigPanel에서 변경 가능
displayField="???" // 다른 테이블로 바꿀 수 있음
valueField="???" // 필드가 맞지 않으면 에러
/>
```
**문제점:**
- 사용자가 `tableName``item_info`로 변경하면 거래처가 아닌 품목이 조회됨
- `valueField`를 변경하면 `formData.customerCode`에 잘못된 값 저장
- 수주 로직이 깨짐
### 전용 컴포넌트 (수주 등록용)
```tsx
// ✅ 수주 등록에서 사용
<OrderCustomerSearch
value={customerCode} // 외부에서 제어 가능
onChange={handleChange} // 값 변경만 처리
// 나머지 설정은 내부에서 고정
/>
```
**장점:**
- 설정이 하드코딩되어 있어 변경 불가
- 수주 등록 로직에 최적화
- 안전하고 예측 가능
---
## API 엔드포인트
### 거래처 검색
```
GET /api/entity-search/customer_mng
Query Parameters:
- searchText: 검색어
- searchFields: customer_name,customer_code,business_number
- page: 페이지 번호
- limit: 페이지 크기
```
### 품목 검색
```
GET /api/entity-search/item_info
Query Parameters:
- searchText: 검색어
- searchFields: item_name,id,item_number
- page: 페이지 번호
- limit: 페이지 크기
```
### 수주 등록
```
POST /api/orders
Body:
{
inputMode: "customer_first" | "quotation" | "unit_price",
customerCode: string,
deliveryDate?: string,
items: Array<{
id: string,
item_name: string,
quantity: number,
selling_price: number,
amount: number,
delivery_date?: string,
note?: string
}>,
memo?: string
}
Response:
{
success: boolean,
data?: {
orderNumber: string,
orderId: number
},
message?: string
}
```
---
## 멀티테넌시 (Multi-Tenancy)
모든 API 호출은 자동으로 `company_code` 필터링이 적용됩니다.
- 거래처 검색: 현재 로그인한 사용자의 회사에 속한 거래처만 조회
- 품목 검색: 현재 로그인한 사용자의 회사에 속한 품목만 조회
- 수주 등록: 자동으로 현재 사용자의 `company_code` 추가
---
## 트러블슈팅
### 1. 거래처가 검색되지 않음
**원인**: `customer_mng` 테이블에 데이터가 없거나 `company_code`가 다름
**해결**:
```sql
-- 거래처 데이터 확인
SELECT * FROM customer_mng WHERE company_code = 'YOUR_COMPANY_CODE';
```
### 2. 품목이 검색되지 않음
**원인**: `item_info` 테이블에 데이터가 없거나 `company_code`가 다름
**해결**:
```sql
-- 품목 데이터 확인
SELECT * FROM item_info WHERE company_code = 'YOUR_COMPANY_CODE';
```
### 3. 수주 등록 실패
**원인**: 필수 필드 누락 또는 백엔드 API 오류
**해결**:
1. 브라우저 개발자 도구 콘솔 확인
2. 네트워크 탭에서 API 응답 확인
3. 백엔드 로그 확인
---
## 개발 참고 사항
### 새로운 전용 컴포넌트 추가 시
1. **범용 컴포넌트 활용**: 기존 범용 컴포넌트를 래핑
2. **설정 고정**: 비즈니스 로직에 필요한 설정을 하드코딩
3. **Props 최소화**: 외부에서 제어 가능한 최소한의 prop만 노출
4. **문서 작성**: README에 사용법 및 고정 설정 명시
### 예시: 견적 등록 전용 컴포넌트
```tsx
// QuotationCustomerSearch.tsx
export function QuotationCustomerSearch({ value, onChange }: Props) {
return (
<AutocompleteSearchInputComponent
tableName="customer_mng" // 고정
displayField="customer_name" // 고정
valueField="customer_code" // 고정
value={value}
onChange={onChange}
/>
);
}
```
---
## 관련 파일
- 범용 컴포넌트:
- `lib/registry/components/autocomplete-search-input/`
- `lib/registry/components/entity-search-input/`
- `lib/registry/components/modal-repeater-table/`
- 백엔드 API:
- `backend-node/src/controllers/entitySearchController.ts`
- `backend-node/src/controllers/orderController.ts`
- 계획서:
- `수주등록_화면_개발_계획서.md`

View File

@ -1,21 +0,0 @@
export const INPUT_MODE = {
CUSTOMER_FIRST: "customer_first",
QUOTATION: "quotation",
UNIT_PRICE: "unit_price",
} as const;
export type InputMode = (typeof INPUT_MODE)[keyof typeof INPUT_MODE];
export const SALES_TYPE = {
DOMESTIC: "domestic",
EXPORT: "export",
} as const;
export type SalesType = (typeof SALES_TYPE)[keyof typeof SALES_TYPE];
export const PRICE_TYPE = {
STANDARD: "standard",
CUSTOMER: "customer",
} as const;
export type PriceType = (typeof PRICE_TYPE)[keyof typeof PRICE_TYPE];

View File

@ -62,7 +62,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 폼 데이터 상태 (편집 데이터로 초기화됨)
const [formData, setFormData] = useState<Record<string, any>>({});
const [originalData, setOriginalData] = useState<Record<string, any>>({});
// 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목)
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
@ -118,7 +118,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => {
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } = event.detail;
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } =
event.detail;
setModalState({
isOpen: true,
@ -136,8 +137,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
setFormData(editData || {});
// 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드)
// originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨
setOriginalData(isCreateMode ? {} : (editData || {}));
setOriginalData(isCreateMode ? {} : editData || {});
if (isCreateMode) {
console.log("[EditModal] 생성 모드로 열림, 초기값:", editData);
}
@ -170,7 +171,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
useEffect(() => {
if (modalState.isOpen && modalState.screenId) {
loadScreenData(modalState.screenId);
// 🆕 그룹 데이터 조회 (groupByColumns가 있는 경우)
if (modalState.groupByColumns && modalState.groupByColumns.length > 0 && modalState.tableName) {
loadGroupData();
@ -308,7 +309,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// universal-form-modal 등에서 자체 저장 완료 후 호출된 경우 스킵
if (saveData?._saveCompleted) {
console.log("[EditModal] 자체 저장 완료된 컴포넌트에서 호출됨 - 저장 스킵");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
@ -317,7 +318,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
console.error("onSave 콜백 에러:", callbackError);
}
}
handleClose();
return;
}
@ -342,13 +343,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 🆕 날짜 필드 정규화 함수 (YYYY-MM-DD 형식으로 변환)
const normalizeDateField = (value: any): string | null => {
if (!value) return null;
// ISO 8601 형식 (2025-11-26T00:00:00.000Z) 또는 Date 객체
if (value instanceof Date || typeof value === "string") {
try {
const date = new Date(value);
if (isNaN(date.getTime())) return null;
// YYYY-MM-DD 형식으로 변환
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
@ -359,7 +360,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
return null;
}
}
return null;
};
@ -380,7 +381,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const insertData: Record<string, any> = { ...currentData };
console.log("📦 [신규 품목] 복사 직후 insertData:", insertData);
console.log("📋 [신규 품목] insertData 키 목록:", Object.keys(insertData));
delete insertData.id; // id는 자동 생성되므로 제거
// 🆕 날짜 필드 정규화 (YYYY-MM-DD 형식으로 변환)
@ -464,9 +465,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
for (const currentData of groupData) {
if (currentData.id) {
// id 기반 매칭 (인덱스 기반 X)
const originalItemData = originalGroupData.find(
(orig) => orig.id === currentData.id
);
const originalItemData = originalGroupData.find((orig) => orig.id === currentData.id);
if (!originalItemData) {
console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`);
@ -476,13 +475,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 🆕 값 정규화 함수 (타입 통일)
const normalizeValue = (val: any, fieldName?: string): any => {
if (val === null || val === undefined || val === "") return null;
// 날짜 필드인 경우 YYYY-MM-DD 형식으로 정규화
if (fieldName && dateFields.includes(fieldName)) {
const normalizedDate = normalizeDateField(val);
return normalizedDate;
}
if (typeof val === "string" && !isNaN(Number(val))) {
// 숫자로 변환 가능한 문자열은 숫자로
return Number(val);
@ -539,9 +538,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 3⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목)
const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean));
const deletedItems = originalGroupData.filter(
(orig) => orig.id && !currentIds.has(orig.id)
);
const deletedItems = originalGroupData.filter((orig) => orig.id && !currentIds.has(orig.id));
for (const deletedItem of deletedItems) {
console.log("🗑️ 품목 삭제:", deletedItem);
@ -549,7 +546,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
try {
const response = await dynamicFormApi.deleteFormDataFromTable(
deletedItem.id,
screenData.screenInfo.tableName
screenData.screenInfo.tableName,
);
if (response.success) {
@ -592,11 +589,11 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// originalData가 비어있으면 INSERT, 있으면 UPDATE
const isCreateMode = Object.keys(originalData).length === 0;
if (isCreateMode) {
// INSERT 모드
console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData);
const response = await dynamicFormApi.saveFormData({
screenId: modalState.screenId!,
tableName: screenData.screenInfo.tableName,
@ -701,10 +698,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
return (
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
<DialogContent
className={`${modalStyle.className} ${className || ""} max-w-none`}
style={modalStyle.style}
>
<DialogContent className={`${modalStyle.className} ${className || ""} max-w-none`} style={modalStyle.style}>
<DialogHeader className="shrink-0 border-b px-4 py-3">
<div className="flex items-center gap-2">
<DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle>
@ -717,7 +711,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div>
</DialogHeader>
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
@ -751,7 +745,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
},
};
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
@ -760,7 +753,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
tableName: screenData.screenInfo?.tableName, // 테이블명 추가
screenId: modalState.screenId, // 화면 ID 추가
};
// 🔍 디버깅: enrichedFormData 확인
console.log("🔑 [EditModal] enrichedFormData 생성:", {
"screenData.screenInfo": screenData.screenInfo,
@ -775,6 +768,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
component={adjustedComponent}
allComponents={screenData.components}
formData={enrichedFormData}
originalData={originalData} // 🆕 원본 데이터 전달 (수정 모드에서 UniversalFormModal 초기화용)
onFormDataChange={(fieldName, value) => {
// 🆕 그룹 데이터가 있으면 처리
if (groupData.length > 0) {
@ -787,14 +781,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
prev.map((item) => ({
...item,
[fieldName]: value,
}))
})),
);
}
} else {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}
}}
screenInfo={{

View File

@ -1,11 +1,14 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { Settings, Clock, Info, Workflow } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Settings, Clock, Info, Workflow, Plus, Trash2, GripVertical, ChevronUp, ChevronDown } from "lucide-react";
import { ComponentData } from "@/types/screen";
import { getNodeFlows, NodeFlow } from "@/lib/api/nodeFlows";
@ -14,11 +17,22 @@ interface ImprovedButtonControlConfigPanelProps {
onUpdateProperty: (path: string, value: any) => void;
}
// 다중 제어 설정 인터페이스
interface FlowControlConfig {
id: string;
flowId: number;
flowName: string;
executionTiming: "before" | "after" | "replace";
order: number;
}
/**
* 🔥
* 🔥
*
* :
* -
* :
* -
* -
* -
*/
export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlConfigPanelProps> = ({
component,
@ -27,6 +41,9 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
const config = component.webTypeConfig || {};
const dataflowConfig = config.dataflowConfig || {};
// 다중 제어 설정 (배열)
const flowControls: FlowControlConfig[] = dataflowConfig.flowControls || [];
// 🔥 State 관리
const [flows, setFlows] = useState<NodeFlow[]>([]);
const [loading, setLoading] = useState(false);
@ -58,24 +75,118 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
};
/**
* 🔥
* 🔥
*/
const handleFlowSelect = (flowId: string) => {
const selectedFlow = flows.find((f) => f.flowId.toString() === flowId);
if (selectedFlow) {
// 전체 dataflowConfig 업데이트 (selectedDiagramId 포함)
onUpdateProperty("webTypeConfig.dataflowConfig", {
...dataflowConfig,
selectedDiagramId: selectedFlow.flowId, // 백엔드에서 사용
selectedRelationshipId: null, // 노드 플로우는 관계 ID 불필요
flowConfig: {
flowId: selectedFlow.flowId,
flowName: selectedFlow.flowName,
executionTiming: "before", // 기본값
contextData: {},
},
});
}
const handleAddControl = useCallback(() => {
const newControl: FlowControlConfig = {
id: `control_${Date.now()}`,
flowId: 0,
flowName: "",
executionTiming: "after",
order: flowControls.length + 1,
};
const updatedControls = [...flowControls, newControl];
updateFlowControls(updatedControls);
}, [flowControls]);
/**
* 🔥
*/
const handleRemoveControl = useCallback(
(controlId: string) => {
const updatedControls = flowControls
.filter((c) => c.id !== controlId)
.map((c, index) => ({ ...c, order: index + 1 }));
updateFlowControls(updatedControls);
},
[flowControls],
);
/**
* 🔥
*/
const handleFlowSelect = useCallback(
(controlId: string, flowId: string) => {
const selectedFlow = flows.find((f) => f.flowId.toString() === flowId);
if (selectedFlow) {
const updatedControls = flowControls.map((c) =>
c.id === controlId ? { ...c, flowId: selectedFlow.flowId, flowName: selectedFlow.flowName } : c,
);
updateFlowControls(updatedControls);
}
},
[flows, flowControls],
);
/**
* 🔥
*/
const handleTimingChange = useCallback(
(controlId: string, timing: "before" | "after" | "replace") => {
const updatedControls = flowControls.map((c) => (c.id === controlId ? { ...c, executionTiming: timing } : c));
updateFlowControls(updatedControls);
},
[flowControls],
);
/**
* 🔥
*/
const handleMoveUp = useCallback(
(controlId: string) => {
const index = flowControls.findIndex((c) => c.id === controlId);
if (index > 0) {
const updatedControls = [...flowControls];
[updatedControls[index - 1], updatedControls[index]] = [updatedControls[index], updatedControls[index - 1]];
// 순서 번호 재정렬
updatedControls.forEach((c, i) => (c.order = i + 1));
updateFlowControls(updatedControls);
}
},
[flowControls],
);
/**
* 🔥
*/
const handleMoveDown = useCallback(
(controlId: string) => {
const index = flowControls.findIndex((c) => c.id === controlId);
if (index < flowControls.length - 1) {
const updatedControls = [...flowControls];
[updatedControls[index], updatedControls[index + 1]] = [updatedControls[index + 1], updatedControls[index]];
// 순서 번호 재정렬
updatedControls.forEach((c, i) => (c.order = i + 1));
updateFlowControls(updatedControls);
}
},
[flowControls],
);
/**
* 🔥 ( )
*/
const updateFlowControls = (controls: FlowControlConfig[]) => {
// 첫 번째 제어를 기존 형식으로도 저장 (하위 호환성)
const firstValidControl = controls.find((c) => c.flowId > 0);
onUpdateProperty("webTypeConfig.dataflowConfig", {
...dataflowConfig,
// 기존 형식 (하위 호환성)
selectedDiagramId: firstValidControl?.flowId || null,
selectedRelationshipId: null,
flowConfig: firstValidControl
? {
flowId: firstValidControl.flowId,
flowName: firstValidControl.flowName,
executionTiming: firstValidControl.executionTiming,
contextData: {},
}
: null,
// 새로운 다중 제어 형식
flowControls: controls,
});
};
return (
@ -98,32 +209,57 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
{/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */}
{config.enableDataflowControl && (
<div className="space-y-4">
<FlowSelector
flows={flows}
selectedFlowId={dataflowConfig.flowConfig?.flowId}
onSelect={handleFlowSelect}
loading={loading}
/>
{/* 제어 목록 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Workflow className="h-4 w-4 text-green-600" />
<Label> ( )</Label>
</div>
<Button variant="outline" size="sm" onClick={handleAddControl} className="h-8">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{dataflowConfig.flowConfig && (
<div className="space-y-4">
<Separator />
<ExecutionTimingSelector
value={dataflowConfig.flowConfig.executionTiming}
onChange={(timing) =>
onUpdateProperty("webTypeConfig.dataflowConfig.flowConfig.executionTiming", timing)
}
/>
{/* 제어 목록 */}
{flowControls.length === 0 ? (
<div className="rounded-md border border-dashed p-6 text-center">
<Workflow className="mx-auto h-8 w-8 text-gray-400" />
<p className="mt-2 text-sm text-gray-500"> </p>
<Button variant="outline" size="sm" onClick={handleAddControl} className="mt-3">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
) : (
<div className="space-y-2">
{flowControls.map((control, index) => (
<FlowControlItem
key={control.id}
control={control}
flows={flows}
loading={loading}
isFirst={index === 0}
isLast={index === flowControls.length - 1}
onFlowSelect={(flowId) => handleFlowSelect(control.id, flowId)}
onTimingChange={(timing) => handleTimingChange(control.id, timing)}
onMoveUp={() => handleMoveUp(control.id)}
onMoveDown={() => handleMoveDown(control.id)}
onRemove={() => handleRemoveControl(control.id)}
/>
))}
</div>
)}
<div className="rounded bg-green-50 p-3">
<div className="flex items-start space-x-2">
<Info className="mt-0.5 h-4 w-4 text-green-600" />
<div className="text-xs text-green-800">
<p className="font-medium"> :</p>
<p className="mt-1"> / .</p>
<p className="mt-1"> 트랜잭션: /</p>
<p> 중단: 부모 </p>
</div>
{/* 안내 메시지 */}
{flowControls.length > 0 && (
<div className="rounded bg-blue-50 p-3">
<div className="flex items-start space-x-2">
<Info className="mt-0.5 h-4 w-4 text-blue-600" />
<div className="text-xs text-blue-800">
<p className="font-medium"> :</p>
<p className="mt-1"> </p>
<p> </p>
<p> </p>
</div>
</div>
</div>
@ -135,90 +271,89 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
};
/**
* 🔥
* 🔥
*/
const FlowSelector: React.FC<{
const FlowControlItem: React.FC<{
control: FlowControlConfig;
flows: NodeFlow[];
selectedFlowId?: number;
onSelect: (flowId: string) => void;
loading: boolean;
}> = ({ flows, selectedFlowId, onSelect, loading }) => {
isFirst: boolean;
isLast: boolean;
onFlowSelect: (flowId: string) => void;
onTimingChange: (timing: "before" | "after" | "replace") => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
}> = ({ control, flows, loading, isFirst, isLast, onFlowSelect, onTimingChange, onMoveUp, onMoveDown, onRemove }) => {
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Workflow className="h-4 w-4 text-green-600" />
<Label> </Label>
</div>
<Card className="p-3">
<div className="flex items-start gap-2">
{/* 순서 표시 및 이동 버튼 */}
<div className="flex flex-col items-center gap-1">
<Badge variant="secondary" className="h-6 w-6 justify-center rounded-full p-0 text-xs">
{control.order}
</Badge>
<div className="flex flex-col">
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onMoveUp} disabled={isFirst}>
<ChevronUp className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onMoveDown} disabled={isLast}>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
</div>
<Select value={selectedFlowId?.toString() || ""} onValueChange={onSelect}>
<SelectTrigger>
<SelectValue placeholder="플로우를 선택하세요" />
</SelectTrigger>
<SelectContent>
{loading ? (
<div className="p-4 text-center text-sm text-gray-500"> ...</div>
) : flows.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500">
<p> </p>
<p className="mt-2 text-xs"> </p>
</div>
) : (
flows.map((flow) => (
<SelectItem key={flow.flowId} value={flow.flowId.toString()}>
<div className="flex flex-col">
<span className="font-medium">{flow.flowName}</span>
{flow.flowDescription && (
<span className="text-muted-foreground text-xs">{flow.flowDescription}</span>
)}
</div>
{/* 플로우 선택 및 설정 */}
<div className="flex-1 space-y-2">
{/* 플로우 선택 */}
<Select value={control.flowId > 0 ? control.flowId.toString() : ""} onValueChange={onFlowSelect}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="플로우를 선택하세요" />
</SelectTrigger>
<SelectContent>
{loading ? (
<div className="p-2 text-center text-xs text-gray-500"> ...</div>
) : flows.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-500"> </div>
) : (
flows.map((flow) => (
<SelectItem key={flow.flowId} value={flow.flowId.toString()}>
<span className="text-xs">{flow.flowName}</span>
</SelectItem>
))
)}
</SelectContent>
</Select>
{/* 실행 타이밍 */}
<Select value={control.executionTiming} onValueChange={onTimingChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="before">
<span className="text-xs">Before ( )</span>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
);
};
<SelectItem value="after">
<span className="text-xs">After ( )</span>
</SelectItem>
<SelectItem value="replace">
<span className="text-xs">Replace ( )</span>
</SelectItem>
</SelectContent>
</Select>
</div>
/**
* 🔥
*/
const ExecutionTimingSelector: React.FC<{
value: string;
onChange: (timing: "before" | "after" | "replace") => void;
}> = ({ value, onChange }) => {
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-orange-600" />
<Label> </Label>
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600"
onClick={onRemove}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="실행 타이밍을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="before">
<div className="flex flex-col">
<span className="font-medium">Before ( )</span>
<span className="text-muted-foreground text-xs"> </span>
</div>
</SelectItem>
<SelectItem value="after">
<div className="flex flex-col">
<span className="font-medium">After ( )</span>
<span className="text-muted-foreground text-xs"> </span>
</div>
</SelectItem>
<SelectItem value="replace">
<div className="flex flex-col">
<span className="font-medium">Replace ( )</span>
<span className="text-muted-foreground text-xs"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
);
};

View File

@ -192,3 +192,4 @@ export function applyAutoFillToFormData(
return result;
}

View File

@ -468,7 +468,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
return rendererInstance.render();
} else {
// 함수형 컴포넌트
return <NewComponentRenderer {...rendererProps} />;
// refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제
return <NewComponentRenderer key={refreshKey} {...rendererProps} />;
}
}
} catch (error) {

View File

@ -47,7 +47,7 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
// 테이블 선택된 행 정보 (다중 선택 액션용)
selectedRows?: any[];
selectedRowsData?: any[];
// 테이블 정렬 정보 (엑셀 다운로드용)
sortBy?: string;
sortOrder?: "asc" | "desc";
@ -57,10 +57,10 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
flowSelectedData?: any[];
flowSelectedStepId?: number | null;
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
allComponents?: any[];
// 🆕 부모창에서 전달된 그룹 데이터 (모달에서 부모 데이터 접근용)
groupedData?: Record<string, any>[];
}
@ -109,11 +109,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
const splitPanelPosition = screenContext?.splitPanelPosition;
// 🆕 tableName이 props로 전달되지 않으면 ScreenContext에서 가져오기
const effectiveTableName = tableName || screenContext?.tableName;
const effectiveScreenId = screenId || screenContext?.screenId;
// 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출)
const propsOnSave = (props as any).onSave as (() => Promise<void>) | undefined;
const finalOnSave = onSave || propsOnSave;
@ -169,10 +169,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
if (!shouldFetchStatus) return;
let isMounted = true;
const fetchStatus = async () => {
if (!isMounted) return;
try {
const response = await apiClient.post(`/table-management/tables/${statusTableName}/data`, {
page: 1,
@ -180,12 +180,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
search: { [statusKeyField]: userId },
autoFilter: true,
});
if (!isMounted) return;
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
const firstRow = Array.isArray(rows) ? rows[0] : null;
if (response.data?.success && firstRow) {
const newStatus = firstRow[statusFieldName];
if (newStatus !== vehicleStatus) {
@ -206,10 +206,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 즉시 실행
setStatusLoading(true);
fetchStatus();
// 2초마다 갱신
const interval = setInterval(fetchStatus, 2000);
return () => {
isMounted = false;
clearInterval(interval);
@ -219,22 +219,22 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 버튼 비활성화 조건 계산
const isOperationButtonDisabled = useMemo(() => {
const actionConfig = component.componentConfig?.action;
if (actionConfig?.type !== "operation_control") return false;
// 1. 출발지/도착지 필수 체크
if (actionConfig?.requireLocationFields) {
const departureField = actionConfig.trackingDepartureField || "departure";
const destinationField = actionConfig.trackingArrivalField || "destination";
const departure = formData?.[departureField];
const destination = formData?.[destinationField];
// console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", {
// departureField, destinationField, departure, destination,
// buttonLabel: component.label
// console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", {
// departureField, destinationField, departure, destination,
// buttonLabel: component.label
// });
if (!departure || departure === "" || !destination || destination === "") {
// console.log("🚫 [ButtonPrimary] 출발지/도착지 미선택 → 비활성화:", component.label);
return true;
@ -246,20 +246,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const statusField = actionConfig.statusCheckField || "status";
// API 조회 결과를 우선 사용 (실시간 DB 상태 반영)
const currentStatus = vehicleStatus || formData?.[statusField];
const conditionType = actionConfig.statusConditionType || "enableOn";
const conditionValues = (actionConfig.statusConditionValues || "")
.split(",")
.map((v: string) => v.trim())
.filter((v: string) => v);
// console.log("🔍 [ButtonPrimary] 상태 조건 체크:", {
// console.log("🔍 [ButtonPrimary] 상태 조건 체크:", {
// statusField,
// formDataStatus: formData?.[statusField],
// apiStatus: vehicleStatus,
// currentStatus,
// conditionType,
// conditionValues,
// currentStatus,
// conditionType,
// conditionValues,
// buttonLabel: component.label,
// });
@ -274,7 +274,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// console.log("🚫 [ButtonPrimary] 상태값 없음 → 비활성화:", component.label);
return true;
}
if (conditionValues.length > 0) {
if (conditionType === "enableOn") {
// 이 상태일 때만 활성화
@ -539,7 +539,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
*/
const handleTransferDataAction = async (actionConfig: any) => {
const dataTransferConfig = actionConfig.dataTransfer;
if (!dataTransferConfig) {
toast.error("데이터 전달 설정이 없습니다.");
return;
@ -553,15 +553,15 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
try {
// 1. 소스 컴포넌트에서 데이터 가져오기
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
if (!sourceProvider) {
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`);
const allProviders = screenContext.getAllDataProviders();
// 테이블 리스트 우선 탐색
for (const [id, provider] of allProviders) {
if (provider.componentType === "table-list") {
@ -570,16 +570,18 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
break;
}
}
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
if (!sourceProvider && allProviders.size > 0) {
const firstEntry = allProviders.entries().next().value;
if (firstEntry) {
sourceProvider = firstEntry[1];
console.log(`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`);
console.log(
`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`,
);
}
}
if (!sourceProvider) {
toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다.");
return;
@ -587,12 +589,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}
const rawSourceData = sourceProvider.getSelectedData();
// 🆕 배열이 아닌 경우 배열로 변환
const sourceData = Array.isArray(rawSourceData) ? rawSourceData : (rawSourceData ? [rawSourceData] : []);
const sourceData = Array.isArray(rawSourceData) ? rawSourceData : rawSourceData ? [rawSourceData] : [];
console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) });
if (!sourceData || sourceData.length === 0) {
toast.warning("선택된 데이터가 없습니다.");
return;
@ -600,31 +602,32 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값)
let additionalData: Record<string, any> = {};
// 방법 1: additionalSources 설정에서 가져오기
if (dataTransferConfig.additionalSources && Array.isArray(dataTransferConfig.additionalSources)) {
for (const additionalSource of dataTransferConfig.additionalSources) {
const additionalProvider = screenContext.getDataProvider(additionalSource.componentId);
if (additionalProvider) {
const additionalValues = additionalProvider.getSelectedData();
if (additionalValues && additionalValues.length > 0) {
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
const firstValue = additionalValues[0];
// fieldName이 지정되어 있으면 그 필드만 추출
if (additionalSource.fieldName) {
additionalData[additionalSource.fieldName] = firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
additionalData[additionalSource.fieldName] =
firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
} else {
// fieldName이 없으면 전체 객체 병합
additionalData = { ...additionalData, ...firstValue };
}
console.log("📦 추가 데이터 수집 (additionalSources):", {
sourceId: additionalSource.componentId,
fieldName: additionalSource.fieldName,
value: additionalData[additionalSource.fieldName || 'all'],
value: additionalData[additionalSource.fieldName || "all"],
});
}
}
@ -639,7 +642,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const conditionalValue = formData.__conditionalContainerValue;
const conditionalLabel = formData.__conditionalContainerLabel;
const controlField = formData.__conditionalContainerControlField; // 🆕 제어 필드명 직접 사용
// 🆕 controlField가 있으면 그것을 필드명으로 사용 (자동 매핑!)
if (controlField) {
additionalData[controlField] = conditionalValue;
@ -651,7 +654,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} else {
// controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기
for (const [key, value] of Object.entries(formData)) {
if (value === conditionalValue && !key.startsWith('__')) {
if (value === conditionalValue && !key.startsWith("__")) {
additionalData[key] = conditionalValue;
console.log("📦 조건부 컨테이너 값 자동 포함:", {
fieldName: key,
@ -661,12 +664,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
break;
}
}
// 못 찾았으면 기본 필드명 사용
if (!Object.keys(additionalData).some(k => !k.startsWith('__'))) {
additionalData['condition_type'] = conditionalValue;
if (!Object.keys(additionalData).some((k) => !k.startsWith("__"))) {
additionalData["condition_type"] = conditionalValue;
console.log("📦 조건부 컨테이너 값 (기본 필드명):", {
fieldName: 'condition_type',
fieldName: "condition_type",
value: conditionalValue,
});
}
@ -698,7 +701,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 4. 매핑 규칙 적용 + 추가 데이터 병합
const mappedData = sourceData.map((row) => {
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
// 추가 데이터를 모든 행에 포함
return {
...mappedRow,
@ -718,7 +721,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
if (dataTransferConfig.targetType === "component") {
// 같은 화면의 컴포넌트로 전달
const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId);
if (!targetReceiver) {
toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`);
return;
@ -730,7 +733,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
mode: dataTransferConfig.mode || "append",
mappingRules: dataTransferConfig.mappingRules || [],
});
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
} else if (dataTransferConfig.targetType === "splitPanel") {
// 🆕 분할 패널의 반대편 화면으로 전달
@ -738,17 +741,18 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
return;
}
// 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
// SplitPanelPositionProvider로 전달된 위치를 우선 사용
const currentPosition = splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
const currentPosition =
splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
if (!currentPosition) {
toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId);
return;
}
console.log("📦 분할 패널 데이터 전달:", {
currentPosition,
splitPanelPositionFromHook: splitPanelPosition,
@ -756,14 +760,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
leftScreenId: splitPanelContext.leftScreenId,
rightScreenId: splitPanelContext.rightScreenId,
});
const result = await splitPanelContext.transferToOtherSide(
currentPosition,
mappedData,
dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항)
dataTransferConfig.mode || "append"
dataTransferConfig.mode || "append",
);
if (result.success) {
toast.success(result.message);
} else {
@ -782,7 +786,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
if (dataTransferConfig.clearAfterTransfer) {
sourceProvider.clearSelection();
}
} catch (error: any) {
console.error("❌ 데이터 전달 실패:", error);
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다.");
@ -816,16 +819,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 2. groupedData (부모창에서 모달로 전달된 데이터)
// 3. modalDataStore (분할 패널 등에서 선택한 데이터)
let effectiveSelectedRowsData = selectedRowsData;
// groupedData가 있으면 우선 사용 (모달에서 부모 데이터 접근)
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && groupedData && groupedData.length > 0) {
if (
(!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) &&
groupedData &&
groupedData.length > 0
) {
effectiveSelectedRowsData = groupedData;
console.log("🔗 [ButtonPrimaryComponent] groupedData에서 부모창 데이터 가져옴:", {
count: groupedData.length,
data: groupedData,
});
}
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && effectiveTableName) {
try {
@ -833,11 +840,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const dataRegistry = useModalDataStore.getState().dataRegistry;
const modalData = dataRegistry[effectiveTableName];
if (modalData && modalData.length > 0) {
effectiveSelectedRowsData = modalData;
// modalDataStore는 {id, originalData, additionalData} 형태로 저장됨
// originalData를 추출하여 실제 행 데이터를 가져옴
effectiveSelectedRowsData = modalData.map((item: any) => {
// originalData가 있으면 그것을 사용, 없으면 item 자체 사용 (하위 호환성)
return item.originalData || item;
});
console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", {
tableName: effectiveTableName,
count: modalData.length,
data: modalData,
rawData: modalData,
extractedData: effectiveSelectedRowsData,
});
}
} catch (error) {
@ -847,7 +860,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
const hasDataToDelete =
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) ||
(flowSelectedData && flowSelectedData.length > 0);
if (processedConfig.action.type === "delete" && !hasDataToDelete) {
toast.warning("삭제할 항목을 먼저 선택해주세요.");
@ -1064,15 +1078,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
alignItems: "center",
justifyContent: "center",
// 🔧 크기에 따른 패딩 조정
padding:
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
margin: "0",
lineHeight: "1.25",
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
...(component.style ? Object.fromEntries(
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
) : {}),
...(component.style
? Object.fromEntries(Object.entries(component.style).filter(([key]) => key !== "width" && key !== "height"))
: {}),
};
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
@ -1094,7 +1107,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
<button
type={componentConfig.actionType || "button"}
disabled={finalDisabled}
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
className="transition-colors transition-transform duration-150 hover:opacity-90 active:scale-95"
style={buttonElementStyle}
onClick={handleClick}
onDragStart={onDragStart}

View File

@ -52,7 +52,6 @@ import "./entity-search-input/EntitySearchInputRenderer";
import "./modal-repeater-table/ModalRepeaterTableRenderer";
import "./simple-repeater-table/SimpleRepeaterTableRenderer"; // 🆕 단순 반복 테이블
import "./repeat-screen-modal/RepeatScreenModalRenderer"; // 🆕 반복 화면 모달 (카드 형태)
import "./order-registration-modal/OrderRegistrationModalRenderer";
// 🆕 조건부 컨테이너 컴포넌트
import "./conditional-container/ConditionalContainerRenderer";

View File

@ -78,7 +78,18 @@ async function fetchReferenceValue(
// 연산자가 "=" 인 경우만 지원 (확장 가능)
if (operator === "=") {
whereConditions[targetField] = value;
// 숫자형 ID인 경우 숫자로 변환 (문자열 '189' → 숫자 189)
// 백엔드에서 entity 타입 컬럼 검색 시 문자열이면 ILIKE 검색을 수행하므로
// 정확한 ID 매칭을 위해 숫자로 변환해야 함
let convertedValue = value;
if (targetField.endsWith('_id') || targetField === 'id') {
const numValue = Number(value);
if (!isNaN(numValue)) {
convertedValue = numValue;
console.log(` 🔢 ID 타입 변환: ${targetField} = "${value}" → ${numValue}`);
}
}
whereConditions[targetField] = convertedValue;
} else {
console.warn(`⚠️ 연산자 "${operator}"는 아직 지원되지 않습니다.`);
}
@ -198,14 +209,43 @@ export function ModalRepeaterTableComponent({
const columnName = component?.columnName;
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
// 빈 객체 판단 함수 (수정 모달의 실제 데이터는 유지)
const isEmptyRow = (item: any): boolean => {
if (!item || typeof item !== 'object') return true;
// id가 있으면 실제 데이터 (수정 모달)
if (item.id) return false;
// 모든 값이 비어있는지 확인 (계산 필드 제외)
const hasValue = Object.entries(item).some(([key, value]) => {
// 계산 필드나 메타데이터는 제외
if (key.startsWith('_') || key === 'total_amount') return false;
// 실제 값이 있는지 확인
return value !== undefined &&
value !== null &&
value !== '' &&
value !== 0 &&
value !== '0' &&
value !== '0.00';
});
return !hasValue;
};
// 🆕 내부 상태로 데이터 관리 (즉시 UI 반영을 위해)
const [localValue, setLocalValue] = useState<any[]>(externalValue);
const [localValue, setLocalValue] = useState<any[]>(() => {
return externalValue.filter((item) => !isEmptyRow(item));
});
// 🆕 외부 값(formData, propValue) 변경 시 내부 상태 동기화
useEffect(() => {
// 빈 객체 필터링
const filteredValue = externalValue.filter((item) => !isEmptyRow(item));
// 외부 값이 변경되었고, 내부 값과 다른 경우에만 동기화
if (JSON.stringify(externalValue) !== JSON.stringify(localValue)) {
setLocalValue(externalValue);
if (JSON.stringify(filteredValue) !== JSON.stringify(localValue)) {
setLocalValue(filteredValue);
}
}, [externalValue]);
@ -475,11 +515,18 @@ export function ModalRepeaterTableComponent({
const whereConditions: Record<string, any> = {};
for (const cond of joinConditions) {
const value = rowData[cond.sourceField];
let value = rowData[cond.sourceField];
if (value === undefined || value === null) {
console.warn(`⚠️ 조인 조건의 소스 필드 "${cond.sourceField}" 값이 없음`);
return undefined;
}
// 숫자형 ID인 경우 숫자로 변환
if (cond.targetField.endsWith('_id') || cond.targetField === 'id') {
const numValue = Number(value);
if (!isNaN(numValue)) {
value = numValue;
}
}
whereConditions[cond.targetField] = value;
}
@ -532,8 +579,16 @@ export function ModalRepeaterTableComponent({
}
// 테이블 조회
// 숫자형 ID인 경우 숫자로 변환
let convertedFromValue = fromValue;
if (joinCondition.toField.endsWith('_id') || joinCondition.toField === 'id') {
const numValue = Number(fromValue);
if (!isNaN(numValue)) {
convertedFromValue = numValue;
}
}
const whereConditions: Record<string, any> = {
[joinCondition.toField]: fromValue
[joinCondition.toField]: convertedFromValue
};
console.log(` 🔍 단계 ${i + 1}: ${tableName} 조회`, whereConditions);

View File

@ -1,93 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface OrderRegistrationModalConfig {
buttonText?: string;
buttonVariant?: "default" | "secondary" | "outline" | "ghost";
buttonSize?: "default" | "sm" | "lg";
}
interface OrderRegistrationModalConfigPanelProps {
config: OrderRegistrationModalConfig;
onConfigChange: (config: OrderRegistrationModalConfig) => void;
}
export function OrderRegistrationModalConfigPanel({
config,
onConfigChange,
}: OrderRegistrationModalConfigPanelProps) {
const [localConfig, setLocalConfig] = useState(config);
useEffect(() => {
setLocalConfig(config);
}, [config]);
const updateConfig = (updates: Partial<OrderRegistrationModalConfig>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.buttonText || "수주 등록"}
onChange={(e) => updateConfig({ buttonText: e.target.value })}
placeholder="수주 등록"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={localConfig.buttonVariant || "default"}
onValueChange={(value: any) => updateConfig({ buttonVariant: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"></SelectItem>
<SelectItem value="secondary"></SelectItem>
<SelectItem value="outline"></SelectItem>
<SelectItem value="ghost"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={localConfig.buttonSize || "default"}
onValueChange={(value: any) => updateConfig({ buttonSize: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"></SelectItem>
<SelectItem value="default"></SelectItem>
<SelectItem value="lg"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="p-4 bg-muted rounded-md text-xs text-muted-foreground">
<p className="font-medium mb-2">💡 :</p>
<ul className="space-y-1 list-disc list-inside">
<li> </li>
<li> , </li>
<li> 방식: 거래처 //</li>
</ul>
</div>
</div>
);
}

View File

@ -1,56 +0,0 @@
"use client";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import OrderRegistrationModalDefinition from "./index";
import { OrderRegistrationModalConfigPanel } from "./OrderRegistrationModalConfigPanel";
interface OrderRegistrationModalRendererProps {
buttonText?: string;
buttonVariant?: "default" | "secondary" | "outline" | "ghost" | "destructive";
buttonSize?: "default" | "sm" | "lg";
style?: React.CSSProperties;
}
export function OrderRegistrationModalRenderer({
buttonText = "수주 등록",
buttonVariant = "default",
buttonSize = "default",
style,
}: OrderRegistrationModalRendererProps) {
const [isOpen, setIsOpen] = useState(false);
// style에서 width, height 제거 (h-full w-full로 제어)
const { width, height, ...restStyle } = style || {};
return (
<>
<Button
variant={buttonVariant}
size={buttonSize}
onClick={() => setIsOpen(true)}
className="h-full w-full"
style={restStyle}
>
<Plus className="mr-2 h-4 w-4" />
{buttonText}
</Button>
<OrderRegistrationModal open={isOpen} onOpenChange={setIsOpen} />
</>
);
}
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent({
...OrderRegistrationModalDefinition,
component: OrderRegistrationModalRenderer,
renderer: OrderRegistrationModalRenderer,
configPanel: OrderRegistrationModalConfigPanel,
} as any);
}

View File

@ -1,68 +0,0 @@
/**
*
* , ,
*/
import { ComponentDefinition, ComponentCategory } from "@/types/component";
export const OrderRegistrationModalDefinition: Omit<ComponentDefinition, "renderer" | "configPanel" | "component"> = {
id: "order-registration-modal",
name: "수주등록 모달",
category: ComponentCategory.ACTION,
webType: "button" as const,
description: "거래처, 품목을 선택하여 수주를 등록하는 모달",
icon: "FileText",
version: "1.0.0",
author: "WACE",
tags: ["수주", "주문", "영업", "모달"],
defaultSize: {
width: 120,
height: 40,
},
defaultConfig: {
buttonText: "수주 등록",
buttonVariant: "default",
buttonSize: "default",
},
defaultProps: {
style: {
width: "120px",
height: "40px",
},
},
configSchema: {
buttonText: {
type: "string",
label: "버튼 텍스트",
defaultValue: "수주 등록",
},
buttonVariant: {
type: "select",
label: "버튼 스타일",
options: [
{ label: "기본", value: "default" },
{ label: "보조", value: "secondary" },
{ label: "외곽선", value: "outline" },
{ label: "고스트", value: "ghost" },
],
defaultValue: "default",
},
buttonSize: {
type: "select",
label: "버튼 크기",
options: [
{ label: "작게", value: "sm" },
{ label: "기본", value: "default" },
{ label: "크게", value: "lg" },
],
defaultValue: "default",
},
},
};
export default OrderRegistrationModalDefinition;

View File

@ -468,8 +468,17 @@ export function RepeatScreenModalComponent({
// 조인 조건 생성
const filters: Record<string, any> = {};
for (const condition of dataSourceConfig.joinConditions) {
const refValue = representativeData[condition.referenceKey];
let refValue = representativeData[condition.referenceKey];
if (refValue !== undefined && refValue !== null) {
// 숫자형 ID인 경우 숫자로 변환 (문자열 '189' → 숫자 189)
// 백엔드에서 entity 타입 컬럼 검색 시 문자열이면 ILIKE 검색을 수행하므로
// 정확한 ID 매칭을 위해 숫자로 변환해야 함
if (condition.sourceKey.endsWith('_id') || condition.sourceKey === 'id') {
const numValue = Number(refValue);
if (!isNaN(numValue)) {
refValue = numValue;
}
}
filters[condition.sourceKey] = refValue;
}
}
@ -479,6 +488,14 @@ export function RepeatScreenModalComponent({
continue;
}
console.log(`[RepeatScreenModal] 외부 테이블 API 호출:`, {
sourceTable: dataSourceConfig.sourceTable,
filters,
joinConditions: dataSourceConfig.joinConditions,
representativeDataId: representativeData.id,
representativeDataIdType: typeof representativeData.id,
});
// API 호출 - 메인 테이블 데이터
const response = await apiClient.post(
`/table-management/tables/${dataSourceConfig.sourceTable}/data`,

View File

@ -100,6 +100,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return item[exactKey];
}
// 🆕 2-1⃣ item_id 패턴 시도 (백엔드가 item_id_xxx 형식으로 반환하는 경우)
// 예: item_info.item_name → item_id_item_name
const idPatternKey = `${tableName.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`;
if (item[idPatternKey] !== undefined) {
return item[idPatternKey];
}
// 3⃣ 별칭 패턴: 소스컬럼_name (기본 표시 컬럼용)
// 예: item_code_name (item_name의 별칭)
if (fieldName === "item_name" || fieldName === "name") {
@ -107,6 +114,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (item[aliasKey] !== undefined) {
return item[aliasKey];
}
// 🆕 item_id_name 패턴도 시도
const idAliasKey = `${tableName.replace("_info", "_id").replace("_mng", "_id")}_name`;
if (item[idAliasKey] !== undefined) {
return item[idAliasKey];
}
}
// 4⃣ entityColumnMap에서 매핑 찾기 (화면 설정에서 지정된 경우)
@ -703,8 +715,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const colName = typeof col === "string" ? col : col.name || col.columnName;
if (colName && colName.includes(".")) {
const [refTable, refColumn] = colName.split(".");
// 소스 컬럼 추론 (item_info → item_code)
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
// 소스 컬럼 추론 (item_info → item_code 또는 warehouse_info → warehouse_id)
// 기본: _info → _code, 백업: _info → _id
const primarySourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
const secondarySourceColumn = refTable.replace("_info", "_id").replace("_mng", "_id");
// 실제 존재하는 소스 컬럼은 백엔드에서 결정 (프론트엔드는 두 패턴 모두 전달)
const inferredSourceColumn = primarySourceColumn;
// 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼)
const existingJoin = additionalJoinColumns.find(
@ -1023,7 +1039,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const uniqueValues = new Set<string>();
leftData.forEach((item) => {
// 🆕 조인 컬럼 처리 (item_info.standard → item_code_standard)
// 🆕 조인 컬럼 처리 (item_info.standard → item_code_standard 또는 item_id_standard)
let value: any;
if (columnName.includes(".")) {
@ -1035,10 +1051,21 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const exactKey = `${inferredSourceColumn}_${fieldName}`;
value = item[exactKey];
// 기본 별칭 패턴 시도 (item_code_name)
// 🆕 item_id 패턴 시도
if (value === undefined) {
const idPatternKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`;
value = item[idPatternKey];
}
// 기본 별칭 패턴 시도 (item_code_name 또는 item_id_name)
if (value === undefined && (fieldName === "item_name" || fieldName === "name")) {
const aliasKey = `${inferredSourceColumn}_name`;
value = item[aliasKey];
// item_id_name 패턴도 시도
if (value === undefined) {
const idAliasKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_name`;
value = item[idAliasKey];
}
}
} else {
// 일반 컬럼

View File

@ -23,7 +23,7 @@ import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2 } from "lucide
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { generateNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { generateNumberingCode, allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
@ -200,29 +200,49 @@ export function UniversalFormModalComponent({
// 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시)
const capturedInitialData = useRef<Record<string, any> | undefined>(undefined);
const hasInitialized = useRef(false);
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
const lastInitializedId = useRef<string | undefined>(undefined);
// 초기화 - 최초 마운트 시에만 실행
// 초기화 - 최초 마운트 시 또는 initialData의 ID가 변경되었을 때 실행
useEffect(() => {
// 이미 초기화되었으면 스킵
if (hasInitialized.current) {
// initialData에서 ID 값 추출 (id, ID, objid 등)
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
// 이미 초기화되었고, ID가 동일하면 스킵
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
return;
}
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
if (hasInitialized.current && currentIdString && lastInitializedId.current !== currentIdString) {
console.log("[UniversalFormModal] ID 변경 감지 - 재초기화:", {
prevId: lastInitializedId.current,
newId: currentIdString,
initialData: initialData,
});
// 채번 플래그 초기화 (새 항목이므로)
numberingGeneratedRef.current = false;
isGeneratingRef.current = false;
}
// 최초 initialData 캡처 (이후 변경되어도 이 값 사용)
if (initialData && Object.keys(initialData).length > 0) {
capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사
lastInitializedId.current = currentIdString;
console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
}
hasInitialized.current = true;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 빈 의존성 배열 - 마운트 시 한 번만 실행
}, [initialData?.id, initialData?.ID, initialData?.objid]); // ID 값 변경 시 재초기화
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
useEffect(() => {
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
console.log('[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)');
console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
@ -230,7 +250,7 @@ export function UniversalFormModalComponent({
// 컴포넌트 unmount 시 채번 플래그 초기화
useEffect(() => {
return () => {
console.log('[채번] 컴포넌트 unmount - 플래그 초기화');
console.log("[채번] 컴포넌트 unmount - 플래그 초기화");
numberingGeneratedRef.current = false;
isGeneratingRef.current = false;
};
@ -241,7 +261,7 @@ export function UniversalFormModalComponent({
useEffect(() => {
const handleBeforeFormSave = (event: Event) => {
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
// 설정에 정의된 필드 columnName 목록 수집
const configuredFields = new Set<string>();
config.sections.forEach((section) => {
@ -251,23 +271,26 @@ export function UniversalFormModalComponent({
}
});
});
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
// (UniversalFormModal이 해당 필드의 주인이므로)
for (const [key, value] of Object.entries(formData)) {
// 설정에 정의된 필드만 병합
if (configuredFields.has(key)) {
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
const isConfiguredField = configuredFields.has(key);
const isNumberingRuleId = key.endsWith("_numberingRuleId");
if (isConfiguredField || isNumberingRuleId) {
if (value !== undefined && value !== null && value !== "") {
event.detail.formData[key] = value;
console.log(`[UniversalFormModal] 필드 병합: ${key} =`, value);
}
}
}
// 반복 섹션 데이터도 병합 (필요한 경우)
if (Object.keys(repeatSections).length > 0) {
for (const [sectionId, items] of Object.entries(repeatSections)) {
@ -277,9 +300,9 @@ export function UniversalFormModalComponent({
}
}
};
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
};
@ -313,11 +336,18 @@ export function UniversalFormModalComponent({
// 폼 초기화
const initializeForm = useCallback(async () => {
console.log('[initializeForm] 시작');
console.log("[initializeForm] 시작");
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
const effectiveInitialData = capturedInitialData.current || initialData;
console.log("[initializeForm] 초기 데이터:", {
capturedInitialData: capturedInitialData.current,
initialData: initialData,
effectiveInitialData: effectiveInitialData,
hasData: effectiveInitialData && Object.keys(effectiveInitialData).length > 0,
});
const newFormData: FormDataState = {};
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
const newCollapsed = new Set<string>();
@ -365,9 +395,9 @@ export function UniversalFormModalComponent({
setOriginalData(effectiveInitialData || {});
// 채번규칙 자동 생성
console.log('[initializeForm] generateNumberingValues 호출');
console.log("[initializeForm] generateNumberingValues 호출");
await generateNumberingValues(newFormData);
console.log('[initializeForm] 완료');
console.log("[initializeForm] 완료");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
@ -388,23 +418,23 @@ export function UniversalFormModalComponent({
// 채번규칙 자동 생성 (중복 호출 방지)
const numberingGeneratedRef = useRef(false);
const isGeneratingRef = useRef(false); // 진행 중 플래그 추가
const generateNumberingValues = useCallback(
async (currentFormData: FormDataState) => {
// 이미 생성되었거나 진행 중이면 스킵
if (numberingGeneratedRef.current) {
console.log('[채번] 이미 생성됨 - 스킵');
console.log("[채번] 이미 생성됨 - 스킵");
return;
}
if (isGeneratingRef.current) {
console.log('[채번] 생성 진행 중 - 스킵');
console.log("[채번] 생성 진행 중 - 스킵");
return;
}
isGeneratingRef.current = true; // 진행 중 표시
console.log('[채번] 생성 시작');
console.log("[채번] 생성 시작");
const updatedData = { ...currentFormData };
let hasChanges = false;
@ -419,29 +449,46 @@ export function UniversalFormModalComponent({
!updatedData[field.columnName]
) {
try {
console.log(`[채번 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`);
// generateOnOpen: 모달 열 때 실제 순번 할당 (DB 시퀀스 즉시 증가)
const response = await allocateNumberingCode(field.numberingRule.ruleId);
console.log(`[채번 미리보기 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`);
// generateOnOpen: 미리보기만 표시 (DB 시퀀스 증가 안 함)
const response = await previewNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
updatedData[field.columnName] = response.data.generatedCode;
// 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
const ruleIdKey = `${field.columnName}_numberingRuleId`;
updatedData[ruleIdKey] = field.numberingRule.ruleId;
hasChanges = true;
numberingGeneratedRef.current = true; // 생성 완료 표시
console.log(`[채번 완료] ${field.columnName} = ${response.data.generatedCode}`);
console.log(
`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
);
console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
// 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
if (onChange) {
onChange({
...updatedData,
[ruleIdKey]: field.numberingRule.ruleId,
});
console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
}
}
} catch (error) {
console.error(`채번규칙 생성 실패 (${field.columnName}):`, error);
console.error(`채번규칙 미리보기 실패 (${field.columnName}):`, error);
}
}
}
}
isGeneratingRef.current = false; // 진행 완료
if (hasChanges) {
setFormData(updatedData);
}
},
[config],
[config, onChange],
);
// 필드 값 변경 핸들러
@ -661,9 +708,9 @@ export function UniversalFormModalComponent({
const saveSingleRow = useCallback(async () => {
const dataToSave = { ...formData };
// 메타데이터 필드 제거
// 메타데이터 필드 제거 (채번 규칙 ID는 유지 - buttonActions.ts에서 사용)
Object.keys(dataToSave).forEach((key) => {
if (key.startsWith("_")) {
if (key.startsWith("_") && !key.includes("_numberingRuleId")) {
delete dataToSave[key];
}
});

View File

@ -681,13 +681,52 @@ export class ButtonActionExecutor {
console.log("📦 최종 formData:", JSON.stringify(formData, null, 2));
// 🆕 분할 패널 부모 데이터 병합 (좌측 화면에서 선택된 데이터)
const splitPanelData = context.splitPanelParentData || {};
if (Object.keys(splitPanelData).length > 0) {
console.log("🔗 [handleSave] 분할 패널 부모 데이터 병합:", splitPanelData);
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 병합해야 함
// 모든 필드를 병합하면 동일한 컬럼명이 있을 때 부모 값이 들어가는 문제 발생
// 예: 설비의 manufacturer가 소모품의 manufacturer로 들어감
const rawSplitPanelData = context.splitPanelParentData || {};
// INSERT 모드에서는 연결에 필요한 필드만 추출
const cleanedSplitPanelData: Record<string, any> = {};
// 필수 연결 필드: company_code (멀티테넌시)
if (rawSplitPanelData.company_code) {
cleanedSplitPanelData.company_code = rawSplitPanelData.company_code;
}
// 연결 필드 패턴으로 자동 감지 (equipment_code, xxx_code, xxx_id 패턴)
const linkFieldPatterns = ["_code", "_id"];
const excludeFields = [
"id",
"company_code",
"created_date",
"updated_date",
"created_at",
"updated_at",
"writer",
"created_by",
"updated_by",
];
for (const [key, value] of Object.entries(rawSplitPanelData)) {
if (excludeFields.includes(key)) continue;
if (value === undefined || value === null) continue;
// 연결 필드 패턴 확인
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
if (isLinkField) {
cleanedSplitPanelData[key] = value;
console.log(`🔗 [handleSave] INSERT 모드 - 연결 필드만 병합: ${key} = ${value}`);
}
}
if (Object.keys(rawSplitPanelData).length > 0) {
console.log("🧹 [handleSave] 원본 분할 패널 부모 데이터:", Object.keys(rawSplitPanelData));
console.log("🧹 [handleSave] 정리된 분할 패널 부모 데이터 (연결 필드만):", cleanedSplitPanelData);
}
const dataWithUserInfo = {
...splitPanelData, // 분할 패널 부모 데이터 먼저 적용
...cleanedSplitPanelData, // 정리된 분할 패널 부모 데이터 먼저 적용
...formData, // 폼 데이터가 우선 (덮어쓰기 가능)
writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
created_by: writerValue, // created_by는 항상 로그인한 사람
@ -695,6 +734,12 @@ export class ButtonActionExecutor {
company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
};
// 🔧 formData에서도 id 제거 (신규 INSERT이므로)
if ("id" in dataWithUserInfo && !formData.id) {
console.log("🗑️ [handleSave] INSERT 모드 - dataWithUserInfo에서 id 제거:", dataWithUserInfo.id);
delete dataWithUserInfo.id;
}
// _numberingRuleId 필드 제거 (실제 저장하지 않음)
for (const key of Object.keys(dataWithUserInfo)) {
if (key.endsWith("_numberingRuleId")) {
@ -1578,14 +1623,16 @@ export class ButtonActionExecutor {
/**
*
* 🔧 modal (INSERT)
* edit (UPDATE)
*/
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
// 모달 열기 로직
console.log("모달 열기:", {
console.log("모달 열기 (신규 등록 모드):", {
title: config.modalTitle,
size: config.modalSize,
targetScreenId: config.targetScreenId,
selectedRowsData: context.selectedRowsData,
// 🔧 selectedRowsData는 modal 액션에서 사용하지 않음 (신규 등록이므로)
});
if (config.targetScreenId) {
@ -1602,10 +1649,11 @@ export class ButtonActionExecutor {
}
}
// 🆕 선택된 행 데이터 수집
const selectedData = context.selectedRowsData || [];
console.log("📦 [handleModal] 선택된 데이터:", selectedData);
console.log("📦 [handleModal] 분할 패널 부모 데이터:", context.splitPanelParentData);
// 🔧 modal 액션은 신규 등록이므로 selectedData를 전달하지 않음
// selectedData가 있으면 ScreenModal에서 originalData로 인식하여 UPDATE 모드로 동작하게 됨
// edit 액션만 selectedData/editData를 사용하여 UPDATE 모드로 동작
console.log("📦 [handleModal] 신규 등록 모드 - selectedData 전달하지 않음");
console.log("📦 [handleModal] 분할 패널 부모 데이터 (초기값으로 사용):", context.splitPanelParentData);
// 전역 모달 상태 업데이트를 위한 이벤트 발생
const modalEvent = new CustomEvent("openScreenModal", {
@ -1614,10 +1662,11 @@ export class ButtonActionExecutor {
title: config.modalTitle || "화면",
description: description,
size: config.modalSize || "md",
// 🆕 선택된 행 데이터 전달
selectedData: selectedData,
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
// 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 사용)
// 🔧 신규 등록이므로 selectedData/selectedIds를 전달하지 않음
// edit 액션에서만 이 데이터를 사용
selectedData: [],
selectedIds: [],
// 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 초기값으로 사용)
splitPanelParentData: context.splitPanelParentData || {},
},
});
@ -2621,6 +2670,7 @@ export class ButtonActionExecutor {
/**
* (After Timing)
*
*/
private static async executeAfterSaveControl(
config: ButtonActionConfig,
@ -2632,12 +2682,6 @@ export class ButtonActionExecutor {
dataflowTiming: config.dataflowTiming,
});
// dataflowTiming이 'after'가 아니면 실행하지 않음
if (config.dataflowTiming && config.dataflowTiming !== "after") {
console.log("⏭️ dataflowTiming이 'after'가 아니므로 제어 실행 건너뜀:", config.dataflowTiming);
return;
}
// 제어 데이터 소스 결정
let controlDataSource = config.dataflowConfig?.controlDataSource;
if (!controlDataSource) {
@ -2651,9 +2695,117 @@ export class ButtonActionExecutor {
controlDataSource,
};
// 🔥 다중 제어 지원 (flowControls 배열)
const flowControls = config.dataflowConfig?.flowControls || [];
if (flowControls.length > 0) {
console.log(`🎯 다중 제어 순차 실행 시작: ${flowControls.length}`);
// 순서대로 정렬
const sortedControls = [...flowControls].sort((a: any, b: any) => (a.order || 0) - (b.order || 0));
// 노드 플로우 실행 API
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
// 데이터 소스 준비
const sourceData: any = context.formData || {};
let allSuccess = true;
const results: Array<{ flowId: number; flowName: string; success: boolean; message?: string }> = [];
for (let i = 0; i < sortedControls.length; i++) {
const control = sortedControls[i];
// 유효하지 않은 flowId 스킵
if (!control.flowId || control.flowId <= 0) {
console.warn(`⚠️ [${i + 1}/${sortedControls.length}] 유효하지 않은 flowId, 스킵:`, control);
continue;
}
// executionTiming 체크 (after만 실행)
if (control.executionTiming && control.executionTiming !== "after") {
console.log(
`⏭️ [${i + 1}/${sortedControls.length}] executionTiming이 'after'가 아님, 스킵:`,
control.executionTiming,
);
continue;
}
console.log(
`\n📍 [${i + 1}/${sortedControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})`,
);
try {
const result = await executeNodeFlow(control.flowId, {
dataSourceType: controlDataSource,
sourceData,
context: extendedContext,
});
results.push({
flowId: control.flowId,
flowName: control.flowName,
success: result.success,
message: result.message,
});
if (result.success) {
console.log(`✅ [${i + 1}/${sortedControls.length}] 제어 성공: ${control.flowName}`);
} else {
console.error(`❌ [${i + 1}/${sortedControls.length}] 제어 실패: ${control.flowName} - ${result.message}`);
allSuccess = false;
// 이전 제어 실패 시 다음 제어 실행 중단
console.warn("⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단");
break;
}
} catch (error: any) {
console.error(`❌ [${i + 1}/${sortedControls.length}] 제어 실행 오류: ${control.flowName}`, error);
results.push({
flowId: control.flowId,
flowName: control.flowName,
success: false,
message: error.message,
});
allSuccess = false;
break;
}
}
// 결과 요약
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
console.log("\n📊 다중 제어 실행 완료:", {
total: sortedControls.length,
executed: results.length,
success: successCount,
failed: failCount,
});
if (allSuccess) {
toast.success(`${successCount}개 제어 실행 완료`);
} else {
toast.error(`제어 실행 중 오류 발생 (${successCount}/${results.length} 성공)`);
}
return;
}
// 🔥 기존 단일 제어 실행 (하위 호환성)
// dataflowTiming이 'after'가 아니면 실행하지 않음
if (config.dataflowTiming && config.dataflowTiming !== "after") {
console.log("⏭️ dataflowTiming이 'after'가 아니므로 제어 실행 건너뜀:", config.dataflowTiming);
return;
}
// 노드 플로우 방식 실행 (flowConfig가 있는 경우)
const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId;
if (hasFlowConfig) {
// executionTiming 체크
const flowTiming = config.dataflowConfig.flowConfig.executionTiming;
if (flowTiming && flowTiming !== "after") {
console.log("⏭️ flowConfig.executionTiming이 'after'가 아니므로 제어 실행 건너뜀:", flowTiming);
return;
}
console.log("🎯 저장 후 노드 플로우 실행:", config.dataflowConfig.flowConfig);
const { flowId } = config.dataflowConfig.flowConfig;
@ -2663,7 +2815,7 @@ export class ButtonActionExecutor {
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
// 데이터 소스 준비
let sourceData: any = context.formData || {};
const sourceData: any = context.formData || {};
// repeat-screen-modal 데이터가 있으면 병합
const repeatScreenModalKeys = Object.keys(context.formData || {}).filter((key) =>

View File

@ -32,8 +32,6 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
import("@/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel"),
"entity-search-input": () => import("@/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel"),
"modal-repeater-table": () => import("@/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel"),
"order-registration-modal": () =>
import("@/lib/registry/components/order-registration-modal/OrderRegistrationModalConfigPanel"),
// 🆕 조건부 컨테이너
"conditional-container": () =>
import("@/lib/registry/components/conditional-container/ConditionalContainerConfigPanel"),
@ -401,7 +399,6 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
"autocomplete-search-input",
"entity-search-input",
"modal-repeater-table",
"order-registration-modal",
"conditional-container",
].includes(componentId);

View File

@ -1684,3 +1684,4 @@ const 출고등록_설정: ScreenSplitPanel = {

View File

@ -531,3 +531,4 @@ const { data: config } = await getScreenSplitPanel(screenId);

View File

@ -518,3 +518,4 @@ function ScreenViewPage() {