Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
commit
8f6af5018c
|
|
@ -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); // 연쇄 드롭다운 관계 관리
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -898,9 +898,10 @@ class NumberingRuleService {
|
|||
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
// 순번 (현재 순번으로 미리보기, 증가 안 함)
|
||||
// 순번 (다음 할당될 순번으로 미리보기, 실제 증가는 allocate 시)
|
||||
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": {
|
||||
|
|
@ -958,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": {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감
|
|||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
|
||||
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신
|
||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 🆕 분할 패널 리사이즈
|
||||
|
||||
function ScreenViewPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -307,10 +308,7 @@ function ScreenViewPage() {
|
|||
return (
|
||||
<ScreenPreviewProvider isPreviewMode={false}>
|
||||
<TableOptionsProvider>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="bg-background h-full w-full overflow-auto p-3"
|
||||
>
|
||||
<div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
|
||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||
{!layoutReady && (
|
||||
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
||||
|
|
@ -358,7 +356,6 @@ function ScreenViewPage() {
|
|||
return isButton;
|
||||
});
|
||||
|
||||
|
||||
topLevelComponents.forEach((component) => {
|
||||
const isButton =
|
||||
(component.type === "component" &&
|
||||
|
|
@ -799,7 +796,9 @@ function ScreenViewPageWrapper() {
|
|||
return (
|
||||
<TableSearchWidgetHeightProvider>
|
||||
<ScreenContextProvider>
|
||||
<ScreenViewPage />
|
||||
<SplitPanelProvider>
|
||||
<ScreenViewPage />
|
||||
</SplitPanelProvider>
|
||||
</ScreenContextProvider>
|
||||
</TableSearchWidgetHeightProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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`
|
||||
|
||||
|
|
@ -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];
|
||||
|
|
@ -176,7 +176,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
loadGroupData();
|
||||
}
|
||||
}
|
||||
}, [modalState.isOpen, modalState.screenId]);
|
||||
}, [modalState.isOpen, modalState.screenId, modalState.groupByColumns, modalState.tableName]);
|
||||
|
||||
// 🆕 그룹 데이터 조회 함수
|
||||
const loadGroupData = async () => {
|
||||
|
|
@ -225,7 +225,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const dataArray = Array.isArray(response) ? response : response?.data || [];
|
||||
|
||||
if (dataArray.length > 0) {
|
||||
console.log("✅ 그룹 데이터 조회 성공:", dataArray);
|
||||
console.log("✅ 그룹 데이터 조회 성공:", dataArray.length, "건");
|
||||
setGroupData(dataArray);
|
||||
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
|
||||
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
|
||||
|
|
@ -751,15 +751,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
},
|
||||
};
|
||||
|
||||
// 🔍 디버깅: 컴포넌트 렌더링 시점의 groupData 확인
|
||||
if (component.id === screenData.components[0]?.id) {
|
||||
console.log("🔍 [EditModal] InteractiveScreenViewerDynamic props:", {
|
||||
componentId: component.id,
|
||||
groupDataLength: groupData.length,
|
||||
groupData: groupData,
|
||||
formData: groupData.length > 0 ? groupData[0] : formData,
|
||||
});
|
||||
}
|
||||
|
||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||
|
||||
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
|
||||
const enrichedFormData = {
|
||||
|
|
@ -811,7 +804,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
onSave={handleSave}
|
||||
isInModal={true}
|
||||
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
|
||||
groupedData={groupData.length > 0 ? groupData : undefined}
|
||||
groupedData={groupedDataProp}
|
||||
// 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처)
|
||||
disabledFields={["order_no", "partner_id"]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -267,7 +267,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
{/* 컨텐츠 */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={autoHeight ? "flex-1" : "flex-1 overflow-auto"}
|
||||
className={autoHeight ? "flex-1 w-full overflow-hidden" : "flex-1 w-full overflow-y-auto overflow-x-hidden"}
|
||||
style={
|
||||
autoHeight
|
||||
? {}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import { cn } from "@/lib/utils";
|
|||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
|
||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||
|
||||
/**
|
||||
* 🔗 연쇄 드롭다운 래퍼 컴포넌트
|
||||
|
|
@ -2101,113 +2102,115 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
: component;
|
||||
|
||||
return (
|
||||
<TableOptionsProvider>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 테이블 옵션 툴바 */}
|
||||
<TableOptionsToolbar />
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div className="h-full flex-1" style={{ width: '100%' }}>
|
||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||
{shouldShowLabel && (
|
||||
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{labelText}
|
||||
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
|
||||
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||
{showValidationPanel && enhancedValidation && (
|
||||
<div className="absolute bottom-4 right-4 z-50">
|
||||
<FormValidationIndicator
|
||||
validationState={enhancedValidation.validationState}
|
||||
saveState={enhancedValidation.saveState}
|
||||
onSave={async () => {
|
||||
const success = await enhancedValidation.saveForm();
|
||||
if (success) {
|
||||
toast.success("데이터가 성공적으로 저장되었습니다!");
|
||||
}
|
||||
}}
|
||||
canSave={enhancedValidation.canSave}
|
||||
compact={true}
|
||||
showDetails={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모달 화면 */}
|
||||
<Dialog open={!!popupScreen} onOpenChange={() => {
|
||||
setPopupScreen(null);
|
||||
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
|
||||
}}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
|
||||
<DialogHeader className="px-6 pt-4 pb-2">
|
||||
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<SplitPanelProvider>
|
||||
<TableOptionsProvider>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 테이블 옵션 툴바 */}
|
||||
<TableOptionsToolbar />
|
||||
|
||||
<div className="overflow-y-auto px-6 pb-6" style={{ maxHeight: "calc(90vh - 80px)" }}>
|
||||
{popupLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">화면을 불러오는 중...</div>
|
||||
</div>
|
||||
) : popupLayout.length > 0 ? (
|
||||
<div className="relative bg-background border rounded" style={{
|
||||
width: popupScreenResolution ? `${popupScreenResolution.width}px` : "100%",
|
||||
height: popupScreenResolution ? `${popupScreenResolution.height}px` : "400px",
|
||||
minHeight: "400px",
|
||||
position: "relative",
|
||||
overflow: "hidden"
|
||||
}}>
|
||||
{/* 팝업에서도 실제 위치와 크기로 렌더링 */}
|
||||
{popupLayout.map((popupComponent) => (
|
||||
<div
|
||||
key={popupComponent.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${popupComponent.position.x}px`,
|
||||
top: `${popupComponent.position.y}px`,
|
||||
width: popupComponent.style?.width || `${popupComponent.size.width}px`,
|
||||
height: popupComponent.style?.height || `${popupComponent.size.height}px`,
|
||||
zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한
|
||||
}}
|
||||
>
|
||||
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
|
||||
<InteractiveScreenViewer
|
||||
component={popupComponent}
|
||||
allComponents={popupLayout}
|
||||
hideLabel={false}
|
||||
screenInfo={popupScreenInfo || undefined}
|
||||
formData={popupFormData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("💾 팝업 formData 업데이트:", {
|
||||
fieldName,
|
||||
value,
|
||||
valueType: typeof value,
|
||||
prevFormData: popupFormData
|
||||
});
|
||||
|
||||
setPopupFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">화면 데이터가 없습니다.</div>
|
||||
</div>
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div className="h-full flex-1" style={{ width: '100%' }}>
|
||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||
{shouldShowLabel && (
|
||||
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{labelText}
|
||||
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
|
||||
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableOptionsProvider>
|
||||
</div>
|
||||
|
||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||
{showValidationPanel && enhancedValidation && (
|
||||
<div className="absolute bottom-4 right-4 z-50">
|
||||
<FormValidationIndicator
|
||||
validationState={enhancedValidation.validationState}
|
||||
saveState={enhancedValidation.saveState}
|
||||
onSave={async () => {
|
||||
const success = await enhancedValidation.saveForm();
|
||||
if (success) {
|
||||
toast.success("데이터가 성공적으로 저장되었습니다!");
|
||||
}
|
||||
}}
|
||||
canSave={enhancedValidation.canSave}
|
||||
compact={true}
|
||||
showDetails={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모달 화면 */}
|
||||
<Dialog open={!!popupScreen} onOpenChange={() => {
|
||||
setPopupScreen(null);
|
||||
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
|
||||
}}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
|
||||
<DialogHeader className="px-6 pt-4 pb-2">
|
||||
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto px-6 pb-6" style={{ maxHeight: "calc(90vh - 80px)" }}>
|
||||
{popupLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">화면을 불러오는 중...</div>
|
||||
</div>
|
||||
) : popupLayout.length > 0 ? (
|
||||
<div className="relative bg-background border rounded" style={{
|
||||
width: popupScreenResolution ? `${popupScreenResolution.width}px` : "100%",
|
||||
height: popupScreenResolution ? `${popupScreenResolution.height}px` : "400px",
|
||||
minHeight: "400px",
|
||||
position: "relative",
|
||||
overflow: "hidden"
|
||||
}}>
|
||||
{/* 팝업에서도 실제 위치와 크기로 렌더링 */}
|
||||
{popupLayout.map((popupComponent) => (
|
||||
<div
|
||||
key={popupComponent.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${popupComponent.position.x}px`,
|
||||
top: `${popupComponent.position.y}px`,
|
||||
width: popupComponent.style?.width || `${popupComponent.size.width}px`,
|
||||
height: popupComponent.style?.height || `${popupComponent.size.height}px`,
|
||||
zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한
|
||||
}}
|
||||
>
|
||||
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
|
||||
<InteractiveScreenViewer
|
||||
component={popupComponent}
|
||||
allComponents={popupLayout}
|
||||
hideLabel={false}
|
||||
screenInfo={popupScreenInfo || undefined}
|
||||
formData={popupFormData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("💾 팝업 formData 업데이트:", {
|
||||
fieldName,
|
||||
value,
|
||||
valueType: typeof value,
|
||||
prevFormData: popupFormData
|
||||
});
|
||||
|
||||
setPopupFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">화면 데이터가 없습니다.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableOptionsProvider>
|
||||
</SplitPanelProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -14,6 +14,7 @@ import { FileUpload } from "./widgets/FileUpload";
|
|||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
|
||||
import { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate";
|
||||
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||
import {
|
||||
Database,
|
||||
Type,
|
||||
|
|
@ -110,8 +111,8 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
|||
};
|
||||
|
||||
// 동적 웹 타입 위젯 렌더링 컴포넌트
|
||||
const WidgetRenderer: React.FC<{
|
||||
component: ComponentData;
|
||||
const WidgetRenderer: React.FC<{
|
||||
component: ComponentData;
|
||||
isDesignMode?: boolean;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
|
|
@ -253,22 +254,23 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
// 플로우 위젯의 실제 높이 측정
|
||||
useEffect(() => {
|
||||
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
||||
|
||||
const isFlowWidget =
|
||||
type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
||||
|
||||
if (isFlowWidget && contentRef.current) {
|
||||
const measureHeight = () => {
|
||||
if (contentRef.current) {
|
||||
// getBoundingClientRect()로 실제 렌더링된 높이 측정
|
||||
const rect = contentRef.current.getBoundingClientRect();
|
||||
const measured = rect.height;
|
||||
|
||||
|
||||
// scrollHeight도 함께 확인하여 더 큰 값 사용
|
||||
const scrollHeight = contentRef.current.scrollHeight;
|
||||
const rawHeight = Math.max(measured, scrollHeight);
|
||||
|
||||
|
||||
// 40px 단위로 올림
|
||||
const finalHeight = Math.ceil(rawHeight / 40) * 40;
|
||||
|
||||
|
||||
if (finalHeight > 0 && Math.abs(finalHeight - (actualHeight || 0)) > 10) {
|
||||
setActualHeight(finalHeight);
|
||||
}
|
||||
|
|
@ -400,12 +402,118 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
}, [component.id, fileUpdateTrigger]);
|
||||
|
||||
// 컴포넌트 스타일 계산
|
||||
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
||||
const isFlowWidget =
|
||||
type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
||||
const isSectionPaper = type === "component" && (component as any).componentConfig?.type === "section-paper";
|
||||
|
||||
|
||||
const positionX = position?.x || 0;
|
||||
const positionY = position?.y || 0;
|
||||
|
||||
// 🆕 분할 패널 리사이즈 Context
|
||||
const { getAdjustedX, getOverlappingSplitPanel } = useSplitPanel();
|
||||
|
||||
// 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상)
|
||||
const componentType = (component as any).componentType || "";
|
||||
const componentId = (component as any).componentId || "";
|
||||
const widgetType = (component as any).widgetType || "";
|
||||
|
||||
const isButtonComponent =
|
||||
(type === "widget" && widgetType === "button") ||
|
||||
(type === "component" &&
|
||||
(["button-primary", "button-secondary"].includes(componentType) ||
|
||||
["button-primary", "button-secondary"].includes(componentId)));
|
||||
|
||||
// 디버깅: 모든 컴포넌트의 타입 정보 출력 (버튼 관련만)
|
||||
if (componentType.includes("button") || componentId.includes("button") || widgetType.includes("button")) {
|
||||
console.log("🔘 [RealtimePreview] 버튼 컴포넌트 발견:", {
|
||||
id: component.id,
|
||||
type,
|
||||
componentType,
|
||||
componentId,
|
||||
widgetType,
|
||||
isButtonComponent,
|
||||
positionX,
|
||||
positionY,
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 분할 패널 위 버튼 위치 자동 조정
|
||||
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = useMemo(() => {
|
||||
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
|
||||
const isSplitPanelComponent =
|
||||
type === "component" &&
|
||||
["split-panel-layout", "split-panel-layout2"].includes((component as any).componentType || "");
|
||||
|
||||
if (!isButtonComponent || isSplitPanelComponent) {
|
||||
return { adjustedPositionX: positionX, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
|
||||
const componentWidth = size?.width || 100;
|
||||
const componentHeight = size?.height || 40;
|
||||
|
||||
// 분할 패널 위에 있는지 확인
|
||||
const overlap = getOverlappingSplitPanel(positionX, positionY, componentWidth, componentHeight);
|
||||
|
||||
// 디버깅: 버튼이 분할 패널 위에 있는지 확인
|
||||
if (isButtonComponent) {
|
||||
console.log("🔍 [RealtimePreview] 버튼 분할 패널 감지:", {
|
||||
componentId: component.id,
|
||||
componentType: (component as any).componentType,
|
||||
positionX,
|
||||
positionY,
|
||||
componentWidth,
|
||||
componentHeight,
|
||||
hasOverlap: !!overlap,
|
||||
isInLeftPanel: overlap?.isInLeftPanel,
|
||||
panelInfo: overlap
|
||||
? {
|
||||
panelId: overlap.panelId,
|
||||
panelX: overlap.panel.x,
|
||||
panelY: overlap.panel.y,
|
||||
panelWidth: overlap.panel.width,
|
||||
leftWidthPercent: overlap.panel.leftWidthPercent,
|
||||
initialLeftWidthPercent: overlap.panel.initialLeftWidthPercent,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (!overlap || !overlap.isInLeftPanel) {
|
||||
// 분할 패널 위에 없거나 우측 패널 위에 있음
|
||||
return {
|
||||
adjustedPositionX: positionX,
|
||||
isOnSplitPanel: !!overlap,
|
||||
isDraggingSplitPanel: overlap?.panel.isDragging ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// 좌측 패널 위에 있음 - 위치 조정
|
||||
const adjusted = getAdjustedX(positionX, positionY, componentWidth, componentHeight);
|
||||
|
||||
console.log("✅ [RealtimePreview] 버튼 위치 조정 적용:", {
|
||||
componentId: component.id,
|
||||
originalX: positionX,
|
||||
adjustedX: adjusted,
|
||||
delta: adjusted - positionX,
|
||||
});
|
||||
|
||||
return {
|
||||
adjustedPositionX: adjusted,
|
||||
isOnSplitPanel: true,
|
||||
isDraggingSplitPanel: overlap.panel.isDragging,
|
||||
};
|
||||
}, [
|
||||
positionX,
|
||||
positionY,
|
||||
size?.width,
|
||||
size?.height,
|
||||
isButtonComponent,
|
||||
type,
|
||||
component,
|
||||
getAdjustedX,
|
||||
getOverlappingSplitPanel,
|
||||
]);
|
||||
|
||||
// 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀)
|
||||
const getWidth = () => {
|
||||
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
|
||||
|
|
@ -437,23 +545,27 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
...style, // 먼저 적용하고
|
||||
left: positionX,
|
||||
left: adjustedPositionX, // 🆕 분할 패널 위 버튼은 조정된 X 좌표 사용
|
||||
top: positionY,
|
||||
width: getWidth(), // 우선순위에 따른 너비
|
||||
height: getHeight(), // 우선순위에 따른 높이
|
||||
zIndex: position?.z || 1,
|
||||
// right 속성 강제 제거
|
||||
right: undefined,
|
||||
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
|
||||
transition:
|
||||
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
|
||||
};
|
||||
|
||||
// 선택된 컴포넌트 스타일
|
||||
// Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거
|
||||
const selectionStyle = isSelected && !isSectionPaper
|
||||
? {
|
||||
outline: "2px solid rgb(59, 130, 246)",
|
||||
outlineOffset: "2px",
|
||||
}
|
||||
: {};
|
||||
const selectionStyle =
|
||||
isSelected && !isSectionPaper
|
||||
? {
|
||||
outline: "2px solid rgb(59, 130, 246)",
|
||||
outlineOffset: "2px",
|
||||
}
|
||||
: {};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// 컴포넌트 영역 내에서만 클릭 이벤트 처리
|
||||
|
|
@ -481,10 +593,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* 컴포넌트 타입별 렌더링 */}
|
||||
<div
|
||||
ref={isFlowWidget ? contentRef : undefined}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<div ref={isFlowWidget ? contentRef : undefined} className="h-full w-full">
|
||||
{/* 영역 타입 */}
|
||||
{type === "area" && renderArea(component, children)}
|
||||
|
||||
|
|
@ -549,16 +658,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
return (
|
||||
<div className="h-auto w-full">
|
||||
<FlowWidget
|
||||
component={flowComponent as any}
|
||||
onSelectedDataChange={onFlowSelectedDataChange}
|
||||
/>
|
||||
<FlowWidget component={flowComponent as any} onSelectedDataChange={onFlowSelectedDataChange} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 탭 컴포넌트 타입 */}
|
||||
{(type === "tabs" || (type === "component" && ((component as any).componentType === "tabs-widget" || (component as any).componentId === "tabs-widget"))) &&
|
||||
{(type === "tabs" ||
|
||||
(type === "component" &&
|
||||
((component as any).componentType === "tabs-widget" ||
|
||||
(component as any).componentId === "tabs-widget"))) &&
|
||||
(() => {
|
||||
console.log("🎯 탭 컴포넌트 조건 충족:", {
|
||||
type,
|
||||
|
|
@ -590,9 +699,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
<Badge key={tab.id} variant="outline" className="text-xs">
|
||||
{tab.label || `탭 ${index + 1}`}
|
||||
{tab.screenName && (
|
||||
<span className="ml-1 text-[10px] text-gray-400">
|
||||
({tab.screenName})
|
||||
</span>
|
||||
<span className="ml-1 text-[10px] text-gray-400">({tab.screenName})</span>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
|
|
@ -632,28 +739,29 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
)}
|
||||
|
||||
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
|
||||
{type === "component" && (() => {
|
||||
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
|
||||
return (
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isSelected={isSelected}
|
||||
isDesignMode={isDesignMode}
|
||||
onClick={onClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...restProps}
|
||||
>
|
||||
{children}
|
||||
</DynamicComponentRenderer>
|
||||
);
|
||||
})()}
|
||||
{type === "component" &&
|
||||
(() => {
|
||||
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
|
||||
return (
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isSelected={isSelected}
|
||||
isDesignMode={isDesignMode}
|
||||
onClick={onClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...restProps}
|
||||
>
|
||||
{children}
|
||||
</DynamicComponentRenderer>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||
{type === "widget" && !isFileComponent(component) && (
|
||||
<div className="h-full w-full">
|
||||
<WidgetRenderer
|
||||
component={component}
|
||||
<WidgetRenderer
|
||||
component={component}
|
||||
isDesignMode={isDesignMode}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import {
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Building,
|
||||
File,
|
||||
} from "lucide-react";
|
||||
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||
|
||||
// 컴포넌트 렌더러들 자동 등록
|
||||
import "@/lib/registry/components";
|
||||
|
|
@ -60,7 +61,7 @@ interface RealtimePreviewProps {
|
|||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
columnOrder?: string[];
|
||||
|
||||
|
||||
// 🆕 조건부 컨테이너 높이 변화 콜백
|
||||
onHeightChange?: (componentId: string, newHeight: number) => void;
|
||||
}
|
||||
|
|
@ -262,14 +263,145 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
}
|
||||
: component;
|
||||
|
||||
// 🆕 분할 패널 리사이즈 Context
|
||||
const splitPanelContext = useSplitPanel();
|
||||
|
||||
// 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상)
|
||||
const componentType = (component as any).componentType || "";
|
||||
const componentId = (component as any).componentId || "";
|
||||
const widgetType = (component as any).widgetType || "";
|
||||
|
||||
const isButtonComponent =
|
||||
(type === "widget" && widgetType === "button") ||
|
||||
(type === "component" &&
|
||||
(["button-primary", "button-secondary"].includes(componentType) ||
|
||||
["button-primary", "button-secondary"].includes(componentId)));
|
||||
|
||||
// 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점)
|
||||
const initialPanelRatioRef = React.useRef<number | null>(null);
|
||||
const initialPanelIdRef = React.useRef<string | null>(null);
|
||||
// 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지)
|
||||
const isInLeftPanelRef = React.useRef<boolean | null>(null);
|
||||
|
||||
// 🆕 분할 패널 위 버튼 위치 자동 조정
|
||||
const calculateButtonPosition = () => {
|
||||
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
|
||||
const isSplitPanelComponent =
|
||||
type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType);
|
||||
|
||||
if (!isButtonComponent || isSplitPanelComponent) {
|
||||
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
|
||||
const componentWidth = size?.width || 100;
|
||||
const componentHeight = size?.height || 40;
|
||||
|
||||
// 분할 패널 위에 있는지 확인 (원래 위치 기준)
|
||||
const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight);
|
||||
|
||||
// 분할 패널 위에 없으면 기준점 초기화
|
||||
if (!overlap) {
|
||||
if (initialPanelIdRef.current !== null) {
|
||||
initialPanelRatioRef.current = null;
|
||||
initialPanelIdRef.current = null;
|
||||
isInLeftPanelRef.current = null;
|
||||
}
|
||||
return {
|
||||
adjustedPositionX: position.x,
|
||||
isOnSplitPanel: false,
|
||||
isDraggingSplitPanel: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { panel } = overlap;
|
||||
|
||||
// 🆕 초기 기준 비율 및 좌측 패널 소속 여부 설정 (처음 한 번만)
|
||||
if (initialPanelIdRef.current !== overlap.panelId) {
|
||||
initialPanelRatioRef.current = panel.leftWidthPercent;
|
||||
initialPanelIdRef.current = overlap.panelId;
|
||||
|
||||
// 초기 배치 시 좌측 패널에 있는지 확인 (초기 비율 기준으로 계산)
|
||||
// 현재 비율이 아닌, 버튼 원래 위치가 초기 좌측 패널 영역 안에 있었는지 판단
|
||||
const initialLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
||||
const componentCenterX = position.x + componentWidth / 2;
|
||||
const relativeX = componentCenterX - panel.x;
|
||||
const wasInLeftPanel = relativeX < initialLeftPanelWidth;
|
||||
|
||||
isInLeftPanelRef.current = wasInLeftPanel;
|
||||
console.log("📌 [버튼 기준점 설정]:", {
|
||||
componentId: component.id,
|
||||
panelId: overlap.panelId,
|
||||
initialRatio: panel.leftWidthPercent,
|
||||
isInLeftPanel: wasInLeftPanel,
|
||||
buttonCenterX: componentCenterX,
|
||||
leftPanelWidth: initialLeftPanelWidth,
|
||||
});
|
||||
}
|
||||
|
||||
// 좌측 패널 소속이 아니면 조정하지 않음 (초기 배치 기준)
|
||||
if (!isInLeftPanelRef.current) {
|
||||
return {
|
||||
adjustedPositionX: position.x,
|
||||
isOnSplitPanel: true,
|
||||
isDraggingSplitPanel: panel.isDragging,
|
||||
};
|
||||
}
|
||||
|
||||
// 초기 기준 비율 (버튼이 처음 배치될 때의 비율)
|
||||
const baseRatio = initialPanelRatioRef.current ?? panel.leftWidthPercent;
|
||||
|
||||
// 기준 비율 대비 현재 비율로 분할선 위치 계산
|
||||
const baseDividerX = panel.x + (panel.width * baseRatio) / 100; // 초기 분할선 위치
|
||||
const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100; // 현재 분할선 위치
|
||||
|
||||
// 분할선 이동량 (px)
|
||||
const dividerDelta = currentDividerX - baseDividerX;
|
||||
|
||||
// 변화가 없으면 원래 위치 반환
|
||||
if (Math.abs(dividerDelta) < 1) {
|
||||
return {
|
||||
adjustedPositionX: position.x,
|
||||
isOnSplitPanel: true,
|
||||
isDraggingSplitPanel: panel.isDragging,
|
||||
};
|
||||
}
|
||||
|
||||
// 🆕 버튼도 분할선과 같은 양만큼 이동
|
||||
// 분할선이 왼쪽으로 100px 이동하면, 버튼도 왼쪽으로 100px 이동
|
||||
const adjustedX = position.x + dividerDelta;
|
||||
|
||||
console.log("📍 [버튼 위치 조정]:", {
|
||||
componentId: component.id,
|
||||
originalX: position.x,
|
||||
adjustedX,
|
||||
dividerDelta,
|
||||
baseRatio,
|
||||
currentRatio: panel.leftWidthPercent,
|
||||
baseDividerX,
|
||||
currentDividerX,
|
||||
isDragging: panel.isDragging,
|
||||
});
|
||||
|
||||
return {
|
||||
adjustedPositionX: adjustedX,
|
||||
isOnSplitPanel: true,
|
||||
isDraggingSplitPanel: panel.isDragging,
|
||||
};
|
||||
};
|
||||
|
||||
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateButtonPosition();
|
||||
|
||||
const baseStyle = {
|
||||
left: `${position.x}px`,
|
||||
left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용
|
||||
top: `${position.y}px`,
|
||||
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨)
|
||||
width: getWidth(), // getWidth() 우선 (table-list 등 특수 케이스)
|
||||
height: getHeight(), // getHeight() 우선 (flow-widget 등 특수 케이스)
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||
right: undefined,
|
||||
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
|
||||
transition:
|
||||
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
|
||||
};
|
||||
|
||||
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||
|
||||
interface SplitPanelAwareWrapperProps {
|
||||
children: React.ReactNode;
|
||||
componentX: number;
|
||||
componentY: number;
|
||||
componentWidth: number;
|
||||
componentHeight: number;
|
||||
componentType?: string;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 드래그 리사이즈에 따라 컴포넌트 위치를 자동 조정하는 래퍼
|
||||
*
|
||||
* 동작 방식:
|
||||
* 1. 컴포넌트가 분할 패널의 좌측 영역 위에 있는지 감지
|
||||
* 2. 좌측 영역 위에 있으면, 드래그 핸들 이동량만큼 X 좌표를 조정
|
||||
* 3. 우측 영역이나 분할 패널 외부에 있으면 원래 위치 유지
|
||||
*/
|
||||
export const SplitPanelAwareWrapper: React.FC<SplitPanelAwareWrapperProps> = ({
|
||||
children,
|
||||
componentX,
|
||||
componentY,
|
||||
componentWidth,
|
||||
componentHeight,
|
||||
componentType,
|
||||
style,
|
||||
className,
|
||||
}) => {
|
||||
const { getAdjustedX, getOverlappingSplitPanel } = useSplitPanel();
|
||||
|
||||
// 분할 패널 위에 있는지 확인 및 조정된 X 좌표 계산
|
||||
const { adjustedX, isInLeftPanel, isDragging } = useMemo(() => {
|
||||
const overlap = getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
|
||||
|
||||
if (!overlap) {
|
||||
// 분할 패널 위에 없음
|
||||
return { adjustedX: componentX, isInLeftPanel: false, isDragging: false };
|
||||
}
|
||||
|
||||
if (!overlap.isInLeftPanel) {
|
||||
// 우측 패널 위에 있음 - 원래 위치 유지
|
||||
return { adjustedX: componentX, isInLeftPanel: false, isDragging: overlap.panel.isDragging };
|
||||
}
|
||||
|
||||
// 좌측 패널 위에 있음 - 위치 조정
|
||||
const adjusted = getAdjustedX(componentX, componentY, componentWidth, componentHeight);
|
||||
|
||||
return {
|
||||
adjustedX: adjusted,
|
||||
isInLeftPanel: true,
|
||||
isDragging: overlap.panel.isDragging,
|
||||
};
|
||||
}, [componentX, componentY, componentWidth, componentHeight, getAdjustedX, getOverlappingSplitPanel]);
|
||||
|
||||
// 조정된 스타일
|
||||
const adjustedStyle: React.CSSProperties = {
|
||||
...style,
|
||||
position: "absolute",
|
||||
left: `${adjustedX}px`,
|
||||
top: `${componentY}px`,
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
// 드래그 중에는 트랜지션 없이 즉시 이동, 드래그 끝나면 부드럽게
|
||||
transition: isDragging ? "none" : "left 0.1s ease-out",
|
||||
};
|
||||
|
||||
// 디버그 로깅 (개발 중에만)
|
||||
// if (isInLeftPanel) {
|
||||
// console.log(`📍 [SplitPanelAwareWrapper] 위치 조정:`, {
|
||||
// componentType,
|
||||
// originalX: componentX,
|
||||
// adjustedX,
|
||||
// delta: adjustedX - componentX,
|
||||
// isInLeftPanel,
|
||||
// isDragging,
|
||||
// });
|
||||
// }
|
||||
|
||||
return (
|
||||
<div style={adjustedStyle} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SplitPanelAwareWrapper;
|
||||
|
|
@ -878,7 +878,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4" key={selectedComponent.id}>
|
||||
<div className="space-y-4 w-full min-w-0" key={selectedComponent.id}>
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Settings className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
|
|
@ -998,7 +998,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 설정 패널 영역 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">{renderComponentConfigPanel()}</div>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 w-full">{renderComponentConfigPanel()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1156,8 +1156,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 컴포넌트 설정 패널 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 pb-6 w-full min-w-0">
|
||||
<div className="space-y-6 w-full min-w-0">
|
||||
{/* DynamicComponentConfigPanel */}
|
||||
<DynamicComponentConfigPanel
|
||||
componentId={componentId}
|
||||
|
|
@ -1396,8 +1396,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 상세 설정 영역 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-6">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 w-full min-w-0">
|
||||
<div className="space-y-6 w-full min-w-0">
|
||||
{console.log("🔍 [DetailSettingsPanel] widget 타입:", selectedComponent?.type, "autoFill:", widget.autoFill)}
|
||||
{/* 🆕 자동 입력 섹션 */}
|
||||
<div className="space-y-4 rounded-md border border-red-500 bg-yellow-50 p-4">
|
||||
|
|
|
|||
|
|
@ -317,7 +317,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
|
||||
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
|
||||
let currentValue;
|
||||
if (componentType === "modal-repeater-table") {
|
||||
if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal") {
|
||||
// EditModal에서 전달된 groupedData가 있으면 우선 사용
|
||||
currentValue = props.groupedData || formData?.[fieldName] || [];
|
||||
} else {
|
||||
|
|
@ -449,7 +449,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable)
|
||||
// Note: 이 props들은 DOM 요소에 전달되면 안 됨
|
||||
// 각 컴포넌트에서 명시적으로 destructure하여 사용해야 함
|
||||
_groupedData: props.groupedData,
|
||||
groupedData: props.groupedData, // ✅ 언더스코어 제거하여 직접 전달
|
||||
_groupedData: props.groupedData, // 하위 호환성 유지
|
||||
// 🆕 UniversalFormModal용 initialData 전달
|
||||
// originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨)
|
||||
_initialData: originalData || formData,
|
||||
|
|
@ -467,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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,71 @@ export function RepeaterTable({
|
|||
|
||||
// 동적 데이터 소스 Popover 열림 상태
|
||||
const [openPopover, setOpenPopover] = useState<string | null>(null);
|
||||
|
||||
// 컬럼 너비 상태 관리
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
|
||||
const widths: Record<string, number> = {};
|
||||
columns.forEach((col) => {
|
||||
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
||||
});
|
||||
return widths;
|
||||
});
|
||||
|
||||
// 기본 너비 저장 (리셋용)
|
||||
const defaultWidths = React.useMemo(() => {
|
||||
const widths: Record<string, number> = {};
|
||||
columns.forEach((col) => {
|
||||
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
||||
});
|
||||
return widths;
|
||||
}, [columns]);
|
||||
|
||||
// 리사이즈 상태
|
||||
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
|
||||
|
||||
// 리사이즈 핸들러
|
||||
const handleMouseDown = (e: React.MouseEvent, field: string) => {
|
||||
e.preventDefault();
|
||||
setResizing({
|
||||
field,
|
||||
startX: e.clientX,
|
||||
startWidth: columnWidths[field] || 120,
|
||||
});
|
||||
};
|
||||
|
||||
// 더블클릭으로 기본 너비로 리셋
|
||||
const handleDoubleClick = (field: string) => {
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[field]: defaultWidths[field] || 120,
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!resizing) return;
|
||||
const diff = e.clientX - resizing.startX;
|
||||
const newWidth = Math.max(60, resizing.startWidth + diff);
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[resizing.field]: newWidth,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizing(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [resizing, columns, data]);
|
||||
|
||||
// 데이터 변경 감지 (필요시 활성화)
|
||||
// useEffect(() => {
|
||||
|
|
@ -79,7 +144,7 @@ export function RepeaterTable({
|
|||
onChange={(e) =>
|
||||
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -107,7 +172,7 @@ export function RepeaterTable({
|
|||
type="date"
|
||||
value={formatDateValue(value)}
|
||||
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -119,7 +184,7 @@ export function RepeaterTable({
|
|||
handleCellEdit(rowIndex, column.field, newValue)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -138,19 +203,19 @@ export function RepeaterTable({
|
|||
type="text"
|
||||
value={value || ""}
|
||||
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-md overflow-hidden bg-background">
|
||||
<div className="overflow-x-auto max-h-[240px] overflow-y-auto">
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-muted sticky top-0 z-10">
|
||||
<div className="border border-gray-200 bg-white">
|
||||
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="bg-gray-50 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 w-12">
|
||||
#
|
||||
</th>
|
||||
{columns.map((col) => {
|
||||
|
|
@ -163,101 +228,113 @@ export function RepeaterTable({
|
|||
return (
|
||||
<th
|
||||
key={col.field}
|
||||
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||||
style={{ width: col.width }}
|
||||
className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 relative group cursor-pointer select-none"
|
||||
style={{ width: `${columnWidths[col.field]}px` }}
|
||||
onDoubleClick={() => handleDoubleClick(col.field)}
|
||||
title="더블클릭하여 기본 너비로 되돌리기"
|
||||
>
|
||||
{hasDynamicSource ? (
|
||||
<Popover
|
||||
open={openPopover === col.field}
|
||||
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 hover:text-primary transition-colors",
|
||||
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded px-1 -mx-1"
|
||||
)}
|
||||
<div className="flex items-center justify-between pointer-events-none">
|
||||
<div className="flex items-center gap-1 pointer-events-auto">
|
||||
{hasDynamicSource ? (
|
||||
<Popover
|
||||
open={openPopover === col.field}
|
||||
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
|
||||
>
|
||||
<span>{col.label}</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto min-w-[160px] p-1"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
|
||||
데이터 소스 선택
|
||||
</div>
|
||||
{col.dynamicDataSource!.options.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDataSourceChange?.(col.field, option.id);
|
||||
setOpenPopover(null);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm",
|
||||
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||
"focus:outline-none focus-visible:bg-accent",
|
||||
activeOption?.id === option.id && "bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-3 w-3",
|
||||
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<>
|
||||
{col.label}
|
||||
{col.required && <span className="text-destructive ml-1">*</span>}
|
||||
</>
|
||||
)}
|
||||
</th>
|
||||
"inline-flex items-center gap-1 hover:text-blue-600 transition-colors",
|
||||
"focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded px-1 -mx-1"
|
||||
)}
|
||||
>
|
||||
<span>{col.label}</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto min-w-[160px] p-1"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
|
||||
데이터 소스 선택
|
||||
</div>
|
||||
{col.dynamicDataSource!.options.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDataSourceChange?.(col.field, option.id);
|
||||
setOpenPopover(null);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm",
|
||||
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||
"focus:outline-none focus-visible:bg-accent",
|
||||
activeOption?.id === option.id && "bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-3 w-3",
|
||||
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<>
|
||||
{col.label}
|
||||
{col.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* 리사이즈 핸들 */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-500 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto"
|
||||
onMouseDown={(e) => handleMouseDown(e, col.field)}
|
||||
title="드래그하여 너비 조정"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 w-20">
|
||||
삭제
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-background">
|
||||
<tbody className="bg-white">
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + 2}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
className="px-4 py-8 text-center text-gray-500 border-b border-gray-200"
|
||||
>
|
||||
추가된 항목이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-t hover:bg-accent/50">
|
||||
<td className="px-4 py-2 text-center text-muted-foreground">
|
||||
<tr key={rowIndex} className="hover:bg-blue-50/50 transition-colors">
|
||||
<td className="px-3 py-1 text-center text-gray-600 border-b border-r border-gray-200">
|
||||
{rowIndex + 1}
|
||||
</td>
|
||||
{columns.map((col) => (
|
||||
<td key={col.field} className="px-2 py-1">
|
||||
<td key={col.field} className="px-1 py-1 border-b border-r border-gray-200">
|
||||
{renderCell(row, col, rowIndex)}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-2 text-center">
|
||||
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRowDelete(rowIndex)}
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -2517,7 +2517,7 @@ function LayoutRowConfigModal({
|
|||
</div>
|
||||
|
||||
{/* 외부 데이터 소스 설정 */}
|
||||
<div className="border rounded p-3 bg-blue-50 space-y-2">
|
||||
<div className="border rounded p-3 bg-blue-50 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold">외부 데이터 소스</Label>
|
||||
<Switch
|
||||
|
|
@ -2529,28 +2529,234 @@ function LayoutRowConfigModal({
|
|||
/>
|
||||
</div>
|
||||
{row.tableDataSource?.enabled && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">소스 테이블</Label>
|
||||
<Select
|
||||
<TableSelector
|
||||
value={row.tableDataSource?.sourceTable || ""}
|
||||
onValueChange={(value) => onUpdateRow({
|
||||
onChange={(value) => onUpdateRow({
|
||||
tableDataSource: { ...row.tableDataSource!, sourceTable: value }
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allTables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName || table.tableName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조인 조건 설정 */}
|
||||
<div className="space-y-2 pt-2 border-t border-blue-200">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] font-semibold">조인 조건</Label>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
두 테이블을 연결하는 키를 설정합니다
|
||||
</p>
|
||||
</div>
|
||||
{(row.tableDataSource?.joinConditions || []).map((condition, conditionIndex) => (
|
||||
<div key={`join-${conditionIndex}`} className="space-y-2 p-2 border rounded bg-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] font-medium">조인 {conditionIndex + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newConditions = [...(row.tableDataSource?.joinConditions || [])];
|
||||
newConditions.splice(conditionIndex, 1);
|
||||
onUpdateRow({
|
||||
tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions }
|
||||
});
|
||||
}}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px]">조인 키 (소스)</Label>
|
||||
<SourceColumnSelector
|
||||
sourceTable={row.tableDataSource?.sourceTable || ""}
|
||||
value={condition.sourceKey}
|
||||
onChange={(value) => {
|
||||
const newConditions = [...(row.tableDataSource?.joinConditions || [])];
|
||||
newConditions[conditionIndex] = { ...condition, sourceKey: value };
|
||||
onUpdateRow({
|
||||
tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions }
|
||||
});
|
||||
}}
|
||||
placeholder="예: sales_order_id"
|
||||
/>
|
||||
<p className="text-[8px] text-muted-foreground">
|
||||
외부 테이블의 컬럼
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px]">조인 키 (대상)</Label>
|
||||
<SourceColumnSelector
|
||||
sourceTable={dataSourceTable || ""}
|
||||
value={condition.referenceKey}
|
||||
onChange={(value) => {
|
||||
const newConditions = [...(row.tableDataSource?.joinConditions || [])];
|
||||
newConditions[conditionIndex] = { ...condition, referenceKey: value };
|
||||
onUpdateRow({
|
||||
tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions }
|
||||
});
|
||||
}}
|
||||
placeholder="예: id"
|
||||
/>
|
||||
<p className="text-[8px] text-muted-foreground">
|
||||
메인 테이블의 컬럼
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 p-1.5 bg-blue-50 rounded border border-blue-100">
|
||||
<p className="text-[8px] text-blue-700 font-mono">
|
||||
{row.tableDataSource?.sourceTable}.{condition.sourceKey} = {dataSourceTable}.{condition.referenceKey}
|
||||
</p>
|
||||
<p className="text-[8px] text-muted-foreground mt-0.5">
|
||||
외부 테이블에서 메인 테이블의 값과 일치하는 데이터를 가져옵니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newConditions = [
|
||||
...(row.tableDataSource?.joinConditions || []),
|
||||
{ sourceKey: "", referenceKey: "", referenceType: "card" as const },
|
||||
];
|
||||
onUpdateRow({
|
||||
tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions }
|
||||
});
|
||||
}}
|
||||
className="w-full h-7 text-[9px]"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
조인 조건 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필터 설정 */}
|
||||
<div className="space-y-2 pt-2 border-t border-blue-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] font-semibold">데이터 필터</Label>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
특정 조건으로 데이터를 제외합니다
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={row.tableDataSource?.filterConfig?.enabled || false}
|
||||
onCheckedChange={(checked) => {
|
||||
onUpdateRow({
|
||||
tableDataSource: {
|
||||
...row.tableDataSource!,
|
||||
filterConfig: {
|
||||
enabled: checked,
|
||||
filterField: "",
|
||||
filterType: "notEquals",
|
||||
referenceField: "",
|
||||
referenceSource: "representativeData",
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{row.tableDataSource?.filterConfig?.enabled && (
|
||||
<div className="space-y-2 p-2 bg-amber-50 rounded border border-amber-200">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px]">필터 필드</Label>
|
||||
<SourceColumnSelector
|
||||
sourceTable={row.tableDataSource?.sourceTable || ""}
|
||||
value={row.tableDataSource?.filterConfig?.filterField || ""}
|
||||
onChange={(value) => {
|
||||
onUpdateRow({
|
||||
tableDataSource: {
|
||||
...row.tableDataSource!,
|
||||
filterConfig: {
|
||||
...row.tableDataSource!.filterConfig!,
|
||||
filterField: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="예: order_no"
|
||||
/>
|
||||
<p className="text-[8px] text-muted-foreground">
|
||||
외부 테이블에서 비교할 컬럼
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px]">비교 필드</Label>
|
||||
<SourceColumnSelector
|
||||
sourceTable={dataSourceTable || ""}
|
||||
value={row.tableDataSource?.filterConfig?.referenceField || ""}
|
||||
onChange={(value) => {
|
||||
onUpdateRow({
|
||||
tableDataSource: {
|
||||
...row.tableDataSource!,
|
||||
filterConfig: {
|
||||
...row.tableDataSource!.filterConfig!,
|
||||
referenceField: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="예: order_no"
|
||||
/>
|
||||
<p className="text-[8px] text-muted-foreground">
|
||||
현재 선택한 행의 컬럼
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px]">필터 조건</Label>
|
||||
<Select
|
||||
value={row.tableDataSource?.filterConfig?.filterType || "notEquals"}
|
||||
onValueChange={(value: "equals" | "notEquals") => {
|
||||
onUpdateRow({
|
||||
tableDataSource: {
|
||||
...row.tableDataSource!,
|
||||
filterConfig: {
|
||||
...row.tableDataSource!.filterConfig!,
|
||||
filterType: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="notEquals">같지 않은 값만 (내꺼 제외)</SelectItem>
|
||||
<SelectItem value="equals">같은 값만 (내꺼만)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 p-1.5 bg-amber-100 rounded border border-amber-200">
|
||||
<p className="text-[8px] text-amber-800 font-mono">
|
||||
{row.tableDataSource?.sourceTable}.{row.tableDataSource?.filterConfig?.filterField} != 현재행.{row.tableDataSource?.filterConfig?.referenceField}
|
||||
</p>
|
||||
<p className="text-[8px] text-muted-foreground mt-0.5">
|
||||
{row.tableDataSource?.filterConfig?.filterType === "notEquals"
|
||||
? "현재 선택한 행과 다른 데이터만 표시합니다"
|
||||
: "현재 선택한 행과 같은 데이터만 표시합니다"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,400 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useRef, useMemo } from "react";
|
||||
|
||||
/**
|
||||
* SplitPanelResize Context 타입 정의
|
||||
* 분할 패널의 드래그 리사이즈 상태를 외부 컴포넌트(버튼 등)와 공유하기 위한 Context
|
||||
*
|
||||
* 주의: contexts/SplitPanelContext.tsx는 데이터 전달용 Context이고,
|
||||
* 이 Context는 드래그 리사이즈 시 버튼 위치 조정을 위한 별도 Context입니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 분할 패널 정보 (컴포넌트 좌표 기준)
|
||||
*/
|
||||
export interface SplitPanelInfo {
|
||||
id: string;
|
||||
// 분할 패널의 좌표 (스크린 캔버스 기준, px)
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
// 좌측 패널 비율 (0-100)
|
||||
leftWidthPercent: number;
|
||||
// 초기 좌측 패널 비율 (드래그 시작 시점)
|
||||
initialLeftWidthPercent: number;
|
||||
// 드래그 중 여부
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
export interface SplitPanelResizeContextValue {
|
||||
// 등록된 분할 패널들
|
||||
splitPanels: Map<string, SplitPanelInfo>;
|
||||
|
||||
// 분할 패널 등록/해제/업데이트
|
||||
registerSplitPanel: (id: string, info: Omit<SplitPanelInfo, "id">) => void;
|
||||
unregisterSplitPanel: (id: string) => void;
|
||||
updateSplitPanel: (id: string, updates: Partial<SplitPanelInfo>) => void;
|
||||
|
||||
// 컴포넌트가 어떤 분할 패널의 좌측 영역 위에 있는지 확인
|
||||
// 반환값: { panelId, offsetX } 또는 null
|
||||
getOverlappingSplitPanel: (
|
||||
componentX: number,
|
||||
componentY: number,
|
||||
componentWidth: number,
|
||||
componentHeight: number,
|
||||
) => { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null;
|
||||
|
||||
// 컴포넌트의 조정된 X 좌표 계산
|
||||
// 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 조정된 X 좌표 반환
|
||||
getAdjustedX: (componentX: number, componentY: number, componentWidth: number, componentHeight: number) => number;
|
||||
|
||||
// 레거시 호환 (단일 분할 패널용)
|
||||
leftWidthPercent: number;
|
||||
containerRect: DOMRect | null;
|
||||
dividerX: number;
|
||||
isDragging: boolean;
|
||||
splitPanelId: string | null;
|
||||
updateLeftWidth: (percent: number) => void;
|
||||
updateContainerRect: (rect: DOMRect | null) => void;
|
||||
updateDragging: (dragging: boolean) => void;
|
||||
}
|
||||
|
||||
// Context 생성
|
||||
const SplitPanelResizeContext = createContext<SplitPanelResizeContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* SplitPanelResize Context Provider
|
||||
* 스크린 빌더 레벨에서 감싸서 사용
|
||||
*/
|
||||
export const SplitPanelProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
// 등록된 분할 패널들
|
||||
const splitPanelsRef = useRef<Map<string, SplitPanelInfo>>(new Map());
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// 레거시 호환용 상태
|
||||
const [legacyLeftWidthPercent, setLegacyLeftWidthPercent] = useState(30);
|
||||
const [legacyContainerRect, setLegacyContainerRect] = useState<DOMRect | null>(null);
|
||||
const [legacyIsDragging, setLegacyIsDragging] = useState(false);
|
||||
const [legacySplitPanelId, setLegacySplitPanelId] = useState<string | null>(null);
|
||||
|
||||
// 분할 패널 등록
|
||||
const registerSplitPanel = useCallback((id: string, info: Omit<SplitPanelInfo, "id">) => {
|
||||
splitPanelsRef.current.set(id, { id, ...info });
|
||||
setLegacySplitPanelId(id);
|
||||
setLegacyLeftWidthPercent(info.leftWidthPercent);
|
||||
forceUpdate((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
// 분할 패널 해제
|
||||
const unregisterSplitPanel = useCallback(
|
||||
(id: string) => {
|
||||
splitPanelsRef.current.delete(id);
|
||||
if (legacySplitPanelId === id) {
|
||||
setLegacySplitPanelId(null);
|
||||
}
|
||||
forceUpdate((n) => n + 1);
|
||||
},
|
||||
[legacySplitPanelId],
|
||||
);
|
||||
|
||||
// 분할 패널 업데이트
|
||||
const updateSplitPanel = useCallback((id: string, updates: Partial<SplitPanelInfo>) => {
|
||||
const panel = splitPanelsRef.current.get(id);
|
||||
if (panel) {
|
||||
const updatedPanel = { ...panel, ...updates };
|
||||
splitPanelsRef.current.set(id, updatedPanel);
|
||||
|
||||
// 레거시 호환 상태 업데이트
|
||||
if (updates.leftWidthPercent !== undefined) {
|
||||
setLegacyLeftWidthPercent(updates.leftWidthPercent);
|
||||
}
|
||||
if (updates.isDragging !== undefined) {
|
||||
setLegacyIsDragging(updates.isDragging);
|
||||
}
|
||||
|
||||
forceUpdate((n) => n + 1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 컴포넌트가 어떤 분할 패널의 좌측 영역 위에 있는지 확인
|
||||
*/
|
||||
const getOverlappingSplitPanel = useCallback(
|
||||
(
|
||||
componentX: number,
|
||||
componentY: number,
|
||||
componentWidth: number,
|
||||
componentHeight: number,
|
||||
): { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null => {
|
||||
for (const [panelId, panel] of splitPanelsRef.current) {
|
||||
// 컴포넌트의 중심점
|
||||
const componentCenterX = componentX + componentWidth / 2;
|
||||
const componentCenterY = componentY + componentHeight / 2;
|
||||
|
||||
// 컴포넌트가 분할 패널 영역 내에 있는지 확인
|
||||
const isInPanelX = componentCenterX >= panel.x && componentCenterX <= panel.x + panel.width;
|
||||
const isInPanelY = componentCenterY >= panel.y && componentCenterY <= panel.y + panel.height;
|
||||
|
||||
if (isInPanelX && isInPanelY) {
|
||||
// 좌측 패널의 현재 너비 (px)
|
||||
const leftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
||||
// 좌측 패널 경계 (분할 패널 기준 상대 좌표)
|
||||
const dividerX = panel.x + leftPanelWidth;
|
||||
|
||||
// 컴포넌트 중심이 좌측 패널 내에 있는지 확인
|
||||
const isInLeftPanel = componentCenterX < dividerX;
|
||||
|
||||
return { panelId, panel, isInLeftPanel };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 컴포넌트의 조정된 X 좌표 계산
|
||||
* 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 조정된 X 좌표 반환
|
||||
*
|
||||
* 핵심 로직:
|
||||
* - 버튼의 원래 X 좌표가 초기 좌측 패널 너비 내에서 어느 비율에 있는지 계산
|
||||
* - 드래그로 좌측 패널 너비가 바뀌면, 같은 비율을 유지하도록 X 좌표 조정
|
||||
*/
|
||||
const getAdjustedX = useCallback(
|
||||
(componentX: number, componentY: number, componentWidth: number, componentHeight: number): number => {
|
||||
const overlap = getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
|
||||
|
||||
if (!overlap || !overlap.isInLeftPanel) {
|
||||
// 분할 패널 위에 없거나, 우측 패널 위에 있으면 원래 위치 유지
|
||||
return componentX;
|
||||
}
|
||||
|
||||
const { panel } = overlap;
|
||||
|
||||
// 초기 좌측 패널 너비 (설정된 splitRatio 기준)
|
||||
const initialLeftPanelWidth = (panel.width * panel.initialLeftWidthPercent) / 100;
|
||||
// 현재 좌측 패널 너비 (드래그로 변경된 값)
|
||||
const currentLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
||||
|
||||
// 변화가 없으면 원래 위치 반환
|
||||
if (Math.abs(initialLeftPanelWidth - currentLeftPanelWidth) < 1) {
|
||||
return componentX;
|
||||
}
|
||||
|
||||
// 컴포넌트의 분할 패널 내 상대 X 좌표
|
||||
const relativeX = componentX - panel.x;
|
||||
|
||||
// 좌측 패널 내에서의 비율 (0~1)
|
||||
const ratioInLeftPanel = relativeX / initialLeftPanelWidth;
|
||||
|
||||
// 조정된 상대 X 좌표 = 원래 비율 * 현재 좌측 패널 너비
|
||||
const adjustedRelativeX = ratioInLeftPanel * currentLeftPanelWidth;
|
||||
|
||||
// 절대 X 좌표로 변환
|
||||
const adjustedX = panel.x + adjustedRelativeX;
|
||||
|
||||
console.log("📍 [SplitPanel] 버튼 위치 조정:", {
|
||||
componentX,
|
||||
panelX: panel.x,
|
||||
relativeX,
|
||||
initialLeftPanelWidth,
|
||||
currentLeftPanelWidth,
|
||||
ratioInLeftPanel,
|
||||
adjustedX,
|
||||
delta: adjustedX - componentX,
|
||||
});
|
||||
|
||||
return adjustedX;
|
||||
},
|
||||
[getOverlappingSplitPanel],
|
||||
);
|
||||
|
||||
// 레거시 호환 - dividerX 계산
|
||||
const legacyDividerX = legacyContainerRect ? (legacyContainerRect.width * legacyLeftWidthPercent) / 100 : 0;
|
||||
|
||||
// 레거시 호환 함수들
|
||||
const updateLeftWidth = useCallback((percent: number) => {
|
||||
setLegacyLeftWidthPercent(percent);
|
||||
// 첫 번째 분할 패널 업데이트
|
||||
const firstPanelId = splitPanelsRef.current.keys().next().value;
|
||||
if (firstPanelId) {
|
||||
const panel = splitPanelsRef.current.get(firstPanelId);
|
||||
if (panel) {
|
||||
splitPanelsRef.current.set(firstPanelId, { ...panel, leftWidthPercent: percent });
|
||||
}
|
||||
}
|
||||
forceUpdate((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
const updateContainerRect = useCallback((rect: DOMRect | null) => {
|
||||
setLegacyContainerRect(rect);
|
||||
}, []);
|
||||
|
||||
const updateDragging = useCallback((dragging: boolean) => {
|
||||
setLegacyIsDragging(dragging);
|
||||
// 첫 번째 분할 패널 업데이트
|
||||
const firstPanelId = splitPanelsRef.current.keys().next().value;
|
||||
if (firstPanelId) {
|
||||
const panel = splitPanelsRef.current.get(firstPanelId);
|
||||
if (panel) {
|
||||
// 드래그 시작 시 초기 비율 저장
|
||||
const updates: Partial<SplitPanelInfo> = { isDragging: dragging };
|
||||
if (dragging) {
|
||||
updates.initialLeftWidthPercent = panel.leftWidthPercent;
|
||||
}
|
||||
splitPanelsRef.current.set(firstPanelId, { ...panel, ...updates });
|
||||
}
|
||||
}
|
||||
forceUpdate((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<SplitPanelResizeContextValue>(
|
||||
() => ({
|
||||
splitPanels: splitPanelsRef.current,
|
||||
registerSplitPanel,
|
||||
unregisterSplitPanel,
|
||||
updateSplitPanel,
|
||||
getOverlappingSplitPanel,
|
||||
getAdjustedX,
|
||||
// 레거시 호환
|
||||
leftWidthPercent: legacyLeftWidthPercent,
|
||||
containerRect: legacyContainerRect,
|
||||
dividerX: legacyDividerX,
|
||||
isDragging: legacyIsDragging,
|
||||
splitPanelId: legacySplitPanelId,
|
||||
updateLeftWidth,
|
||||
updateContainerRect,
|
||||
updateDragging,
|
||||
}),
|
||||
[
|
||||
registerSplitPanel,
|
||||
unregisterSplitPanel,
|
||||
updateSplitPanel,
|
||||
getOverlappingSplitPanel,
|
||||
getAdjustedX,
|
||||
legacyLeftWidthPercent,
|
||||
legacyContainerRect,
|
||||
legacyDividerX,
|
||||
legacyIsDragging,
|
||||
legacySplitPanelId,
|
||||
updateLeftWidth,
|
||||
updateContainerRect,
|
||||
updateDragging,
|
||||
],
|
||||
);
|
||||
|
||||
return <SplitPanelResizeContext.Provider value={value}>{children}</SplitPanelResizeContext.Provider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* SplitPanelResize Context 사용 훅
|
||||
* 분할 패널의 드래그 리사이즈 상태를 구독합니다.
|
||||
*/
|
||||
export const useSplitPanel = (): SplitPanelResizeContextValue => {
|
||||
const context = useContext(SplitPanelResizeContext);
|
||||
|
||||
// Context가 없으면 기본값 반환 (Provider 외부에서 사용 시)
|
||||
if (!context) {
|
||||
return {
|
||||
splitPanels: new Map(),
|
||||
registerSplitPanel: () => {},
|
||||
unregisterSplitPanel: () => {},
|
||||
updateSplitPanel: () => {},
|
||||
getOverlappingSplitPanel: () => null,
|
||||
getAdjustedX: (x) => x,
|
||||
leftWidthPercent: 30,
|
||||
containerRect: null,
|
||||
dividerX: 0,
|
||||
isDragging: false,
|
||||
splitPanelId: null,
|
||||
updateLeftWidth: () => {},
|
||||
updateContainerRect: () => {},
|
||||
updateDragging: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트의 조정된 위치를 계산하는 훅
|
||||
* 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 X 좌표가 조정됨
|
||||
*
|
||||
* @param componentX - 컴포넌트의 X 좌표 (px)
|
||||
* @param componentY - 컴포넌트의 Y 좌표 (px)
|
||||
* @param componentWidth - 컴포넌트 너비 (px)
|
||||
* @param componentHeight - 컴포넌트 높이 (px)
|
||||
* @returns 조정된 X 좌표와 관련 정보
|
||||
*/
|
||||
export const useAdjustedComponentPosition = (
|
||||
componentX: number,
|
||||
componentY: number,
|
||||
componentWidth: number,
|
||||
componentHeight: number,
|
||||
) => {
|
||||
const context = useSplitPanel();
|
||||
|
||||
const adjustedX = context.getAdjustedX(componentX, componentY, componentWidth, componentHeight);
|
||||
const overlap = context.getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
|
||||
|
||||
return {
|
||||
adjustedX,
|
||||
isInSplitPanel: !!overlap,
|
||||
isInLeftPanel: overlap?.isInLeftPanel ?? false,
|
||||
isDragging: overlap?.panel.isDragging ?? false,
|
||||
panelId: overlap?.panelId ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 버튼 등 외부 컴포넌트에서 분할 패널 좌측 영역 내 위치를 계산하는 훅 (레거시 호환)
|
||||
*/
|
||||
export const useAdjustedPosition = (originalXPercent: number) => {
|
||||
const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel();
|
||||
|
||||
const isInLeftPanel = originalXPercent <= leftWidthPercent;
|
||||
const adjustedXPercent = isInLeftPanel ? (originalXPercent / 100) * leftWidthPercent : originalXPercent;
|
||||
const adjustedXPx = containerRect ? (containerRect.width * adjustedXPercent) / 100 : 0;
|
||||
|
||||
return {
|
||||
adjustedXPercent,
|
||||
adjustedXPx,
|
||||
isInLeftPanel,
|
||||
isDragging,
|
||||
dividerX,
|
||||
containerRect,
|
||||
leftWidthPercent,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 버튼이 좌측 패널 위에 배치되었을 때, 드래그에 따라 위치가 조정되는 스타일을 반환하는 훅 (레거시 호환)
|
||||
*/
|
||||
export const useSplitPanelAwarePosition = (
|
||||
initialLeftPercent: number,
|
||||
options?: {
|
||||
followDivider?: boolean;
|
||||
offset?: number;
|
||||
},
|
||||
) => {
|
||||
const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel();
|
||||
const { followDivider = false, offset = 0 } = options || {};
|
||||
|
||||
if (followDivider) {
|
||||
return {
|
||||
left: containerRect ? `${dividerX + offset}px` : `${leftWidthPercent}%`,
|
||||
transition: isDragging ? "none" : "left 0.15s ease-out",
|
||||
};
|
||||
}
|
||||
|
||||
const adjustedLeft = (initialLeftPercent / 100) * leftWidthPercent;
|
||||
|
||||
return {
|
||||
left: `${adjustedLeft}%`,
|
||||
transition: isDragging ? "none" : "left 0.15s ease-out",
|
||||
};
|
||||
};
|
||||
|
||||
export default SplitPanelResizeContext;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { ComponentRendererProps } from "../../types";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -36,6 +36,7 @@ import { Label } from "@/components/ui/label";
|
|||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useSplitPanel } from "./SplitPanelContext";
|
||||
|
||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||
// 추가 props
|
||||
|
|
@ -99,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") {
|
||||
|
|
@ -106,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에서 매핑 찾기 (화면 설정에서 지정된 경우)
|
||||
|
|
@ -182,6 +195,120 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const [leftWidth, setLeftWidth] = useState(splitRatio);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// 🆕 SplitPanel Resize Context 연동 (버튼 등 외부 컴포넌트와 드래그 리사이즈 상태 공유)
|
||||
const splitPanelContext = useSplitPanel();
|
||||
const {
|
||||
registerSplitPanel: ctxRegisterSplitPanel,
|
||||
unregisterSplitPanel: ctxUnregisterSplitPanel,
|
||||
updateSplitPanel: ctxUpdateSplitPanel,
|
||||
} = splitPanelContext;
|
||||
const splitPanelId = `split-panel-${component.id}`;
|
||||
|
||||
// 디버깅: Context 연결 상태 확인
|
||||
console.log("🔗 [SplitPanelLayout] Context 연결 상태:", {
|
||||
componentId: component.id,
|
||||
splitPanelId,
|
||||
hasRegisterFunc: typeof ctxRegisterSplitPanel === "function",
|
||||
splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음",
|
||||
});
|
||||
|
||||
// Context에 분할 패널 등록 (좌표 정보 포함) - 마운트 시 1회만 실행
|
||||
const ctxRegisterRef = useRef(ctxRegisterSplitPanel);
|
||||
const ctxUnregisterRef = useRef(ctxUnregisterSplitPanel);
|
||||
ctxRegisterRef.current = ctxRegisterSplitPanel;
|
||||
ctxUnregisterRef.current = ctxUnregisterSplitPanel;
|
||||
|
||||
useEffect(() => {
|
||||
// 컴포넌트의 위치와 크기 정보
|
||||
const panelX = component.position?.x || 0;
|
||||
const panelY = component.position?.y || 0;
|
||||
const panelWidth = component.size?.width || component.style?.width || 800;
|
||||
const panelHeight = component.size?.height || component.style?.height || 600;
|
||||
|
||||
const panelInfo = {
|
||||
x: panelX,
|
||||
y: panelY,
|
||||
width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800,
|
||||
height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600,
|
||||
leftWidthPercent: splitRatio, // 초기값은 splitRatio 사용
|
||||
initialLeftWidthPercent: splitRatio,
|
||||
isDragging: false,
|
||||
};
|
||||
|
||||
console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", {
|
||||
splitPanelId,
|
||||
panelInfo,
|
||||
});
|
||||
|
||||
ctxRegisterRef.current(splitPanelId, panelInfo);
|
||||
|
||||
return () => {
|
||||
console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId);
|
||||
ctxUnregisterRef.current(splitPanelId);
|
||||
};
|
||||
// 마운트/언마운트 시에만 실행, 위치/크기 변경은 별도 업데이트로 처리
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [splitPanelId]);
|
||||
|
||||
// 위치/크기 변경 시 Context 업데이트 (등록 후)
|
||||
const ctxUpdateRef = useRef(ctxUpdateSplitPanel);
|
||||
ctxUpdateRef.current = ctxUpdateSplitPanel;
|
||||
|
||||
useEffect(() => {
|
||||
const panelX = component.position?.x || 0;
|
||||
const panelY = component.position?.y || 0;
|
||||
const panelWidth = component.size?.width || component.style?.width || 800;
|
||||
const panelHeight = component.size?.height || component.style?.height || 600;
|
||||
|
||||
ctxUpdateRef.current(splitPanelId, {
|
||||
x: panelX,
|
||||
y: panelY,
|
||||
width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800,
|
||||
height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600,
|
||||
});
|
||||
}, [
|
||||
splitPanelId,
|
||||
component.position?.x,
|
||||
component.position?.y,
|
||||
component.size?.width,
|
||||
component.size?.height,
|
||||
component.style?.width,
|
||||
component.style?.height,
|
||||
]);
|
||||
|
||||
// leftWidth 변경 시 Context 업데이트
|
||||
useEffect(() => {
|
||||
ctxUpdateRef.current(splitPanelId, { leftWidthPercent: leftWidth });
|
||||
}, [leftWidth, splitPanelId]);
|
||||
|
||||
// 드래그 상태 변경 시 Context 업데이트
|
||||
// 이전 드래그 상태를 추적하여 드래그 종료 시점을 감지
|
||||
const prevIsDraggingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const wasJustDragging = prevIsDraggingRef.current && !isDragging;
|
||||
|
||||
if (isDragging) {
|
||||
// 드래그 시작 시: 현재 비율을 초기 비율로 저장
|
||||
ctxUpdateRef.current(splitPanelId, {
|
||||
isDragging: true,
|
||||
initialLeftWidthPercent: leftWidth,
|
||||
});
|
||||
} else if (wasJustDragging) {
|
||||
// 드래그 종료 시: 최종 비율을 초기 비율로 업데이트 (버튼 위치 고정)
|
||||
ctxUpdateRef.current(splitPanelId, {
|
||||
isDragging: false,
|
||||
initialLeftWidthPercent: leftWidth,
|
||||
});
|
||||
console.log("🛑 [SplitPanelLayout] 드래그 종료 - 버튼 위치 고정:", {
|
||||
splitPanelId,
|
||||
finalLeftWidthPercent: leftWidth,
|
||||
});
|
||||
}
|
||||
|
||||
prevIsDraggingRef.current = isDragging;
|
||||
}, [isDragging, splitPanelId, leftWidth]);
|
||||
|
||||
// 🆕 그룹별 합산된 데이터 계산
|
||||
const summedLeftData = useMemo(() => {
|
||||
console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig);
|
||||
|
|
@ -908,7 +1035,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(".")) {
|
||||
|
|
@ -920,10 +1047,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 {
|
||||
// 일반 컬럼
|
||||
|
|
|
|||
|
|
@ -58,3 +58,13 @@ export type { SplitPanelLayoutConfig } from "./types";
|
|||
// 컴포넌트 내보내기
|
||||
export { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent";
|
||||
export { SplitPanelLayoutRenderer } from "./SplitPanelLayoutRenderer";
|
||||
|
||||
// Resize Context 내보내기 (버튼 등 외부 컴포넌트에서 분할 패널 드래그 리사이즈 상태 활용)
|
||||
export {
|
||||
SplitPanelProvider,
|
||||
useSplitPanel,
|
||||
useAdjustedPosition,
|
||||
useSplitPanelAwarePosition,
|
||||
useAdjustedComponentPosition,
|
||||
} from "./SplitPanelContext";
|
||||
export type { SplitPanelResizeContextValue, SplitPanelInfo } from "./SplitPanelContext";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -218,14 +218,24 @@ export function UniversalFormModalComponent({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 빈 의존성 배열 - 마운트 시 한 번만 실행
|
||||
|
||||
// config 변경 시에만 재초기화 (initialData 변경은 무시)
|
||||
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
|
||||
useEffect(() => {
|
||||
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
|
||||
|
||||
initializeForm();
|
||||
console.log('[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)');
|
||||
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
// 컴포넌트 unmount 시 채번 플래그 초기화
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
console.log('[채번] 컴포넌트 unmount - 플래그 초기화');
|
||||
numberingGeneratedRef.current = false;
|
||||
isGeneratingRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 🆕 beforeFormSave 이벤트 리스너 - ButtonPrimary 저장 시 formData를 전달
|
||||
// 설정된 필드(columnName)만 병합하여 의도치 않은 덮어쓰기 방지
|
||||
useEffect(() => {
|
||||
|
|
@ -249,8 +259,11 @@ export function UniversalFormModalComponent({
|
|||
// 외부 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);
|
||||
|
|
@ -303,6 +316,8 @@ export function UniversalFormModalComponent({
|
|||
|
||||
// 폼 초기화
|
||||
const initializeForm = useCallback(async () => {
|
||||
console.log('[initializeForm] 시작');
|
||||
|
||||
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
|
||||
const effectiveInitialData = capturedInitialData.current || initialData;
|
||||
|
||||
|
|
@ -353,7 +368,9 @@ export function UniversalFormModalComponent({
|
|||
setOriginalData(effectiveInitialData || {});
|
||||
|
||||
// 채번규칙 자동 생성
|
||||
console.log('[initializeForm] generateNumberingValues 호출');
|
||||
await generateNumberingValues(newFormData);
|
||||
console.log('[initializeForm] 완료');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
|
||||
|
||||
|
|
@ -371,9 +388,26 @@ export function UniversalFormModalComponent({
|
|||
return item;
|
||||
};
|
||||
|
||||
// 채번규칙 자동 생성
|
||||
// 채번규칙 자동 생성 (중복 호출 방지)
|
||||
const numberingGeneratedRef = useRef(false);
|
||||
const isGeneratingRef = useRef(false); // 진행 중 플래그 추가
|
||||
|
||||
const generateNumberingValues = useCallback(
|
||||
async (currentFormData: FormDataState) => {
|
||||
// 이미 생성되었거나 진행 중이면 스킵
|
||||
if (numberingGeneratedRef.current) {
|
||||
console.log('[채번] 이미 생성됨 - 스킵');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGeneratingRef.current) {
|
||||
console.log('[채번] 생성 진행 중 - 스킵');
|
||||
return;
|
||||
}
|
||||
|
||||
isGeneratingRef.current = true; // 진행 중 표시
|
||||
console.log('[채번] 미리보기 생성 시작');
|
||||
|
||||
const updatedData = { ...currentFormData };
|
||||
let hasChanges = false;
|
||||
|
||||
|
|
@ -388,23 +422,44 @@ export function UniversalFormModalComponent({
|
|||
!updatedData[field.columnName]
|
||||
) {
|
||||
try {
|
||||
const response = await generateNumberingCode(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(`[채번 규칙 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],
|
||||
);
|
||||
|
||||
// 필드 값 변경 핸들러
|
||||
|
|
@ -624,30 +679,23 @@ 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];
|
||||
}
|
||||
});
|
||||
|
||||
// 저장 시점 채번규칙 처리 (allocateNumberingCode로 실제 순번 증가)
|
||||
// 저장 시점 채번규칙 처리 (generateOnSave만 처리)
|
||||
for (const section of config.sections) {
|
||||
for (const field of section.fields) {
|
||||
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||
// generateOnSave: 저장 시 새로 생성
|
||||
// generateOnOpen: 열 때 미리보기로 표시했지만, 저장 시 실제 순번 할당 필요
|
||||
if (field.numberingRule.generateOnSave && !dataToSave[field.columnName]) {
|
||||
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
||||
if (response.success && response.data?.generatedCode) {
|
||||
dataToSave[field.columnName] = response.data.generatedCode;
|
||||
}
|
||||
} else if (field.numberingRule.generateOnOpen && dataToSave[field.columnName]) {
|
||||
// generateOnOpen인 경우, 미리보기 값이 있더라도 실제 순번 할당
|
||||
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
||||
if (response.success && response.data?.generatedCode) {
|
||||
dataToSave[field.columnName] = response.data.generatedCode;
|
||||
}
|
||||
if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
|
||||
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
||||
if (response.success && response.data?.generatedCode) {
|
||||
dataToSave[field.columnName] = response.data.generatedCode;
|
||||
console.log(`[채번 할당] ${field.columnName} = ${response.data.generatedCode}`);
|
||||
} else {
|
||||
console.error(`[채번 실패] ${field.columnName}:`, response.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,842 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Plus, Trash2, Settings as SettingsIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
FormFieldConfig,
|
||||
LinkedFieldMapping,
|
||||
FIELD_TYPE_OPTIONS,
|
||||
SELECT_OPTION_TYPE_OPTIONS,
|
||||
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
|
||||
} from "../types";
|
||||
|
||||
// 도움말 텍스트 컴포넌트
|
||||
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
|
||||
);
|
||||
|
||||
interface FieldDetailSettingsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
field: FormFieldConfig;
|
||||
onSave: (updates: Partial<FormFieldConfig>) => void;
|
||||
tables: { name: string; label: string }[];
|
||||
tableColumns: { [tableName: string]: { name: string; type: string; label: string }[] };
|
||||
numberingRules: { id: string; name: string }[];
|
||||
onLoadTableColumns: (tableName: string) => void;
|
||||
}
|
||||
|
||||
export function FieldDetailSettingsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
field,
|
||||
onSave,
|
||||
tables,
|
||||
tableColumns,
|
||||
numberingRules,
|
||||
onLoadTableColumns,
|
||||
}: FieldDetailSettingsModalProps) {
|
||||
// 로컬 상태로 필드 설정 관리
|
||||
const [localField, setLocalField] = useState<FormFieldConfig>(field);
|
||||
|
||||
// open이 변경될 때마다 필드 데이터 동기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLocalField(field);
|
||||
}
|
||||
}, [open, field]);
|
||||
|
||||
// 필드 업데이트 함수
|
||||
const updateField = (updates: Partial<FormFieldConfig>) => {
|
||||
setLocalField((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
// 저장 함수
|
||||
const handleSave = () => {
|
||||
onSave(localField);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 연결 필드 매핑 추가
|
||||
const addLinkedFieldMapping = () => {
|
||||
const newMapping: LinkedFieldMapping = {
|
||||
sourceColumn: "",
|
||||
targetColumn: "",
|
||||
};
|
||||
const mappings = [...(localField.linkedFieldGroup?.mappings || []), newMapping];
|
||||
updateField({
|
||||
linkedFieldGroup: {
|
||||
...localField.linkedFieldGroup,
|
||||
enabled: true,
|
||||
mappings,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 연결 필드 매핑 삭제
|
||||
const removeLinkedFieldMapping = (index: number) => {
|
||||
const mappings = [...(localField.linkedFieldGroup?.mappings || [])];
|
||||
mappings.splice(index, 1);
|
||||
updateField({
|
||||
linkedFieldGroup: {
|
||||
...localField.linkedFieldGroup,
|
||||
mappings,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 연결 필드 매핑 업데이트
|
||||
const updateLinkedFieldMapping = (index: number, updates: Partial<LinkedFieldMapping>) => {
|
||||
const mappings = [...(localField.linkedFieldGroup?.mappings || [])];
|
||||
mappings[index] = { ...mappings[index], ...updates };
|
||||
updateField({
|
||||
linkedFieldGroup: {
|
||||
...localField.linkedFieldGroup,
|
||||
mappings,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 소스 테이블 컬럼 목록
|
||||
const sourceTableColumns = localField.linkedFieldGroup?.sourceTable
|
||||
? tableColumns[localField.linkedFieldGroup.sourceTable] || []
|
||||
: [];
|
||||
|
||||
// Select 옵션의 참조 테이블 컬럼 목록
|
||||
const selectTableColumns = localField.selectOptions?.tableName
|
||||
? tableColumns[localField.selectOptions.tableName] || []
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[700px] max-h-[85vh] flex flex-col p-0">
|
||||
<DialogHeader className="px-4 pt-4 pb-2 border-b shrink-0">
|
||||
<DialogTitle className="text-base">필드 상세 설정: {localField.label}</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
필드의 타입, 동작 방식, 고급 옵션을 설정합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden px-4">
|
||||
<ScrollArea className="h-[calc(85vh-180px)]">
|
||||
<div className="space-y-4 py-3 pr-3">
|
||||
{/* 기본 정보 섹션 */}
|
||||
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
||||
<h3 className="text-xs font-semibold">기본 정보</h3>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">필드 타입</Label>
|
||||
<Select
|
||||
value={localField.fieldType}
|
||||
onValueChange={(value) =>
|
||||
updateField({
|
||||
fieldType: value as FormFieldConfig["fieldType"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>입력 필드의 유형을 선택하세요 (텍스트, 숫자, 날짜 등)</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">그리드 너비</Label>
|
||||
<Select
|
||||
value={String(localField.gridSpan || 6)}
|
||||
onValueChange={(value) => updateField({ gridSpan: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">1/4 너비</SelectItem>
|
||||
<SelectItem value="4">1/3 너비</SelectItem>
|
||||
<SelectItem value="6">1/2 너비</SelectItem>
|
||||
<SelectItem value="8">2/3 너비</SelectItem>
|
||||
<SelectItem value="12">전체 너비</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>폼에서 차지할 너비를 설정합니다 (12칸 그리드 기준)</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">플레이스홀더</Label>
|
||||
<Input
|
||||
value={localField.placeholder || ""}
|
||||
onChange={(e) => updateField({ placeholder: e.target.value })}
|
||||
placeholder="입력 힌트"
|
||||
className="h-7 text-xs mt-1"
|
||||
/>
|
||||
<HelpText>입력 필드에 표시될 힌트 텍스트입니다</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 옵션 토글 */}
|
||||
<div className="space-y-2 border rounded-lg p-3 bg-card">
|
||||
<h3 className="text-xs font-semibold mb-2">필드 옵션</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px]">필수 입력</span>
|
||||
<Switch
|
||||
checked={localField.required || false}
|
||||
onCheckedChange={(checked) => updateField({ required: checked })}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>이 필드를 필수 입력으로 만듭니다</HelpText>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px]">비활성화 (읽기전용)</span>
|
||||
<Switch
|
||||
checked={localField.disabled || false}
|
||||
onCheckedChange={(checked) => updateField({ disabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>필드를 비활성화하여 수정할 수 없게 만듭니다</HelpText>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px]">숨김 (자동 저장만)</span>
|
||||
<Switch
|
||||
checked={localField.hidden || false}
|
||||
onCheckedChange={(checked) => updateField({ hidden: checked })}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>화면에 표시하지 않지만 값은 저장됩니다</HelpText>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px]">부모에서 값 받기</span>
|
||||
<Switch
|
||||
checked={localField.receiveFromParent || false}
|
||||
onCheckedChange={(checked) => updateField({ receiveFromParent: checked })}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>부모 화면에서 전달받은 값으로 자동 채워집니다</HelpText>
|
||||
</div>
|
||||
|
||||
{/* Accordion으로 고급 설정 */}
|
||||
<Accordion type="single" collapsible className="space-y-2">
|
||||
{/* Select 옵션 설정 */}
|
||||
{localField.fieldType === "select" && (
|
||||
<AccordionItem value="select-options" className="border rounded-lg">
|
||||
<AccordionTrigger className="px-3 py-2 text-xs font-medium hover:no-underline bg-green-50/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsIcon className="h-3.5 w-3.5 text-green-600" />
|
||||
<span>Select 옵션 설정</span>
|
||||
{localField.selectOptions?.type && (
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
({localField.selectOptions.type === "table" ? "테이블 참조" : localField.selectOptions.type === "code" ? "공통코드" : "직접 입력"})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 space-y-3">
|
||||
<HelpText>드롭다운에 표시될 옵션 목록을 어디서 가져올지 설정합니다.</HelpText>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">옵션 타입</Label>
|
||||
<Select
|
||||
value={localField.selectOptions?.type || "static"}
|
||||
onValueChange={(value) =>
|
||||
updateField({
|
||||
selectOptions: {
|
||||
...localField.selectOptions,
|
||||
type: value as "static" | "table" | "code",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SELECT_OPTION_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{localField.selectOptions?.type === "table" && (
|
||||
<div className="space-y-3 pt-2 border-t">
|
||||
<HelpText>테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.</HelpText>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">참조 테이블</Label>
|
||||
<Select
|
||||
value={localField.selectOptions?.tableName || ""}
|
||||
onValueChange={(value) => {
|
||||
updateField({
|
||||
selectOptions: {
|
||||
...localField.selectOptions,
|
||||
tableName: value,
|
||||
},
|
||||
});
|
||||
onLoadTableColumns(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.name} value={t.name}>
|
||||
{t.label || t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>드롭다운 목록을 가져올 테이블을 선택하세요</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">조인할 컬럼 (값)</Label>
|
||||
{selectTableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={localField.selectOptions?.valueColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateField({
|
||||
selectOptions: {
|
||||
...localField.selectOptions,
|
||||
valueColumn: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectTableColumns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.name}
|
||||
{col.label !== col.name && ` (${col.label})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={localField.selectOptions?.valueColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateField({
|
||||
selectOptions: {
|
||||
...localField.selectOptions,
|
||||
valueColumn: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="customer_code"
|
||||
className="h-7 text-xs mt-1"
|
||||
/>
|
||||
)}
|
||||
<HelpText>
|
||||
참조 테이블에서 조인할 컬럼 (기본키)
|
||||
<br />
|
||||
예: customer_code, customer_id
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">표시할 컬럼 (라벨)</Label>
|
||||
{selectTableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={localField.selectOptions?.labelColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateField({
|
||||
selectOptions: {
|
||||
...localField.selectOptions,
|
||||
labelColumn: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectTableColumns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.name}
|
||||
{col.label !== col.name && ` (${col.label})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={localField.selectOptions?.labelColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateField({
|
||||
selectOptions: {
|
||||
...localField.selectOptions,
|
||||
labelColumn: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="customer_name"
|
||||
className="h-7 text-xs mt-1"
|
||||
/>
|
||||
)}
|
||||
<HelpText>
|
||||
드롭다운에 표시할 컬럼 (이름)
|
||||
<br />
|
||||
예: customer_name, dept_name
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">저장할 컬럼</Label>
|
||||
{selectTableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={localField.selectOptions?.saveColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateField({
|
||||
selectOptions: {
|
||||
...localField.selectOptions,
|
||||
saveColumn: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="컬럼 선택 (미선택 시 조인 컬럼 저장)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">조인 컬럼 사용 (기본)</SelectItem>
|
||||
{selectTableColumns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.name}
|
||||
{col.label !== col.name && ` (${col.label})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={localField.selectOptions?.saveColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateField({
|
||||
selectOptions: {
|
||||
...localField.selectOptions,
|
||||
saveColumn: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="비워두면 조인 컬럼 저장"
|
||||
className="h-7 text-xs mt-1"
|
||||
/>
|
||||
)}
|
||||
<HelpText>
|
||||
실제로 DB에 저장할 컬럼을 선택하세요
|
||||
<br />
|
||||
예: customer_name 저장 (비워두면 customer_code 저장)
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localField.selectOptions?.type === "code" && (
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<HelpText>공통코드: 시스템 공통코드에서 옵션을 가져옵니다.</HelpText>
|
||||
<div>
|
||||
<Label className="text-[10px]">코드 카테고리</Label>
|
||||
<Input
|
||||
value={localField.selectOptions?.codeCategory || ""}
|
||||
onChange={(e) =>
|
||||
updateField({
|
||||
selectOptions: {
|
||||
...localField.selectOptions,
|
||||
codeCategory: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="DEPT_TYPE"
|
||||
className="h-7 text-xs mt-1"
|
||||
/>
|
||||
<HelpText>공통코드 카테고리를 입력하세요 (예: DEPT_TYPE, USER_STATUS)</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* 연결 필드 설정 */}
|
||||
<AccordionItem value="linked-fields" className="border rounded-lg">
|
||||
<AccordionTrigger className="px-3 py-2 text-xs font-medium hover:no-underline bg-orange-50/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsIcon className="h-3.5 w-3.5 text-orange-600" />
|
||||
<span>연결 필드 설정 (다중 컬럼 저장)</span>
|
||||
{localField.linkedFieldGroup?.enabled && (
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
({(localField.linkedFieldGroup?.mappings || []).length}개)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium">연결 필드 사용</span>
|
||||
<Switch
|
||||
checked={localField.linkedFieldGroup?.enabled || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField({
|
||||
linkedFieldGroup: {
|
||||
...localField.linkedFieldGroup,
|
||||
enabled: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>
|
||||
드롭다운 선택 시 다른 테이블의 값도 함께 저장합니다.
|
||||
<br />
|
||||
예: 고객 선택 → 고객코드, 고객명, 연락처를 각각 저장
|
||||
</HelpText>
|
||||
|
||||
{localField.linkedFieldGroup?.enabled && (
|
||||
<div className="space-y-3 pt-2 border-t">
|
||||
<div>
|
||||
<Label className="text-[10px]">소스 테이블</Label>
|
||||
<Select
|
||||
value={localField.linkedFieldGroup?.sourceTable || ""}
|
||||
onValueChange={(value) => {
|
||||
updateField({
|
||||
linkedFieldGroup: {
|
||||
...localField.linkedFieldGroup,
|
||||
sourceTable: value,
|
||||
},
|
||||
});
|
||||
onLoadTableColumns(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.name} value={t.name}>
|
||||
{t.label || t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>값을 가져올 소스 테이블 (예: customer_mng)</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">표시 컬럼</Label>
|
||||
{sourceTableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={localField.linkedFieldGroup?.displayColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateField({
|
||||
linkedFieldGroup: {
|
||||
...localField.linkedFieldGroup,
|
||||
displayColumn: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceTableColumns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.name}
|
||||
{col.label !== col.name && ` (${col.label})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={localField.linkedFieldGroup?.displayColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateField({
|
||||
linkedFieldGroup: {
|
||||
...localField.linkedFieldGroup,
|
||||
displayColumn: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="customer_name"
|
||||
className="h-7 text-xs mt-1"
|
||||
/>
|
||||
)}
|
||||
<HelpText>드롭다운에 표시할 컬럼 (예: customer_name)</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">표시 형식</Label>
|
||||
<Select
|
||||
value={localField.linkedFieldGroup?.displayFormat || "name_only"}
|
||||
onValueChange={(value) =>
|
||||
updateField({
|
||||
linkedFieldGroup: {
|
||||
...localField.linkedFieldGroup,
|
||||
displayFormat: value as "name_only" | "code_name" | "name_code",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LINKED_FIELD_DISPLAY_FORMAT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>드롭다운에 표시될 형식을 선택하세요</HelpText>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] font-medium">컬럼 매핑 목록</Label>
|
||||
<Button size="sm" variant="outline" onClick={addLinkedFieldMapping} className="h-6 text-[9px] px-2">
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
<HelpText>
|
||||
소스 테이블의 컬럼을 현재 폼의 어느 컬럼에 저장할지 매핑합니다.
|
||||
<br />
|
||||
예: customer_code → partner_id, customer_name → partner_name
|
||||
</HelpText>
|
||||
|
||||
{(localField.linkedFieldGroup?.mappings || []).length === 0 ? (
|
||||
<div className="text-center py-4 border border-dashed rounded-lg">
|
||||
<p className="text-[10px] text-muted-foreground">매핑이 없습니다</p>
|
||||
<p className="text-[9px] text-muted-foreground">위의 "매핑 추가" 버튼을 클릭하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(localField.linkedFieldGroup?.mappings || []).map((mapping, index) => (
|
||||
<div key={index} className="border rounded-lg p-2 space-y-2 bg-muted/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] font-medium text-muted-foreground">매핑 {index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeLinkedFieldMapping(index)}
|
||||
className="h-5 w-5 p-0 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[9px]">소스 컬럼 (가져올 값)</Label>
|
||||
{sourceTableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={mapping.sourceColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateLinkedFieldMapping(index, { sourceColumn: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[9px] mt-0.5">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceTableColumns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.name}
|
||||
{col.label !== col.name && ` (${col.label})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={mapping.sourceColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateLinkedFieldMapping(index, { sourceColumn: e.target.value })
|
||||
}
|
||||
placeholder="customer_code"
|
||||
className="h-6 text-[9px] mt-0.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center text-[9px] text-muted-foreground">↓</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[9px]">타겟 컬럼 (저장할 위치)</Label>
|
||||
<Input
|
||||
value={mapping.targetColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateLinkedFieldMapping(index, { targetColumn: e.target.value })
|
||||
}
|
||||
placeholder="partner_id"
|
||||
className="h-6 text-[9px] mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* 채번규칙 설정 */}
|
||||
<AccordionItem value="numbering-rule" className="border rounded-lg">
|
||||
<AccordionTrigger className="px-3 py-2 text-xs font-medium hover:no-underline bg-blue-50/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsIcon className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span>채번규칙 설정</span>
|
||||
{localField.numberingRule?.enabled && (
|
||||
<span className="text-[9px] text-muted-foreground">(활성화됨)</span>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium">채번규칙 사용</span>
|
||||
<Switch
|
||||
checked={localField.numberingRule?.enabled || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField({
|
||||
numberingRule: {
|
||||
...localField.numberingRule,
|
||||
enabled: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>
|
||||
자동으로 코드/번호를 생성합니다.
|
||||
<br />
|
||||
예: EMP-001, ORD-20240101-001
|
||||
</HelpText>
|
||||
|
||||
{localField.numberingRule?.enabled && (
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<div>
|
||||
<Label className="text-[10px]">채번규칙 선택</Label>
|
||||
<Select
|
||||
value={localField.numberingRule?.ruleId || ""}
|
||||
onValueChange={(value) =>
|
||||
updateField({
|
||||
numberingRule: {
|
||||
...localField.numberingRule,
|
||||
ruleId: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="규칙 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{numberingRules.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
등록된 채번규칙이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
numberingRules.map((rule) => (
|
||||
<SelectItem key={rule.id} value={rule.id}>
|
||||
{rule.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>사용할 채번규칙을 선택하세요</HelpText>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px]">사용자 수정 가능</span>
|
||||
<Switch
|
||||
checked={localField.numberingRule?.editable || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField({
|
||||
numberingRule: {
|
||||
...localField.numberingRule,
|
||||
editable: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>생성된 번호를 사용자가 수정할 수 있게 합니다</HelpText>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px]">저장 시점에 생성</span>
|
||||
<Switch
|
||||
checked={localField.numberingRule?.generateOnSave || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField({
|
||||
numberingRule: {
|
||||
...localField.numberingRule,
|
||||
generateOnSave: checked,
|
||||
generateOnOpen: !checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>OFF: 모달 열릴 때 생성 / ON: 저장 버튼 클릭 시 생성</HelpText>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-4 py-3 border-t shrink-0">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 text-sm">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="h-9 text-sm">
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,796 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Plus, Trash2, Database, Layers } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig } from "../types";
|
||||
|
||||
// 도움말 텍스트 컴포넌트
|
||||
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
|
||||
);
|
||||
|
||||
interface SaveSettingsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
saveConfig: SaveConfig;
|
||||
sections: FormSectionConfig[];
|
||||
onSave: (updates: SaveConfig) => void;
|
||||
tables: { name: string; label: string }[];
|
||||
tableColumns: { [tableName: string]: { name: string; type: string; label: string }[] };
|
||||
onLoadTableColumns: (tableName: string) => void;
|
||||
}
|
||||
|
||||
export function SaveSettingsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
saveConfig,
|
||||
sections,
|
||||
onSave,
|
||||
tables,
|
||||
tableColumns,
|
||||
onLoadTableColumns,
|
||||
}: SaveSettingsModalProps) {
|
||||
// 로컬 상태로 저장 설정 관리
|
||||
const [localSaveConfig, setLocalSaveConfig] = useState<SaveConfig>(saveConfig);
|
||||
|
||||
// 저장 모드 (단일 테이블 vs 다중 테이블)
|
||||
const [saveMode, setSaveMode] = useState<"single" | "multi">(
|
||||
saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single"
|
||||
);
|
||||
|
||||
// open이 변경될 때마다 데이터 동기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLocalSaveConfig(saveConfig);
|
||||
setSaveMode(saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single");
|
||||
}
|
||||
}, [open, saveConfig]);
|
||||
|
||||
// 저장 설정 업데이트 함수
|
||||
const updateSaveConfig = (updates: Partial<SaveConfig>) => {
|
||||
setLocalSaveConfig((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
// 저장 함수
|
||||
const handleSave = () => {
|
||||
// 저장 모드에 따라 설정 조정
|
||||
let finalConfig = { ...localSaveConfig };
|
||||
|
||||
if (saveMode === "single") {
|
||||
// 단일 테이블 모드: customApiSave 비활성화
|
||||
finalConfig = {
|
||||
...finalConfig,
|
||||
customApiSave: {
|
||||
enabled: false,
|
||||
apiType: "custom",
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// 다중 테이블 모드: customApiSave 활성화
|
||||
finalConfig = {
|
||||
...finalConfig,
|
||||
customApiSave: {
|
||||
...finalConfig.customApiSave,
|
||||
enabled: true,
|
||||
apiType: "multi-table",
|
||||
multiTable: {
|
||||
...finalConfig.customApiSave?.multiTable,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onSave(finalConfig);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 서브 테이블 추가
|
||||
const addSubTable = () => {
|
||||
const newSubTable: SubTableSaveConfig = {
|
||||
enabled: true,
|
||||
tableName: "",
|
||||
repeatSectionId: "",
|
||||
linkColumn: {
|
||||
mainField: "",
|
||||
subColumn: "",
|
||||
},
|
||||
fieldMappings: [],
|
||||
};
|
||||
|
||||
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || []), newSubTable];
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...localSaveConfig.customApiSave,
|
||||
apiType: "multi-table",
|
||||
multiTable: {
|
||||
...localSaveConfig.customApiSave?.multiTable,
|
||||
enabled: true,
|
||||
subTables,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 서브 테이블 삭제
|
||||
const removeSubTable = (index: number) => {
|
||||
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
|
||||
subTables.splice(index, 1);
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...localSaveConfig.customApiSave,
|
||||
multiTable: {
|
||||
...localSaveConfig.customApiSave?.multiTable,
|
||||
subTables,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 서브 테이블 업데이트
|
||||
const updateSubTable = (index: number, updates: Partial<SubTableSaveConfig>) => {
|
||||
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
|
||||
subTables[index] = { ...subTables[index], ...updates };
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...localSaveConfig.customApiSave,
|
||||
multiTable: {
|
||||
...localSaveConfig.customApiSave?.multiTable,
|
||||
subTables,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 필드 매핑 추가
|
||||
const addFieldMapping = (subTableIndex: number) => {
|
||||
const newMapping: SubTableFieldMapping = {
|
||||
formField: "",
|
||||
targetColumn: "",
|
||||
};
|
||||
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
|
||||
const fieldMappings = [...(subTables[subTableIndex].fieldMappings || []), newMapping];
|
||||
subTables[subTableIndex] = { ...subTables[subTableIndex], fieldMappings };
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...localSaveConfig.customApiSave,
|
||||
multiTable: {
|
||||
...localSaveConfig.customApiSave?.multiTable,
|
||||
subTables,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 필드 매핑 삭제
|
||||
const removeFieldMapping = (subTableIndex: number, mappingIndex: number) => {
|
||||
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
|
||||
const fieldMappings = [...(subTables[subTableIndex].fieldMappings || [])];
|
||||
fieldMappings.splice(mappingIndex, 1);
|
||||
subTables[subTableIndex] = { ...subTables[subTableIndex], fieldMappings };
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...localSaveConfig.customApiSave,
|
||||
multiTable: {
|
||||
...localSaveConfig.customApiSave?.multiTable,
|
||||
subTables,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 필드 매핑 업데이트
|
||||
const updateFieldMapping = (subTableIndex: number, mappingIndex: number, updates: Partial<SubTableFieldMapping>) => {
|
||||
const subTables = [...(localSaveConfig.customApiSave?.multiTable?.subTables || [])];
|
||||
const fieldMappings = [...(subTables[subTableIndex].fieldMappings || [])];
|
||||
fieldMappings[mappingIndex] = { ...fieldMappings[mappingIndex], ...updates };
|
||||
subTables[subTableIndex] = { ...subTables[subTableIndex], fieldMappings };
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...localSaveConfig.customApiSave,
|
||||
multiTable: {
|
||||
...localSaveConfig.customApiSave?.multiTable,
|
||||
subTables,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 메인 테이블 컬럼 목록
|
||||
const mainTableColumns = localSaveConfig.customApiSave?.multiTable?.mainTable?.tableName
|
||||
? tableColumns[localSaveConfig.customApiSave.multiTable.mainTable.tableName] || []
|
||||
: [];
|
||||
|
||||
// 반복 섹션 목록
|
||||
const repeatSections = sections.filter((s) => s.repeatable);
|
||||
|
||||
// 모든 필드 목록 (반복 섹션 포함)
|
||||
const getAllFields = (): { columnName: string; label: string; sectionTitle: string }[] => {
|
||||
const fields: { columnName: string; label: string; sectionTitle: string }[] = [];
|
||||
sections.forEach((section) => {
|
||||
section.fields.forEach((field) => {
|
||||
fields.push({
|
||||
columnName: field.columnName,
|
||||
label: field.label,
|
||||
sectionTitle: section.title,
|
||||
});
|
||||
});
|
||||
});
|
||||
return fields;
|
||||
};
|
||||
|
||||
const allFields = getAllFields();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
|
||||
<DialogHeader className="px-4 pt-4 pb-2 border-b shrink-0">
|
||||
<DialogTitle className="text-base">저장 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
폼 데이터를 데이터베이스에 저장하는 방식을 설정합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden px-4">
|
||||
<ScrollArea className="h-[calc(90vh-180px)]">
|
||||
<div className="space-y-4 py-3 pr-3">
|
||||
{/* 저장 모드 선택 */}
|
||||
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
||||
<Label className="text-xs font-semibold">저장 모드</Label>
|
||||
<RadioGroup value={saveMode} onValueChange={(value) => setSaveMode(value as "single" | "multi")}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="single" id="mode-single" />
|
||||
<Label htmlFor="mode-single" className="text-[10px] cursor-pointer">
|
||||
단일 테이블 저장
|
||||
</Label>
|
||||
</div>
|
||||
<HelpText>모든 필드를 하나의 테이블에 저장합니다 (기본 방식)</HelpText>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<RadioGroupItem value="multi" id="mode-multi" />
|
||||
<Label htmlFor="mode-multi" className="text-[10px] cursor-pointer">
|
||||
다중 테이블 저장
|
||||
</Label>
|
||||
</div>
|
||||
<HelpText>
|
||||
메인 테이블 + 서브 테이블에 트랜잭션으로 저장합니다
|
||||
<br />
|
||||
예: 주문(orders) + 주문상세(order_items), 사원(user_info) + 부서(user_dept)
|
||||
</HelpText>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 단일 테이블 저장 설정 */}
|
||||
{saveMode === "single" && (
|
||||
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-blue-600" />
|
||||
<h3 className="text-xs font-semibold">단일 테이블 설정</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">저장 테이블</Label>
|
||||
<Select
|
||||
value={localSaveConfig.tableName || ""}
|
||||
onValueChange={(value) => {
|
||||
updateSaveConfig({ tableName: value });
|
||||
onLoadTableColumns(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.name} value={t.name}>
|
||||
{t.label || t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>폼 데이터를 저장할 테이블을 선택하세요</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">키 컬럼 (Primary Key)</Label>
|
||||
<Input
|
||||
value={localSaveConfig.primaryKeyColumn || ""}
|
||||
onChange={(e) => updateSaveConfig({ primaryKeyColumn: e.target.value })}
|
||||
placeholder="id"
|
||||
className="h-7 text-xs mt-1"
|
||||
/>
|
||||
<HelpText>
|
||||
수정 모드에서 사용할 기본키 컬럼명
|
||||
<br />
|
||||
예: id, user_id, order_id
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 다중 테이블 저장 설정 */}
|
||||
{saveMode === "multi" && (
|
||||
<div className="space-y-3">
|
||||
{/* 메인 테이블 설정 */}
|
||||
<div className="border rounded-lg p-3 bg-card space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-blue-600" />
|
||||
<h3 className="text-xs font-semibold">메인 테이블 설정</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">메인 테이블명</Label>
|
||||
<Select
|
||||
value={localSaveConfig.customApiSave?.multiTable?.mainTable?.tableName || ""}
|
||||
onValueChange={(value) => {
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...localSaveConfig.customApiSave,
|
||||
apiType: "multi-table",
|
||||
multiTable: {
|
||||
...localSaveConfig.customApiSave?.multiTable,
|
||||
enabled: true,
|
||||
mainTable: {
|
||||
...localSaveConfig.customApiSave?.multiTable?.mainTable,
|
||||
tableName: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
onLoadTableColumns(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.name} value={t.name}>
|
||||
{t.label || t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>주요 데이터를 저장할 메인 테이블 (예: orders, user_info)</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">메인 테이블 키 컬럼</Label>
|
||||
{mainTableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...localSaveConfig.customApiSave,
|
||||
multiTable: {
|
||||
...localSaveConfig.customApiSave?.multiTable,
|
||||
mainTable: {
|
||||
...localSaveConfig.customApiSave?.multiTable?.mainTable,
|
||||
primaryKeyColumn: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mainTableColumns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.name}
|
||||
{col.label !== col.name && ` (${col.label})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={localSaveConfig.customApiSave?.multiTable?.mainTable?.primaryKeyColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateSaveConfig({
|
||||
customApiSave: {
|
||||
...localSaveConfig.customApiSave,
|
||||
multiTable: {
|
||||
...localSaveConfig.customApiSave?.multiTable,
|
||||
mainTable: {
|
||||
...localSaveConfig.customApiSave?.multiTable?.mainTable,
|
||||
primaryKeyColumn: e.target.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="id"
|
||||
className="h-7 text-xs mt-1"
|
||||
/>
|
||||
)}
|
||||
<HelpText>메인 테이블의 기본키 컬럼 (예: order_id, user_id)</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 서브 테이블 목록 */}
|
||||
<div className="border rounded-lg p-3 bg-card space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-orange-600" />
|
||||
<h3 className="text-xs font-semibold">서브 테이블 설정</h3>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
({(localSaveConfig.customApiSave?.multiTable?.subTables || []).length}개)
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={addSubTable} className="h-6 text-[9px] px-2">
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
서브 테이블 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<HelpText>
|
||||
반복 섹션 데이터를 별도 테이블에 저장합니다.
|
||||
<br />
|
||||
예: 주문상세(order_items), 겸직부서(user_dept)
|
||||
</HelpText>
|
||||
|
||||
{(localSaveConfig.customApiSave?.multiTable?.subTables || []).length === 0 ? (
|
||||
<div className="text-center py-6 border border-dashed rounded-lg">
|
||||
<p className="text-[10px] text-muted-foreground mb-1">서브 테이블이 없습니다</p>
|
||||
<p className="text-[9px] text-muted-foreground">위의 "서브 테이블 추가" 버튼을 클릭하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 pt-2">
|
||||
{(localSaveConfig.customApiSave?.multiTable?.subTables || []).map((subTable, subIndex) => {
|
||||
const subTableColumns = subTable.tableName ? tableColumns[subTable.tableName] || [] : [];
|
||||
return (
|
||||
<Accordion key={subIndex} type="single" collapsible>
|
||||
<AccordionItem value={`sub-${subIndex}`} className="border rounded-lg bg-orange-50/30">
|
||||
<AccordionTrigger className="px-3 py-2 text-xs hover:no-underline">
|
||||
<div className="flex items-center justify-between flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
서브 테이블 {subIndex + 1}: {subTable.tableName || "(미설정)"}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
({subTable.fieldMappings?.length || 0}개 매핑)
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeSubTable(subIndex);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-destructive hover:text-destructive mr-2"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3 space-y-3">
|
||||
<div>
|
||||
<Label className="text-[10px]">서브 테이블명</Label>
|
||||
<Select
|
||||
value={subTable.tableName || ""}
|
||||
onValueChange={(value) => {
|
||||
updateSubTable(subIndex, { tableName: value });
|
||||
onLoadTableColumns(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.name} value={t.name}>
|
||||
{t.label || t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>반복 데이터를 저장할 서브 테이블</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">연결할 반복 섹션</Label>
|
||||
<Select
|
||||
value={subTable.repeatSectionId || ""}
|
||||
onValueChange={(value) => updateSubTable(subIndex, { repeatSectionId: value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="섹션 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{repeatSections.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
반복 섹션이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
repeatSections.map((section) => (
|
||||
<SelectItem key={section.id} value={section.id}>
|
||||
{section.title}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>이 서브 테이블에 저장할 반복 섹션을 선택하세요</HelpText>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] font-medium">테이블 연결 설정</Label>
|
||||
<HelpText>메인 테이블과 서브 테이블을 연결하는 키 컬럼</HelpText>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-[9px]">메인 필드</Label>
|
||||
{mainTableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={subTable.linkColumn?.mainField || ""}
|
||||
onValueChange={(value) =>
|
||||
updateSubTable(subIndex, {
|
||||
linkColumn: { ...subTable.linkColumn, mainField: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[9px] mt-0.5">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mainTableColumns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={subTable.linkColumn?.mainField || ""}
|
||||
onChange={(e) =>
|
||||
updateSubTable(subIndex, {
|
||||
linkColumn: { ...subTable.linkColumn, mainField: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="order_id"
|
||||
className="h-6 text-[9px] mt-0.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[9px]">서브 컬럼</Label>
|
||||
{subTableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={subTable.linkColumn?.subColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateSubTable(subIndex, {
|
||||
linkColumn: { ...subTable.linkColumn, subColumn: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[9px] mt-0.5">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subTableColumns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={subTable.linkColumn?.subColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateSubTable(subIndex, {
|
||||
linkColumn: { ...subTable.linkColumn, subColumn: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="order_id"
|
||||
className="h-6 text-[9px] mt-0.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] font-medium">필드 매핑</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addFieldMapping(subIndex)}
|
||||
className="h-5 text-[8px] px-1.5"
|
||||
>
|
||||
<Plus className="h-2.5 w-2.5 mr-0.5" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<HelpText>폼 필드를 서브 테이블 컬럼에 매핑합니다</HelpText>
|
||||
|
||||
{(subTable.fieldMappings || []).length === 0 ? (
|
||||
<div className="text-center py-3 border border-dashed rounded-lg">
|
||||
<p className="text-[9px] text-muted-foreground">매핑이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(subTable.fieldMappings || []).map((mapping, mapIndex) => (
|
||||
<div key={mapIndex} className="border rounded-lg p-2 bg-white space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[8px] font-medium text-muted-foreground">
|
||||
매핑 {mapIndex + 1}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeFieldMapping(subIndex, mapIndex)}
|
||||
className="h-4 w-4 p-0 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[8px]">폼 필드</Label>
|
||||
<Select
|
||||
value={mapping.formField || ""}
|
||||
onValueChange={(value) =>
|
||||
updateFieldMapping(subIndex, mapIndex, { formField: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-5 text-[8px] mt-0.5">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allFields.map((field) => (
|
||||
<SelectItem key={field.columnName} value={field.columnName}>
|
||||
{field.label} ({field.sectionTitle})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-[8px] text-muted-foreground">↓</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[8px]">서브 테이블 컬럼</Label>
|
||||
{subTableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={mapping.targetColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateFieldMapping(subIndex, mapIndex, { targetColumn: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-5 text-[8px] mt-0.5">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subTableColumns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={mapping.targetColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateFieldMapping(subIndex, mapIndex, {
|
||||
targetColumn: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="item_name"
|
||||
className="h-5 text-[8px] mt-0.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 저장 후 동작 */}
|
||||
<div className="space-y-2 border rounded-lg p-3 bg-card">
|
||||
<h3 className="text-xs font-semibold">저장 후 동작</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px]">토스트 메시지 표시</span>
|
||||
<Switch
|
||||
checked={localSaveConfig.afterSave?.showToast !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSaveConfig({
|
||||
afterSave: {
|
||||
...localSaveConfig.afterSave,
|
||||
showToast: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>저장 성공 시 "저장되었습니다" 메시지를 표시합니다</HelpText>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px]">모달 자동 닫기</span>
|
||||
<Switch
|
||||
checked={localSaveConfig.afterSave?.closeModal !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSaveConfig({
|
||||
afterSave: {
|
||||
...localSaveConfig.afterSave,
|
||||
closeModal: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>저장 성공 시 모달을 자동으로 닫습니다</HelpText>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px]">부모 화면 새로고침</span>
|
||||
<Switch
|
||||
checked={localSaveConfig.afterSave?.refreshParent !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSaveConfig({
|
||||
afterSave: {
|
||||
...localSaveConfig.afterSave,
|
||||
refreshParent: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>저장 후 부모 화면의 데이터를 새로고침합니다</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-4 py-3 border-t shrink-0">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 text-sm">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="h-9 text-sm">
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,516 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FormSectionConfig, FormFieldConfig, FIELD_TYPE_OPTIONS } from "../types";
|
||||
import { defaultFieldConfig, generateFieldId } from "../config";
|
||||
|
||||
// 도움말 텍스트 컴포넌트
|
||||
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
|
||||
);
|
||||
|
||||
interface SectionLayoutModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
section: FormSectionConfig;
|
||||
onSave: (updates: Partial<FormSectionConfig>) => void;
|
||||
onOpenFieldDetail: (field: FormFieldConfig) => void;
|
||||
}
|
||||
|
||||
export function SectionLayoutModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
section,
|
||||
onSave,
|
||||
onOpenFieldDetail,
|
||||
}: SectionLayoutModalProps) {
|
||||
// 로컬 상태로 섹션 관리
|
||||
const [localSection, setLocalSection] = useState<FormSectionConfig>(section);
|
||||
|
||||
// open이 변경될 때마다 데이터 동기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLocalSection(section);
|
||||
}
|
||||
}, [open, section]);
|
||||
|
||||
// 섹션 업데이트 함수
|
||||
const updateSection = (updates: Partial<FormSectionConfig>) => {
|
||||
setLocalSection((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
// 저장 함수
|
||||
const handleSave = () => {
|
||||
onSave(localSection);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 필드 추가
|
||||
const addField = () => {
|
||||
const newField: FormFieldConfig = {
|
||||
...defaultFieldConfig,
|
||||
id: generateFieldId(),
|
||||
label: `새 필드 ${localSection.fields.length + 1}`,
|
||||
columnName: `field_${localSection.fields.length + 1}`,
|
||||
};
|
||||
updateSection({
|
||||
fields: [...localSection.fields, newField],
|
||||
});
|
||||
};
|
||||
|
||||
// 필드 삭제
|
||||
const removeField = (fieldId: string) => {
|
||||
updateSection({
|
||||
fields: localSection.fields.filter((f) => f.id !== fieldId),
|
||||
});
|
||||
};
|
||||
|
||||
// 필드 업데이트
|
||||
const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => {
|
||||
updateSection({
|
||||
fields: localSection.fields.map((f) => (f.id === fieldId ? { ...f, ...updates } : f)),
|
||||
});
|
||||
};
|
||||
|
||||
// 필드 이동
|
||||
const moveField = (fieldId: string, direction: "up" | "down") => {
|
||||
const index = localSection.fields.findIndex((f) => f.id === fieldId);
|
||||
if (index === -1) return;
|
||||
|
||||
if (direction === "up" && index === 0) return;
|
||||
if (direction === "down" && index === localSection.fields.length - 1) return;
|
||||
|
||||
const newFields = [...localSection.fields];
|
||||
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
||||
[newFields[index], newFields[targetIndex]] = [newFields[targetIndex], newFields[index]];
|
||||
|
||||
updateSection({ fields: newFields });
|
||||
};
|
||||
|
||||
// 필드 타입별 색상
|
||||
const getFieldTypeColor = (fieldType: FormFieldConfig["fieldType"]): string => {
|
||||
switch (fieldType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "password":
|
||||
case "tel":
|
||||
return "bg-blue-50 border-blue-200 text-blue-700";
|
||||
case "number":
|
||||
return "bg-cyan-50 border-cyan-200 text-cyan-700";
|
||||
case "date":
|
||||
case "datetime":
|
||||
return "bg-purple-50 border-purple-200 text-purple-700";
|
||||
case "select":
|
||||
return "bg-green-50 border-green-200 text-green-700";
|
||||
case "checkbox":
|
||||
return "bg-pink-50 border-pink-200 text-pink-700";
|
||||
case "textarea":
|
||||
return "bg-orange-50 border-orange-200 text-orange-700";
|
||||
default:
|
||||
return "bg-gray-50 border-gray-200 text-gray-700";
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 타입 라벨
|
||||
const getFieldTypeLabel = (fieldType: FormFieldConfig["fieldType"]): string => {
|
||||
const option = FIELD_TYPE_OPTIONS.find((opt) => opt.value === fieldType);
|
||||
return option?.label || fieldType;
|
||||
};
|
||||
|
||||
// 그리드 너비 라벨
|
||||
const getGridSpanLabel = (span: number): string => {
|
||||
switch (span) {
|
||||
case 3:
|
||||
return "1/4";
|
||||
case 4:
|
||||
return "1/3";
|
||||
case 6:
|
||||
return "1/2";
|
||||
case 8:
|
||||
return "2/3";
|
||||
case 12:
|
||||
return "전체";
|
||||
default:
|
||||
return `${span}/12`;
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 상세 설정 열기
|
||||
const handleOpenFieldDetail = (field: FormFieldConfig) => {
|
||||
// 먼저 현재 변경사항 저장
|
||||
onSave(localSection);
|
||||
// 그 다음 필드 상세 모달 열기
|
||||
onOpenFieldDetail(field);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh] flex flex-col p-0">
|
||||
<DialogHeader className="px-4 pt-4 pb-2 border-b shrink-0">
|
||||
<DialogTitle className="text-base">섹션 레이아웃: {localSection.title}</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
섹션의 필드 구성과 배치를 관리합니다. 필드를 추가하거나 순서를 변경할 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden px-4">
|
||||
<ScrollArea className="h-[calc(90vh-180px)]">
|
||||
<div className="space-y-4 py-3 pr-3">
|
||||
{/* 섹션 기본 정보 */}
|
||||
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
||||
<h3 className="text-xs font-semibold">섹션 기본 정보</h3>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">섹션 제목</Label>
|
||||
<Input
|
||||
value={localSection.title}
|
||||
onChange={(e) => updateSection({ title: e.target.value })}
|
||||
className="h-7 text-xs mt-1"
|
||||
/>
|
||||
<HelpText>섹션의 제목을 입력하세요 (예: 기본 정보, 배송 정보)</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">섹션 설명</Label>
|
||||
<Textarea
|
||||
value={localSection.description || ""}
|
||||
onChange={(e) => updateSection({ description: e.target.value })}
|
||||
className="text-xs mt-1 min-h-[50px]"
|
||||
placeholder="섹션에 대한 설명 (선택사항)"
|
||||
/>
|
||||
<HelpText>사용자에게 표시될 섹션 설명입니다</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">열 수 (레이아웃)</Label>
|
||||
<Select
|
||||
value={String(localSection.columns || 2)}
|
||||
onValueChange={(value) => updateSection({ columns: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1열 (세로 배치)</SelectItem>
|
||||
<SelectItem value="2">2열 (기본)</SelectItem>
|
||||
<SelectItem value="3">3열</SelectItem>
|
||||
<SelectItem value="4">4열</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>필드들을 몇 열로 배치할지 설정합니다</HelpText>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px]">접을 수 있음</span>
|
||||
<Switch
|
||||
checked={localSection.collapsible || false}
|
||||
onCheckedChange={(checked) => updateSection({ collapsible: checked })}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>섹션을 접었다 펼 수 있게 만듭니다</HelpText>
|
||||
|
||||
{localSection.collapsible && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px]">기본으로 접힌 상태</span>
|
||||
<Switch
|
||||
checked={localSection.defaultCollapsed || false}
|
||||
onCheckedChange={(checked) => updateSection({ defaultCollapsed: checked })}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>처음 열릴 때 섹션이 접힌 상태로 표시됩니다</HelpText>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 반복 섹션 설정 */}
|
||||
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold">반복 섹션</span>
|
||||
<Switch
|
||||
checked={localSection.repeatable || false}
|
||||
onCheckedChange={(checked) => updateSection({ repeatable: checked })}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>
|
||||
겸직처럼 동일한 필드 그룹을 여러 개 추가할 수 있습니다
|
||||
<br />
|
||||
예: 경력사항, 학력사항, 자격증
|
||||
</HelpText>
|
||||
|
||||
{localSection.repeatable && (
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-[10px]">최소 개수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={localSection.repeatConfig?.minItems || 0}
|
||||
onChange={(e) =>
|
||||
updateSection({
|
||||
repeatConfig: {
|
||||
...localSection.repeatConfig,
|
||||
minItems: parseInt(e.target.value) || 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">최대 개수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={localSection.repeatConfig?.maxItems || 10}
|
||||
onChange={(e) =>
|
||||
updateSection({
|
||||
repeatConfig: {
|
||||
...localSection.repeatConfig,
|
||||
maxItems: parseInt(e.target.value) || 10,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">추가 버튼 텍스트</Label>
|
||||
<Input
|
||||
value={localSection.repeatConfig?.addButtonText || "+ 추가"}
|
||||
onChange={(e) =>
|
||||
updateSection({
|
||||
repeatConfig: {
|
||||
...localSection.repeatConfig,
|
||||
addButtonText: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xs font-semibold">필드 목록</h3>
|
||||
<Badge variant="secondary" className="text-[9px] px-1.5 py-0">
|
||||
{localSection.fields.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={addField} className="h-7 text-[10px] px-2">
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
필드 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<HelpText>
|
||||
필드를 추가하고 순서를 변경할 수 있습니다. "상세 설정"에서 필드 타입과 옵션을 설정하세요.
|
||||
</HelpText>
|
||||
|
||||
{localSection.fields.length === 0 ? (
|
||||
<div className="text-center py-8 border border-dashed rounded-lg">
|
||||
<p className="text-sm text-muted-foreground mb-2">필드가 없습니다</p>
|
||||
<p className="text-xs text-muted-foreground">위의 "필드 추가" 버튼으로 필드를 추가하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{localSection.fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className={cn(
|
||||
"border rounded-lg overflow-hidden",
|
||||
getFieldTypeColor(field.fieldType)
|
||||
)}
|
||||
>
|
||||
{/* 필드 헤더 */}
|
||||
<div className="flex items-center justify-between p-2 bg-white/50">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{/* 순서 변경 버튼 */}
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => moveField(field.id, "up")}
|
||||
disabled={index === 0}
|
||||
className="h-3 w-5 p-0"
|
||||
>
|
||||
<ChevronUp className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => moveField(field.id, "down")}
|
||||
disabled={index === localSection.fields.length - 1}
|
||||
className="h-3 w-5 p-0"
|
||||
>
|
||||
<ChevronDown className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
||||
{/* 필드 정보 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-medium">{field.label}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-[8px] px-1 py-0", getFieldTypeColor(field.fieldType))}
|
||||
>
|
||||
{getFieldTypeLabel(field.fieldType)}
|
||||
</Badge>
|
||||
{field.required && (
|
||||
<Badge variant="destructive" className="text-[8px] px-1 py-0">
|
||||
필수
|
||||
</Badge>
|
||||
)}
|
||||
{field.hidden && (
|
||||
<Badge variant="secondary" className="text-[8px] px-1 py-0">
|
||||
숨김
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground mt-0.5">
|
||||
컬럼: {field.columnName} | 너비: {getGridSpanLabel(field.gridSpan || 6)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenFieldDetail(field)}
|
||||
className="h-6 px-2 text-[9px]"
|
||||
>
|
||||
<SettingsIcon className="h-3 w-3 mr-1" />
|
||||
상세 설정
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeField(field.id)}
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 간단한 인라인 설정 */}
|
||||
<div className="px-2 pb-2 space-y-2 bg-white/30">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-[9px]">라벨</Label>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(field.id, { label: e.target.value })}
|
||||
className="h-6 text-[9px] mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[9px]">컬럼명</Label>
|
||||
<Input
|
||||
value={field.columnName}
|
||||
onChange={(e) => updateField(field.id, { columnName: e.target.value })}
|
||||
className="h-6 text-[9px] mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-[9px]">필드 타입</Label>
|
||||
<Select
|
||||
value={field.fieldType}
|
||||
onValueChange={(value) =>
|
||||
updateField(field.id, {
|
||||
fieldType: value as FormFieldConfig["fieldType"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[9px] mt-0.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[9px]">너비</Label>
|
||||
<Select
|
||||
value={String(field.gridSpan || 6)}
|
||||
onValueChange={(value) => updateField(field.id, { gridSpan: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[9px] mt-0.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">1/4</SelectItem>
|
||||
<SelectItem value="4">1/3</SelectItem>
|
||||
<SelectItem value="6">1/2</SelectItem>
|
||||
<SelectItem value="8">2/3</SelectItem>
|
||||
<SelectItem value="12">전체</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-[9px]">필수</span>
|
||||
<Switch
|
||||
checked={field.required || false}
|
||||
onCheckedChange={(checked) => updateField(field.id, { required: checked })}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-4 py-3 border-t shrink-0">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 text-sm">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="h-9 text-sm">
|
||||
저장 ({localSection.fields.length}개 필드)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -12,8 +12,9 @@ export interface SelectOptionConfig {
|
|||
staticOptions?: { value: string; label: string }[];
|
||||
// 테이블 기반 옵션
|
||||
tableName?: string;
|
||||
valueColumn?: string;
|
||||
labelColumn?: string;
|
||||
valueColumn?: string; // 조인할 컬럼 (조회용 기본키)
|
||||
labelColumn?: string; // 표시할 컬럼 (화면에 보여줄 텍스트)
|
||||
saveColumn?: string; // 저장할 컬럼 (실제로 DB에 저장할 값, 미지정 시 valueColumn 사용)
|
||||
filterCondition?: string;
|
||||
// 공통코드 기반 옵션
|
||||
codeCategory?: string;
|
||||
|
|
|
|||
|
|
@ -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 || {},
|
||||
},
|
||||
});
|
||||
|
|
@ -2663,7 +2712,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) =>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
@ -269,7 +267,7 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50 p-4">
|
||||
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50 p-4 w-full">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<span className="text-sm font-medium">⏳ 로딩 중...</span>
|
||||
</div>
|
||||
|
|
@ -280,7 +278,7 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-red-300 bg-red-50 p-4">
|
||||
<div className="rounded-md border border-dashed border-red-300 bg-red-50 p-4 w-full">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<span className="text-sm font-medium">⚠️ 로드 실패</span>
|
||||
</div>
|
||||
|
|
@ -292,7 +290,7 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
if (!ConfigPanelComponent) {
|
||||
console.warn(`⚠️ DynamicComponentConfigPanel: ${componentId} ConfigPanelComponent가 null`);
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-yellow-300 bg-yellow-50 p-4">
|
||||
<div className="rounded-md border border-dashed border-yellow-300 bg-yellow-50 p-4 w-full">
|
||||
<div className="flex items-center gap-2 text-yellow-600">
|
||||
<span className="text-sm font-medium">⚠️ 설정 패널 없음</span>
|
||||
</div>
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -34,9 +34,9 @@
|
|||
"@react-three/fiber": "^9.4.0",
|
||||
"@tanstack/react-query": "^5.86.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tiptap/core": "^3.13.0",
|
||||
"@tiptap/core": "^2.27.1",
|
||||
"@tiptap/extension-placeholder": "^2.27.1",
|
||||
"@tiptap/pm": "^2.11.5",
|
||||
"@tiptap/pm": "^2.27.1",
|
||||
"@tiptap/react": "^2.27.1",
|
||||
"@tiptap/starter-kit": "^2.27.1",
|
||||
"@turf/buffer": "^7.2.0",
|
||||
|
|
@ -3302,16 +3302,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.13.0.tgz",
|
||||
"integrity": "sha512-iUelgiTMgPVMpY5ZqASUpk8mC8HuR9FWKaDzK27w9oWip9tuB54Z8mePTxNcQaSPb6ErzEaC8x8egrRt7OsdGQ==",
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "^3.13.0"
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
|
|
@ -3700,19 +3700,6 @@
|
|||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit/node_modules/@tiptap/core": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@turf/along": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@turf/along/-/along-7.2.0.tgz",
|
||||
|
|
@ -6084,7 +6071,7 @@
|
|||
"version": "20.19.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
|
||||
"integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
|
|
@ -6122,7 +6109,7 @@
|
|||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
|
|
@ -12538,6 +12525,13 @@
|
|||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.1.tgz",
|
||||
"integrity": "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
|
|
@ -14197,7 +14191,7 @@
|
|||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue