Merge pull request 'feature/screen-management' (#208) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/208
This commit is contained in:
commit
d420d1dd40
|
|
@ -68,6 +68,8 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
|||
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 { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -230,6 +232,8 @@ app.use("/api/departments", departmentRoutes); // 부서 관리
|
|||
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/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
import { Request, Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 엔티티 검색 API
|
||||
* GET /api/entity-search/:tableName
|
||||
*/
|
||||
export async function searchEntity(req: Request, res: Response) {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const {
|
||||
searchText = "",
|
||||
searchFields = "",
|
||||
filterCondition = "{}",
|
||||
page = "1",
|
||||
limit = "20",
|
||||
} = req.query;
|
||||
|
||||
// 멀티테넌시
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
// 검색 필드 파싱
|
||||
const fields = searchFields
|
||||
? (searchFields as string).split(",").map((f) => f.trim())
|
||||
: [];
|
||||
|
||||
// WHERE 조건 생성
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 멀티테넌시 필터링
|
||||
if (companyCode !== "*") {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 검색 조건
|
||||
if (searchText && fields.length > 0) {
|
||||
const searchConditions = fields.map((field) => {
|
||||
const condition = `${field}::text ILIKE $${paramIndex}`;
|
||||
paramIndex++;
|
||||
return condition;
|
||||
});
|
||||
whereConditions.push(`(${searchConditions.join(" OR ")})`);
|
||||
|
||||
// 검색어 파라미터 추가
|
||||
fields.forEach(() => {
|
||||
params.push(`%${searchText}%`);
|
||||
});
|
||||
}
|
||||
|
||||
// 추가 필터 조건
|
||||
const additionalFilter = JSON.parse(filterCondition as string);
|
||||
for (const [key, value] of Object.entries(additionalFilter)) {
|
||||
whereConditions.push(`${key} = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 페이징
|
||||
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 쿼리 실행
|
||||
const pool = getPool();
|
||||
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
||||
const dataQuery = `
|
||||
SELECT * FROM ${tableName} ${whereClause}
|
||||
ORDER BY id DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
params.push(parseInt(limit as string));
|
||||
params.push(offset);
|
||||
|
||||
const countResult = await pool.query(
|
||||
countQuery,
|
||||
params.slice(0, params.length - 2)
|
||||
);
|
||||
const dataResult = await pool.query(dataQuery, params);
|
||||
|
||||
logger.info("엔티티 검색 성공", {
|
||||
tableName,
|
||||
searchText,
|
||||
companyCode,
|
||||
rowCount: dataResult.rowCount,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dataResult.rows,
|
||||
pagination: {
|
||||
total: parseInt(countResult.rows[0].count),
|
||||
page: parseInt(page as string),
|
||||
limit: parseInt(limit as string),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("엔티티 검색 오류", { error: error.message, stack: error.stack });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import { Request, Response } from "express";
|
||||
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: Request, 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
|
||||
* GET /api/orders
|
||||
*/
|
||||
export async function getOrders(req: Request, 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(`writer LIKE $${paramIndex}`);
|
||||
params.push(`%${companyCode}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 검색
|
||||
if (searchText) {
|
||||
whereConditions.push(`objid LIKE $${paramIndex}`);
|
||||
params.push(`%${searchText}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 카운트 쿼리
|
||||
const countQuery = `SELECT COUNT(*) as count FROM order_mng_master ${whereClause}`;
|
||||
const countResult = await pool.query(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0]?.count || "0");
|
||||
|
||||
// 데이터 쿼리
|
||||
const dataQuery = `
|
||||
SELECT * FROM order_mng_master
|
||||
${whereClause}
|
||||
ORDER BY reg_date DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
params.push(parseInt(limit as string));
|
||||
params.push(offset);
|
||||
|
||||
const dataResult = await pool.query(dataQuery, params);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { searchEntity } from "../controllers/entitySearchController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* 엔티티 검색 API
|
||||
* GET /api/entity-search/:tableName
|
||||
*/
|
||||
router.get("/:tableName", authenticateToken, searchEntity);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
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;
|
||||
|
||||
|
|
@ -65,6 +65,9 @@ function ScreenViewPage() {
|
|||
// 플로우 새로고침을 위한 키 (값이 변경되면 플로우 데이터가 리렌더링됨)
|
||||
const [flowRefreshKey, setFlowRefreshKey] = useState(0);
|
||||
|
||||
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
|
||||
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
|
||||
|
||||
// 편집 모달 상태
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editModalConfig, setEditModalConfig] = useState<{
|
||||
|
|
@ -402,19 +405,39 @@ function ScreenViewPage() {
|
|||
(c) => (c as any).componentId === "table-search-widget"
|
||||
);
|
||||
|
||||
// TableSearchWidget 높이 차이를 계산하여 Y 위치 조정
|
||||
// 디버그: 모든 컴포넌트 타입 확인
|
||||
console.log("🔍 전체 컴포넌트 타입:", regularComponents.map(c => ({
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
componentType: (c as any).componentType,
|
||||
componentId: (c as any).componentId,
|
||||
})));
|
||||
|
||||
// 🆕 조건부 컨테이너들을 찾기
|
||||
const conditionalContainers = regularComponents.filter(
|
||||
(c) => (c as any).componentId === "conditional-container" || (c as any).componentType === "conditional-container"
|
||||
);
|
||||
|
||||
console.log("🔍 조건부 컨테이너 발견:", conditionalContainers.map(c => ({
|
||||
id: c.id,
|
||||
y: c.position.y,
|
||||
size: c.size,
|
||||
})));
|
||||
|
||||
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정
|
||||
const adjustedComponents = regularComponents.map((component) => {
|
||||
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||||
const isConditionalContainer = (component as any).componentId === "conditional-container";
|
||||
|
||||
if (isTableSearchWidget) {
|
||||
// TableSearchWidget 자체는 조정하지 않음
|
||||
if (isTableSearchWidget || isConditionalContainer) {
|
||||
// 자기 자신은 조정하지 않음
|
||||
return component;
|
||||
}
|
||||
|
||||
let totalHeightAdjustment = 0;
|
||||
|
||||
// TableSearchWidget 높이 조정
|
||||
for (const widget of tableSearchWidgets) {
|
||||
// 현재 컴포넌트가 이 위젯 아래에 있는지 확인
|
||||
const isBelow = component.position.y > widget.position.y;
|
||||
const heightDiff = getHeightDiff(screenId, widget.id);
|
||||
|
||||
|
|
@ -423,6 +446,31 @@ function ScreenViewPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 조건부 컨테이너 높이 조정
|
||||
for (const container of conditionalContainers) {
|
||||
const isBelow = component.position.y > container.position.y;
|
||||
const actualHeight = conditionalContainerHeights[container.id];
|
||||
const originalHeight = container.size?.height || 200;
|
||||
const heightDiff = actualHeight ? (actualHeight - originalHeight) : 0;
|
||||
|
||||
console.log(`🔍 높이 조정 체크:`, {
|
||||
componentId: component.id,
|
||||
componentY: component.position.y,
|
||||
containerY: container.position.y,
|
||||
isBelow,
|
||||
actualHeight,
|
||||
originalHeight,
|
||||
heightDiff,
|
||||
containerId: container.id,
|
||||
containerSize: container.size,
|
||||
});
|
||||
|
||||
if (isBelow && heightDiff > 0) {
|
||||
totalHeightAdjustment += heightDiff;
|
||||
console.log(`📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (totalHeightAdjustment > 0) {
|
||||
return {
|
||||
...component,
|
||||
|
|
@ -491,6 +539,12 @@ function ScreenViewPage() {
|
|||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
onHeightChange={(componentId, newHeight) => {
|
||||
setConditionalContainerHeights((prev) => ({
|
||||
...prev,
|
||||
[componentId]: newHeight,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { EntitySearchInputComponent } from "@/lib/registry/components/entity-search-input";
|
||||
import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function TestEntitySearchPage() {
|
||||
const [customerCode, setCustomerCode] = useState<string>("");
|
||||
const [customerData, setCustomerData] = useState<any>(null);
|
||||
|
||||
const [itemCode, setItemCode] = useState<string>("");
|
||||
const [itemData, setItemData] = useState<any>(null);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">EntitySearchInput 테스트</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
엔티티 검색 입력 컴포넌트 동작 테스트
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 거래처 검색 테스트 - 자동완성 방식 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>거래처 검색 (자동완성 드롭다운 방식) ⭐ NEW</CardTitle>
|
||||
<CardDescription>
|
||||
타이핑하면 바로 드롭다운이 나타나는 방식 - 수주 등록에서 사용
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>거래처</Label>
|
||||
<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"]}
|
||||
value={customerCode}
|
||||
onChange={(code, fullData) => {
|
||||
setCustomerCode(code || "");
|
||||
setCustomerData(fullData);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{customerData && (
|
||||
<div className="mt-4 p-4 bg-muted rounded-md">
|
||||
<h3 className="font-semibold mb-2">선택된 거래처 정보:</h3>
|
||||
<pre className="text-xs">
|
||||
{JSON.stringify(customerData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 거래처 검색 테스트 - 모달 방식 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>거래처 검색 (모달 방식)</CardTitle>
|
||||
<CardDescription>
|
||||
버튼 클릭 → 모달 열기 → 검색 및 선택 방식
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>거래처</Label>
|
||||
<EntitySearchInputComponent
|
||||
tableName="customer_mng"
|
||||
displayField="customer_name"
|
||||
valueField="customer_code"
|
||||
searchFields={["customer_name", "customer_code", "business_number"]}
|
||||
mode="combo"
|
||||
placeholder="거래처를 검색하세요"
|
||||
modalTitle="거래처 검색 및 선택"
|
||||
modalColumns={["customer_code", "customer_name", "address", "contact_phone"]}
|
||||
showAdditionalInfo
|
||||
additionalFields={["address", "contact_phone", "business_number"]}
|
||||
value={customerCode}
|
||||
onChange={(code, fullData) => {
|
||||
setCustomerCode(code || "");
|
||||
setCustomerData(fullData);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 품목 검색 테스트 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>품목 검색 (Modal 모드)</CardTitle>
|
||||
<CardDescription>
|
||||
item_info 테이블에서 품목을 검색합니다
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>품목</Label>
|
||||
<EntitySearchInputComponent
|
||||
tableName="item_info"
|
||||
displayField="item_name"
|
||||
valueField="id"
|
||||
searchFields={["item_name", "id", "item_number"]}
|
||||
mode="modal"
|
||||
placeholder="품목 선택"
|
||||
modalTitle="품목 검색"
|
||||
modalColumns={["id", "item_name", "item_number", "unit", "selling_price"]}
|
||||
showAdditionalInfo
|
||||
additionalFields={["item_number", "unit", "selling_price"]}
|
||||
value={itemCode}
|
||||
onChange={(code, fullData) => {
|
||||
setItemCode(code || "");
|
||||
setItemData(fullData);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{itemData && (
|
||||
<div className="mt-4 p-4 bg-muted rounded-md">
|
||||
<h3 className="font-semibold mb-2">선택된 품목 정보:</h3>
|
||||
<pre className="text-xs">
|
||||
{JSON.stringify(itemData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function TestOrderRegistrationPage() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const handleSuccess = () => {
|
||||
console.log("수주 등록 성공!");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">수주 등록 테스트</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
EntitySearchInput + ModalRepeaterTable을 활용한 수주 등록 화면
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>수주 등록 모달</CardTitle>
|
||||
<CardDescription>
|
||||
모달 버튼을 클릭하여 수주 등록 화면을 테스트하세요
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => setModalOpen(true)}>
|
||||
수주 등록 모달 열기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>구현된 기능</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>EntitySearchInput: 거래처 검색 및 선택 (콤보 모드)</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>ModalRepeaterTable: 품목 검색 및 동적 추가</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>자동 계산: 수량 × 단가 = 금액</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>인라인 편집: 수량, 단가, 납품일, 비고 수정 가능</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>중복 방지: 이미 추가된 품목은 선택 불가</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>행 삭제: 추가된 품목 개별 삭제 가능</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>전체 금액 표시: 모든 품목 금액의 합계</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>입력 방식 전환: 거래처 우선 / 견대 방식 / 단가 방식</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 수주 등록 모달 */}
|
||||
<OrderRegistrationModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -414,6 +414,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
return newFormData;
|
||||
});
|
||||
}}
|
||||
onRefresh={() => {
|
||||
// 부모 화면의 테이블 새로고침 이벤트 발송
|
||||
console.log("🔄 모달에서 부모 화면 테이블 새로고침 이벤트 발송");
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
"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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
"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: "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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,524 @@
|
|||
"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 { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
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 [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([]);
|
||||
};
|
||||
|
||||
// 전체 금액 계산
|
||||
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-y-auto">
|
||||
<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
|
||||
id="contactPerson"
|
||||
placeholder="담당자"
|
||||
value={formData.contactPerson}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, contactPerson: e.target.value })
|
||||
}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 납품처 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deliveryDestination" className="text-xs sm:text-sm">
|
||||
납품처
|
||||
</Label>
|
||||
<Input
|
||||
id="deliveryDestination"
|
||||
placeholder="납품처"
|
||||
value={formData.deliveryDestination}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, deliveryDestination: e.target.value })
|
||||
}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 납품장소 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deliveryAddress" className="text-xs sm:text-sm">
|
||||
납품장소
|
||||
</Label>
|
||||
<Input
|
||||
id="deliveryAddress"
|
||||
placeholder="납품장소"
|
||||
value={formData.deliveryAddress}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, deliveryAddress: e.target.value })
|
||||
}
|
||||
className="h-8 text-xs 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
|
||||
placeholder="견대 번호를 입력하세요"
|
||||
className="h-8 text-xs 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
|
||||
placeholder="단가 정보 입력"
|
||||
className="h-8 text-xs 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={setSelectedItems}
|
||||
/>
|
||||
</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
|
||||
id="portOfLoading"
|
||||
placeholder="선적항"
|
||||
value={formData.portOfLoading}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, portOfLoading: e.target.value })
|
||||
}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 도착항 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="portOfDischarge" className="text-xs sm:text-sm">
|
||||
도착항
|
||||
</Label>
|
||||
<Input
|
||||
id="portOfDischarge"
|
||||
placeholder="도착항"
|
||||
value={formData.portOfDischarge}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, portOfDischarge: e.target.value })
|
||||
}
|
||||
className="h-8 text-xs 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
|
||||
id="hsCode"
|
||||
placeholder="HS Code"
|
||||
value={formData.hsCode}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, hsCode: e.target.value })
|
||||
}
|
||||
className="h-8 text-xs 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="text-xs sm:text-sm"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
# 수주 등록 컴포넌트
|
||||
|
||||
## 개요
|
||||
|
||||
수주 등록 기능을 위한 전용 컴포넌트들입니다. 이 컴포넌트들은 범용 컴포넌트를 래핑하여 수주 등록에 최적화된 고정 설정을 제공합니다.
|
||||
|
||||
## 컴포넌트 구조
|
||||
|
||||
```
|
||||
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`
|
||||
|
||||
|
|
@ -8,7 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, ResizableDialog, ResizableDialogContent, ResizableDialogHeader } from "@/components/ui/dialog";
|
||||
import { CalendarIcon, File, Upload, X } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ interface InteractiveScreenViewerProps {
|
|||
tableName?: string;
|
||||
};
|
||||
onSave?: () => Promise<void>;
|
||||
onRefresh?: () => void;
|
||||
onFlowRefresh?: () => void;
|
||||
}
|
||||
|
||||
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
||||
|
|
@ -50,6 +52,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
hideLabel = false,
|
||||
screenInfo,
|
||||
onSave,
|
||||
onRefresh,
|
||||
onFlowRefresh,
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { userName, user } = useAuth();
|
||||
|
|
@ -324,9 +328,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
setFlowSelectedData(selectedData);
|
||||
setFlowSelectedStepId(stepId);
|
||||
}}
|
||||
onRefresh={() => {
|
||||
// 테이블 컴포넌트는 자체적으로 loadData 호출
|
||||
}}
|
||||
onRefresh={onRefresh || (() => {
|
||||
// 부모로부터 전달받은 onRefresh 또는 기본 동작
|
||||
console.log("🔄 InteractiveScreenViewerDynamic onRefresh 호출");
|
||||
})}
|
||||
onFlowRefresh={onFlowRefresh}
|
||||
onClose={() => {
|
||||
// buttonActions.ts가 이미 처리함
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ interface RealtimePreviewProps {
|
|||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
columnOrder?: string[];
|
||||
|
||||
// 🆕 조건부 컨테이너 높이 변화 콜백
|
||||
onHeightChange?: (componentId: string, newHeight: number) => void;
|
||||
}
|
||||
|
||||
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
|
||||
|
|
@ -123,6 +126,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
onFlowRefresh,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
||||
}) => {
|
||||
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
|
@ -218,6 +222,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
};
|
||||
|
||||
const getHeight = () => {
|
||||
// 🆕 조건부 컨테이너는 높이를 자동으로 설정 (내용물에 따라 자동 조정)
|
||||
const isConditionalContainer = (component as any).componentType === "conditional-container";
|
||||
if (isConditionalContainer && !isDesignMode) {
|
||||
return "auto"; // 런타임에서는 내용물 높이에 맞춤
|
||||
}
|
||||
|
||||
// 플로우 위젯의 경우 측정된 높이 사용
|
||||
const isFlowWidget = component.type === "component" && (component as any).componentType === "flow-widget";
|
||||
if (isFlowWidget && actualHeight) {
|
||||
|
|
@ -329,7 +339,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
(contentRef as any).current = node;
|
||||
}
|
||||
}}
|
||||
className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} overflow-visible`}
|
||||
className={`${
|
||||
(component.type === "component" && (component as any).componentType === "flow-widget") ||
|
||||
((component as any).componentType === "conditional-container" && !isDesignMode)
|
||||
? "h-auto"
|
||||
: "h-full"
|
||||
} overflow-visible`}
|
||||
style={{ width: "100%", maxWidth: "100%" }}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
|
|
@ -365,6 +380,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
columnOrder={columnOrder}
|
||||
onHeightChange={onHeightChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ import { toast } from "sonner";
|
|||
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
||||
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
import { AutocompleteSearchInputRenderer } from "@/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputRenderer";
|
||||
import { EntitySearchInputRenderer } from "@/lib/registry/components/entity-search-input/EntitySearchInputRenderer";
|
||||
import { ModalRepeaterTableRenderer } from "@/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer";
|
||||
import { ScreenFileAPI } from "@/lib/api/screenFile";
|
||||
import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan";
|
||||
|
||||
|
|
@ -4964,6 +4967,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
)}
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
{/* 숨겨진 컴포넌트 렌더러들 (레지스트리 등록용) */}
|
||||
<div style={{ display: "none" }}>
|
||||
<AutocompleteSearchInputRenderer />
|
||||
<EntitySearchInputRenderer />
|
||||
<ModalRepeaterTableRenderer />
|
||||
</div>
|
||||
</ScreenPreviewProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectItem value="edit">편집</SelectItem>
|
||||
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
||||
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||
<SelectItem value="openModalWithData">데이터 전달 + 모달 열기 🆕</SelectItem>
|
||||
<SelectItem value="modal">모달 열기</SelectItem>
|
||||
<SelectItem value="control">제어 흐름</SelectItem>
|
||||
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
||||
|
|
@ -409,6 +410,136 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 */}
|
||||
{component.componentConfig?.action?.type === "openModalWithData" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4 dark:bg-blue-950/20">
|
||||
<h4 className="text-sm font-medium text-foreground">데이터 전달 + 모달 설정</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
TableList에서 선택된 데이터를 다음 모달로 전달합니다
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="data-source-id">데이터 소스 ID</Label>
|
||||
<Input
|
||||
id="data-source-id"
|
||||
placeholder="예: item_info (테이블명과 동일하게 입력)"
|
||||
value={component.componentConfig?.action?.dataSourceId || ""}
|
||||
onChange={(e) => {
|
||||
onUpdateProperty("componentConfig.action.dataSourceId", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
TableList에서 데이터를 저장한 ID와 동일해야 합니다 (보통 테이블명)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="modal-title-with-data">모달 제목</Label>
|
||||
<Input
|
||||
id="modal-title-with-data"
|
||||
placeholder="예: 상세 정보 입력"
|
||||
value={localInputs.modalTitle}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
|
||||
onUpdateProperty("componentConfig.action.modalTitle", newValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="modal-size-with-data">모달 크기</Label>
|
||||
<Select
|
||||
value={component.componentConfig?.action?.modalSize || "lg"}
|
||||
onValueChange={(value) => {
|
||||
onUpdateProperty("componentConfig.action.modalSize", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="모달 크기 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sm">작음 (Small)</SelectItem>
|
||||
<SelectItem value="md">보통 (Medium)</SelectItem>
|
||||
<SelectItem value="lg">큼 (Large) - 권장</SelectItem>
|
||||
<SelectItem value="xl">매우 큼 (Extra Large)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="target-screen-with-data">대상 화면 선택</Label>
|
||||
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={modalScreenOpen}
|
||||
className="h-6 w-full justify-between px-2 py-0"
|
||||
style={{ fontSize: "12px" }}
|
||||
disabled={screensLoading}
|
||||
>
|
||||
{config.action?.targetScreenId
|
||||
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
|
||||
"화면을 선택하세요..."
|
||||
: "화면을 선택하세요..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center border-b px-3 py-2">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<Input
|
||||
placeholder="화면 검색..."
|
||||
value={modalSearchTerm}
|
||||
onChange={(e) => setModalSearchTerm(e.target.value)}
|
||||
className="border-0 p-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-auto">
|
||||
{(() => {
|
||||
const filteredScreens = filterScreens(modalSearchTerm);
|
||||
if (screensLoading) {
|
||||
return <div className="p-3 text-sm text-muted-foreground">화면 목록을 불러오는 중...</div>;
|
||||
}
|
||||
if (filteredScreens.length === 0) {
|
||||
return <div className="p-3 text-sm text-muted-foreground">검색 결과가 없습니다.</div>;
|
||||
}
|
||||
return filteredScreens.map((screen, index) => (
|
||||
<div
|
||||
key={`modal-data-screen-${screen.id}-${index}`}
|
||||
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
|
||||
onClick={() => {
|
||||
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
||||
setModalScreenOpen(false);
|
||||
setModalSearchTerm("");
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{screen.name}</span>
|
||||
{screen.description && <span className="text-xs text-muted-foreground">{screen.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
SelectedItemsDetailInput 컴포넌트가 있는 화면을 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 수정 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "edit" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-success/10 p-4">
|
||||
|
|
|
|||
|
|
@ -63,8 +63,9 @@ export function ComponentsPanel({
|
|||
),
|
||||
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
|
||||
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
|
||||
data: allComponents.filter((c) => c.category === ComponentCategory.DATA), // 🆕 데이터 카테고리 추가
|
||||
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
|
||||
utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY), // 🆕 유틸리티 카테고리 추가
|
||||
utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY),
|
||||
};
|
||||
}, [allComponents]);
|
||||
|
||||
|
|
@ -92,6 +93,8 @@ export function ComponentsPanel({
|
|||
return <Palette className="h-6 w-6" />;
|
||||
case "action":
|
||||
return <Zap className="h-6 w-6" />;
|
||||
case "data":
|
||||
return <Database className="h-6 w-6" />;
|
||||
case "layout":
|
||||
return <Layers className="h-6 w-6" />;
|
||||
case "utility":
|
||||
|
|
@ -185,7 +188,7 @@ export function ComponentsPanel({
|
|||
|
||||
{/* 카테고리 탭 */}
|
||||
<Tabs defaultValue="input" className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-6 gap-1 p-1">
|
||||
<TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-7 gap-1 p-1">
|
||||
<TabsTrigger
|
||||
value="tables"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
|
|
@ -198,6 +201,14 @@ export function ComponentsPanel({
|
|||
<Edit3 className="h-3 w-3" />
|
||||
<span className="hidden">입력</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="data"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
title="데이터"
|
||||
>
|
||||
<Grid className="h-3 w-3" />
|
||||
<span className="hidden">데이터</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="action"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
|
|
@ -260,6 +271,13 @@ export function ComponentsPanel({
|
|||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 데이터 컴포넌트 */}
|
||||
<TabsContent value="data" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("data").length > 0
|
||||
? getFilteredComponents("data").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 액션 컴포넌트 */}
|
||||
<TabsContent value="action" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("action").length > 0
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
|
|||
// 동적 컴포넌트 설정 패널
|
||||
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||
|
||||
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
|
||||
interface DetailSettingsPanelProps {
|
||||
selectedComponent?: ComponentData;
|
||||
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
||||
|
|
@ -859,6 +862,55 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty(selectedComponent.id, path, value);
|
||||
};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
|
||||
};
|
||||
|
||||
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기
|
||||
const componentId = selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.id;
|
||||
if (componentId) {
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
if (definition?.configPanel) {
|
||||
const ConfigPanelComponent = definition.configPanel;
|
||||
const currentConfig = selectedComponent.componentConfig || {};
|
||||
|
||||
console.log("✅ ConfigPanel 표시:", {
|
||||
componentId,
|
||||
definitionName: definition.name,
|
||||
hasConfigPanel: !!definition.configPanel,
|
||||
currentConfig,
|
||||
});
|
||||
|
||||
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
|
||||
const ConfigPanelWrapper = () => {
|
||||
const config = currentConfig.config || definition.defaultConfig || {};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
<ConfigPanelComponent config={config} onConfigChange={handleConfigChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return <ConfigPanelWrapper key={selectedComponent.id} />;
|
||||
} else {
|
||||
console.warn("⚠️ ConfigPanel 없음:", {
|
||||
componentId,
|
||||
definitionName: definition?.name,
|
||||
hasDefinition: !!definition,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 하드코딩된 설정 패널들 (레거시)
|
||||
switch (componentType) {
|
||||
case "button":
|
||||
case "button-primary":
|
||||
|
|
@ -904,8 +956,10 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<Settings className="mb-4 h-12 w-12 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">설정 패널 준비 중</h3>
|
||||
<p className="text-sm text-gray-500">컴포넌트 타입 "{componentType}"의 설정 패널이 준비 중입니다.</p>
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">⚠️ 설정 패널 없음</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
컴포넌트 "{componentId || componentType}"에 대한 설정 패널이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ import { ButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
|||
import { CardConfigPanel } from "../config-panels/CardConfigPanel";
|
||||
import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
|
||||
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
|
||||
|
||||
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel";
|
||||
import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
|
||||
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
|
||||
|
|
@ -269,6 +272,55 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
onUpdateProperty(selectedComponent.id, path, value);
|
||||
};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
|
||||
};
|
||||
|
||||
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기
|
||||
const componentId = selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.id;
|
||||
if (componentId) {
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
if (definition?.configPanel) {
|
||||
const ConfigPanelComponent = definition.configPanel;
|
||||
const currentConfig = selectedComponent.componentConfig || {};
|
||||
|
||||
console.log("✅ ConfigPanel 표시:", {
|
||||
componentId,
|
||||
definitionName: definition.name,
|
||||
hasConfigPanel: !!definition.configPanel,
|
||||
currentConfig,
|
||||
});
|
||||
|
||||
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
|
||||
const ConfigPanelWrapper = () => {
|
||||
const config = currentConfig.config || definition.defaultConfig || {};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
<ConfigPanelComponent config={config} onConfigChange={handleConfigChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return <ConfigPanelWrapper key={selectedComponent.id} />;
|
||||
} else {
|
||||
console.warn("⚠️ ConfigPanel 없음:", {
|
||||
componentId,
|
||||
definitionName: definition?.name,
|
||||
hasDefinition: !!definition,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 하드코딩된 설정 패널들 (레거시)
|
||||
switch (componentType) {
|
||||
case "button":
|
||||
case "button-primary":
|
||||
|
|
@ -312,7 +364,16 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
return <BadgeConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
// ConfigPanel이 없는 경우 경고 표시
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<Settings className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mb-2 text-base font-medium">⚠️ 설정 패널 없음</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
컴포넌트 "{componentId || componentType}"에 대한 설정 패널이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
|
|||
|
|
@ -29,7 +29,14 @@ export interface ComponentRenderer {
|
|||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
selectedRows?: any[];
|
||||
selectedRowsData?: any[];
|
||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
|
||||
onSelectedRowsChange?: (
|
||||
selectedRows: any[],
|
||||
selectedRowsData: any[],
|
||||
sortBy?: string,
|
||||
sortOrder?: "asc" | "desc",
|
||||
columnOrder?: string[],
|
||||
tableDisplayData?: any[],
|
||||
) => void;
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
|
|
@ -98,6 +105,8 @@ export interface DynamicComponentRendererProps {
|
|||
screenId?: number;
|
||||
tableName?: string;
|
||||
menuId?: number; // 🆕 메뉴 ID (카테고리 관리 등에 필요)
|
||||
// 🆕 조건부 컨테이너 높이 변화 콜백
|
||||
onHeightChange?: (componentId: string, newHeight: number) => void;
|
||||
menuObjid?: number; // 🆕 메뉴 OBJID (메뉴 스코프 - 카테고리/채번)
|
||||
selectedScreen?: any; // 🆕 화면 정보 전체 (menuId 등 추출용)
|
||||
userId?: string; // 🆕 현재 사용자 ID
|
||||
|
|
@ -108,7 +117,14 @@ export interface DynamicComponentRendererProps {
|
|||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
selectedRows?: any[];
|
||||
selectedRowsData?: any[];
|
||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
|
||||
onSelectedRowsChange?: (
|
||||
selectedRows: any[],
|
||||
selectedRowsData: any[],
|
||||
sortBy?: string,
|
||||
sortOrder?: "asc" | "desc",
|
||||
columnOrder?: string[],
|
||||
tableDisplayData?: any[],
|
||||
) => void;
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
|
|
@ -148,14 +164,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
const webType = (component as any).componentConfig?.webType;
|
||||
const tableName = (component as any).tableName;
|
||||
const columnName = (component as any).columnName;
|
||||
|
||||
|
||||
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
|
||||
if ((inputType === "category" || webType === "category") && tableName && columnName) {
|
||||
try {
|
||||
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||
const fieldName = columnName || component.id;
|
||||
const currentValue = props.formData?.[fieldName] || "";
|
||||
|
||||
|
||||
const handleChange = (value: any) => {
|
||||
if (props.onFormDataChange) {
|
||||
props.onFormDataChange(fieldName, value);
|
||||
|
|
@ -254,6 +270,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onConfigChange,
|
||||
isPreview,
|
||||
autoGeneration,
|
||||
onHeightChange, // 🆕 높이 변화 콜백
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
|
|
@ -290,12 +307,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 숨김 값 추출
|
||||
const hiddenValue = component.hidden || component.componentConfig?.hidden;
|
||||
|
||||
// size.width와 size.height를 style.width와 style.height로 변환
|
||||
const finalStyle: React.CSSProperties = {
|
||||
...component.style,
|
||||
width: component.size?.width ? `${component.size.width}px` : component.style?.width,
|
||||
height: component.size?.height ? `${component.size.height}px` : component.style?.height,
|
||||
};
|
||||
// 🆕 조건부 컨테이너용 높이 변화 핸들러
|
||||
const handleHeightChange = props.onHeightChange
|
||||
? (newHeight: number) => {
|
||||
props.onHeightChange!(component.id, newHeight);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const rendererProps = {
|
||||
component,
|
||||
|
|
@ -345,6 +362,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
tableDisplayData, // 🆕 화면 표시 데이터
|
||||
// 플로우 선택된 데이터 정보 전달
|
||||
flowSelectedData,
|
||||
// 🆕 조건부 컨테이너 높이 변화 콜백
|
||||
onHeightChange: handleHeightChange,
|
||||
componentId: component.id,
|
||||
flowSelectedStepId,
|
||||
onFlowSelectedDataChange,
|
||||
// 설정 변경 핸들러 전달
|
||||
|
|
@ -370,7 +390,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
return rendererInstance.render();
|
||||
} else {
|
||||
// 함수형 컴포넌트
|
||||
return <NewComponentRenderer {...rendererProps} />;
|
||||
// config 내부 속성도 펼쳐서 전달 (tableName, displayField 등)
|
||||
const configProps = component.componentConfig?.config || component.componentConfig || {};
|
||||
return <NewComponentRenderer {...rendererProps} {...configProps} />;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -392,10 +414,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
|
||||
// 폴백 렌더링 - 기본 플레이스홀더
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-border bg-muted p-4">
|
||||
<div className="border-border bg-muted flex h-full w-full items-center justify-center rounded border-2 border-dashed p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-sm font-medium text-muted-foreground">{component.label || component.id}</div>
|
||||
<div className="text-xs text-muted-foreground/70">미구현 컴포넌트: {componentType}</div>
|
||||
<div className="text-muted-foreground mb-2 text-sm font-medium">{component.label || component.id}</div>
|
||||
<div className="text-muted-foreground/70 text-xs">미구현 컴포넌트: {componentType}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Loader2, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
|
||||
import { EntitySearchResult } from "../entity-search-input/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AutocompleteSearchInputConfig } from "./types";
|
||||
|
||||
interface AutocompleteSearchInputProps extends Partial<AutocompleteSearchInputConfig> {
|
||||
config?: AutocompleteSearchInputConfig;
|
||||
filterCondition?: Record<string, any>;
|
||||
disabled?: boolean;
|
||||
value?: any;
|
||||
onChange?: (value: any, fullData?: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AutocompleteSearchInputComponent({
|
||||
config,
|
||||
tableName: propTableName,
|
||||
displayField: propDisplayField,
|
||||
valueField: propValueField,
|
||||
searchFields: propSearchFields,
|
||||
filterCondition = {},
|
||||
placeholder: propPlaceholder,
|
||||
disabled = false,
|
||||
value,
|
||||
onChange,
|
||||
showAdditionalInfo: propShowAdditionalInfo,
|
||||
additionalFields: propAdditionalFields,
|
||||
className,
|
||||
}: AutocompleteSearchInputProps) {
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const tableName = config?.tableName || propTableName || "";
|
||||
const displayField = config?.displayField || propDisplayField || "";
|
||||
const valueField = config?.valueField || propValueField || "";
|
||||
const searchFields = config?.searchFields || propSearchFields || [displayField];
|
||||
const placeholder = config?.placeholder || propPlaceholder || "검색...";
|
||||
const showAdditionalInfo = config?.showAdditionalInfo ?? propShowAdditionalInfo ?? false;
|
||||
const additionalFields = config?.additionalFields || propAdditionalFields || [];
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { searchText, setSearchText, results, loading, clearSearch } = useEntitySearch({
|
||||
tableName,
|
||||
searchFields,
|
||||
filterCondition,
|
||||
});
|
||||
|
||||
// value가 변경되면 표시값 업데이트
|
||||
useEffect(() => {
|
||||
if (value && selectedData) {
|
||||
setInputValue(selectedData[displayField] || "");
|
||||
} else if (!value) {
|
||||
setInputValue("");
|
||||
setSelectedData(null);
|
||||
}
|
||||
}, [value, displayField]);
|
||||
|
||||
// 외부 클릭 감지
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setInputValue(newValue);
|
||||
setSearchText(newValue);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleSelect = (item: EntitySearchResult) => {
|
||||
setSelectedData(item);
|
||||
setInputValue(item[displayField] || "");
|
||||
onChange?.(item[valueField], item);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setInputValue("");
|
||||
setSelectedData(null);
|
||||
onChange?.(null, null);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleInputFocus = () => {
|
||||
// 포커스 시 항상 검색 실행 (빈 값이면 전체 목록)
|
||||
if (!selectedData) {
|
||||
setSearchText(inputValue || "");
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)} ref={containerRef}>
|
||||
{/* 입력 필드 */}
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm pr-16"
|
||||
/>
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
{loading && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{inputValue && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 드롭다운 결과 */}
|
||||
{isOpen && (results.length > 0 || loading) && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-background border rounded-md shadow-lg max-h-[300px] overflow-y-auto">
|
||||
{loading && results.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
|
||||
검색 중...
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
검색 결과가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{results.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => handleSelect(item)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-accent text-xs sm:text-sm transition-colors"
|
||||
>
|
||||
<div className="font-medium">{item[displayField]}</div>
|
||||
{additionalFields.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground mt-1 space-y-0.5">
|
||||
{additionalFields.map((field) => (
|
||||
<div key={field}>
|
||||
{field}: {item[field] || "-"}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 정보 표시 */}
|
||||
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
|
||||
<div className="mt-2 text-xs text-muted-foreground space-y-1 px-2">
|
||||
{additionalFields.map((field) => (
|
||||
<div key={field} className="flex gap-2">
|
||||
<span className="font-medium">{field}:</span>
|
||||
<span>{selectedData[field] || "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
"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";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { AutocompleteSearchInputConfig } from "./types";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AutocompleteSearchInputConfigPanelProps {
|
||||
config: AutocompleteSearchInputConfig;
|
||||
onConfigChange: (config: AutocompleteSearchInputConfig) => void;
|
||||
}
|
||||
|
||||
export function AutocompleteSearchInputConfigPanel({
|
||||
config,
|
||||
onConfigChange,
|
||||
}: AutocompleteSearchInputConfigPanelProps) {
|
||||
const [localConfig, setLocalConfig] = useState(config);
|
||||
const [allTables, setAllTables] = useState<any[]>([]);
|
||||
const [tableColumns, setTableColumns] = useState<any[]>([]);
|
||||
const [isLoadingTables, setIsLoadingTables] = useState(false);
|
||||
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
|
||||
const [openTableCombo, setOpenTableCombo] = useState(false);
|
||||
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
|
||||
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
|
||||
|
||||
// 전체 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setIsLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!localConfig.tableName) {
|
||||
setTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingColumns(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(localConfig.tableName);
|
||||
if (response.success && response.data) {
|
||||
setTableColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
setTableColumns([]);
|
||||
} finally {
|
||||
setIsLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [localConfig.tableName]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalConfig(config);
|
||||
}, [config]);
|
||||
|
||||
const updateConfig = (updates: Partial<AutocompleteSearchInputConfig>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
||||
const addSearchField = () => {
|
||||
const fields = localConfig.searchFields || [];
|
||||
updateConfig({ searchFields: [...fields, ""] });
|
||||
};
|
||||
|
||||
const updateSearchField = (index: number, value: string) => {
|
||||
const fields = [...(localConfig.searchFields || [])];
|
||||
fields[index] = value;
|
||||
updateConfig({ searchFields: fields });
|
||||
};
|
||||
|
||||
const removeSearchField = (index: number) => {
|
||||
const fields = [...(localConfig.searchFields || [])];
|
||||
fields.splice(index, 1);
|
||||
updateConfig({ searchFields: fields });
|
||||
};
|
||||
|
||||
const addAdditionalField = () => {
|
||||
const fields = localConfig.additionalFields || [];
|
||||
updateConfig({ additionalFields: [...fields, ""] });
|
||||
};
|
||||
|
||||
const updateAdditionalField = (index: number, value: string) => {
|
||||
const fields = [...(localConfig.additionalFields || [])];
|
||||
fields[index] = value;
|
||||
updateConfig({ additionalFields: fields });
|
||||
};
|
||||
|
||||
const removeAdditionalField = (index: number) => {
|
||||
const fields = [...(localConfig.additionalFields || [])];
|
||||
fields.splice(index, 1);
|
||||
updateConfig({ additionalFields: fields });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">테이블명 *</Label>
|
||||
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openTableCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={isLoadingTables}
|
||||
>
|
||||
{localConfig.tableName
|
||||
? allTables.find((t) => t.tableName === localConfig.tableName)?.displayName || localConfig.tableName
|
||||
: isLoadingTables ? "로딩 중..." : "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={() => {
|
||||
updateConfig({ tableName: table.tableName });
|
||||
setOpenTableCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", localConfig.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||
{table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">표시 필드 *</Label>
|
||||
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openDisplayFieldCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
{localConfig.displayField
|
||||
? tableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
|
||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
updateConfig({ displayField: column.columnName });
|
||||
setOpenDisplayFieldCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", localConfig.displayField === column.columnName ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
||||
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">값 필드 *</Label>
|
||||
<Popover open={openValueFieldCombo} onOpenChange={setOpenValueFieldCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openValueFieldCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
{localConfig.valueField
|
||||
? tableColumns.find((c) => c.columnName === localConfig.valueField)?.displayName || localConfig.valueField
|
||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
updateConfig({ valueField: column.columnName });
|
||||
setOpenValueFieldCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", localConfig.valueField === column.columnName ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
||||
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">플레이스홀더</Label>
|
||||
<Input
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig({ placeholder: e.target.value })}
|
||||
placeholder="검색..."
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">검색 필드</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addSearchField}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(localConfig.searchFields || []).map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={field}
|
||||
onValueChange={(value) => updateSearchField(index, value)}
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeSearchField(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">추가 정보 표시</Label>
|
||||
<Switch
|
||||
checked={localConfig.showAdditionalInfo || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ showAdditionalInfo: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localConfig.showAdditionalInfo && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">추가 필드</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addAdditionalField}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(localConfig.additionalFields || []).map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={field}
|
||||
onValueChange={(value) => updateAdditionalField(index, value)}
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeAdditionalField(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import { AutocompleteSearchInputDefinition } from "./index";
|
||||
|
||||
export function AutocompleteSearchInputRenderer() {
|
||||
useEffect(() => {
|
||||
ComponentRegistry.registerComponent(AutocompleteSearchInputDefinition);
|
||||
console.log("✅ AutocompleteSearchInput 컴포넌트 등록 완료");
|
||||
|
||||
return () => {
|
||||
// 컴포넌트 언마운트 시 해제하지 않음 (싱글톤 패턴)
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# AutocompleteSearchInput 컴포넌트
|
||||
|
||||
자동완성 드롭다운 방식의 엔티티 검색 입력 컴포넌트입니다.
|
||||
|
||||
## 특징
|
||||
|
||||
- 타이핑하면 즉시 드롭다운 표시
|
||||
- 빈 값일 때 전체 목록 조회
|
||||
- 추가 정보 표시 가능
|
||||
- X 버튼으로 선택 초기화
|
||||
- 외부 클릭 시 자동 닫힘
|
||||
|
||||
## 사용 예시
|
||||
|
||||
```tsx
|
||||
<AutocompleteSearchInputComponent
|
||||
tableName="customer_mng"
|
||||
displayField="customer_name"
|
||||
valueField="customer_code"
|
||||
searchFields={["customer_name", "customer_code"]}
|
||||
placeholder="거래처명 입력"
|
||||
showAdditionalInfo
|
||||
additionalFields={["customer_code", "address"]}
|
||||
value={selectedCode}
|
||||
onChange={(code, fullData) => {
|
||||
console.log("선택됨:", code, fullData);
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 설정 옵션
|
||||
|
||||
- `tableName`: 검색할 테이블명
|
||||
- `displayField`: 표시할 필드
|
||||
- `valueField`: 값으로 사용할 필드
|
||||
- `searchFields`: 검색 대상 필드들
|
||||
- `placeholder`: 플레이스홀더
|
||||
- `showAdditionalInfo`: 추가 정보 표시 여부
|
||||
- `additionalFields`: 추가로 표시할 필드들
|
||||
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { AutocompleteSearchInputComponent } from "./AutocompleteSearchInputComponent";
|
||||
import { AutocompleteSearchInputConfigPanel } from "./AutocompleteSearchInputConfigPanel";
|
||||
|
||||
/**
|
||||
* AutocompleteSearchInput 컴포넌트 정의
|
||||
* 자동완성 드롭다운 방식의 엔티티 검색 입력
|
||||
*/
|
||||
export const AutocompleteSearchInputDefinition = createComponentDefinition({
|
||||
id: "autocomplete-search-input",
|
||||
name: "자동완성 검색 입력",
|
||||
nameEng: "Autocomplete Search Input",
|
||||
description: "타이핑하면 드롭다운이 나타나는 엔티티 검색 입력 (거래처, 사용자 등)",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "entity",
|
||||
component: AutocompleteSearchInputComponent,
|
||||
defaultConfig: {
|
||||
tableName: "customer_mng",
|
||||
displayField: "customer_name",
|
||||
valueField: "customer_code",
|
||||
searchFields: ["customer_name", "customer_code"],
|
||||
placeholder: "검색...",
|
||||
showAdditionalInfo: false,
|
||||
additionalFields: [],
|
||||
},
|
||||
defaultSize: { width: 300, height: 40 },
|
||||
configPanel: AutocompleteSearchInputConfigPanel,
|
||||
icon: "Search",
|
||||
tags: ["검색", "자동완성", "엔티티", "드롭다운", "거래처"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { AutocompleteSearchInputConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { AutocompleteSearchInputComponent } from "./AutocompleteSearchInputComponent";
|
||||
export { AutocompleteSearchInputRenderer } from "./AutocompleteSearchInputRenderer";
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export interface AutocompleteSearchInputConfig {
|
||||
tableName: string;
|
||||
displayField: string;
|
||||
valueField: string;
|
||||
searchFields?: string[];
|
||||
filterCondition?: Record<string, any>;
|
||||
placeholder?: string;
|
||||
showAdditionalInfo?: boolean;
|
||||
additionalFields?: string[];
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ConditionalContainerProps, ConditionalSection } from "./types";
|
||||
import { ConditionalSectionViewer } from "./ConditionalSectionViewer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
console.log("🚀 ConditionalContainerComponent 모듈 로드됨!");
|
||||
|
||||
/**
|
||||
* 조건부 컨테이너 컴포넌트
|
||||
* 상단 셀렉트박스 값에 따라 하단에 다른 UI를 표시
|
||||
*/
|
||||
export function ConditionalContainerComponent({
|
||||
config,
|
||||
controlField: propControlField,
|
||||
controlLabel: propControlLabel,
|
||||
sections: propSections,
|
||||
defaultValue: propDefaultValue,
|
||||
showBorder: propShowBorder,
|
||||
spacing: propSpacing,
|
||||
value,
|
||||
onChange,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
isDesignMode = false,
|
||||
onUpdateComponent,
|
||||
onDeleteComponent,
|
||||
onSelectComponent,
|
||||
selectedComponentId,
|
||||
onHeightChange,
|
||||
componentId,
|
||||
style,
|
||||
className,
|
||||
}: ConditionalContainerProps) {
|
||||
console.log("🎯 ConditionalContainerComponent 렌더링!", {
|
||||
isDesignMode,
|
||||
hasOnHeightChange: !!onHeightChange,
|
||||
componentId,
|
||||
});
|
||||
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const controlField = config?.controlField || propControlField || "condition";
|
||||
const controlLabel = config?.controlLabel || propControlLabel || "조건 선택";
|
||||
const sections = config?.sections || propSections || [];
|
||||
const defaultValue = config?.defaultValue || propDefaultValue || sections[0]?.condition;
|
||||
const showBorder = config?.showBorder ?? propShowBorder ?? true;
|
||||
const spacing = config?.spacing || propSpacing || "normal";
|
||||
|
||||
// 현재 선택된 값
|
||||
const [selectedValue, setSelectedValue] = useState<string>(
|
||||
value || formData?.[controlField] || defaultValue || ""
|
||||
);
|
||||
|
||||
// formData 변경 시 동기화
|
||||
useEffect(() => {
|
||||
if (formData?.[controlField]) {
|
||||
setSelectedValue(formData[controlField]);
|
||||
}
|
||||
}, [formData, controlField]);
|
||||
|
||||
// 값 변경 핸들러
|
||||
const handleValueChange = (newValue: string) => {
|
||||
setSelectedValue(newValue);
|
||||
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
}
|
||||
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(controlField, newValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 컨테이너 높이 측정용 ref
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const previousHeightRef = useRef<number>(0);
|
||||
|
||||
// 🔍 디버그: props 확인
|
||||
useEffect(() => {
|
||||
console.log("🔍 ConditionalContainer props:", {
|
||||
isDesignMode,
|
||||
hasOnHeightChange: !!onHeightChange,
|
||||
componentId,
|
||||
selectedValue,
|
||||
});
|
||||
}, [isDesignMode, onHeightChange, componentId, selectedValue]);
|
||||
|
||||
// 높이 변화 감지 및 콜백 호출
|
||||
useEffect(() => {
|
||||
console.log("🔍 ResizeObserver 등록 조건:", {
|
||||
hasContainer: !!containerRef.current,
|
||||
isDesignMode,
|
||||
hasOnHeightChange: !!onHeightChange,
|
||||
});
|
||||
|
||||
if (!containerRef.current || isDesignMode || !onHeightChange) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const newHeight = entry.contentRect.height;
|
||||
|
||||
// 높이가 실제로 변경되었을 때만 콜백 호출
|
||||
if (Math.abs(newHeight - previousHeightRef.current) > 5) {
|
||||
console.log(`📏 조건부 컨테이너 높이 변화: ${previousHeightRef.current}px → ${newHeight}px`);
|
||||
previousHeightRef.current = newHeight;
|
||||
onHeightChange(newHeight);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [isDesignMode, onHeightChange, selectedValue]); // selectedValue 변경 시에도 감지
|
||||
|
||||
// 간격 스타일
|
||||
const spacingClass = {
|
||||
tight: "space-y-2",
|
||||
normal: "space-y-4",
|
||||
loose: "space-y-8",
|
||||
}[spacing];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("w-full flex flex-col", spacingClass, className)}
|
||||
style={style}
|
||||
>
|
||||
{/* 제어 셀렉트박스 */}
|
||||
<div className="space-y-2 flex-shrink-0">
|
||||
<Label htmlFor={controlField} className="text-xs sm:text-sm">
|
||||
{controlLabel}
|
||||
</Label>
|
||||
<Select value={selectedValue} onValueChange={handleValueChange}>
|
||||
<SelectTrigger
|
||||
id={controlField}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sections.map((section) => (
|
||||
<SelectItem key={section.id} value={section.condition}>
|
||||
{section.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 조건별 섹션들 */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{isDesignMode ? (
|
||||
// 디자인 모드: 모든 섹션 표시
|
||||
<div className={spacingClass}>
|
||||
{sections.map((section) => (
|
||||
<ConditionalSectionViewer
|
||||
key={section.id}
|
||||
sectionId={section.id}
|
||||
condition={section.condition}
|
||||
label={section.label}
|
||||
screenId={section.screenId}
|
||||
screenName={section.screenName}
|
||||
isActive={selectedValue === section.condition}
|
||||
isDesignMode={isDesignMode}
|
||||
showBorder={showBorder}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// 실행 모드: 활성 섹션만 표시
|
||||
sections.map((section) =>
|
||||
selectedValue === section.condition ? (
|
||||
<ConditionalSectionViewer
|
||||
key={section.id}
|
||||
sectionId={section.id}
|
||||
condition={section.condition}
|
||||
label={section.label}
|
||||
screenId={section.screenId}
|
||||
screenName={section.screenName}
|
||||
isActive={true}
|
||||
isDesignMode={false}
|
||||
showBorder={showBorder}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 섹션이 없는 경우 안내 */}
|
||||
{sections.length === 0 && isDesignMode && (
|
||||
<div className="flex items-center justify-center min-h-[200px] border-2 border-dashed border-muted-foreground/30 rounded-lg bg-muted/20">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
설정 패널에서 조건을 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, Trash2, GripVertical, Loader2 } from "lucide-react";
|
||||
import { ConditionalContainerConfig, ConditionalSection } from "./types";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
|
||||
interface ConditionalContainerConfigPanelProps {
|
||||
config: ConditionalContainerConfig;
|
||||
onConfigChange: (config: ConditionalContainerConfig) => void;
|
||||
}
|
||||
|
||||
export function ConditionalContainerConfigPanel({
|
||||
config,
|
||||
onConfigChange,
|
||||
}: ConditionalContainerConfigPanelProps) {
|
||||
const [localConfig, setLocalConfig] = useState<ConditionalContainerConfig>({
|
||||
controlField: config.controlField || "condition",
|
||||
controlLabel: config.controlLabel || "조건 선택",
|
||||
sections: config.sections || [],
|
||||
defaultValue: config.defaultValue || "",
|
||||
showBorder: config.showBorder ?? true,
|
||||
spacing: config.spacing || "normal",
|
||||
});
|
||||
|
||||
// 화면 목록 상태
|
||||
const [screens, setScreens] = useState<any[]>([]);
|
||||
const [screensLoading, setScreensLoading] = useState(false);
|
||||
|
||||
// 화면 목록 로드
|
||||
useEffect(() => {
|
||||
const loadScreens = async () => {
|
||||
setScreensLoading(true);
|
||||
try {
|
||||
const response = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||
if (response.data) {
|
||||
setScreens(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("화면 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setScreensLoading(false);
|
||||
}
|
||||
};
|
||||
loadScreens();
|
||||
}, []);
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = (updates: Partial<ConditionalContainerConfig>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
||||
// 새 섹션 추가
|
||||
const addSection = () => {
|
||||
const newSection: ConditionalSection = {
|
||||
id: `section_${Date.now()}`,
|
||||
condition: `condition_${localConfig.sections.length + 1}`,
|
||||
label: `조건 ${localConfig.sections.length + 1}`,
|
||||
screenId: null,
|
||||
screenName: undefined,
|
||||
};
|
||||
|
||||
updateConfig({
|
||||
sections: [...localConfig.sections, newSection],
|
||||
});
|
||||
};
|
||||
|
||||
// 섹션 삭제
|
||||
const removeSection = (sectionId: string) => {
|
||||
updateConfig({
|
||||
sections: localConfig.sections.filter((s) => s.id !== sectionId),
|
||||
});
|
||||
};
|
||||
|
||||
// 섹션 업데이트
|
||||
const updateSection = (
|
||||
sectionId: string,
|
||||
updates: Partial<ConditionalSection>
|
||||
) => {
|
||||
updateConfig({
|
||||
sections: localConfig.sections.map((s) =>
|
||||
s.id === sectionId ? { ...s, ...updates } : s
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-4">조건부 컨테이너 설정</h3>
|
||||
|
||||
{/* 제어 필드 설정 */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="controlField" className="text-xs">
|
||||
제어 필드명
|
||||
</Label>
|
||||
<Input
|
||||
id="controlField"
|
||||
value={localConfig.controlField}
|
||||
onChange={(e) => updateConfig({ controlField: e.target.value })}
|
||||
placeholder="예: inputMode"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
formData에 저장될 필드명
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="controlLabel" className="text-xs">
|
||||
셀렉트박스 라벨
|
||||
</Label>
|
||||
<Input
|
||||
id="controlLabel"
|
||||
value={localConfig.controlLabel}
|
||||
onChange={(e) => updateConfig({ controlLabel: e.target.value })}
|
||||
placeholder="예: 입력 방식"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건별 섹션 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold">조건별 섹션</Label>
|
||||
<Button
|
||||
onClick={addSection}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
섹션 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{localConfig.sections.length === 0 ? (
|
||||
<div className="text-center py-8 border-2 border-dashed rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
조건별 섹션을 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{localConfig.sections.map((section, index) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="p-3 border rounded-lg space-y-3 bg-muted/20"
|
||||
>
|
||||
{/* 섹션 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">
|
||||
섹션 {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => removeSection(section.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 조건 값 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
조건 값 (고유값)
|
||||
</Label>
|
||||
<Input
|
||||
value={section.condition}
|
||||
onChange={(e) =>
|
||||
updateSection(section.id, { condition: e.target.value })
|
||||
}
|
||||
placeholder="예: customer_first"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 조건 라벨 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
표시 라벨
|
||||
</Label>
|
||||
<Input
|
||||
value={section.label}
|
||||
onChange={(e) =>
|
||||
updateSection(section.id, { label: e.target.value })
|
||||
}
|
||||
placeholder="예: 거래처 우선"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 화면 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
표시할 화면
|
||||
</Label>
|
||||
{screensLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground h-7 px-3 border rounded">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={section.screenId?.toString() || "none"}
|
||||
onValueChange={(value) => {
|
||||
if (value === "none") {
|
||||
updateSection(section.id, {
|
||||
screenId: null,
|
||||
screenName: undefined,
|
||||
});
|
||||
} else {
|
||||
const screenId = parseInt(value);
|
||||
const selectedScreen = screens.find(
|
||||
(s) => s.screenId === screenId
|
||||
);
|
||||
updateSection(section.id, {
|
||||
screenId,
|
||||
screenName: selectedScreen?.screenName,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="화면 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안 함</SelectItem>
|
||||
{screens.map((screen) => (
|
||||
<SelectItem
|
||||
key={screen.screenId}
|
||||
value={screen.screenId.toString()}
|
||||
>
|
||||
{screen.screenName}
|
||||
{screen.description && (
|
||||
<span className="text-[10px] text-muted-foreground ml-1">
|
||||
({screen.description})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{section.screenId && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
화면 ID: {section.screenId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 기본값 설정 */}
|
||||
{localConfig.sections.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<Label htmlFor="defaultValue" className="text-xs">
|
||||
기본 선택 값
|
||||
</Label>
|
||||
<Select
|
||||
value={localConfig.defaultValue || ""}
|
||||
onValueChange={(value) => updateConfig({ defaultValue: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="기본값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{localConfig.sections.map((section) => (
|
||||
<SelectItem key={section.id} value={section.condition}>
|
||||
{section.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 스타일 설정 */}
|
||||
<div className="space-y-4 mt-6 pt-6 border-t">
|
||||
<Label className="text-xs font-semibold">스타일 설정</Label>
|
||||
|
||||
{/* 테두리 표시 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showBorder" className="text-xs">
|
||||
섹션 테두리 표시
|
||||
</Label>
|
||||
<Switch
|
||||
id="showBorder"
|
||||
checked={localConfig.showBorder}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ showBorder: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 간격 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="spacing" className="text-xs">
|
||||
섹션 간격
|
||||
</Label>
|
||||
<Select
|
||||
value={localConfig.spacing || "normal"}
|
||||
onValueChange={(value: any) => updateConfig({ spacing: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tight">좁게</SelectItem>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="loose">넓게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import ConditionalContainerDefinition from "./index";
|
||||
import { ConditionalContainerComponent } from "./ConditionalContainerComponent";
|
||||
import { ConditionalContainerConfigPanel } from "./ConditionalContainerConfigPanel";
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
if (typeof window !== "undefined") {
|
||||
ComponentRegistry.registerComponent({
|
||||
...ConditionalContainerDefinition,
|
||||
component: ConditionalContainerComponent,
|
||||
renderer: ConditionalContainerComponent,
|
||||
configPanel: ConditionalContainerConfigPanel,
|
||||
} as any);
|
||||
}
|
||||
|
||||
export { ConditionalContainerComponent };
|
||||
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ConditionalSectionViewerProps } from "./types";
|
||||
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
|
||||
/**
|
||||
* 조건부 섹션 뷰어 컴포넌트
|
||||
* 각 조건에 해당하는 화면을 표시
|
||||
*/
|
||||
export function ConditionalSectionViewer({
|
||||
sectionId,
|
||||
condition,
|
||||
label,
|
||||
screenId,
|
||||
screenName,
|
||||
isActive,
|
||||
isDesignMode,
|
||||
showBorder = true,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
}: ConditionalSectionViewerProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [components, setComponents] = useState<ComponentData[]>([]);
|
||||
const [screenInfo, setScreenInfo] = useState<{ id: number; tableName?: string } | null>(null);
|
||||
const [screenResolution, setScreenResolution] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
// 화면 로드
|
||||
useEffect(() => {
|
||||
if (!screenId) {
|
||||
setComponents([]);
|
||||
setScreenInfo(null);
|
||||
setScreenResolution(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadScreen = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [layout, screen] = await Promise.all([screenApi.getLayout(screenId), screenApi.getScreen(screenId)]);
|
||||
|
||||
setComponents(layout.components || []);
|
||||
setScreenInfo({
|
||||
id: screenId,
|
||||
tableName: screen.tableName,
|
||||
});
|
||||
setScreenResolution(layout.screenResolution || null);
|
||||
} catch (error) {
|
||||
console.error("화면 로드 실패:", error);
|
||||
setComponents([]);
|
||||
setScreenInfo(null);
|
||||
setScreenResolution(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadScreen();
|
||||
}, [screenId]);
|
||||
|
||||
// 디자인 모드가 아니고 비활성 섹션이면 렌더링하지 않음
|
||||
if (!isDesignMode && !isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full transition-all",
|
||||
isDesignMode && showBorder && "border-muted-foreground/30 bg-muted/20 rounded-lg border-2 border-dashed",
|
||||
!isDesignMode && !isActive && "hidden",
|
||||
)}
|
||||
style={{
|
||||
minHeight: isDesignMode ? "200px" : undefined,
|
||||
}}
|
||||
data-section-id={sectionId}
|
||||
>
|
||||
{/* 섹션 라벨 (디자인 모드에서만 표시) */}
|
||||
{isDesignMode && (
|
||||
<div className="bg-background text-muted-foreground absolute -top-3 left-4 z-10 px-2 text-xs font-medium">
|
||||
{label} {isActive && "(활성)"}
|
||||
{screenId && ` - 화면 ID: ${screenId}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 화면 미선택 안내 (디자인 모드 + 화면 없을 때) */}
|
||||
{isDesignMode && !screenId && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-muted-foreground text-center">
|
||||
<p className="text-sm">설정 패널에서 화면을 선택하세요</p>
|
||||
<p className="mt-1 text-xs">조건: {condition}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 중 */}
|
||||
{isLoading && (
|
||||
<div className="bg-background/50 absolute inset-0 z-20 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
<p className="text-muted-foreground text-xs">화면 로드 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 화면 렌더링 */}
|
||||
{screenId && components.length > 0 && (
|
||||
<>
|
||||
{isDesignMode ? (
|
||||
/* 디자인 모드: 화면 정보만 표시 */
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-foreground mb-2 text-sm font-medium">{screenName || `화면 ID: ${screenId}`}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{screenResolution?.width} x {screenResolution?.height}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">컴포넌트 {components.length}개</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 실행 모드: 실제 화면 렌더링 */
|
||||
<div className="w-full">
|
||||
{/* 화면 크기만큼의 절대 위치 캔버스 */}
|
||||
<div
|
||||
className="relative mx-auto"
|
||||
style={{
|
||||
width: screenResolution?.width ? `${screenResolution.width}px` : "100%",
|
||||
height: screenResolution?.height ? `${screenResolution.height}px` : "auto",
|
||||
minHeight: "200px",
|
||||
}}
|
||||
>
|
||||
{components.map((component) => (
|
||||
<RealtimePreview
|
||||
key={component.id}
|
||||
component={component}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
# 조건부 컨테이너 (ConditionalContainer) - 화면 선택 방식
|
||||
|
||||
제어 셀렉트박스 값에 따라 다른 **화면**을 표시하는 조건부 컨테이너 컴포넌트입니다.
|
||||
|
||||
## 📋 개요
|
||||
|
||||
화면 편집기에서 조건별로 표시할 화면을 선택하여 조건부 UI를 구성할 수 있는 컨테이너입니다. 상단의 셀렉트박스 값에 따라 하단에 미리 만들어진 화면을 표시합니다.
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||
- ✅ **조건별 화면 전환**: 셀렉트박스 값에 따라 다른 화면 표시
|
||||
- ✅ **화면 재사용**: 기존에 만든 화면을 조건별로 할당
|
||||
- ✅ **간편한 구성**: 복잡한 입력 폼도 화면 선택으로 간단히 구성
|
||||
- ✅ **자동 동기화**: 화면 수정 시 자동 반영
|
||||
- ✅ **폼 데이터 연동**: formData와 자동 동기화
|
||||
- ✅ **커스터마이징**: 테두리, 간격, 기본값 등 설정 가능
|
||||
|
||||
## 🎯 사용 사례
|
||||
|
||||
### 1. 입력 방식 선택
|
||||
```
|
||||
[셀렉트: 입력 방식]
|
||||
├─ 거래처 우선: "거래처_우선_입력_화면" (화면 ID: 101)
|
||||
├─ 견적서 기반: "견적서_업로드_화면" (화면 ID: 102)
|
||||
└─ 단가 직접입력: "단가_직접입력_화면" (화면 ID: 103)
|
||||
```
|
||||
|
||||
### 2. 판매 유형 선택
|
||||
```
|
||||
[셀렉트: 판매 유형]
|
||||
├─ 국내 판매: "국내판매_기본폼" (화면 ID: 201)
|
||||
└─ 해외 판매: "해외판매_무역정보폼" (화면 ID: 202)
|
||||
```
|
||||
|
||||
### 3. 문서 유형 선택
|
||||
```
|
||||
[셀렉트: 문서 유형]
|
||||
├─ 신규 작성: "신규문서_입력폼" (화면 ID: 301)
|
||||
├─ 복사 생성: "문서복사_화면" (화면 ID: 302)
|
||||
└─ 불러오기: "파일업로드_화면" (화면 ID: 303)
|
||||
```
|
||||
|
||||
## 📐 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ ConditionalContainer │
|
||||
├─────────────────────────────────┤
|
||||
│ [제어 셀렉트박스] │ ← controlField, controlLabel
|
||||
├─────────────────────────────────┤
|
||||
│ 📄 조건 1: "옵션 A" 선택 시 │ ← sections[0]
|
||||
│ ┌─────────────────────────────┐│
|
||||
│ │ [선택된 화면이 표시됨] ││ ← screenId로 지정된 화면
|
||||
│ │ (화면 ID: 101) ││
|
||||
│ │ ││
|
||||
│ └─────────────────────────────┘│
|
||||
├─────────────────────────────────┤
|
||||
│ 📄 조건 2: "옵션 B" 선택 시 │ ← sections[1]
|
||||
│ ┌─────────────────────────────┐│
|
||||
│ │ [다른 화면이 표시됨] ││ ← screenId로 지정된 다른 화면
|
||||
│ │ (화면 ID: 102) ││
|
||||
│ └─────────────────────────────┘│
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🔧 설정 방법
|
||||
|
||||
### 1. 컴포넌트 추가
|
||||
화면 편집기의 컴포넌트 패널에서 **"조건부 컨테이너"**를 드래그하여 캔버스에 배치합니다.
|
||||
|
||||
### 2. 설정 패널에서 구성
|
||||
|
||||
#### 제어 필드 설정
|
||||
- **제어 필드명**: formData에 저장될 필드명 (예: `inputMode`)
|
||||
- **셀렉트박스 라벨**: 화면에 표시될 라벨 (예: "입력 방식")
|
||||
|
||||
#### 조건별 섹션 추가
|
||||
1. **"섹션 추가"** 버튼 클릭
|
||||
2. 각 섹션 설정:
|
||||
- **조건 값**: 고유한 값 (예: `customer_first`)
|
||||
- **표시 라벨**: 사용자에게 보이는 텍스트 (예: "거래처 우선")
|
||||
|
||||
#### 기본값 설정
|
||||
- 처음 화면 로드 시 선택될 기본 조건 선택
|
||||
|
||||
#### 스타일 설정
|
||||
- **섹션 테두리 표시**: ON/OFF
|
||||
- **섹션 간격**: 좁게 / 보통 / 넓게
|
||||
|
||||
### 3. 조건별 화면 선택
|
||||
|
||||
1. **디자인 모드**에서 모든 조건 섹션이 표시됩니다
|
||||
2. 각 섹션의 **"표시할 화면"** 드롭다운에서 화면을 선택합니다
|
||||
3. 선택된 화면 ID와 이름이 자동으로 저장됩니다
|
||||
|
||||
**장점:**
|
||||
- ✅ 이미 만든 화면을 재사용
|
||||
- ✅ 복잡한 입력 폼도 간단히 구성
|
||||
- ✅ 화면 수정 시 자동 반영
|
||||
|
||||
### 4. 실행 모드 동작
|
||||
|
||||
- 셀렉트박스에서 조건 선택
|
||||
- 선택된 조건의 **화면**이 표시됨
|
||||
- 다른 조건의 화면은 자동으로 숨김
|
||||
|
||||
## 💻 기술 사양
|
||||
|
||||
### Props
|
||||
|
||||
```typescript
|
||||
interface ConditionalContainerProps {
|
||||
// 제어 필드
|
||||
controlField: string; // 예: "inputMode"
|
||||
controlLabel: string; // 예: "입력 방식"
|
||||
|
||||
// 조건별 섹션
|
||||
sections: ConditionalSection[];
|
||||
|
||||
// 기본값
|
||||
defaultValue?: string;
|
||||
|
||||
// 스타일
|
||||
showBorder?: boolean; // 기본: true
|
||||
spacing?: "tight" | "normal" | "loose"; // 기본: "normal"
|
||||
|
||||
// 폼 연동
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
}
|
||||
|
||||
interface ConditionalSection {
|
||||
id: string; // 고유 ID
|
||||
condition: string; // 조건 값
|
||||
label: string; // 표시 라벨
|
||||
screenId: number | null; // 표시할 화면 ID
|
||||
screenName?: string; // 화면 이름 (표시용)
|
||||
}
|
||||
```
|
||||
|
||||
### 기본 설정
|
||||
|
||||
```typescript
|
||||
defaultSize: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
}
|
||||
|
||||
defaultConfig: {
|
||||
controlField: "condition",
|
||||
controlLabel: "조건 선택",
|
||||
sections: [
|
||||
{
|
||||
id: "section_1",
|
||||
condition: "option1",
|
||||
label: "옵션 1",
|
||||
screenId: null, // 화면 미선택 상태
|
||||
},
|
||||
{
|
||||
id: "section_2",
|
||||
condition: "option2",
|
||||
label: "옵션 2",
|
||||
screenId: null, // 화면 미선택 상태
|
||||
},
|
||||
],
|
||||
defaultValue: "option1",
|
||||
showBorder: true,
|
||||
spacing: "normal",
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 디자인 모드 vs 실행 모드
|
||||
|
||||
### 디자인 모드 (편집기)
|
||||
- ✅ 모든 조건 섹션 표시
|
||||
- ✅ 각 섹션에 "조건: XXX" 라벨 표시
|
||||
- ✅ 화면 선택 안내 메시지 (미선택 시)
|
||||
- ✅ 선택된 화면 ID 표시
|
||||
- ✅ 활성 조건 "(활성)" 표시
|
||||
|
||||
### 실행 모드 (할당된 화면)
|
||||
- ✅ 선택된 조건의 화면만 표시
|
||||
- ✅ 다른 조건의 화면 자동 숨김
|
||||
- ✅ 깔끔한 UI (라벨, 점선 테두리 제거)
|
||||
- ✅ 선택된 화면이 완전히 통합되어 표시
|
||||
|
||||
## 📊 폼 데이터 연동
|
||||
|
||||
### 자동 동기화
|
||||
```typescript
|
||||
// formData 읽기
|
||||
formData[controlField] // 현재 선택된 값
|
||||
|
||||
// formData 쓰기
|
||||
onFormDataChange(controlField, newValue)
|
||||
```
|
||||
|
||||
### 예시
|
||||
```typescript
|
||||
// controlField = "salesType"
|
||||
formData = {
|
||||
salesType: "export", // ← 자동으로 여기에 저장됨
|
||||
// ... 다른 필드들
|
||||
}
|
||||
|
||||
// 셀렉트박스 값 변경 시 자동으로 formData 업데이트
|
||||
```
|
||||
|
||||
## 🔍 주의사항
|
||||
|
||||
1. **조건 값은 고유해야 함**: 각 섹션의 `condition` 값은 중복되면 안 됩니다
|
||||
2. **최소 1개 섹션 필요**: 섹션이 없으면 안내 메시지 표시
|
||||
3. **컴포넌트 ID 충돌 방지**: 각 섹션의 컴포넌트 ID는 전역적으로 고유해야 함
|
||||
|
||||
## 📝 예시: 수주 입력 방식 선택
|
||||
|
||||
```typescript
|
||||
{
|
||||
controlField: "inputMode",
|
||||
controlLabel: "입력 방식",
|
||||
sections: [
|
||||
{
|
||||
id: "customer_first",
|
||||
condition: "customer_first",
|
||||
label: "거래처 우선",
|
||||
components: [
|
||||
// 거래처 검색 컴포넌트
|
||||
// 품목 선택 테이블
|
||||
// 저장 버튼
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "quotation",
|
||||
condition: "quotation",
|
||||
label: "견적서 기반",
|
||||
components: [
|
||||
// 견적서 검색 컴포넌트
|
||||
// 견적서 내용 표시
|
||||
// 수주 전환 버튼
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "unit_price",
|
||||
condition: "unit_price",
|
||||
label: "단가 직접입력",
|
||||
components: [
|
||||
// 품목 입력 테이블
|
||||
// 단가 입력 필드들
|
||||
// 계산 위젯
|
||||
]
|
||||
}
|
||||
],
|
||||
defaultValue: "customer_first",
|
||||
showBorder: true,
|
||||
spacing: "normal"
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 로드맵
|
||||
|
||||
- [ ] 다중 제어 필드 지원 (AND/OR 조건)
|
||||
- [ ] 섹션 전환 애니메이션
|
||||
- [ ] 조건별 검증 규칙
|
||||
- [ ] 템플릿 저장/불러오기
|
||||
|
||||
## 🐛 트러블슈팅
|
||||
|
||||
### Q: 섹션이 전환되지 않아요
|
||||
A: `controlField` 값이 formData에 제대로 저장되고 있는지 확인하세요.
|
||||
|
||||
### Q: 컴포넌트가 드롭되지 않아요
|
||||
A: 디자인 모드인지 확인하고, 드롭존 영역에 정확히 드롭하세요.
|
||||
|
||||
### Q: 다른 조건의 UI가 계속 보여요
|
||||
A: 실행 모드로 전환했는지 확인하세요. 디자인 모드에서는 모든 조건이 표시됩니다.
|
||||
|
||||
## 📦 파일 구조
|
||||
|
||||
```
|
||||
conditional-container/
|
||||
├── types.ts # 타입 정의
|
||||
├── ConditionalContainerComponent.tsx # 메인 컴포넌트
|
||||
├── ConditionalSectionDropZone.tsx # 드롭존 컴포넌트
|
||||
├── ConditionalContainerConfigPanel.tsx # 설정 패널
|
||||
├── ConditionalContainerRenderer.tsx # 렌더러 및 등록
|
||||
├── index.ts # 컴포넌트 정의
|
||||
└── README.md # 이 파일
|
||||
```
|
||||
|
||||
## 🎉 완료!
|
||||
|
||||
이제 화면 편집기에서 **조건부 컨테이너**를 사용하여 동적인 UI를 만들 수 있습니다! 🚀
|
||||
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* 조건부 컨테이너 컴포넌트
|
||||
* 제어 셀렉트박스 값에 따라 다른 UI를 표시하는 컨테이너
|
||||
*/
|
||||
|
||||
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
||||
|
||||
export const ConditionalContainerDefinition: Omit<
|
||||
ComponentDefinition,
|
||||
"renderer" | "configPanel" | "component"
|
||||
> = {
|
||||
id: "conditional-container",
|
||||
name: "조건부 컨테이너",
|
||||
category: ComponentCategory.LAYOUT,
|
||||
webType: "container" as const,
|
||||
description: "셀렉트박스 값에 따라 다른 UI를 표시하는 조건부 컨테이너",
|
||||
icon: "GitBranch",
|
||||
version: "1.0.0",
|
||||
author: "WACE",
|
||||
tags: ["조건부", "분기", "동적", "레이아웃"],
|
||||
|
||||
defaultSize: {
|
||||
width: 1400,
|
||||
height: 800,
|
||||
},
|
||||
|
||||
defaultConfig: {
|
||||
controlField: "condition",
|
||||
controlLabel: "조건 선택",
|
||||
sections: [
|
||||
{
|
||||
id: "section_1",
|
||||
condition: "option1",
|
||||
label: "옵션 1",
|
||||
screenId: null,
|
||||
},
|
||||
{
|
||||
id: "section_2",
|
||||
condition: "option2",
|
||||
label: "옵션 2",
|
||||
screenId: null,
|
||||
},
|
||||
],
|
||||
defaultValue: "option1",
|
||||
showBorder: true,
|
||||
spacing: "normal",
|
||||
},
|
||||
|
||||
defaultProps: {
|
||||
style: {
|
||||
width: "1400px",
|
||||
height: "800px",
|
||||
},
|
||||
},
|
||||
|
||||
configSchema: {
|
||||
controlField: {
|
||||
type: "string",
|
||||
label: "제어 필드명",
|
||||
defaultValue: "condition",
|
||||
},
|
||||
controlLabel: {
|
||||
type: "string",
|
||||
label: "셀렉트박스 라벨",
|
||||
defaultValue: "조건 선택",
|
||||
},
|
||||
sections: {
|
||||
type: "array",
|
||||
label: "조건별 섹션",
|
||||
defaultValue: [],
|
||||
},
|
||||
defaultValue: {
|
||||
type: "string",
|
||||
label: "기본 선택 값",
|
||||
defaultValue: "",
|
||||
},
|
||||
showBorder: {
|
||||
type: "boolean",
|
||||
label: "섹션 테두리 표시",
|
||||
defaultValue: true,
|
||||
},
|
||||
spacing: {
|
||||
type: "select",
|
||||
label: "섹션 간격",
|
||||
options: [
|
||||
{ label: "좁게", value: "tight" },
|
||||
{ label: "보통", value: "normal" },
|
||||
{ label: "넓게", value: "loose" },
|
||||
],
|
||||
defaultValue: "normal",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default ConditionalContainerDefinition;
|
||||
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* ConditionalContainer 컴포넌트 타입 정의
|
||||
* 제어 셀렉트박스 값에 따라 다른 UI를 표시하는 조건부 컨테이너
|
||||
*/
|
||||
|
||||
import { ComponentData } from "@/types/screen";
|
||||
|
||||
export interface ConditionalSection {
|
||||
id: string; // 고유 ID
|
||||
condition: string; // 조건 값 (예: "customer_first", "quotation")
|
||||
label: string; // 조건 라벨 (예: "거래처 우선", "견적서 기반")
|
||||
screenId: number | null; // 이 조건일 때 표시할 화면 ID
|
||||
screenName?: string; // 화면 이름 (표시용)
|
||||
}
|
||||
|
||||
export interface ConditionalContainerConfig {
|
||||
// 제어 셀렉트박스 설정
|
||||
controlField: string; // 제어할 필드명 (예: "inputMode")
|
||||
controlLabel: string; // 셀렉트박스 라벨 (예: "입력 방식")
|
||||
|
||||
// 조건별 섹션
|
||||
sections: ConditionalSection[];
|
||||
|
||||
// 기본 선택 값
|
||||
defaultValue?: string;
|
||||
|
||||
// 스타일
|
||||
showBorder?: boolean; // 섹션별 테두리 표시
|
||||
spacing?: "tight" | "normal" | "loose"; // 섹션 간격
|
||||
}
|
||||
|
||||
export interface ConditionalContainerProps {
|
||||
config?: ConditionalContainerConfig;
|
||||
|
||||
// 개별 props (config 우선)
|
||||
controlField?: string;
|
||||
controlLabel?: string;
|
||||
sections?: ConditionalSection[];
|
||||
defaultValue?: string;
|
||||
showBorder?: boolean;
|
||||
spacing?: "tight" | "normal" | "loose";
|
||||
|
||||
// 폼 데이터 연동
|
||||
value?: any; // 현재 선택된 값
|
||||
onChange?: (value: string) => void;
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
|
||||
// 화면 편집기 관련
|
||||
isDesignMode?: boolean; // 디자인 모드 여부
|
||||
onUpdateComponent?: (componentId: string, updates: Partial<ComponentData>) => void;
|
||||
onDeleteComponent?: (componentId: string) => void;
|
||||
onSelectComponent?: (componentId: string) => void;
|
||||
selectedComponentId?: string;
|
||||
|
||||
// 높이 변화 알림 (아래 컴포넌트 재배치용)
|
||||
onHeightChange?: (newHeight: number) => void;
|
||||
componentId?: string; // 자신의 컴포넌트 ID
|
||||
|
||||
// 스타일
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 조건부 섹션 뷰어 Props
|
||||
export interface ConditionalSectionViewerProps {
|
||||
sectionId: string;
|
||||
condition: string;
|
||||
label: string;
|
||||
screenId: number | null; // 표시할 화면 ID
|
||||
screenName?: string; // 화면 이름
|
||||
isActive: boolean; // 현재 조건이 활성화되어 있는지
|
||||
isDesignMode: boolean;
|
||||
showBorder?: boolean;
|
||||
// 폼 데이터 전달
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { EntitySearchModal } from "./EntitySearchModal";
|
||||
import { EntitySearchInputProps, EntitySearchResult } from "./types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function EntitySearchInputComponent({
|
||||
tableName,
|
||||
displayField,
|
||||
valueField,
|
||||
searchFields = [displayField],
|
||||
mode = "combo",
|
||||
placeholder = "검색...",
|
||||
disabled = false,
|
||||
filterCondition = {},
|
||||
value,
|
||||
onChange,
|
||||
modalTitle = "검색",
|
||||
modalColumns = [],
|
||||
showAdditionalInfo = false,
|
||||
additionalFields = [],
|
||||
className,
|
||||
}: EntitySearchInputProps) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [displayValue, setDisplayValue] = useState("");
|
||||
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
|
||||
|
||||
// value가 변경되면 표시값 업데이트
|
||||
useEffect(() => {
|
||||
if (value && selectedData) {
|
||||
setDisplayValue(selectedData[displayField] || "");
|
||||
} else {
|
||||
setDisplayValue("");
|
||||
setSelectedData(null);
|
||||
}
|
||||
}, [value, displayField]);
|
||||
|
||||
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
|
||||
setSelectedData(fullData);
|
||||
setDisplayValue(fullData[displayField] || "");
|
||||
onChange?.(newValue, fullData);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setDisplayValue("");
|
||||
setSelectedData(null);
|
||||
onChange?.(null, null);
|
||||
};
|
||||
|
||||
const handleOpenModal = () => {
|
||||
if (!disabled) {
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{/* 입력 필드 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
value={displayValue}
|
||||
onChange={(e) => setDisplayValue(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
readOnly={mode === "modal" || mode === "combo"}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm pr-8"
|
||||
/>
|
||||
{displayValue && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(mode === "modal" || mode === "combo") && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleOpenModal}
|
||||
disabled={disabled}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 추가 정보 표시 */}
|
||||
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground space-y-1 px-2">
|
||||
{additionalFields.map((field) => (
|
||||
<div key={field} className="flex gap-2">
|
||||
<span className="font-medium">{field}:</span>
|
||||
<span>{selectedData[field] || "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 모달 */}
|
||||
<EntitySearchModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
tableName={tableName}
|
||||
displayField={displayField}
|
||||
valueField={valueField}
|
||||
searchFields={searchFields}
|
||||
filterCondition={filterCondition}
|
||||
modalTitle={modalTitle}
|
||||
modalColumns={modalColumns}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,498 @@
|
|||
"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";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { EntitySearchInputConfig } from "./config";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface EntitySearchInputConfigPanelProps {
|
||||
config: EntitySearchInputConfig;
|
||||
onConfigChange: (config: EntitySearchInputConfig) => void;
|
||||
}
|
||||
|
||||
export function EntitySearchInputConfigPanel({
|
||||
config,
|
||||
onConfigChange,
|
||||
}: EntitySearchInputConfigPanelProps) {
|
||||
const [localConfig, setLocalConfig] = useState(config);
|
||||
const [allTables, setAllTables] = useState<any[]>([]);
|
||||
const [tableColumns, setTableColumns] = useState<any[]>([]);
|
||||
const [isLoadingTables, setIsLoadingTables] = useState(false);
|
||||
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
|
||||
const [openTableCombo, setOpenTableCombo] = useState(false);
|
||||
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
|
||||
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
|
||||
|
||||
// 전체 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setIsLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!localConfig.tableName) {
|
||||
setTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingColumns(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(localConfig.tableName);
|
||||
if (response.success && response.data) {
|
||||
setTableColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
setTableColumns([]);
|
||||
} finally {
|
||||
setIsLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [localConfig.tableName]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalConfig(config);
|
||||
}, [config]);
|
||||
|
||||
const updateConfig = (updates: Partial<EntitySearchInputConfig>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
||||
const addSearchField = () => {
|
||||
const fields = localConfig.searchFields || [];
|
||||
updateConfig({ searchFields: [...fields, ""] });
|
||||
};
|
||||
|
||||
const updateSearchField = (index: number, value: string) => {
|
||||
const fields = [...(localConfig.searchFields || [])];
|
||||
fields[index] = value;
|
||||
updateConfig({ searchFields: fields });
|
||||
};
|
||||
|
||||
const removeSearchField = (index: number) => {
|
||||
const fields = [...(localConfig.searchFields || [])];
|
||||
fields.splice(index, 1);
|
||||
updateConfig({ searchFields: fields });
|
||||
};
|
||||
|
||||
const addModalColumn = () => {
|
||||
const columns = localConfig.modalColumns || [];
|
||||
updateConfig({ modalColumns: [...columns, ""] });
|
||||
};
|
||||
|
||||
const updateModalColumn = (index: number, value: string) => {
|
||||
const columns = [...(localConfig.modalColumns || [])];
|
||||
columns[index] = value;
|
||||
updateConfig({ modalColumns: columns });
|
||||
};
|
||||
|
||||
const removeModalColumn = (index: number) => {
|
||||
const columns = [...(localConfig.modalColumns || [])];
|
||||
columns.splice(index, 1);
|
||||
updateConfig({ modalColumns: columns });
|
||||
};
|
||||
|
||||
const addAdditionalField = () => {
|
||||
const fields = localConfig.additionalFields || [];
|
||||
updateConfig({ additionalFields: [...fields, ""] });
|
||||
};
|
||||
|
||||
const updateAdditionalField = (index: number, value: string) => {
|
||||
const fields = [...(localConfig.additionalFields || [])];
|
||||
fields[index] = value;
|
||||
updateConfig({ additionalFields: fields });
|
||||
};
|
||||
|
||||
const removeAdditionalField = (index: number) => {
|
||||
const fields = [...(localConfig.additionalFields || [])];
|
||||
fields.splice(index, 1);
|
||||
updateConfig({ additionalFields: fields });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">테이블명 *</Label>
|
||||
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openTableCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={isLoadingTables}
|
||||
>
|
||||
{localConfig.tableName
|
||||
? allTables.find((t) => t.tableName === localConfig.tableName)?.displayName || localConfig.tableName
|
||||
: isLoadingTables ? "로딩 중..." : "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.displayName || table.tableName}-${table.tableName}`}
|
||||
onSelect={() => {
|
||||
updateConfig({ tableName: table.tableName });
|
||||
setOpenTableCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
localConfig.tableName === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||
{table.displayName && table.displayName !== table.tableName && (
|
||||
<span className="text-[10px] text-gray-500">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">표시 필드 *</Label>
|
||||
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openDisplayFieldCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
{localConfig.displayField
|
||||
? tableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
|
||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={`${column.displayName || column.columnName}-${column.columnName}`}
|
||||
onSelect={() => {
|
||||
updateConfig({ displayField: column.columnName });
|
||||
setOpenDisplayFieldCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
localConfig.displayField === column.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
||||
{column.displayName && column.displayName !== column.columnName && (
|
||||
<span className="text-[10px] text-gray-500">{column.columnName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">값 필드 *</Label>
|
||||
<Popover open={openValueFieldCombo} onOpenChange={setOpenValueFieldCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openValueFieldCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
{localConfig.valueField
|
||||
? tableColumns.find((c) => c.columnName === localConfig.valueField)?.displayName || localConfig.valueField
|
||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={`${column.displayName || column.columnName}-${column.columnName}`}
|
||||
onSelect={() => {
|
||||
updateConfig({ valueField: column.columnName });
|
||||
setOpenValueFieldCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
localConfig.valueField === column.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
||||
{column.displayName && column.displayName !== column.columnName && (
|
||||
<span className="text-[10px] text-gray-500">{column.columnName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">UI 모드</Label>
|
||||
<Select
|
||||
value={localConfig.mode || "combo"}
|
||||
onValueChange={(value: "autocomplete" | "modal" | "combo") =>
|
||||
updateConfig({ mode: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="combo">콤보 (입력 + 모달)</SelectItem>
|
||||
<SelectItem value="modal">모달만</SelectItem>
|
||||
<SelectItem value="autocomplete">자동완성만</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">플레이스홀더</Label>
|
||||
<Input
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig({ placeholder: e.target.value })}
|
||||
placeholder="검색..."
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(localConfig.mode === "modal" || localConfig.mode === "combo") && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">모달 제목</Label>
|
||||
<Input
|
||||
value={localConfig.modalTitle || ""}
|
||||
onChange={(e) => updateConfig({ modalTitle: e.target.value })}
|
||||
placeholder="검색 및 선택"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">모달 컬럼</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addModalColumn}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(localConfig.modalColumns || []).map((column, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={column}
|
||||
onValueChange={(value) => updateModalColumn(index, value)}
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeModalColumn(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">검색 필드</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addSearchField}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(localConfig.searchFields || []).map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={field}
|
||||
onValueChange={(value) => updateSearchField(index, value)}
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeSearchField(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">추가 정보 표시</Label>
|
||||
<Switch
|
||||
checked={localConfig.showAdditionalInfo || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ showAdditionalInfo: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localConfig.showAdditionalInfo && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">추가 필드</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addAdditionalField}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(localConfig.additionalFields || []).map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={field}
|
||||
onValueChange={(value) => updateAdditionalField(index, value)}
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeAdditionalField(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import { EntitySearchInputDefinition } from "./index";
|
||||
|
||||
export function EntitySearchInputRenderer() {
|
||||
useEffect(() => {
|
||||
ComponentRegistry.registerComponent(EntitySearchInputDefinition);
|
||||
console.log("✅ EntitySearchInput 컴포넌트 등록 완료");
|
||||
|
||||
return () => {
|
||||
// 컴포넌트 언마운트 시 해제하지 않음 (싱글톤 패턴)
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search, Loader2 } from "lucide-react";
|
||||
import { useEntitySearch } from "./useEntitySearch";
|
||||
import { EntitySearchResult } from "./types";
|
||||
|
||||
interface EntitySearchModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tableName: string;
|
||||
displayField: string;
|
||||
valueField: string;
|
||||
searchFields?: string[];
|
||||
filterCondition?: Record<string, any>;
|
||||
modalTitle?: string;
|
||||
modalColumns?: string[];
|
||||
onSelect: (value: any, fullData: EntitySearchResult) => void;
|
||||
}
|
||||
|
||||
export function EntitySearchModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
tableName,
|
||||
displayField,
|
||||
valueField,
|
||||
searchFields = [displayField],
|
||||
filterCondition = {},
|
||||
modalTitle = "검색",
|
||||
modalColumns = [],
|
||||
onSelect,
|
||||
}: EntitySearchModalProps) {
|
||||
const [localSearchText, setLocalSearchText] = useState("");
|
||||
const {
|
||||
results,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
search,
|
||||
clearSearch,
|
||||
loadMore,
|
||||
} = useEntitySearch({
|
||||
tableName,
|
||||
searchFields,
|
||||
filterCondition,
|
||||
});
|
||||
|
||||
// 모달 열릴 때 초기 검색
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
search("", 1); // 빈 검색어로 전체 목록 조회
|
||||
} else {
|
||||
clearSearch();
|
||||
setLocalSearchText("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSearch = () => {
|
||||
search(localSearchText, 1);
|
||||
};
|
||||
|
||||
const handleSelect = (item: EntitySearchResult) => {
|
||||
onSelect(item[valueField], item);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 표시할 컬럼 결정
|
||||
const displayColumns = modalColumns.length > 0 ? modalColumns : [displayField];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{modalTitle}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
항목을 검색하고 선택하세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="검색어를 입력하세요"
|
||||
value={localSearchText}
|
||||
onChange={(e) => setLocalSearchText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={loading}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-2">검색</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 결과 테이블 */}
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
{displayColumns.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-24">
|
||||
선택
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && results.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||
<p className="mt-2 text-muted-foreground">검색 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : results.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center text-muted-foreground">
|
||||
검색 결과가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
results.map((item, index) => (
|
||||
<tr
|
||||
key={item[valueField] || index}
|
||||
className="border-t hover:bg-accent cursor-pointer transition-colors"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
{displayColumns.map((col, colIndex) => (
|
||||
<td key={`${item[valueField] || index}-${col}-${colIndex}`} className="px-4 py-2">
|
||||
{item[col] || "-"}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(item);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 정보 */}
|
||||
{results.length > 0 && (
|
||||
<div className="flex justify-between items-center text-xs sm:text-sm text-muted-foreground">
|
||||
<span>
|
||||
전체 {pagination.total}개 중 {results.length}개 표시
|
||||
</span>
|
||||
{pagination.page * pagination.limit < pagination.total && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadMore}
|
||||
disabled={loading}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
더 보기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
export interface EntitySearchInputConfig {
|
||||
tableName: string;
|
||||
displayField: string;
|
||||
valueField: string;
|
||||
searchFields?: string[];
|
||||
filterCondition?: Record<string, any>;
|
||||
mode?: "autocomplete" | "modal" | "combo";
|
||||
placeholder?: string;
|
||||
modalTitle?: string;
|
||||
modalColumns?: string[];
|
||||
showAdditionalInfo?: boolean;
|
||||
additionalFields?: string[];
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { EntitySearchInputComponent } from "./EntitySearchInputComponent";
|
||||
import { EntitySearchInputConfigPanel } from "./EntitySearchInputConfigPanel";
|
||||
|
||||
/**
|
||||
* EntitySearchInput 컴포넌트 정의
|
||||
* 모달 기반 엔티티 검색 입력
|
||||
*/
|
||||
export const EntitySearchInputDefinition = createComponentDefinition({
|
||||
id: "entity-search-input",
|
||||
name: "엔티티 검색 입력 (모달)",
|
||||
nameEng: "Entity Search Input",
|
||||
description: "모달을 통한 엔티티 검색 및 선택 (거래처, 품목 등)",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "entity",
|
||||
component: EntitySearchInputComponent,
|
||||
defaultConfig: {
|
||||
tableName: "customer_mng",
|
||||
displayField: "customer_name",
|
||||
valueField: "customer_code",
|
||||
searchFields: ["customer_name", "customer_code"],
|
||||
mode: "combo",
|
||||
placeholder: "검색...",
|
||||
modalTitle: "검색 및 선택",
|
||||
modalColumns: ["customer_code", "customer_name", "address"],
|
||||
showAdditionalInfo: false,
|
||||
additionalFields: [],
|
||||
},
|
||||
defaultSize: { width: 300, height: 40 },
|
||||
configPanel: EntitySearchInputConfigPanel,
|
||||
icon: "Search",
|
||||
tags: ["검색", "모달", "엔티티", "거래처", "품목"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { EntitySearchInputConfig } from "./config";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { EntitySearchInputComponent } from "./EntitySearchInputComponent";
|
||||
export { EntitySearchInputRenderer } from "./EntitySearchInputRenderer";
|
||||
export { EntitySearchModal } from "./EntitySearchModal";
|
||||
export { useEntitySearch } from "./useEntitySearch";
|
||||
export type {
|
||||
EntitySearchInputProps,
|
||||
EntitySearchResult,
|
||||
EntitySearchResponse,
|
||||
} from "./types";
|
||||
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* EntitySearchInput 컴포넌트 타입 정의
|
||||
* 엔티티 테이블에서 데이터를 검색하고 선택하는 입력 컴포넌트
|
||||
*/
|
||||
|
||||
export interface EntitySearchInputProps {
|
||||
// 데이터 소스
|
||||
tableName: string; // 검색할 테이블명 (예: "customer_mng")
|
||||
displayField: string; // 표시할 필드 (예: "customer_name")
|
||||
valueField: string; // 값으로 사용할 필드 (예: "customer_code")
|
||||
searchFields?: string[]; // 검색 대상 필드들 (기본: [displayField])
|
||||
|
||||
// UI 모드
|
||||
mode?: "autocomplete" | "modal" | "combo"; // 기본: "combo"
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
|
||||
// 필터링
|
||||
filterCondition?: Record<string, any>; // 추가 WHERE 조건
|
||||
companyCode?: string; // 멀티테넌시
|
||||
|
||||
// 선택된 값
|
||||
value?: any;
|
||||
onChange?: (value: any, fullData?: any) => void;
|
||||
|
||||
// 모달 설정 (mode가 "modal" 또는 "combo"일 때)
|
||||
modalTitle?: string;
|
||||
modalColumns?: string[]; // 모달에 표시할 컬럼들
|
||||
|
||||
// 추가 표시 정보
|
||||
showAdditionalInfo?: boolean; // 선택 후 추가 정보 표시 (예: 주소)
|
||||
additionalFields?: string[]; // 추가로 표시할 필드들
|
||||
|
||||
// 스타일
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface EntitySearchResult {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface EntitySearchResponse {
|
||||
success: boolean;
|
||||
data: EntitySearchResult[];
|
||||
pagination?: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { EntitySearchResult, EntitySearchResponse } from "./types";
|
||||
|
||||
interface UseEntitySearchProps {
|
||||
tableName: string;
|
||||
searchFields?: string[];
|
||||
filterCondition?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function useEntitySearch({
|
||||
tableName,
|
||||
searchFields = [],
|
||||
filterCondition = {},
|
||||
}: UseEntitySearchProps) {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [results, setResults] = useState<EntitySearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pagination, setPagination] = useState({
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
// searchFields와 filterCondition을 ref로 관리하여 useCallback 의존성 문제 해결
|
||||
const searchFieldsRef = useRef(searchFields);
|
||||
const filterConditionRef = useRef(filterCondition);
|
||||
|
||||
useEffect(() => {
|
||||
searchFieldsRef.current = searchFields;
|
||||
filterConditionRef.current = filterCondition;
|
||||
}, [searchFields, filterCondition]);
|
||||
|
||||
const search = useCallback(
|
||||
async (text: string, page: number = 1) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
searchText: text,
|
||||
searchFields: searchFieldsRef.current.join(","),
|
||||
filterCondition: JSON.stringify(filterConditionRef.current),
|
||||
page: page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
});
|
||||
|
||||
const response = await apiClient.get<EntitySearchResponse>(
|
||||
`/entity-search/${tableName}?${params.toString()}`
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
setResults(response.data.data);
|
||||
if (response.data.pagination) {
|
||||
setPagination(response.data.pagination);
|
||||
}
|
||||
} else {
|
||||
setError(response.data.error || "검색에 실패했습니다");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Entity search error:", err);
|
||||
setError(err.response?.data?.message || "검색 중 오류가 발생했습니다");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[tableName, pagination.limit]
|
||||
);
|
||||
|
||||
// 디바운스된 검색
|
||||
useEffect(() => {
|
||||
// searchText가 명시적으로 설정되지 않은 경우(null/undefined)만 건너뛰기
|
||||
if (searchText === null || searchText === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
// 빈 문자열("")도 검색 (전체 목록 조회)
|
||||
search(searchText.trim(), 1);
|
||||
}, 300); // 300ms 디바운스
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchText, search]);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setSearchText("");
|
||||
setResults([]);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (pagination.page * pagination.limit < pagination.total) {
|
||||
search(searchText, pagination.page + 1);
|
||||
}
|
||||
}, [search, searchText, pagination]);
|
||||
|
||||
return {
|
||||
searchText,
|
||||
setSearchText,
|
||||
results,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
search,
|
||||
clearSearch,
|
||||
loadMore,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +45,16 @@ import "./category-manager/CategoryManagerRenderer";
|
|||
import "./table-search-widget"; // 🆕 테이블 검색 필터 위젯
|
||||
import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처별 품목정보
|
||||
|
||||
// 🆕 수주 등록 관련 컴포넌트들
|
||||
import { AutocompleteSearchInputRenderer } from "./autocomplete-search-input/AutocompleteSearchInputRenderer";
|
||||
import { EntitySearchInputRenderer } from "./entity-search-input/EntitySearchInputRenderer";
|
||||
import { ModalRepeaterTableRenderer } from "./modal-repeater-table/ModalRepeaterTableRenderer";
|
||||
import "./order-registration-modal/OrderRegistrationModalRenderer";
|
||||
|
||||
// 🆕 조건부 컨테이너 컴포넌트
|
||||
import "./conditional-container/ConditionalContainerRenderer";
|
||||
import "./selected-items-detail-input/SelectedItemsDetailInputRenderer";
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Search, Loader2 } from "lucide-react";
|
||||
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
|
||||
import { ItemSelectionModalProps } from "./types";
|
||||
|
||||
export function ItemSelectionModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
sourceTable,
|
||||
sourceColumns,
|
||||
sourceSearchFields = [],
|
||||
multiSelect = true,
|
||||
filterCondition = {},
|
||||
modalTitle,
|
||||
alreadySelected = [],
|
||||
uniqueField,
|
||||
onSelect,
|
||||
columnLabels = {},
|
||||
}: ItemSelectionModalProps) {
|
||||
const [localSearchText, setLocalSearchText] = useState("");
|
||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||
|
||||
const { results, loading, error, search, clearSearch } = useEntitySearch({
|
||||
tableName: sourceTable,
|
||||
searchFields: sourceSearchFields.length > 0 ? sourceSearchFields : sourceColumns,
|
||||
filterCondition,
|
||||
});
|
||||
|
||||
// 모달 열릴 때 초기 검색
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
search("", 1); // 빈 검색어로 전체 목록 조회
|
||||
setSelectedItems([]);
|
||||
} else {
|
||||
clearSearch();
|
||||
setLocalSearchText("");
|
||||
setSelectedItems([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSearch = () => {
|
||||
search(localSearchText, 1);
|
||||
};
|
||||
|
||||
const handleToggleItem = (item: any) => {
|
||||
if (!multiSelect) {
|
||||
setSelectedItems([item]);
|
||||
return;
|
||||
}
|
||||
|
||||
const isSelected = selectedItems.some((selected) =>
|
||||
uniqueField
|
||||
? selected[uniqueField] === item[uniqueField]
|
||||
: selected === item
|
||||
);
|
||||
|
||||
if (isSelected) {
|
||||
setSelectedItems(
|
||||
selectedItems.filter((selected) =>
|
||||
uniqueField
|
||||
? selected[uniqueField] !== item[uniqueField]
|
||||
: selected !== item
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setSelectedItems([...selectedItems, item]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onSelect(selectedItems);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 이미 추가된 항목인지 확인
|
||||
const isAlreadyAdded = (item: any): boolean => {
|
||||
if (!uniqueField) return false;
|
||||
return alreadySelected.some(
|
||||
(selected) => selected[uniqueField] === item[uniqueField]
|
||||
);
|
||||
};
|
||||
|
||||
// 이미 추가된 항목 제외한 결과 필터링
|
||||
const filteredResults = results.filter((item) => !isAlreadyAdded(item));
|
||||
|
||||
// 선택된 항목인지 확인
|
||||
const isSelected = (item: any): boolean => {
|
||||
return selectedItems.some((selected) =>
|
||||
uniqueField
|
||||
? selected[uniqueField] === item[uniqueField]
|
||||
: selected === item
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{modalTitle}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
항목을 검색하고 선택하세요
|
||||
{multiSelect && " (다중 선택 가능)"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="검색어를 입력하세요"
|
||||
value={localSearchText}
|
||||
onChange={(e) => setLocalSearchText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={loading}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-2">검색</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 선택된 항목 수 */}
|
||||
{selectedItems.length > 0 && (
|
||||
<div className="text-sm text-primary">
|
||||
{selectedItems.length}개 항목 선택됨
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 결과 테이블 */}
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
{multiSelect && (
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
||||
선택
|
||||
</th>
|
||||
)}
|
||||
{sourceColumns.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||||
>
|
||||
{columnLabels[col] || col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && filteredResults.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={sourceColumns.length + (multiSelect ? 1 : 0)}
|
||||
className="px-4 py-8 text-center"
|
||||
>
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||
<p className="mt-2 text-muted-foreground">검색 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredResults.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={sourceColumns.length + (multiSelect ? 1 : 0)}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{results.length > 0
|
||||
? "모든 항목이 이미 추가되었습니다"
|
||||
: "검색 결과가 없습니다"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredResults.map((item, index) => {
|
||||
const selected = isSelected(item);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={`border-t transition-colors ${
|
||||
selected
|
||||
? "bg-primary/10"
|
||||
: "hover:bg-accent cursor-pointer"
|
||||
}`}
|
||||
onClick={() => handleToggleItem(item)}
|
||||
>
|
||||
{multiSelect && (
|
||||
<td className="px-4 py-2">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={() => handleToggleItem(item)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{sourceColumns.map((col) => (
|
||||
<td key={col} className="px-4 py-2">
|
||||
{item[col] || "-"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedItems.length === 0}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
추가 ({selectedItems.length})
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { ItemSelectionModal } from "./ItemSelectionModal";
|
||||
import { RepeaterTable } from "./RepeaterTable";
|
||||
import { ModalRepeaterTableProps } from "./types";
|
||||
import { useCalculation } from "./useCalculation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModalRepeaterTableComponentProps extends Partial<ModalRepeaterTableProps> {
|
||||
config?: ModalRepeaterTableProps;
|
||||
}
|
||||
|
||||
export function ModalRepeaterTableComponent({
|
||||
config,
|
||||
sourceTable: propSourceTable,
|
||||
sourceColumns: propSourceColumns,
|
||||
sourceSearchFields: propSourceSearchFields,
|
||||
modalTitle: propModalTitle,
|
||||
modalButtonText: propModalButtonText,
|
||||
multiSelect: propMultiSelect,
|
||||
columns: propColumns,
|
||||
calculationRules: propCalculationRules,
|
||||
value: propValue,
|
||||
onChange: propOnChange,
|
||||
uniqueField: propUniqueField,
|
||||
filterCondition: propFilterCondition,
|
||||
companyCode: propCompanyCode,
|
||||
className,
|
||||
}: ModalRepeaterTableComponentProps) {
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const sourceTable = config?.sourceTable || propSourceTable || "";
|
||||
const sourceColumns = config?.sourceColumns || propSourceColumns || [];
|
||||
const sourceSearchFields = config?.sourceSearchFields || propSourceSearchFields || [];
|
||||
const modalTitle = config?.modalTitle || propModalTitle || "항목 검색";
|
||||
const modalButtonText = config?.modalButtonText || propModalButtonText || "품목 검색";
|
||||
const multiSelect = config?.multiSelect ?? propMultiSelect ?? true;
|
||||
const columns = config?.columns || propColumns || [];
|
||||
const calculationRules = config?.calculationRules || propCalculationRules || [];
|
||||
const value = config?.value || propValue || [];
|
||||
const onChange = config?.onChange || propOnChange || (() => {});
|
||||
const uniqueField = config?.uniqueField || propUniqueField;
|
||||
const filterCondition = config?.filterCondition || propFilterCondition || {};
|
||||
const companyCode = config?.companyCode || propCompanyCode;
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const { calculateRow, calculateAll } = useCalculation(calculationRules);
|
||||
|
||||
// 초기 데이터에 계산 필드 적용
|
||||
useEffect(() => {
|
||||
if (value.length > 0 && calculationRules.length > 0) {
|
||||
const calculated = calculateAll(value);
|
||||
// 값이 실제로 변경된 경우만 업데이트
|
||||
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
|
||||
onChange(calculated);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAddItems = (items: any[]) => {
|
||||
// 기본값 적용
|
||||
const itemsWithDefaults = items.map((item) => {
|
||||
const newItem = { ...item };
|
||||
columns.forEach((col) => {
|
||||
if (col.defaultValue !== undefined && newItem[col.field] === undefined) {
|
||||
newItem[col.field] = col.defaultValue;
|
||||
}
|
||||
});
|
||||
return newItem;
|
||||
});
|
||||
|
||||
// 계산 필드 업데이트
|
||||
const calculatedItems = calculateAll(itemsWithDefaults);
|
||||
|
||||
// 기존 데이터에 추가
|
||||
onChange([...value, ...calculatedItems]);
|
||||
};
|
||||
|
||||
const handleRowChange = (index: number, newRow: any) => {
|
||||
// 계산 필드 업데이트
|
||||
const calculatedRow = calculateRow(newRow);
|
||||
|
||||
// 데이터 업데이트
|
||||
const newData = [...value];
|
||||
newData[index] = calculatedRow;
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
const handleRowDelete = (index: number) => {
|
||||
const newData = value.filter((_, i) => i !== index);
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
// 컬럼명 -> 라벨명 매핑 생성
|
||||
const columnLabels = columns.reduce((acc, col) => {
|
||||
acc[col.field] = col.label;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* 추가 버튼 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{value.length > 0 && `${value.length}개 항목`}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{modalButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Repeater 테이블 */}
|
||||
<RepeaterTable
|
||||
columns={columns}
|
||||
data={value}
|
||||
onDataChange={onChange}
|
||||
onRowChange={handleRowChange}
|
||||
onRowDelete={handleRowDelete}
|
||||
/>
|
||||
|
||||
{/* 항목 선택 모달 */}
|
||||
<ItemSelectionModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
sourceTable={sourceTable}
|
||||
sourceColumns={sourceColumns}
|
||||
sourceSearchFields={sourceSearchFields}
|
||||
multiSelect={multiSelect}
|
||||
filterCondition={filterCondition}
|
||||
modalTitle={modalTitle}
|
||||
alreadySelected={value}
|
||||
uniqueField={uniqueField}
|
||||
onSelect={handleAddItems}
|
||||
columnLabels={columnLabels}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
"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";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { ModalRepeaterTableProps } from "./types";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModalRepeaterTableConfigPanelProps {
|
||||
config: Partial<ModalRepeaterTableProps>;
|
||||
onConfigChange: (config: Partial<ModalRepeaterTableProps>) => void;
|
||||
}
|
||||
|
||||
export function ModalRepeaterTableConfigPanel({
|
||||
config,
|
||||
onConfigChange,
|
||||
}: ModalRepeaterTableConfigPanelProps) {
|
||||
const [localConfig, setLocalConfig] = useState(config);
|
||||
const [allTables, setAllTables] = useState<any[]>([]);
|
||||
const [tableColumns, setTableColumns] = useState<any[]>([]);
|
||||
const [isLoadingTables, setIsLoadingTables] = useState(false);
|
||||
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
|
||||
const [openTableCombo, setOpenTableCombo] = useState(false);
|
||||
const [openUniqueFieldCombo, setOpenUniqueFieldCombo] = useState(false);
|
||||
|
||||
// 전체 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setIsLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!localConfig.sourceTable) {
|
||||
setTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingColumns(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(localConfig.sourceTable);
|
||||
if (response.success && response.data) {
|
||||
setTableColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
setTableColumns([]);
|
||||
} finally {
|
||||
setIsLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [localConfig.sourceTable]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalConfig(config);
|
||||
}, [config]);
|
||||
|
||||
const updateConfig = (updates: Partial<ModalRepeaterTableProps>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
||||
const addSourceColumn = () => {
|
||||
const columns = localConfig.sourceColumns || [];
|
||||
updateConfig({ sourceColumns: [...columns, ""] });
|
||||
};
|
||||
|
||||
const updateSourceColumn = (index: number, value: string) => {
|
||||
const columns = [...(localConfig.sourceColumns || [])];
|
||||
columns[index] = value;
|
||||
updateConfig({ sourceColumns: columns });
|
||||
};
|
||||
|
||||
const removeSourceColumn = (index: number) => {
|
||||
const columns = [...(localConfig.sourceColumns || [])];
|
||||
columns.splice(index, 1);
|
||||
updateConfig({ sourceColumns: columns });
|
||||
};
|
||||
|
||||
const addSearchField = () => {
|
||||
const fields = localConfig.sourceSearchFields || [];
|
||||
updateConfig({ sourceSearchFields: [...fields, ""] });
|
||||
};
|
||||
|
||||
const updateSearchField = (index: number, value: string) => {
|
||||
const fields = [...(localConfig.sourceSearchFields || [])];
|
||||
fields[index] = value;
|
||||
updateConfig({ sourceSearchFields: fields });
|
||||
};
|
||||
|
||||
const removeSearchField = (index: number) => {
|
||||
const fields = [...(localConfig.sourceSearchFields || [])];
|
||||
fields.splice(index, 1);
|
||||
updateConfig({ sourceSearchFields: fields });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">소스 테이블 *</Label>
|
||||
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openTableCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={isLoadingTables}
|
||||
>
|
||||
{localConfig.sourceTable
|
||||
? allTables.find((t) => t.tableName === localConfig.sourceTable)?.displayName || localConfig.sourceTable
|
||||
: isLoadingTables ? "로딩 중..." : "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={() => {
|
||||
updateConfig({ sourceTable: table.tableName });
|
||||
setOpenTableCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", localConfig.sourceTable === table.tableName ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||
{table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">모달 제목</Label>
|
||||
<Input
|
||||
value={localConfig.modalTitle || ""}
|
||||
onChange={(e) => updateConfig({ modalTitle: 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>
|
||||
<Input
|
||||
value={localConfig.modalButtonText || ""}
|
||||
onChange={(e) => updateConfig({ modalButtonText: 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>
|
||||
<Popover open={openUniqueFieldCombo} onOpenChange={setOpenUniqueFieldCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openUniqueFieldCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={!localConfig.sourceTable || isLoadingColumns}
|
||||
>
|
||||
{localConfig.uniqueField
|
||||
? tableColumns.find((c) => c.columnName === localConfig.uniqueField)?.displayName || localConfig.uniqueField
|
||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
updateConfig({ uniqueField: column.columnName });
|
||||
setOpenUniqueFieldCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", localConfig.uniqueField === column.columnName ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
||||
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">다중 선택</Label>
|
||||
<Switch
|
||||
checked={localConfig.multiSelect ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ multiSelect: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">소스 컬럼</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addSourceColumn}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.sourceTable || isLoadingColumns}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(localConfig.sourceColumns || []).map((column, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={column}
|
||||
onValueChange={(value) => updateSourceColumn(index, value)}
|
||||
disabled={!localConfig.sourceTable || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeSourceColumn(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">검색 필드</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addSearchField}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.sourceTable || isLoadingColumns}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(localConfig.sourceSearchFields || []).map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={field}
|
||||
onValueChange={(value) => updateSearchField(index, value)}
|
||||
disabled={!localConfig.sourceTable || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeSearchField(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import { ModalRepeaterTableDefinition } from "./index";
|
||||
|
||||
export function ModalRepeaterTableRenderer() {
|
||||
useEffect(() => {
|
||||
ComponentRegistry.registerComponent(ModalRepeaterTableDefinition);
|
||||
console.log("✅ ModalRepeaterTable 컴포넌트 등록 완료");
|
||||
|
||||
return () => {
|
||||
// 컴포넌트 언마운트 시 해제하지 않음 (싱글톤 패턴)
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { RepeaterColumnConfig } from "./types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RepeaterTableProps {
|
||||
columns: RepeaterColumnConfig[];
|
||||
data: any[];
|
||||
onDataChange: (newData: any[]) => void;
|
||||
onRowChange: (index: number, newRow: any) => void;
|
||||
onRowDelete: (index: number) => void;
|
||||
}
|
||||
|
||||
export function RepeaterTable({
|
||||
columns,
|
||||
data,
|
||||
onDataChange,
|
||||
onRowChange,
|
||||
onRowDelete,
|
||||
}: RepeaterTableProps) {
|
||||
const [editingCell, setEditingCell] = useState<{
|
||||
rowIndex: number;
|
||||
field: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleCellEdit = (rowIndex: number, field: string, value: any) => {
|
||||
const newRow = { ...data[rowIndex], [field]: value };
|
||||
onRowChange(rowIndex, newRow);
|
||||
};
|
||||
|
||||
const renderCell = (
|
||||
row: any,
|
||||
column: RepeaterColumnConfig,
|
||||
rowIndex: number
|
||||
) => {
|
||||
const isEditing =
|
||||
editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
||||
const value = row[column.field];
|
||||
|
||||
// 계산 필드는 편집 불가
|
||||
if (column.calculated || !column.editable) {
|
||||
return (
|
||||
<div className="px-2 py-1">
|
||||
{column.type === "number"
|
||||
? typeof value === "number"
|
||||
? value.toLocaleString()
|
||||
: value || "0"
|
||||
: value || "-"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 편집 가능한 필드
|
||||
switch (column.type) {
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value || ""}
|
||||
onChange={(e) =>
|
||||
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={value || ""}
|
||||
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={value || ""}
|
||||
onValueChange={(newValue) =>
|
||||
handleCellEdit(rowIndex, column.field, newValue)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{column.selectOptions?.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
default: // text
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value || ""}
|
||||
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
||||
#
|
||||
</th>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.field}
|
||||
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
{col.label}
|
||||
{col.required && <span className="text-destructive ml-1">*</span>}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
|
||||
삭제
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + 2}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
추가된 항목이 없습니다
|
||||
</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">
|
||||
{rowIndex + 1}
|
||||
</td>
|
||||
{columns.map((col) => (
|
||||
<td key={col.field} className="px-2 py-1">
|
||||
{renderCell(row, col, rowIndex)}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-2 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRowDelete(rowIndex)}
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent";
|
||||
import { ModalRepeaterTableConfigPanel } from "./ModalRepeaterTableConfigPanel";
|
||||
|
||||
/**
|
||||
* ModalRepeaterTable 컴포넌트 정의
|
||||
* 모달 검색 + 동적 테이블 (반복기)
|
||||
*/
|
||||
export const ModalRepeaterTableDefinition = createComponentDefinition({
|
||||
id: "modal-repeater-table",
|
||||
name: "모달 반복 테이블",
|
||||
nameEng: "Modal Repeater Table",
|
||||
description: "모달에서 항목을 검색하여 동적으로 추가/편집할 수 있는 테이블 (수주 품목 등)",
|
||||
category: ComponentCategory.DATA,
|
||||
webType: "table",
|
||||
component: ModalRepeaterTableComponent,
|
||||
defaultConfig: {
|
||||
sourceTable: "item_info",
|
||||
sourceColumns: ["item_code", "item_name", "size", "unit_price"],
|
||||
sourceSearchFields: ["item_code", "item_name"],
|
||||
modalTitle: "항목 검색 및 선택",
|
||||
modalButtonText: "항목 검색",
|
||||
multiSelect: true,
|
||||
columns: [],
|
||||
uniqueField: "item_code",
|
||||
},
|
||||
defaultSize: { width: 800, height: 400 },
|
||||
configPanel: ModalRepeaterTableConfigPanel,
|
||||
icon: "Table",
|
||||
tags: ["테이블", "반복", "동적", "모달", "수주", "품목"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type {
|
||||
ModalRepeaterTableProps,
|
||||
RepeaterColumnConfig,
|
||||
CalculationRule,
|
||||
ItemSelectionModalProps,
|
||||
} from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent";
|
||||
export { ModalRepeaterTableRenderer } from "./ModalRepeaterTableRenderer";
|
||||
export { ItemSelectionModal } from "./ItemSelectionModal";
|
||||
export { RepeaterTable } from "./RepeaterTable";
|
||||
export { useCalculation } from "./useCalculation";
|
||||
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* ModalRepeaterTable 컴포넌트 타입 정의
|
||||
* 모달에서 데이터를 검색하여 선택하고, Repeater 테이블에 추가하는 컴포넌트
|
||||
*/
|
||||
|
||||
export interface ModalRepeaterTableProps {
|
||||
// 소스 데이터 (모달에서 가져올 데이터)
|
||||
sourceTable: string; // 검색할 테이블 (예: "item_info")
|
||||
sourceColumns: string[]; // 모달에 표시할 컬럼들
|
||||
sourceSearchFields?: string[]; // 검색 가능한 필드들
|
||||
|
||||
// 모달 설정
|
||||
modalTitle: string; // 모달 제목 (예: "품목 검색 및 선택")
|
||||
modalButtonText?: string; // 모달 열기 버튼 텍스트 (기본: "품목 검색")
|
||||
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
|
||||
|
||||
// Repeater 테이블 설정
|
||||
columns: RepeaterColumnConfig[]; // 테이블 컬럼 설정
|
||||
|
||||
// 계산 규칙
|
||||
calculationRules?: CalculationRule[]; // 자동 계산 규칙
|
||||
|
||||
// 데이터
|
||||
value: any[]; // 현재 추가된 항목들
|
||||
onChange: (newData: any[]) => void; // 데이터 변경 콜백
|
||||
|
||||
// 중복 체크
|
||||
uniqueField?: string; // 중복 체크할 필드 (예: "item_code")
|
||||
|
||||
// 필터링
|
||||
filterCondition?: Record<string, any>;
|
||||
companyCode?: string;
|
||||
|
||||
// 스타일
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface RepeaterColumnConfig {
|
||||
field: string; // 필드명
|
||||
label: string; // 컬럼 헤더 라벨
|
||||
type?: "text" | "number" | "date" | "select"; // 입력 타입
|
||||
editable?: boolean; // 편집 가능 여부
|
||||
calculated?: boolean; // 계산 필드 여부
|
||||
width?: string; // 컬럼 너비
|
||||
required?: boolean; // 필수 입력 여부
|
||||
defaultValue?: any; // 기본값
|
||||
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
|
||||
}
|
||||
|
||||
export interface CalculationRule {
|
||||
result: string; // 결과를 저장할 필드
|
||||
formula: string; // 계산 공식 (예: "quantity * unit_price")
|
||||
dependencies: string[]; // 의존하는 필드들
|
||||
}
|
||||
|
||||
export interface ItemSelectionModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sourceTable: string;
|
||||
sourceColumns: string[];
|
||||
sourceSearchFields?: string[];
|
||||
multiSelect?: boolean;
|
||||
filterCondition?: Record<string, any>;
|
||||
modalTitle: string;
|
||||
alreadySelected: any[]; // 이미 선택된 항목들 (중복 방지용)
|
||||
uniqueField?: string;
|
||||
onSelect: (items: any[]) => void;
|
||||
columnLabels?: Record<string, string>; // 컬럼명 -> 라벨명 매핑
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { useCallback } from "react";
|
||||
import { CalculationRule } from "./types";
|
||||
|
||||
/**
|
||||
* 계산 필드 자동 업데이트 훅
|
||||
*/
|
||||
export function useCalculation(calculationRules: CalculationRule[] = []) {
|
||||
/**
|
||||
* 단일 행의 계산 필드 업데이트
|
||||
*/
|
||||
const calculateRow = useCallback(
|
||||
(row: any): any => {
|
||||
if (calculationRules.length === 0) return row;
|
||||
|
||||
const updatedRow = { ...row };
|
||||
|
||||
for (const rule of calculationRules) {
|
||||
try {
|
||||
// formula에서 필드명 추출 및 값으로 대체
|
||||
let formula = rule.formula;
|
||||
|
||||
for (const dep of rule.dependencies) {
|
||||
const value = parseFloat(row[dep]) || 0;
|
||||
formula = formula.replace(new RegExp(dep, "g"), value.toString());
|
||||
}
|
||||
|
||||
// 계산 실행 (eval 대신 Function 사용)
|
||||
const result = new Function(`return ${formula}`)();
|
||||
updatedRow[rule.result] = result;
|
||||
} catch (error) {
|
||||
console.error(`계산 오류 (${rule.formula}):`, error);
|
||||
updatedRow[rule.result] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRow;
|
||||
},
|
||||
[calculationRules]
|
||||
);
|
||||
|
||||
/**
|
||||
* 전체 데이터의 계산 필드 업데이트
|
||||
*/
|
||||
const calculateAll = useCallback(
|
||||
(data: any[]): any[] => {
|
||||
return data.map((row) => calculateRow(row));
|
||||
},
|
||||
[calculateRow]
|
||||
);
|
||||
|
||||
return {
|
||||
calculateRow,
|
||||
calculateAll,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"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);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* 수주등록 모달 컴포넌트
|
||||
* 거래처 검색, 품목 선택, 수주 정보 입력을 한 번에 처리하는 모달
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
# SelectedItemsDetailInput 컴포넌트
|
||||
|
||||
선택된 항목들의 상세 정보를 입력하는 컴포넌트입니다.
|
||||
|
||||
## 개요
|
||||
|
||||
이 컴포넌트는 다음과 같은 흐름에서 사용됩니다:
|
||||
|
||||
1. **첫 번째 모달**: TableList에서 여러 항목 선택 (체크박스)
|
||||
2. **버튼 클릭**: "다음" 버튼 클릭 → 선택된 데이터를 modalDataStore에 저장
|
||||
3. **두 번째 모달**: SelectedItemsDetailInput이 자동으로 데이터를 읽어와서 표시
|
||||
4. **추가 입력**: 각 항목별로 추가 정보 입력 (거래처 품번, 단가 등)
|
||||
5. **저장**: 모든 데이터를 백엔드로 일괄 전송
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- ✅ 전달받은 원본 데이터 표시 (읽기 전용)
|
||||
- ✅ 각 항목별 추가 입력 필드 제공
|
||||
- ✅ Grid/Table 레이아웃 또는 Card 레이아웃 지원
|
||||
- ✅ 필드별 타입 지정 (text, number, date, select, checkbox, textarea)
|
||||
- ✅ 필수 입력 검증
|
||||
- ✅ 항목 삭제 기능 (선택적)
|
||||
|
||||
## 사용 방법
|
||||
|
||||
### 1단계: 첫 번째 모달 (품목 선택)
|
||||
|
||||
```tsx
|
||||
// TableList 컴포넌트 설정
|
||||
{
|
||||
type: "table-list",
|
||||
config: {
|
||||
selectedTable: "item_info",
|
||||
multiSelect: true, // 다중 선택 활성화
|
||||
columns: [
|
||||
{ columnName: "item_code", label: "품목코드" },
|
||||
{ columnName: "item_name", label: "품목명" },
|
||||
{ columnName: "spec", label: "규격" },
|
||||
{ columnName: "unit", label: "단위" },
|
||||
{ columnName: "price", label: "단가" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// "다음" 버튼 설정
|
||||
{
|
||||
type: "button-primary",
|
||||
config: {
|
||||
text: "다음 (상세정보 입력)",
|
||||
action: {
|
||||
type: "openModalWithData", // 새 액션 타입
|
||||
targetScreenId: "123", // 두 번째 모달 화면 ID
|
||||
dataSourceId: "table-list-456" // TableList 컴포넌트 ID
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2단계: 두 번째 모달 (상세 입력)
|
||||
|
||||
```tsx
|
||||
// SelectedItemsDetailInput 컴포넌트 설정
|
||||
{
|
||||
type: "selected-items-detail-input",
|
||||
config: {
|
||||
dataSourceId: "table-list-456", // 첫 번째 모달의 TableList ID
|
||||
targetTable: "sales_detail", // 최종 저장 테이블
|
||||
layout: "grid", // 테이블 형식
|
||||
|
||||
// 전달받은 원본 데이터 중 표시할 컬럼
|
||||
displayColumns: ["item_code", "item_name", "spec", "unit"],
|
||||
|
||||
// 추가 입력 필드 정의
|
||||
additionalFields: [
|
||||
{
|
||||
name: "customer_item_code",
|
||||
label: "거래처 품번",
|
||||
type: "text",
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: "customer_item_name",
|
||||
label: "거래처 품명",
|
||||
type: "text",
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: "year",
|
||||
label: "연도",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "2024", label: "2024년" },
|
||||
{ value: "2025", label: "2025년" }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "currency",
|
||||
label: "통화단위",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "KRW", label: "KRW (원)" },
|
||||
{ value: "USD", label: "USD (달러)" }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "unit_price",
|
||||
label: "단가",
|
||||
type: "number",
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: "quantity",
|
||||
label: "수량",
|
||||
type: "number",
|
||||
required: true
|
||||
}
|
||||
],
|
||||
|
||||
showIndex: true,
|
||||
allowRemove: true // 항목 삭제 허용
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3단계: 저장 버튼
|
||||
|
||||
```tsx
|
||||
{
|
||||
type: "button-primary",
|
||||
config: {
|
||||
text: "저장",
|
||||
action: {
|
||||
type: "save",
|
||||
targetTable: "sales_detail",
|
||||
// formData에 selected_items 데이터가 자동으로 포함됨
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 데이터 구조
|
||||
|
||||
### 전달되는 데이터 형식
|
||||
|
||||
```typescript
|
||||
const modalData: ModalDataItem[] = [
|
||||
{
|
||||
id: "SALE-003", // 항목 ID
|
||||
originalData: { // 원본 데이터 (TableList에서 선택한 행)
|
||||
item_code: "SALE-003",
|
||||
item_name: "와셔 M8",
|
||||
spec: "M8",
|
||||
unit: "EA",
|
||||
price: 50
|
||||
},
|
||||
additionalData: { // 사용자가 입력한 추가 데이터
|
||||
customer_item_code: "ABC-001",
|
||||
customer_item_name: "와셔",
|
||||
year: "2025",
|
||||
currency: "KRW",
|
||||
unit_price: 50,
|
||||
quantity: 100
|
||||
}
|
||||
},
|
||||
// ... 더 많은 항목들
|
||||
];
|
||||
```
|
||||
|
||||
## 설정 옵션
|
||||
|
||||
| 속성 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `dataSourceId` | string | - | 데이터를 전달하는 컴포넌트 ID (필수) |
|
||||
| `displayColumns` | string[] | [] | 표시할 원본 데이터 컬럼명 |
|
||||
| `additionalFields` | AdditionalFieldDefinition[] | [] | 추가 입력 필드 정의 |
|
||||
| `targetTable` | string | - | 최종 저장 대상 테이블 |
|
||||
| `layout` | "grid" \| "card" | "grid" | 레이아웃 모드 |
|
||||
| `showIndex` | boolean | true | 항목 번호 표시 여부 |
|
||||
| `allowRemove` | boolean | false | 항목 삭제 허용 여부 |
|
||||
| `emptyMessage` | string | "전달받은 데이터가 없습니다." | 빈 상태 메시지 |
|
||||
| `disabled` | boolean | false | 비활성화 여부 |
|
||||
| `readonly` | boolean | false | 읽기 전용 여부 |
|
||||
|
||||
## 추가 필드 정의
|
||||
|
||||
```typescript
|
||||
interface AdditionalFieldDefinition {
|
||||
name: string; // 필드명 (컬럼명)
|
||||
label: string; // 필드 라벨
|
||||
type: "text" | "number" | "date" | "select" | "checkbox" | "textarea";
|
||||
required?: boolean; // 필수 입력 여부
|
||||
placeholder?: string; // 플레이스홀더
|
||||
defaultValue?: any; // 기본값
|
||||
options?: Array<{ label: string; value: string }>; // 선택 옵션 (select 타입일 때)
|
||||
validation?: { // 검증 규칙
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 실전 예시: 수주 등록 화면
|
||||
|
||||
### 시나리오
|
||||
1. 품목 선택 모달에서 여러 품목 선택
|
||||
2. "다음" 버튼 클릭
|
||||
3. 각 품목별로 거래처 정보, 단가, 수량 입력
|
||||
4. "저장" 버튼으로 일괄 저장
|
||||
|
||||
### 구현
|
||||
```tsx
|
||||
// [모달 1] 품목 선택
|
||||
<TableList id="item-selection-table" multiSelect={true} />
|
||||
<Button action="openModalWithData" targetScreenId="detail-input-modal" dataSourceId="item-selection-table" />
|
||||
|
||||
// [모달 2] 상세 입력
|
||||
<SelectedItemsDetailInput
|
||||
dataSourceId="item-selection-table"
|
||||
displayColumns={["item_code", "item_name", "spec"]}
|
||||
additionalFields={[
|
||||
{ name: "customer_item_code", label: "거래처 품번", type: "text" },
|
||||
{ name: "unit_price", label: "단가", type: "number", required: true },
|
||||
{ name: "quantity", label: "수량", type: "number", required: true }
|
||||
]}
|
||||
targetTable="sales_detail"
|
||||
/>
|
||||
<Button action="save" />
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **dataSourceId 일치**: 첫 번째 모달의 TableList ID와 두 번째 모달의 dataSourceId가 정확히 일치해야 합니다.
|
||||
2. **컬럼명 정확성**: displayColumns와 additionalFields의 name은 실제 데이터베이스 컬럼명과 일치해야 합니다.
|
||||
3. **필수 필드 검증**: required=true인 필드는 반드시 입력해야 저장이 가능합니다.
|
||||
4. **데이터 정리**: 모달이 닫힐 때 modalDataStore의 데이터가 자동으로 정리됩니다.
|
||||
|
||||
## 향후 개선 사항
|
||||
|
||||
- [ ] 일괄 수정 기능 (모든 항목에 같은 값 적용)
|
||||
- [ ] 엑셀 업로드로 일괄 입력
|
||||
- [ ] 조건부 필드 표시 (특정 조건에서만 필드 활성화)
|
||||
- [ ] 커스텀 검증 규칙
|
||||
- [ ] 실시간 계산 필드 (단가 × 수량 = 금액)
|
||||
|
|
@ -0,0 +1,396 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition } from "./types";
|
||||
import { useModalDataStore, ModalDataItem } from "@/stores/modalDataStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SelectedItemsDetailInputComponentProps extends ComponentRendererProps {
|
||||
config?: SelectedItemsDetailInputConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 컴포넌트
|
||||
* 선택된 항목들의 상세 정보를 입력하는 컴포넌트
|
||||
*/
|
||||
export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInputComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
screenId,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = useMemo(() => ({
|
||||
dataSourceId: component.id || "default",
|
||||
displayColumns: [],
|
||||
additionalFields: [],
|
||||
layout: "grid",
|
||||
showIndex: true,
|
||||
allowRemove: false,
|
||||
emptyMessage: "전달받은 데이터가 없습니다.",
|
||||
targetTable: "",
|
||||
...config,
|
||||
...component.config,
|
||||
} as SelectedItemsDetailInputConfig), [config, component.config, component.id]);
|
||||
|
||||
// 모달 데이터 스토어에서 데이터 가져오기
|
||||
// dataSourceId를 안정적으로 유지
|
||||
const dataSourceId = useMemo(
|
||||
() => componentConfig.dataSourceId || component.id || "default",
|
||||
[componentConfig.dataSourceId, component.id]
|
||||
);
|
||||
|
||||
// 전체 레지스트리를 가져와서 컴포넌트 내부에서 필터링 (캐싱 문제 회피)
|
||||
const dataRegistry = useModalDataStore((state) => state.dataRegistry);
|
||||
const modalData = useMemo(
|
||||
() => dataRegistry[dataSourceId] || [],
|
||||
[dataRegistry, dataSourceId]
|
||||
);
|
||||
|
||||
const updateItemData = useModalDataStore((state) => state.updateItemData);
|
||||
|
||||
// 로컬 상태로 데이터 관리
|
||||
const [items, setItems] = useState<ModalDataItem[]>([]);
|
||||
|
||||
// 모달 데이터가 변경되면 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
if (modalData && modalData.length > 0) {
|
||||
console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", modalData);
|
||||
setItems(modalData);
|
||||
|
||||
// formData에도 반영 (초기 로드 시에만)
|
||||
if (onFormDataChange && items.length === 0) {
|
||||
onFormDataChange({ [component.id || "selected_items"]: modalData });
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modalData, component.id]); // onFormDataChange는 의존성에서 제외
|
||||
|
||||
// 스타일 계산
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
componentStyle.padding = "16px";
|
||||
componentStyle.borderRadius = "8px";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
// 필드 값 변경 핸들러
|
||||
const handleFieldChange = useCallback((itemId: string | number, fieldName: string, value: any) => {
|
||||
// 상태 업데이트
|
||||
setItems((prevItems) => {
|
||||
const updatedItems = prevItems.map((item) =>
|
||||
item.id === itemId
|
||||
? {
|
||||
...item,
|
||||
additionalData: {
|
||||
...item.additionalData,
|
||||
[fieldName]: value,
|
||||
},
|
||||
}
|
||||
: item
|
||||
);
|
||||
|
||||
// formData에도 반영 (디바운스 없이 즉시 반영)
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange({ [component.id || "selected_items"]: updatedItems });
|
||||
}
|
||||
|
||||
return updatedItems;
|
||||
});
|
||||
|
||||
// 스토어에도 업데이트
|
||||
updateItemData(dataSourceId, itemId, { [fieldName]: value });
|
||||
}, [dataSourceId, updateItemData, onFormDataChange, component.id]);
|
||||
|
||||
// 항목 제거 핸들러
|
||||
const handleRemoveItem = (itemId: string | number) => {
|
||||
setItems((prevItems) => prevItems.filter((item) => item.id !== itemId));
|
||||
};
|
||||
|
||||
// 개별 필드 렌더링
|
||||
const renderField = (field: AdditionalFieldDefinition, item: ModalDataItem) => {
|
||||
const value = item.additionalData?.[field.name] || field.defaultValue || "";
|
||||
|
||||
const commonProps = {
|
||||
value: value || "",
|
||||
disabled: componentConfig.disabled || componentConfig.readonly,
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
};
|
||||
|
||||
switch (field.type) {
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={value || ""}
|
||||
onValueChange={(val) => handleFieldChange(item.id, field.name, val)}
|
||||
disabled={componentConfig.disabled || componentConfig.readonly}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Textarea
|
||||
{...commonProps}
|
||||
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none text-xs sm:text-sm"
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="date"
|
||||
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="number"
|
||||
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
|
||||
min={field.validation?.min}
|
||||
max={field.validation?.max}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
);
|
||||
|
||||
case "checkbox":
|
||||
return (
|
||||
<Checkbox
|
||||
checked={value === true || value === "true"}
|
||||
onCheckedChange={(checked) => handleFieldChange(item.id, field.name, checked)}
|
||||
disabled={componentConfig.disabled || componentConfig.readonly}
|
||||
/>
|
||||
);
|
||||
|
||||
default: // text
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="text"
|
||||
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
|
||||
maxLength={field.validation?.maxLength}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 빈 상태 렌더링
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div style={componentStyle} className={className} onClick={handleClick}>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30 p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">{componentConfig.emptyMessage}</p>
|
||||
{isDesignMode && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
💡 이전 모달에서 "다음" 버튼으로 데이터를 전달하면 여기에 표시됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid 레이아웃 렌더링
|
||||
const renderGridLayout = () => {
|
||||
return (
|
||||
<div className="overflow-auto bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-background">
|
||||
{componentConfig.showIndex && (
|
||||
<TableHead className="h-12 w-12 px-4 py-3 text-center text-xs font-semibold sm:text-sm">#</TableHead>
|
||||
)}
|
||||
|
||||
{/* 원본 데이터 컬럼 */}
|
||||
{componentConfig.displayColumns?.map((colName) => (
|
||||
<TableHead key={colName} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
|
||||
{colName}
|
||||
</TableHead>
|
||||
))}
|
||||
|
||||
{/* 추가 입력 필드 컬럼 */}
|
||||
{componentConfig.additionalFields?.map((field) => (
|
||||
<TableHead key={field.name} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
||||
</TableHead>
|
||||
))}
|
||||
|
||||
{componentConfig.allowRemove && (
|
||||
<TableHead className="h-12 w-20 px-4 py-3 text-center text-xs font-semibold sm:text-sm">작업</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, index) => (
|
||||
<TableRow key={item.id} className="bg-background transition-colors hover:bg-muted/50">
|
||||
{/* 인덱스 번호 */}
|
||||
{componentConfig.showIndex && (
|
||||
<TableCell className="h-14 px-4 py-3 text-center text-xs font-medium sm:text-sm">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* 원본 데이터 표시 */}
|
||||
{componentConfig.displayColumns?.map((colName) => (
|
||||
<TableCell key={colName} className="h-14 px-4 py-3 text-xs sm:text-sm">
|
||||
{item.originalData[colName] || "-"}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
{/* 추가 입력 필드 */}
|
||||
{componentConfig.additionalFields?.map((field) => (
|
||||
<TableCell key={field.name} className="h-14 px-4 py-3">
|
||||
{renderField(field, item)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
{componentConfig.allowRemove && (
|
||||
<TableCell className="h-14 px-4 py-3 text-center">
|
||||
{!componentConfig.disabled && !componentConfig.readonly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="h-7 w-7 text-destructive hover:bg-destructive/10 hover:text-destructive sm:h-8 sm:w-8"
|
||||
title="항목 제거"
|
||||
>
|
||||
<X className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Card 레이아웃 렌더링
|
||||
const renderCardLayout = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map((item, index) => (
|
||||
<Card key={item.id} className="relative">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-sm font-semibold sm:text-base">
|
||||
{componentConfig.showIndex && `${index + 1}. `}
|
||||
{item.originalData[componentConfig.displayColumns?.[0] || "name"] || `항목 ${index + 1}`}
|
||||
</CardTitle>
|
||||
|
||||
{componentConfig.allowRemove && !componentConfig.disabled && !componentConfig.readonly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="h-7 w-7 text-destructive hover:bg-destructive/10 sm:h-8 sm:w-8"
|
||||
>
|
||||
<X className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{/* 원본 데이터 표시 */}
|
||||
{componentConfig.displayColumns?.map((colName) => (
|
||||
<div key={colName} className="flex items-center justify-between text-xs sm:text-sm">
|
||||
<span className="font-medium text-muted-foreground">{colName}:</span>
|
||||
<span>{item.originalData[colName] || "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 추가 입력 필드 */}
|
||||
{componentConfig.additionalFields?.map((field) => (
|
||||
<div key={field.name} className="space-y-1">
|
||||
<label className="text-xs font-medium sm:text-sm">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
||||
</label>
|
||||
{renderField(field, item)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={cn("space-y-4", className)} onClick={handleClick}>
|
||||
{/* 레이아웃에 따라 렌더링 */}
|
||||
{componentConfig.layout === "grid" ? renderGridLayout() : renderCardLayout()}
|
||||
|
||||
{/* 항목 수 표시 */}
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>총 {items.length}개 항목</span>
|
||||
{componentConfig.targetTable && <span>저장 대상: {componentConfig.targetTable}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const SelectedItemsDetailInputWrapper: React.FC<SelectedItemsDetailInputComponentProps> = (props) => {
|
||||
return <SelectedItemsDetailInputComponent {...props} />;
|
||||
};
|
||||
|
|
@ -0,0 +1,474 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition } from "./types";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SelectedItemsDetailInputConfigPanelProps {
|
||||
config: SelectedItemsDetailInputConfig;
|
||||
onChange: (config: Partial<SelectedItemsDetailInputConfig>) => void;
|
||||
tableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>;
|
||||
allTables?: Array<{ tableName: string; displayName?: string }>;
|
||||
onTableChange?: (tableName: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailInputConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
tableColumns = [],
|
||||
allTables = [],
|
||||
onTableChange,
|
||||
}) => {
|
||||
const [localFields, setLocalFields] = useState<AdditionalFieldDefinition[]>(config.additionalFields || []);
|
||||
const [displayColumns, setDisplayColumns] = useState<string[]>(config.displayColumns || []);
|
||||
const [fieldPopoverOpen, setFieldPopoverOpen] = useState<Record<number, boolean>>({});
|
||||
|
||||
const handleChange = (key: keyof SelectedItemsDetailInputConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
const handleFieldsChange = (fields: AdditionalFieldDefinition[]) => {
|
||||
setLocalFields(fields);
|
||||
handleChange("additionalFields", fields);
|
||||
};
|
||||
|
||||
const handleDisplayColumnsChange = (columns: string[]) => {
|
||||
setDisplayColumns(columns);
|
||||
handleChange("displayColumns", columns);
|
||||
};
|
||||
|
||||
// 필드 추가
|
||||
const addField = () => {
|
||||
const newField: AdditionalFieldDefinition = {
|
||||
name: `field_${localFields.length + 1}`,
|
||||
label: `필드 ${localFields.length + 1}`,
|
||||
type: "text",
|
||||
};
|
||||
handleFieldsChange([...localFields, newField]);
|
||||
};
|
||||
|
||||
// 필드 제거
|
||||
const removeField = (index: number) => {
|
||||
handleFieldsChange(localFields.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 필드 수정
|
||||
const updateField = (index: number, updates: Partial<AdditionalFieldDefinition>) => {
|
||||
const newFields = [...localFields];
|
||||
newFields[index] = { ...newFields[index], ...updates };
|
||||
handleFieldsChange(newFields);
|
||||
};
|
||||
|
||||
// 표시 컬럼 추가
|
||||
const addDisplayColumn = (columnName: string) => {
|
||||
if (!displayColumns.includes(columnName)) {
|
||||
handleDisplayColumnsChange([...displayColumns, columnName]);
|
||||
}
|
||||
};
|
||||
|
||||
// 표시 컬럼 제거
|
||||
const removeDisplayColumn = (columnName: string) => {
|
||||
handleDisplayColumnsChange(displayColumns.filter((col) => col !== columnName));
|
||||
};
|
||||
|
||||
// 사용되지 않은 컬럼 목록
|
||||
const availableColumns = useMemo(() => {
|
||||
const usedColumns = new Set([...displayColumns, ...localFields.map((f) => f.name)]);
|
||||
return tableColumns.filter((col) => !usedColumns.has(col.columnName));
|
||||
}, [tableColumns, displayColumns, localFields]);
|
||||
|
||||
// 테이블 선택 Combobox 상태
|
||||
const [tableSelectOpen, setTableSelectOpen] = useState(false);
|
||||
const [tableSearchValue, setTableSearchValue] = useState("");
|
||||
|
||||
// 필터링된 테이블 목록
|
||||
const filteredTables = useMemo(() => {
|
||||
if (!tableSearchValue) return allTables;
|
||||
const searchLower = tableSearchValue.toLowerCase();
|
||||
return allTables.filter(
|
||||
(table) =>
|
||||
table.tableName.toLowerCase().includes(searchLower) || table.displayName?.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}, [allTables, tableSearchValue]);
|
||||
|
||||
// 선택된 테이블 표시명
|
||||
const selectedTableLabel = useMemo(() => {
|
||||
if (!config.targetTable) return "테이블을 선택하세요";
|
||||
const table = allTables.find((t) => t.tableName === config.targetTable);
|
||||
return table ? table.displayName || table.tableName : config.targetTable;
|
||||
}, [config.targetTable, allTables]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 데이터 소스 ID */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold sm:text-sm">데이터 소스 ID</Label>
|
||||
<Input
|
||||
value={config.dataSourceId || ""}
|
||||
onChange={(e) => handleChange("dataSourceId", e.target.value)}
|
||||
placeholder="table-list-123"
|
||||
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-500 sm:text-xs">
|
||||
💡 이전 모달에서 데이터를 전달하는 컴포넌트 ID (보통 TableList의 ID)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 저장 대상 테이블 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold sm:text-sm">저장 대상 테이블</Label>
|
||||
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableSelectOpen}
|
||||
className="h-7 w-full justify-between text-xs sm:h-8 sm:text-sm"
|
||||
>
|
||||
{selectedTableLabel}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="테이블 검색..."
|
||||
value={tableSearchValue}
|
||||
onValueChange={setTableSearchValue}
|
||||
className="text-xs sm:text-sm"
|
||||
/>
|
||||
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-48 overflow-auto sm:max-h-64">
|
||||
{filteredTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={(currentValue) => {
|
||||
handleChange("targetTable", currentValue);
|
||||
setTableSelectOpen(false);
|
||||
setTableSearchValue("");
|
||||
if (onTableChange) {
|
||||
onTableChange(currentValue);
|
||||
}
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3 sm:h-4 sm:w-4",
|
||||
config.targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{table.displayName || table.tableName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-[10px] text-gray-500 sm:text-xs">최종 데이터를 저장할 테이블</p>
|
||||
</div>
|
||||
|
||||
{/* 표시할 원본 데이터 컬럼 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold sm:text-sm">표시할 원본 데이터 컬럼</Label>
|
||||
<div className="space-y-2">
|
||||
{displayColumns.map((colName, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input value={colName} readOnly className="h-7 flex-1 text-xs sm:h-8 sm:text-sm" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeDisplayColumn(colName)}
|
||||
className="h-6 w-6 text-red-500 hover:bg-red-50 sm:h-7 sm:w-7"
|
||||
>
|
||||
<X className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full border-dashed text-xs sm:text-sm"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
컬럼 추가
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandEmpty className="text-xs sm:text-sm">사용 가능한 컬럼이 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-48 overflow-auto sm:max-h-64">
|
||||
{availableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => addDisplayColumn(column.columnName)}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{column.columnLabel || column.columnName}</div>
|
||||
{column.dataType && <div className="text-[10px] text-gray-500">{column.dataType}</div>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 sm:text-xs">
|
||||
전달받은 원본 데이터 중 화면에 표시할 컬럼 (예: 품목코드, 품목명)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 추가 입력 필드 정의 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold sm:text-sm">추가 입력 필드 정의</Label>
|
||||
|
||||
{localFields.map((field, index) => (
|
||||
<Card key={index} className="border-2">
|
||||
<CardContent className="space-y-2 pt-3 sm:space-y-3 sm:pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-700 sm:text-sm">필드 {index + 1}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeField(index)}
|
||||
className="h-5 w-5 text-red-500 hover:bg-red-50 sm:h-6 sm:w-6"
|
||||
>
|
||||
<X className="h-2 w-2 sm:h-3 sm:w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">필드명 (컬럼)</Label>
|
||||
<Popover
|
||||
open={fieldPopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setFieldPopoverOpen({ ...fieldPopoverOpen, [index]: open })}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs"
|
||||
>
|
||||
{field.name || "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[180px] p-0 sm:w-[200px]">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" />
|
||||
<CommandEmpty className="text-[10px] sm:text-xs">사용 가능한 컬럼이 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[150px] overflow-auto sm:max-h-[200px]">
|
||||
{availableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
updateField(index, {
|
||||
name: column.columnName,
|
||||
label: column.columnLabel || column.columnName,
|
||||
});
|
||||
setFieldPopoverOpen({ ...fieldPopoverOpen, [index]: false });
|
||||
}}
|
||||
className="text-[10px] sm:text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
|
||||
field.name === column.columnName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{column.columnLabel}</div>
|
||||
<div className="text-[9px] text-gray-500">{column.columnName}</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">라벨</Label>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(index, { label: e.target.value })}
|
||||
placeholder="필드 라벨"
|
||||
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">타입</Label>
|
||||
<Select
|
||||
value={field.type}
|
||||
onValueChange={(value) =>
|
||||
updateField(index, { type: value as AdditionalFieldDefinition["type"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-full text-[10px] sm:h-7 sm:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text" className="text-[10px] sm:text-xs">
|
||||
텍스트
|
||||
</SelectItem>
|
||||
<SelectItem value="number" className="text-[10px] sm:text-xs">
|
||||
숫자
|
||||
</SelectItem>
|
||||
<SelectItem value="date" className="text-[10px] sm:text-xs">
|
||||
날짜
|
||||
</SelectItem>
|
||||
<SelectItem value="select" className="text-[10px] sm:text-xs">
|
||||
선택박스
|
||||
</SelectItem>
|
||||
<SelectItem value="checkbox" className="text-[10px] sm:text-xs">
|
||||
체크박스
|
||||
</SelectItem>
|
||||
<SelectItem value="textarea" className="text-[10px] sm:text-xs">
|
||||
텍스트영역
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">Placeholder</Label>
|
||||
<Input
|
||||
value={field.placeholder || ""}
|
||||
onChange={(e) => updateField(index, { placeholder: e.target.value })}
|
||||
placeholder="입력 안내"
|
||||
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={field.required ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="cursor-pointer text-[10px] font-normal sm:text-xs">
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addField}
|
||||
className="h-7 w-full border-dashed text-xs sm:text-sm"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4" />
|
||||
필드 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold sm:text-sm">레이아웃</Label>
|
||||
<Select
|
||||
value={config.layout || "grid"}
|
||||
onValueChange={(value) => handleChange("layout", value as "grid" | "card")}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs sm:h-8 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="grid" className="text-xs sm:text-sm">
|
||||
테이블 형식 (Grid)
|
||||
</SelectItem>
|
||||
<SelectItem value="card" className="text-xs sm:text-sm">
|
||||
카드 형식 (Card)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground sm:text-xs">
|
||||
{config.layout === "grid" ? "행 단위로 데이터를 표시합니다" : "각 항목을 카드로 표시합니다"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="space-y-2 rounded-lg border p-3 sm:p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="show-index"
|
||||
checked={config.showIndex ?? true}
|
||||
onCheckedChange={(checked) => handleChange("showIndex", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="show-index" className="cursor-pointer text-[10px] font-normal sm:text-xs">
|
||||
항목 번호 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="allow-remove"
|
||||
checked={config.allowRemove ?? false}
|
||||
onCheckedChange={(checked) => handleChange("allowRemove", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="allow-remove" className="cursor-pointer text-[10px] font-normal sm:text-xs">
|
||||
항목 삭제 허용
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled ?? false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="disabled" className="cursor-pointer text-[10px] font-normal sm:text-xs">
|
||||
비활성화 (읽기 전용)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용 예시 */}
|
||||
<div className="rounded-lg bg-blue-50 p-2 text-xs sm:p-3 sm:text-sm">
|
||||
<p className="mb-1 font-medium text-blue-900">💡 사용 예시</p>
|
||||
<ul className="space-y-1 text-[10px] text-blue-700 sm:text-xs">
|
||||
<li>• 품목 선택 모달 → 다음 버튼 → 거래처별 가격 입력</li>
|
||||
<li>• 사용자 선택 모달 → 다음 버튼 → 권한 및 부서 할당</li>
|
||||
<li>• 제품 선택 모달 → 다음 버튼 → 수량 및 납기일 입력</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SelectedItemsDetailInputConfigPanel.displayName = "SelectedItemsDetailInputConfigPanel";
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { SelectedItemsDetailInputDefinition } from "./index";
|
||||
import { SelectedItemsDetailInputComponent } from "./SelectedItemsDetailInputComponent";
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class SelectedItemsDetailInputRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = SelectedItemsDetailInputDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <SelectedItemsDetailInputComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// text 타입 특화 속성 처리
|
||||
protected getSelectedItemsDetailInputProps() {
|
||||
const baseProps = this.getWebTypeProps();
|
||||
|
||||
// text 타입에 특화된 추가 속성들
|
||||
return {
|
||||
...baseProps,
|
||||
// 여기에 text 타입 특화 속성들 추가
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변경 처리
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
|
||||
// 포커스 처리
|
||||
protected handleFocus = () => {
|
||||
// 포커스 로직
|
||||
};
|
||||
|
||||
// 블러 처리
|
||||
protected handleBlur = () => {
|
||||
// 블러 로직
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
SelectedItemsDetailInputRenderer.registerSelf();
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { SelectedItemsDetailInputWrapper } from "./SelectedItemsDetailInputComponent";
|
||||
import { SelectedItemsDetailInputConfigPanel } from "./SelectedItemsDetailInputConfigPanel";
|
||||
import { SelectedItemsDetailInputConfig } from "./types";
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 컴포넌트 정의
|
||||
* 선택된 항목들의 상세 정보를 입력하는 컴포넌트
|
||||
*/
|
||||
export const SelectedItemsDetailInputDefinition = createComponentDefinition({
|
||||
id: "selected-items-detail-input",
|
||||
name: "선택 항목 상세입력",
|
||||
nameEng: "SelectedItemsDetailInput Component",
|
||||
description: "선택된 항목들의 상세 정보를 입력하는 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "text",
|
||||
component: SelectedItemsDetailInputWrapper,
|
||||
defaultConfig: {
|
||||
dataSourceId: "",
|
||||
displayColumns: [],
|
||||
additionalFields: [],
|
||||
targetTable: "",
|
||||
layout: "grid",
|
||||
showIndex: true,
|
||||
allowRemove: false,
|
||||
emptyMessage: "전달받은 데이터가 없습니다.",
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
} as SelectedItemsDetailInputConfig,
|
||||
defaultSize: { width: 800, height: 400 },
|
||||
configPanel: SelectedItemsDetailInputConfigPanel,
|
||||
icon: "Table",
|
||||
tags: ["선택", "상세입력", "반복", "테이블", "데이터전달"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/selected-items-detail-input",
|
||||
});
|
||||
|
||||
// 컴포넌트는 SelectedItemsDetailInputRenderer에서 자동 등록됩니다
|
||||
|
||||
// 타입 내보내기
|
||||
export type { SelectedItemsDetailInputConfig, AdditionalFieldDefinition } from "./types";
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/**
|
||||
* 추가 입력 필드 정의
|
||||
*/
|
||||
export interface AdditionalFieldDefinition {
|
||||
/** 필드명 (컬럼명) */
|
||||
name: string;
|
||||
/** 필드 라벨 */
|
||||
label: string;
|
||||
/** 입력 타입 */
|
||||
type: "text" | "number" | "date" | "select" | "checkbox" | "textarea";
|
||||
/** 필수 입력 여부 */
|
||||
required?: boolean;
|
||||
/** 플레이스홀더 */
|
||||
placeholder?: string;
|
||||
/** 기본값 */
|
||||
defaultValue?: any;
|
||||
/** 선택 옵션 (type이 select일 때) */
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
/** 필드 너비 (px 또는 %) */
|
||||
width?: string;
|
||||
/** 검증 규칙 */
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 컴포넌트 설정 타입
|
||||
*/
|
||||
export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
||||
/**
|
||||
* 데이터 소스 ID (TableList 컴포넌트 ID 등)
|
||||
* 이 ID를 통해 modalDataStore에서 데이터를 가져옴
|
||||
*/
|
||||
dataSourceId?: string;
|
||||
|
||||
/**
|
||||
* 표시할 원본 데이터 컬럼들
|
||||
* 예: ["item_code", "item_name", "spec", "unit"]
|
||||
*/
|
||||
displayColumns?: string[];
|
||||
|
||||
/**
|
||||
* 추가 입력 필드 정의
|
||||
*/
|
||||
additionalFields?: AdditionalFieldDefinition[];
|
||||
|
||||
/**
|
||||
* 저장 대상 테이블
|
||||
*/
|
||||
targetTable?: string;
|
||||
|
||||
/**
|
||||
* 레이아웃 모드
|
||||
* - grid: 테이블 형식 (기본)
|
||||
* - card: 카드 형식
|
||||
*/
|
||||
layout?: "grid" | "card";
|
||||
|
||||
/**
|
||||
* 항목 번호 표시 여부
|
||||
*/
|
||||
showIndex?: boolean;
|
||||
|
||||
/**
|
||||
* 항목 삭제 허용 여부
|
||||
*/
|
||||
allowRemove?: boolean;
|
||||
|
||||
/**
|
||||
* 빈 상태 메시지
|
||||
*/
|
||||
emptyMessage?: string;
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 컴포넌트 Props 타입
|
||||
*/
|
||||
export interface SelectedItemsDetailInputProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: any;
|
||||
config?: SelectedItemsDetailInputConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
// 이벤트 핸들러
|
||||
onChange?: (value: any) => void;
|
||||
onSave?: (data: any[]) => void;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1107,6 +1107,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
// 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId)
|
||||
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
|
||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||
const modalItems = selectedRowsData.map((row, idx) => ({
|
||||
id: getRowKey(row, idx),
|
||||
originalData: row,
|
||||
additionalData: {},
|
||||
}));
|
||||
|
||||
useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems);
|
||||
console.log("✅ [TableList] modalDataStore에 데이터 저장:", {
|
||||
dataSourceId: tableConfig.selectedTable,
|
||||
count: modalItems.length,
|
||||
});
|
||||
});
|
||||
} else if (tableConfig.selectedTable && selectedRowsData.length === 0) {
|
||||
// 선택 해제 시 데이터 제거
|
||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||
useModalDataStore.getState().clearData(tableConfig.selectedTable!);
|
||||
console.log("🗑️ [TableList] modalDataStore 데이터 제거:", tableConfig.selectedTable);
|
||||
});
|
||||
}
|
||||
|
||||
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
||||
setIsAllSelected(allRowsSelected && data.length > 0);
|
||||
};
|
||||
|
|
@ -1127,6 +1150,23 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
selectedRowsData: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 modalDataStore에 전체 데이터 저장
|
||||
if (tableConfig.selectedTable && data.length > 0) {
|
||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||
const modalItems = data.map((row, idx) => ({
|
||||
id: getRowKey(row, idx),
|
||||
originalData: row,
|
||||
additionalData: {},
|
||||
}));
|
||||
|
||||
useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems);
|
||||
console.log("✅ [TableList] modalDataStore에 전체 데이터 저장:", {
|
||||
dataSourceId: tableConfig.selectedTable,
|
||||
count: modalItems.length,
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setSelectedRows(new Set());
|
||||
setIsAllSelected(false);
|
||||
|
|
@ -1137,6 +1177,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (onFormDataChange) {
|
||||
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
|
||||
}
|
||||
|
||||
// 🆕 modalDataStore 데이터 제거
|
||||
if (tableConfig.selectedTable) {
|
||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||
useModalDataStore.getState().clearData(tableConfig.selectedTable!);
|
||||
console.log("🗑️ [TableList] modalDataStore 전체 데이터 제거:", tableConfig.selectedTable);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1766,6 +1814,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
}, [tableConfig.refreshInterval, isDesignMode]);
|
||||
|
||||
// 🆕 전역 테이블 새로고침 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleRefreshTable = () => {
|
||||
if (tableConfig.selectedTable && !isDesignMode) {
|
||||
console.log("🔄 [TableList] refreshTable 이벤트 수신 - 데이터 새로고침");
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("refreshTable", handleRefreshTable);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("refreshTable", handleRefreshTable);
|
||||
};
|
||||
}, [tableConfig.selectedTable, isDesignMode]);
|
||||
|
||||
// 초기 컬럼 너비 측정 (한 번만)
|
||||
useEffect(() => {
|
||||
if (!hasInitializedWidths.current && visibleColumns.length > 0) {
|
||||
|
|
|
|||
|
|
@ -63,7 +63,9 @@ export function createComponentDefinition(options: CreateComponentDefinitionOpti
|
|||
category, // 카테고리를 태그로 추가
|
||||
webType, // 웹타입을 태그로 추가
|
||||
...name.split(" "), // 이름을 단어별로 분리하여 태그로 추가
|
||||
].filter((tag, index, array) => array.indexOf(tag) === index); // 중복 제거
|
||||
]
|
||||
.filter((tag) => tag && typeof tag === 'string' && tag.trim().length > 0) // undefined, null, 빈 문자열 제거
|
||||
.filter((tag, index, array) => array.indexOf(tag) === index); // 중복 제거
|
||||
|
||||
// 컴포넌트 정의 생성
|
||||
const definition: ComponentDefinition = {
|
||||
|
|
@ -163,7 +165,7 @@ export function validateComponentDefinition(definition: ComponentDefinition): {
|
|||
warnings.push("태그가 너무 많습니다 (20개 초과)");
|
||||
}
|
||||
definition.tags.forEach((tag) => {
|
||||
if (tag.length > 30) {
|
||||
if (tag && typeof tag === 'string' && tag.length > 30) {
|
||||
warnings.push(`태그가 너무 깁니다: ${tag}`);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type ButtonActionType =
|
|||
| "edit" // 편집
|
||||
| "copy" // 복사 (품목코드 초기화)
|
||||
| "navigate" // 페이지 이동
|
||||
| "openModalWithData" // 🆕 데이터를 전달하면서 모달 열기
|
||||
| "modal" // 모달 열기
|
||||
| "control" // 제어 흐름
|
||||
| "view_table_history" // 테이블 이력 보기
|
||||
|
|
@ -44,6 +45,7 @@ export interface ButtonActionConfig {
|
|||
modalSize?: "sm" | "md" | "lg" | "xl";
|
||||
popupWidth?: number;
|
||||
popupHeight?: number;
|
||||
dataSourceId?: string; // 🆕 modalDataStore에서 데이터를 가져올 ID (openModalWithData용)
|
||||
|
||||
// 확인 메시지
|
||||
confirmMessage?: string;
|
||||
|
|
@ -149,6 +151,9 @@ export class ButtonActionExecutor {
|
|||
case "navigate":
|
||||
return this.handleNavigate(config, context);
|
||||
|
||||
case "openModalWithData":
|
||||
return await this.handleOpenModalWithData(config, context);
|
||||
|
||||
case "modal":
|
||||
return await this.handleModal(config, context);
|
||||
|
||||
|
|
@ -667,6 +672,83 @@ export class ButtonActionExecutor {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 데이터를 전달하면서 모달 열기 액션 처리
|
||||
*/
|
||||
private static async handleOpenModalWithData(
|
||||
config: ButtonActionConfig,
|
||||
context: ButtonActionContext,
|
||||
): Promise<boolean> {
|
||||
console.log("📦 데이터와 함께 모달 열기:", {
|
||||
title: config.modalTitle,
|
||||
size: config.modalSize,
|
||||
targetScreenId: config.targetScreenId,
|
||||
dataSourceId: config.dataSourceId,
|
||||
});
|
||||
|
||||
// 1. dataSourceId 확인 (없으면 selectedRows에서 데이터 전달)
|
||||
const dataSourceId = config.dataSourceId || context.tableName || "default";
|
||||
|
||||
// 2. modalDataStore에서 데이터 확인
|
||||
try {
|
||||
const { useModalDataStore } = await import("@/stores/modalDataStore");
|
||||
const modalData = useModalDataStore.getState().dataRegistry[dataSourceId] || [];
|
||||
|
||||
if (modalData.length === 0) {
|
||||
console.warn("⚠️ 전달할 데이터가 없습니다:", dataSourceId);
|
||||
toast.warning("선택된 데이터가 없습니다. 먼저 항목을 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("✅ 전달할 데이터:", {
|
||||
dataSourceId,
|
||||
count: modalData.length,
|
||||
data: modalData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 데이터 확인 실패:", error);
|
||||
toast.error("데이터 확인 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 모달 열기
|
||||
if (config.targetScreenId) {
|
||||
// config에 modalDescription이 있으면 우선 사용
|
||||
let description = config.modalDescription || "";
|
||||
|
||||
// config에 없으면 화면 정보에서 가져오기
|
||||
if (!description) {
|
||||
try {
|
||||
const screenInfo = await screenApi.getScreen(config.targetScreenId);
|
||||
description = screenInfo?.description || "";
|
||||
} catch (error) {
|
||||
console.warn("화면 설명을 가져오지 못했습니다:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 모달 상태 업데이트를 위한 이벤트 발생
|
||||
const modalEvent = new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
screenId: config.targetScreenId,
|
||||
title: config.modalTitle || "데이터 입력",
|
||||
description: description,
|
||||
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
|
||||
},
|
||||
});
|
||||
|
||||
window.dispatchEvent(modalEvent);
|
||||
|
||||
// 성공 메시지 (간단하게)
|
||||
toast.success(config.successMessage || "다음 단계로 진행합니다.");
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.error("모달로 열 화면이 지정되지 않았습니다.");
|
||||
toast.error("대상 화면이 지정되지 않았습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 창 액션 처리
|
||||
*/
|
||||
|
|
@ -2599,6 +2681,13 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
|||
navigate: {
|
||||
type: "navigate",
|
||||
},
|
||||
openModalWithData: {
|
||||
type: "openModalWithData",
|
||||
modalSize: "md",
|
||||
confirmMessage: "다음 단계로 진행하시겠습니까?",
|
||||
successMessage: "데이터가 전달되었습니다.",
|
||||
errorMessage: "데이터 전달 중 오류가 발생했습니다.",
|
||||
},
|
||||
modal: {
|
||||
type: "modal",
|
||||
modalSize: "md",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,19 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
|||
"split-panel-layout": () => import("@/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel"),
|
||||
"repeater-field-group": () => import("@/components/webtypes/config/RepeaterConfigPanel"),
|
||||
"flow-widget": () => import("@/components/screen/config-panels/FlowWidgetConfigPanel"),
|
||||
"customer-item-mapping": () => import("@/lib/registry/components/customer-item-mapping/CustomerItemMappingConfigPanel"),
|
||||
// 🆕 수주 등록 관련 컴포넌트들
|
||||
"autocomplete-search-input": () =>
|
||||
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"),
|
||||
// 🆕 선택 항목 상세입력
|
||||
"selected-items-detail-input": () =>
|
||||
import("@/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel"),
|
||||
};
|
||||
|
||||
// ConfigPanel 컴포넌트 캐시
|
||||
|
|
@ -57,6 +69,7 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
|
|||
module.RepeaterConfigPanel || // repeater-field-group의 export명
|
||||
module.FlowWidgetConfigPanel || // flow-widget의 export명
|
||||
module.CustomerItemMappingConfigPanel || // customer-item-mapping의 export명
|
||||
module.SelectedItemsDetailInputConfigPanel || // selected-items-detail-input의 export명
|
||||
module.default;
|
||||
|
||||
if (!ConfigPanelComponent) {
|
||||
|
|
@ -253,6 +266,19 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 수주 등록 관련 컴포넌트들은 간단한 인터페이스 사용
|
||||
const isSimpleConfigPanel = [
|
||||
"autocomplete-search-input",
|
||||
"entity-search-input",
|
||||
"modal-repeater-table",
|
||||
"order-registration-modal",
|
||||
"conditional-container",
|
||||
].includes(componentId);
|
||||
|
||||
if (isSimpleConfigPanel) {
|
||||
return <ConfigPanelComponent config={config} onConfigChange={onChange} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigPanelComponent
|
||||
config={config}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { devtools } from "zustand/middleware";
|
||||
|
||||
/**
|
||||
* 모달 간 데이터 전달을 위한 전역 상태 관리
|
||||
*
|
||||
* 용도:
|
||||
* 1. 첫 번째 모달에서 선택된 데이터를 저장
|
||||
* 2. 두 번째 모달에서 데이터를 읽어와서 사용
|
||||
* 3. 데이터 전달 완료 후 자동 정리
|
||||
*/
|
||||
|
||||
export interface ModalDataItem {
|
||||
/**
|
||||
* 항목 고유 ID (선택된 행의 ID나 키)
|
||||
*/
|
||||
id: string | number;
|
||||
|
||||
/**
|
||||
* 원본 데이터 (테이블 행 데이터 전체)
|
||||
*/
|
||||
originalData: Record<string, any>;
|
||||
|
||||
/**
|
||||
* 추가 입력 데이터 (사용자가 입력한 추가 정보)
|
||||
*/
|
||||
additionalData?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface ModalDataState {
|
||||
/**
|
||||
* 현재 전달할 데이터
|
||||
* key: 소스 컴포넌트 ID (예: "table-list-123")
|
||||
* value: 선택된 항목들의 배열
|
||||
*/
|
||||
dataRegistry: Record<string, ModalDataItem[]>;
|
||||
|
||||
/**
|
||||
* 데이터 설정 (첫 번째 모달에서 호출)
|
||||
* @param sourceId 데이터를 전달하는 컴포넌트 ID
|
||||
* @param items 전달할 항목들
|
||||
*/
|
||||
setData: (sourceId: string, items: ModalDataItem[]) => void;
|
||||
|
||||
/**
|
||||
* 데이터 조회 (두 번째 모달에서 호출)
|
||||
* @param sourceId 데이터를 전달한 컴포넌트 ID
|
||||
* @returns 전달된 항목들 (없으면 빈 배열)
|
||||
*/
|
||||
getData: (sourceId: string) => ModalDataItem[];
|
||||
|
||||
/**
|
||||
* 데이터 정리 (모달 닫힐 때 호출)
|
||||
* @param sourceId 정리할 데이터의 소스 ID
|
||||
*/
|
||||
clearData: (sourceId: string) => void;
|
||||
|
||||
/**
|
||||
* 모든 데이터 정리
|
||||
*/
|
||||
clearAll: () => void;
|
||||
|
||||
/**
|
||||
* 특정 항목의 추가 데이터 업데이트
|
||||
* @param sourceId 소스 컴포넌트 ID
|
||||
* @param itemId 항목 ID
|
||||
* @param additionalData 추가 입력 데이터
|
||||
*/
|
||||
updateItemData: (sourceId: string, itemId: string | number, additionalData: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
export const useModalDataStore = create<ModalDataState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
dataRegistry: {},
|
||||
|
||||
setData: (sourceId, items) => {
|
||||
console.log("📦 [ModalDataStore] 데이터 저장:", { sourceId, itemCount: items.length, items });
|
||||
set((state) => ({
|
||||
dataRegistry: {
|
||||
...state.dataRegistry,
|
||||
[sourceId]: items,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
getData: (sourceId) => {
|
||||
const items = get().dataRegistry[sourceId] || [];
|
||||
console.log("📭 [ModalDataStore] 데이터 조회:", { sourceId, itemCount: items.length });
|
||||
return items;
|
||||
},
|
||||
|
||||
clearData: (sourceId) => {
|
||||
console.log("🗑️ [ModalDataStore] 데이터 정리:", { sourceId });
|
||||
set((state) => {
|
||||
const { [sourceId]: _, ...rest } = state.dataRegistry;
|
||||
return { dataRegistry: rest };
|
||||
});
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
console.log("🗑️ [ModalDataStore] 모든 데이터 정리");
|
||||
set({ dataRegistry: {} });
|
||||
},
|
||||
|
||||
updateItemData: (sourceId, itemId, additionalData) => {
|
||||
set((state) => {
|
||||
const items = state.dataRegistry[sourceId] || [];
|
||||
const updatedItems = items.map((item) =>
|
||||
item.id === itemId
|
||||
? { ...item, additionalData: { ...item.additionalData, ...additionalData } }
|
||||
: item
|
||||
);
|
||||
|
||||
console.log("✏️ [ModalDataStore] 항목 데이터 업데이트:", { sourceId, itemId, additionalData });
|
||||
|
||||
return {
|
||||
dataRegistry: {
|
||||
...state.dataRegistry,
|
||||
[sourceId]: updatedItems,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
{ name: "ModalDataStore" }
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* 모달 데이터 조회 Hook
|
||||
*
|
||||
* @example
|
||||
* const items = useModalData("table-list-123");
|
||||
*/
|
||||
export const useModalData = (sourceId: string) => {
|
||||
return useModalDataStore((state) => state.getData(sourceId));
|
||||
};
|
||||
|
||||
/**
|
||||
* 모달 데이터 설정 Hook
|
||||
*
|
||||
* @example
|
||||
* const setModalData = useSetModalData();
|
||||
* setModalData("table-list-123", selectedItems);
|
||||
*/
|
||||
export const useSetModalData = () => {
|
||||
return useModalDataStore((state) => state.setData);
|
||||
};
|
||||
|
||||
/**
|
||||
* 모달 데이터 정리 Hook
|
||||
*
|
||||
* @example
|
||||
* const clearModalData = useClearModalData();
|
||||
* clearModalData("table-list-123");
|
||||
*/
|
||||
export const useClearModalData = () => {
|
||||
return useModalDataStore((state) => state.clearData);
|
||||
};
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ export enum ComponentCategory {
|
|||
DISPLAY = "display", // 표시 컴포넌트 (라벨, 이미지, 아이콘 등)
|
||||
ACTION = "action", // 액션 컴포넌트 (버튼, 링크 등)
|
||||
LAYOUT = "layout", // 레이아웃 컴포넌트 (컨테이너, 그룹 등)
|
||||
DATA = "data", // 데이터 컴포넌트 (테이블, 리스트 등)
|
||||
CHART = "chart", // 차트 컴포넌트 (그래프, 차트 등)
|
||||
FORM = "form", // 폼 컴포넌트 (폼 그룹, 필드셋 등)
|
||||
MEDIA = "media", // 미디어 컴포넌트 (이미지, 비디오 등)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,413 @@
|
|||
# 선택 항목 상세입력 컴포넌트 - 완성 가이드
|
||||
|
||||
## 📦 구현 완료 사항
|
||||
|
||||
### ✅ 1. Zustand 스토어 생성 (modalDataStore)
|
||||
- 파일: `frontend/stores/modalDataStore.ts`
|
||||
- 기능:
|
||||
- 모달 간 데이터 전달 관리
|
||||
- `setData()`: 데이터 저장
|
||||
- `getData()`: 데이터 조회
|
||||
- `clearData()`: 데이터 정리
|
||||
- `updateItemData()`: 항목별 추가 데이터 업데이트
|
||||
|
||||
### ✅ 2. SelectedItemsDetailInput 컴포넌트 생성
|
||||
- 디렉토리: `frontend/lib/registry/components/selected-items-detail-input/`
|
||||
- 파일들:
|
||||
- `types.ts`: 타입 정의
|
||||
- `SelectedItemsDetailInputComponent.tsx`: 메인 컴포넌트
|
||||
- `SelectedItemsDetailInputConfigPanel.tsx`: 설정 패널
|
||||
- `SelectedItemsDetailInputRenderer.tsx`: 렌더러
|
||||
- `index.ts`: 컴포넌트 정의
|
||||
- `README.md`: 사용 가이드
|
||||
|
||||
### ✅ 3. 컴포넌트 기능
|
||||
- 전달받은 원본 데이터 표시 (읽기 전용)
|
||||
- 각 항목별 추가 입력 필드 제공
|
||||
- Grid/Table 레이아웃 및 Card 레이아웃 지원
|
||||
- 6가지 입력 타입 지원 (text, number, date, select, checkbox, textarea)
|
||||
- 필수 입력 검증
|
||||
- 항목 삭제 기능
|
||||
|
||||
### ✅ 4. 설정 패널 기능
|
||||
- 데이터 소스 ID 설정
|
||||
- 저장 대상 테이블 선택 (검색 가능한 Combobox)
|
||||
- 표시할 원본 데이터 컬럼 선택
|
||||
- 추가 입력 필드 정의 (필드명, 라벨, 타입, 필수 여부 등)
|
||||
- 레이아웃 모드 선택 (Grid/Card)
|
||||
- 옵션 설정 (번호 표시, 삭제 허용, 비활성화)
|
||||
|
||||
---
|
||||
|
||||
## 🚧 남은 작업 (구현 필요)
|
||||
|
||||
### 1. TableList에서 선택된 행 데이터를 스토어에 저장
|
||||
|
||||
**필요한 수정 파일:**
|
||||
- `frontend/lib/registry/components/table-list/TableListComponent.tsx`
|
||||
|
||||
**구현 방법:**
|
||||
```typescript
|
||||
import { useModalDataStore } from "@/stores/modalDataStore";
|
||||
|
||||
// TableList 컴포넌트 내부
|
||||
const setModalData = useModalDataStore((state) => state.setData);
|
||||
|
||||
// 선택된 행이 변경될 때마다 스토어에 저장
|
||||
useEffect(() => {
|
||||
if (selectedRows.length > 0) {
|
||||
const modalDataItems = selectedRows.map((row) => ({
|
||||
id: row[primaryKeyColumn] || row.id,
|
||||
originalData: row,
|
||||
additionalData: {},
|
||||
}));
|
||||
|
||||
// 컴포넌트 ID를 키로 사용하여 저장
|
||||
setModalData(component.id || "default", modalDataItems);
|
||||
|
||||
console.log("📦 [TableList] 선택된 데이터 저장:", modalDataItems);
|
||||
}
|
||||
}, [selectedRows, component.id, setModalData]);
|
||||
```
|
||||
|
||||
**참고:**
|
||||
- `selectedRows`: TableList의 체크박스로 선택된 행들
|
||||
- `component.id`: 컴포넌트 고유 ID
|
||||
- 이 ID가 SelectedItemsDetailInput의 `dataSourceId`와 일치해야 함
|
||||
|
||||
---
|
||||
|
||||
### 2. ButtonPrimary에 'openModalWithData' 액션 타입 추가
|
||||
|
||||
**필요한 수정 파일:**
|
||||
- `frontend/lib/registry/components/button-primary/types.ts`
|
||||
- `frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx`
|
||||
- `frontend/lib/registry/components/button-primary/ButtonPrimaryConfigPanel.tsx`
|
||||
|
||||
#### A. types.ts 수정
|
||||
|
||||
```typescript
|
||||
export interface ButtonPrimaryConfig extends ComponentConfig {
|
||||
action?: {
|
||||
type:
|
||||
| "save"
|
||||
| "delete"
|
||||
| "popup"
|
||||
| "navigate"
|
||||
| "custom"
|
||||
| "openModalWithData"; // 🆕 새 액션 타입
|
||||
|
||||
// 기존 필드들...
|
||||
|
||||
// 🆕 모달 데이터 전달용 필드
|
||||
targetScreenId?: number; // 열릴 모달 화면 ID
|
||||
dataSourceId?: string; // 데이터를 전달할 컴포넌트 ID
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### B. ButtonPrimaryComponent.tsx 수정
|
||||
|
||||
```typescript
|
||||
import { useModalDataStore } from "@/stores/modalDataStore";
|
||||
|
||||
// 컴포넌트 내부
|
||||
const modalData = useModalDataStore((state) => state.getData);
|
||||
|
||||
// handleClick 함수 수정
|
||||
const handleClick = async () => {
|
||||
// ... 기존 코드 ...
|
||||
|
||||
// openModalWithData 액션 처리
|
||||
if (processedConfig.action?.type === "openModalWithData") {
|
||||
const { targetScreenId, dataSourceId } = processedConfig.action;
|
||||
|
||||
if (!targetScreenId) {
|
||||
toast.error("대상 화면이 설정되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dataSourceId) {
|
||||
toast.error("데이터 소스가 설정되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 확인
|
||||
const data = modalData(dataSourceId);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
toast.warning("전달할 데이터가 없습니다. 먼저 항목을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📦 [ButtonPrimary] 데이터와 함께 모달 열기:", {
|
||||
targetScreenId,
|
||||
dataSourceId,
|
||||
dataCount: data.length,
|
||||
});
|
||||
|
||||
// 모달 열기 (기존 popup 액션과 동일)
|
||||
toast.success(`${data.length}개 항목을 전달합니다.`);
|
||||
|
||||
// TODO: 실제 모달 열기 로직 (popup 액션 참고)
|
||||
window.open(`/screens/${targetScreenId}`, "_blank");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ... 기존 액션 처리 코드 ...
|
||||
};
|
||||
```
|
||||
|
||||
#### C. ButtonPrimaryConfigPanel.tsx 수정
|
||||
|
||||
설정 패널에 openModalWithData 액션 설정 UI 추가:
|
||||
|
||||
```typescript
|
||||
{config.action?.type === "openModalWithData" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
<h4 className="text-sm font-medium">데이터 전달 설정</h4>
|
||||
|
||||
{/* 대상 화면 선택 */}
|
||||
<div>
|
||||
<Label htmlFor="target-screen">열릴 모달 화면</Label>
|
||||
<Popover open={screenOpen} onOpenChange={setScreenOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="w-full justify-between">
|
||||
{config.action?.targetScreenId
|
||||
? screens.find((s) => s.id === config.action?.targetScreenId)?.name || "화면 선택"
|
||||
: "화면 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
{/* 화면 목록 표시 */}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 데이터 소스 ID 입력 */}
|
||||
<div>
|
||||
<Label htmlFor="data-source-id">데이터 소스 ID</Label>
|
||||
<Input
|
||||
id="data-source-id"
|
||||
value={config.action?.dataSourceId || ""}
|
||||
onChange={(e) =>
|
||||
updateActionConfig("dataSourceId", e.target.value)
|
||||
}
|
||||
placeholder="table-list-123"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
💡 데이터를 전달할 컴포넌트의 ID (예: TableList의 ID)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 저장 기능 구현
|
||||
|
||||
**방법 1: 기존 save 액션 활용**
|
||||
|
||||
SelectedItemsDetailInput의 데이터는 자동으로 `formData`에 포함되므로, 기존 save 액션을 그대로 사용할 수 있습니다:
|
||||
|
||||
```typescript
|
||||
// formData 구조
|
||||
{
|
||||
"selected-items-component-id": [
|
||||
{
|
||||
id: "SALE-003",
|
||||
originalData: { item_code: "SALE-003", ... },
|
||||
additionalData: { customer_item_code: "ABC-001", unit_price: 50, ... }
|
||||
},
|
||||
// ... 더 많은 항목들
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
백엔드에서 이 데이터를 받아서 각 항목을 개별 INSERT하면 됩니다.
|
||||
|
||||
**방법 2: 전용 save 로직 추가**
|
||||
|
||||
더 나은 UX를 위해 전용 저장 로직을 추가할 수 있습니다:
|
||||
|
||||
```typescript
|
||||
// ButtonPrimary의 save 액션에서
|
||||
if (config.action?.type === "save") {
|
||||
// formData에서 SelectedItemsDetailInput 데이터 찾기
|
||||
const selectedItemsKey = Object.keys(formData).find(
|
||||
(key) => Array.isArray(formData[key]) && formData[key][0]?.originalData
|
||||
);
|
||||
|
||||
if (selectedItemsKey) {
|
||||
const items = formData[selectedItemsKey] as ModalDataItem[];
|
||||
|
||||
// 저장할 데이터 변환
|
||||
const dataToSave = items.map((item) => ({
|
||||
...item.originalData,
|
||||
...item.additionalData,
|
||||
}));
|
||||
|
||||
// 백엔드 API 호출
|
||||
const response = await apiClient.post(`/api/table-data/${targetTable}`, {
|
||||
data: dataToSave,
|
||||
batchInsert: true,
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success(`${dataToSave.length}개 항목이 저장되었습니다.`);
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 통합 테스트 시나리오
|
||||
|
||||
### 시나리오: 수주 등록 - 품목 상세 입력
|
||||
|
||||
#### 1단계: 화면 구성
|
||||
|
||||
**[모달 1] 품목 선택 화면 (screen_id: 100)**
|
||||
|
||||
- TableList 컴포넌트
|
||||
- ID: `item-selection-table`
|
||||
- multiSelect: `true`
|
||||
- selectedTable: `item_info`
|
||||
- columns: 품목코드, 품목명, 규격, 단위, 단가
|
||||
|
||||
- ButtonPrimary 컴포넌트
|
||||
- text: "다음 (상세정보 입력)"
|
||||
- action.type: `openModalWithData`
|
||||
- action.targetScreenId: `101` (두 번째 모달)
|
||||
- action.dataSourceId: `item-selection-table`
|
||||
|
||||
**[모달 2] 상세 입력 화면 (screen_id: 101)**
|
||||
|
||||
- SelectedItemsDetailInput 컴포넌트
|
||||
- ID: `selected-items-detail`
|
||||
- dataSourceId: `item-selection-table`
|
||||
- displayColumns: `["item_code", "item_name", "spec", "unit"]`
|
||||
- additionalFields:
|
||||
```json
|
||||
[
|
||||
{ "name": "customer_item_code", "label": "거래처 품번", "type": "text" },
|
||||
{ "name": "customer_item_name", "label": "거래처 품명", "type": "text" },
|
||||
{ "name": "year", "label": "연도", "type": "select", "options": [...] },
|
||||
{ "name": "currency", "label": "통화", "type": "select", "options": [...] },
|
||||
{ "name": "unit_price", "label": "단가", "type": "number", "required": true },
|
||||
{ "name": "quantity", "label": "수량", "type": "number", "required": true }
|
||||
]
|
||||
```
|
||||
- targetTable: `sales_detail`
|
||||
- layout: `grid`
|
||||
|
||||
- ButtonPrimary 컴포넌트 (저장)
|
||||
- text: "저장"
|
||||
- action.type: `save`
|
||||
- action.targetTable: `sales_detail`
|
||||
|
||||
#### 2단계: 테스트 절차
|
||||
|
||||
1. [모달 1] 품목 선택 화면 열기
|
||||
2. TableList에서 3개 품목 체크박스 선택
|
||||
3. "다음" 버튼 클릭
|
||||
- ✅ modalDataStore에 3개 항목 저장 확인 (콘솔 로그)
|
||||
- ✅ 모달 2가 열림
|
||||
4. [모달 2] SelectedItemsDetailInput에 3개 항목 자동 표시 확인
|
||||
- ✅ 원본 데이터 (품목코드, 품목명, 규격, 단위) 표시
|
||||
- ✅ 추가 입력 필드 (거래처 품번, 단가, 수량 등) 빈 상태
|
||||
5. 각 항목별로 추가 정보 입력
|
||||
- 거래처 품번: "ABC-001", "ABC-002", "ABC-003"
|
||||
- 단가: 50, 200, 3000
|
||||
- 수량: 100, 50, 200
|
||||
6. "저장" 버튼 클릭
|
||||
- ✅ formData에 전체 데이터 포함 확인
|
||||
- ✅ 백엔드 API 호출
|
||||
- ✅ 저장 성공 토스트 메시지
|
||||
- ✅ 모달 닫힘
|
||||
|
||||
#### 3단계: 데이터 검증
|
||||
|
||||
데이터베이스에 다음과 같이 저장되어야 합니다:
|
||||
|
||||
```sql
|
||||
SELECT * FROM sales_detail;
|
||||
-- 결과:
|
||||
-- item_code | item_name | spec | unit | customer_item_code | unit_price | quantity
|
||||
-- SALE-003 | 와셔 M8 | M8 | EA | ABC-001 | 50 | 100
|
||||
-- SALE-005 | 육각 볼트 | M10 | EA | ABC-002 | 200 | 50
|
||||
-- SIL-003 | 실리콘 | 325 | kg | ABC-003 | 3000 | 200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 추가 참고 자료
|
||||
|
||||
### 관련 파일 위치
|
||||
|
||||
- 스토어: `frontend/stores/modalDataStore.ts`
|
||||
- 컴포넌트: `frontend/lib/registry/components/selected-items-detail-input/`
|
||||
- TableList: `frontend/lib/registry/components/table-list/`
|
||||
- ButtonPrimary: `frontend/lib/registry/components/button-primary/`
|
||||
|
||||
### 디버깅 팁
|
||||
|
||||
콘솔에서 다음 명령어로 상태 확인:
|
||||
|
||||
```javascript
|
||||
// 모달 데이터 확인
|
||||
__MODAL_DATA_STORE__.getState().dataRegistry
|
||||
|
||||
// 컴포넌트 등록 확인
|
||||
__COMPONENT_REGISTRY__.get("selected-items-detail-input")
|
||||
|
||||
// TableList 선택 상태 확인
|
||||
// (TableList 컴포넌트 내부에 로그 추가 필요)
|
||||
```
|
||||
|
||||
### 예상 문제 및 해결
|
||||
|
||||
1. **데이터가 전달되지 않음**
|
||||
- dataSourceId가 정확히 일치하는지 확인
|
||||
- modalDataStore에 데이터가 저장되었는지 콘솔 로그 확인
|
||||
|
||||
2. **컴포넌트가 표시되지 않음**
|
||||
- `frontend/lib/registry/components/index.ts`에 import 추가되었는지 확인
|
||||
- 브라우저 새로고침 후 재시도
|
||||
|
||||
3. **저장이 안 됨**
|
||||
- formData에 데이터가 포함되어 있는지 확인
|
||||
- 백엔드 API 응답 확인
|
||||
- targetTable이 올바른지 확인
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료 체크리스트
|
||||
|
||||
- [x] Zustand 스토어 생성 (modalDataStore)
|
||||
- [x] SelectedItemsDetailInput 컴포넌트 생성
|
||||
- [x] 컴포넌트 렌더링 로직 구현
|
||||
- [x] 설정 패널 구현
|
||||
- [ ] TableList에서 선택된 데이터를 스토어에 저장
|
||||
- [ ] ButtonPrimary에 openModalWithData 액션 추가
|
||||
- [ ] 저장 기능 구현
|
||||
- [ ] 통합 테스트
|
||||
- [ ] 사용자 매뉴얼 작성
|
||||
|
||||
---
|
||||
|
||||
## 🚀 다음 단계
|
||||
|
||||
1. TableList 컴포넌트에 modalDataStore 연동 추가
|
||||
2. ButtonPrimary에 openModalWithData 액션 구현
|
||||
3. 수주 등록 화면에서 실제 테스트
|
||||
4. 문제 발견 시 디버깅 및 수정
|
||||
5. 문서 업데이트 및 배포
|
||||
|
||||
**예상 소요 시간**: 2~3시간
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue