Compare commits
10 Commits
b755f8f017
...
4777c2bc0a
| Author | SHA1 | Date |
|---|---|---|
|
|
4777c2bc0a | |
|
|
e8bc770439 | |
|
|
8f6af5018c | |
|
|
76f6bd7f27 | |
|
|
7ad70462d5 | |
|
|
51099ba858 | |
|
|
11215e3316 | |
|
|
c85841b59f | |
|
|
1680163c61 | |
|
|
a9135165d9 |
|
|
@ -71,7 +71,6 @@ import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카
|
||||||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||||
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
|
||||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||||
|
|
@ -249,7 +248,6 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값
|
||||||
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
||||||
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
|
||||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||||
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||||
|
|
|
||||||
|
|
@ -1,276 +0,0 @@
|
||||||
import { Response } from "express";
|
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
|
||||||
import { getPool } from "../database/db";
|
|
||||||
import { logger } from "../utils/logger";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수주 번호 생성 함수
|
|
||||||
* 형식: ORD + YYMMDD + 4자리 시퀀스
|
|
||||||
* 예: ORD250114001
|
|
||||||
*/
|
|
||||||
async function generateOrderNumber(companyCode: string): Promise<string> {
|
|
||||||
const pool = getPool();
|
|
||||||
const today = new Date();
|
|
||||||
const year = today.getFullYear().toString().slice(2); // 25
|
|
||||||
const month = String(today.getMonth() + 1).padStart(2, "0"); // 01
|
|
||||||
const day = String(today.getDate()).padStart(2, "0"); // 14
|
|
||||||
const dateStr = `${year}${month}${day}`; // 250114
|
|
||||||
|
|
||||||
// 당일 수주 카운트 조회
|
|
||||||
const countQuery = `
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM order_mng_master
|
|
||||||
WHERE objid LIKE $1
|
|
||||||
AND writer LIKE $2
|
|
||||||
`;
|
|
||||||
|
|
||||||
const pattern = `ORD${dateStr}%`;
|
|
||||||
const result = await pool.query(countQuery, [pattern, `%${companyCode}%`]);
|
|
||||||
const count = parseInt(result.rows[0]?.count || "0");
|
|
||||||
const seq = count + 1;
|
|
||||||
|
|
||||||
return `ORD${dateStr}${String(seq).padStart(4, "0")}`; // ORD250114001
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수주 등록 API
|
|
||||||
* POST /api/orders
|
|
||||||
*/
|
|
||||||
export async function createOrder(req: AuthenticatedRequest, res: Response) {
|
|
||||||
const pool = getPool();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
inputMode, // 입력 방식
|
|
||||||
customerCode, // 거래처 코드
|
|
||||||
deliveryDate, // 납품일
|
|
||||||
items, // 품목 목록
|
|
||||||
memo, // 메모
|
|
||||||
} = req.body;
|
|
||||||
|
|
||||||
// 멀티테넌시
|
|
||||||
const companyCode = req.user!.companyCode;
|
|
||||||
const userId = req.user!.userId;
|
|
||||||
|
|
||||||
// 유효성 검사
|
|
||||||
if (!customerCode) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "거래처 코드는 필수입니다",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "품목은 최소 1개 이상 필요합니다",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 수주 번호 생성
|
|
||||||
const orderNo = await generateOrderNumber(companyCode);
|
|
||||||
|
|
||||||
// 전체 금액 계산
|
|
||||||
const totalAmount = items.reduce(
|
|
||||||
(sum: number, item: any) => sum + (item.amount || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
// 수주 마스터 생성
|
|
||||||
const masterQuery = `
|
|
||||||
INSERT INTO order_mng_master (
|
|
||||||
objid,
|
|
||||||
partner_objid,
|
|
||||||
final_delivery_date,
|
|
||||||
reason,
|
|
||||||
status,
|
|
||||||
reg_date,
|
|
||||||
writer
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, NOW(), $6)
|
|
||||||
RETURNING *
|
|
||||||
`;
|
|
||||||
|
|
||||||
const masterResult = await pool.query(masterQuery, [
|
|
||||||
orderNo,
|
|
||||||
customerCode,
|
|
||||||
deliveryDate || null,
|
|
||||||
memo || null,
|
|
||||||
"진행중",
|
|
||||||
`${userId}|${companyCode}`,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const masterObjid = masterResult.rows[0].objid;
|
|
||||||
|
|
||||||
// 수주 상세 (품목) 생성
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const item = items[i];
|
|
||||||
const subObjid = `${orderNo}_${i + 1}`;
|
|
||||||
|
|
||||||
const subQuery = `
|
|
||||||
INSERT INTO order_mng_sub (
|
|
||||||
objid,
|
|
||||||
order_mng_master_objid,
|
|
||||||
part_objid,
|
|
||||||
partner_objid,
|
|
||||||
partner_price,
|
|
||||||
partner_qty,
|
|
||||||
delivery_date,
|
|
||||||
status,
|
|
||||||
regdate,
|
|
||||||
writer
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
|
|
||||||
`;
|
|
||||||
|
|
||||||
await pool.query(subQuery, [
|
|
||||||
subObjid,
|
|
||||||
masterObjid,
|
|
||||||
item.item_code || item.id, // 품목 코드
|
|
||||||
customerCode,
|
|
||||||
item.unit_price || 0,
|
|
||||||
item.quantity || 0,
|
|
||||||
item.delivery_date || deliveryDate || null,
|
|
||||||
"진행중",
|
|
||||||
`${userId}|${companyCode}`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("수주 등록 성공", {
|
|
||||||
companyCode,
|
|
||||||
orderNo,
|
|
||||||
masterObjid,
|
|
||||||
itemCount: items.length,
|
|
||||||
totalAmount,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
orderNo,
|
|
||||||
masterObjid,
|
|
||||||
itemCount: items.length,
|
|
||||||
totalAmount,
|
|
||||||
},
|
|
||||||
message: "수주가 등록되었습니다",
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error("수주 등록 오류", {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
});
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: error.message || "수주 등록 중 오류가 발생했습니다",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수주 목록 조회 API (마스터 + 품목 JOIN)
|
|
||||||
* GET /api/orders
|
|
||||||
*/
|
|
||||||
export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
|
||||||
const pool = getPool();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { page = "1", limit = "20", searchText = "" } = req.query;
|
|
||||||
const companyCode = req.user!.companyCode;
|
|
||||||
|
|
||||||
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
|
||||||
|
|
||||||
// WHERE 조건
|
|
||||||
const whereConditions: string[] = [];
|
|
||||||
const params: any[] = [];
|
|
||||||
let paramIndex = 1;
|
|
||||||
|
|
||||||
// 멀티테넌시 (writer 필드에 company_code 포함)
|
|
||||||
if (companyCode !== "*") {
|
|
||||||
whereConditions.push(`m.writer LIKE $${paramIndex}`);
|
|
||||||
params.push(`%${companyCode}%`);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 검색
|
|
||||||
if (searchText) {
|
|
||||||
whereConditions.push(`m.objid LIKE $${paramIndex}`);
|
|
||||||
params.push(`%${searchText}%`);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereClause =
|
|
||||||
whereConditions.length > 0
|
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
// 카운트 쿼리 (고유한 수주 개수)
|
|
||||||
const countQuery = `
|
|
||||||
SELECT COUNT(DISTINCT m.objid) as count
|
|
||||||
FROM order_mng_master m
|
|
||||||
${whereClause}
|
|
||||||
`;
|
|
||||||
const countResult = await pool.query(countQuery, params);
|
|
||||||
const total = parseInt(countResult.rows[0]?.count || "0");
|
|
||||||
|
|
||||||
// 데이터 쿼리 (마스터 + 품목 JOIN)
|
|
||||||
const dataQuery = `
|
|
||||||
SELECT
|
|
||||||
m.objid as order_no,
|
|
||||||
m.partner_objid,
|
|
||||||
m.final_delivery_date,
|
|
||||||
m.reason,
|
|
||||||
m.status,
|
|
||||||
m.reg_date,
|
|
||||||
m.writer,
|
|
||||||
COALESCE(
|
|
||||||
json_agg(
|
|
||||||
CASE WHEN s.objid IS NOT NULL THEN
|
|
||||||
json_build_object(
|
|
||||||
'sub_objid', s.objid,
|
|
||||||
'part_objid', s.part_objid,
|
|
||||||
'partner_price', s.partner_price,
|
|
||||||
'partner_qty', s.partner_qty,
|
|
||||||
'delivery_date', s.delivery_date,
|
|
||||||
'status', s.status,
|
|
||||||
'regdate', s.regdate
|
|
||||||
)
|
|
||||||
END
|
|
||||||
ORDER BY s.regdate
|
|
||||||
) FILTER (WHERE s.objid IS NOT NULL),
|
|
||||||
'[]'::json
|
|
||||||
) as items
|
|
||||||
FROM order_mng_master m
|
|
||||||
LEFT JOIN order_mng_sub s ON m.objid = s.order_mng_master_objid
|
|
||||||
${whereClause}
|
|
||||||
GROUP BY m.objid, m.partner_objid, m.final_delivery_date, m.reason, m.status, m.reg_date, m.writer
|
|
||||||
ORDER BY m.reg_date DESC
|
|
||||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
|
||||||
`;
|
|
||||||
|
|
||||||
params.push(parseInt(limit as string));
|
|
||||||
params.push(offset);
|
|
||||||
|
|
||||||
const dataResult = await pool.query(dataQuery, params);
|
|
||||||
|
|
||||||
logger.info("수주 목록 조회 성공", {
|
|
||||||
companyCode,
|
|
||||||
total,
|
|
||||||
page: parseInt(page as string),
|
|
||||||
itemCount: dataResult.rows.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: dataResult.rows,
|
|
||||||
pagination: {
|
|
||||||
total,
|
|
||||||
page: parseInt(page as string),
|
|
||||||
limit: parseInt(limit as string),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error("수주 목록 조회 오류", { error: error.message });
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { Router } from "express";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
|
||||||
import { createOrder, getOrders } from "../controllers/orderController";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수주 등록
|
|
||||||
* POST /api/orders
|
|
||||||
*/
|
|
||||||
router.post("/", authenticateToken, createOrder);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수주 목록 조회
|
|
||||||
* GET /api/orders
|
|
||||||
*/
|
|
||||||
router.get("/", authenticateToken, getOrders);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
|
|
@ -959,9 +959,10 @@ class NumberingRuleService {
|
||||||
|
|
||||||
switch (part.partType) {
|
switch (part.partType) {
|
||||||
case "sequence": {
|
case "sequence": {
|
||||||
// 순번 (자동 증가 숫자)
|
// 순번 (자동 증가 숫자 - 다음 번호 사용)
|
||||||
const length = autoConfig.sequenceLength || 3;
|
const length = autoConfig.sequenceLength || 3;
|
||||||
return String(rule.currentSequence || 1).padStart(length, "0");
|
const nextSequence = (rule.currentSequence || 0) + 1;
|
||||||
|
return String(nextSequence).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
case "number": {
|
case "number": {
|
||||||
|
|
|
||||||
|
|
@ -390,9 +390,11 @@ export interface RowDetailPopupConfig {
|
||||||
// 추가 데이터 조회 설정
|
// 추가 데이터 조회 설정
|
||||||
additionalQuery?: {
|
additionalQuery?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
queryMode?: "table" | "custom"; // 조회 모드: table(테이블 조회), custom(커스텀 쿼리)
|
||||||
tableName: string; // 조회할 테이블명 (예: vehicles)
|
tableName: string; // 조회할 테이블명 (예: vehicles)
|
||||||
matchColumn: string; // 매칭할 컬럼 (예: id)
|
matchColumn: string; // 매칭할 컬럼 (예: id)
|
||||||
sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일)
|
sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일)
|
||||||
|
customQuery?: string; // 커스텀 쿼리 ({id}, {vehicle_number} 등 파라미터 사용)
|
||||||
// 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시)
|
// 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시)
|
||||||
displayColumns?: DisplayColumnConfig[];
|
displayColumns?: DisplayColumnConfig[];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
||||||
checked={popupConfig.additionalQuery?.enabled || false}
|
checked={popupConfig.additionalQuery?.enabled || false}
|
||||||
onCheckedChange={(enabled) =>
|
onCheckedChange={(enabled) =>
|
||||||
updatePopupConfig({
|
updatePopupConfig({
|
||||||
additionalQuery: { ...popupConfig.additionalQuery, enabled, tableName: "", matchColumn: "" },
|
additionalQuery: { ...popupConfig.additionalQuery, enabled, queryMode: "table", tableName: "", matchColumn: "" },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
aria-label="추가 데이터 조회 활성화"
|
aria-label="추가 데이터 조회 활성화"
|
||||||
|
|
@ -167,116 +167,230 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
||||||
|
|
||||||
{popupConfig.additionalQuery?.enabled && (
|
{popupConfig.additionalQuery?.enabled && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{/* 조회 모드 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">테이블명</Label>
|
<Label className="text-xs">조회 모드</Label>
|
||||||
<Input
|
<Select
|
||||||
value={popupConfig.additionalQuery?.tableName || ""}
|
value={popupConfig.additionalQuery?.queryMode || "table"}
|
||||||
onChange={(e) =>
|
onValueChange={(value: "table" | "custom") =>
|
||||||
updatePopupConfig({
|
updatePopupConfig({
|
||||||
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
|
additionalQuery: { ...popupConfig.additionalQuery!, queryMode: value },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="vehicles"
|
>
|
||||||
className="mt-1 h-8 text-xs"
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||||
/>
|
<SelectValue />
|
||||||
</div>
|
</SelectTrigger>
|
||||||
<div>
|
<SelectContent>
|
||||||
<Label className="text-xs">매칭 컬럼 (조회 테이블)</Label>
|
<SelectItem value="table">테이블 조회</SelectItem>
|
||||||
<Input
|
<SelectItem value="custom">커스텀 쿼리</SelectItem>
|
||||||
value={popupConfig.additionalQuery?.matchColumn || ""}
|
</SelectContent>
|
||||||
onChange={(e) =>
|
</Select>
|
||||||
updatePopupConfig({
|
|
||||||
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="id"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">소스 컬럼 (클릭한 행)</Label>
|
|
||||||
<Input
|
|
||||||
value={popupConfig.additionalQuery?.sourceColumn || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updatePopupConfig({
|
|
||||||
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="비워두면 매칭 컬럼과 동일"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 표시할 컬럼 선택 (다중 선택 + 라벨 편집) */}
|
{/* 테이블 조회 모드 */}
|
||||||
|
{(popupConfig.additionalQuery?.queryMode || "table") === "table" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">테이블명</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.additionalQuery?.tableName || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="vehicles"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">매칭 컬럼 (조회 테이블)</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.additionalQuery?.matchColumn || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="id"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">소스 컬럼 (클릭한 행)</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.additionalQuery?.sourceColumn || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="비워두면 매칭 컬럼과 동일"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 커스텀 쿼리 모드 */}
|
||||||
|
{popupConfig.additionalQuery?.queryMode === "custom" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">소스 컬럼 (클릭한 행)</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.additionalQuery?.sourceColumn || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="id"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">쿼리에서 사용할 파라미터 컬럼</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">커스텀 쿼리</Label>
|
||||||
|
<textarea
|
||||||
|
value={popupConfig.additionalQuery?.customQuery || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, customQuery: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={`SELECT
|
||||||
|
v.vehicle_number AS "차량번호",
|
||||||
|
ROUND(SUM(ts.loaded_distance_km)::NUMERIC, 2) AS "운행거리"
|
||||||
|
FROM vehicles v
|
||||||
|
LEFT JOIN transport_statistics ts ON v.id = ts.vehicle_id
|
||||||
|
WHERE v.id = {id}
|
||||||
|
GROUP BY v.id;`}
|
||||||
|
className="mt-1 h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
{"{id}"}, {"{vehicle_number}"} 등 클릭한 행의 컬럼값을 파라미터로 사용
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 표시할 컬럼 선택 - 테이블 모드와 커스텀 쿼리 모드 분기 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">표시할 컬럼 선택</Label>
|
<Label className="text-xs">표시할 컬럼 선택</Label>
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
{/* 테이블 모드: 기존 쿼리 결과에서 선택 */}
|
||||||
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
|
{popupConfig.additionalQuery?.queryMode !== "custom" && (
|
||||||
<span className="truncate">
|
<>
|
||||||
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
|
<Popover>
|
||||||
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
|
<PopoverTrigger asChild>
|
||||||
: "전체 표시 (클릭하여 선택)"}
|
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
|
||||||
</span>
|
<span className="truncate">
|
||||||
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
|
||||||
</Button>
|
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
|
||||||
</PopoverTrigger>
|
: "전체 표시 (클릭하여 선택)"}
|
||||||
<PopoverContent className="w-72 p-2" align="start">
|
</span>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
<span className="text-xs font-medium">컬럼 선택</span>
|
</Button>
|
||||||
<Button
|
</PopoverTrigger>
|
||||||
variant="ghost"
|
<PopoverContent className="w-72 p-2" align="start">
|
||||||
size="sm"
|
<div className="mb-2 flex items-center justify-between">
|
||||||
className="h-6 text-xs"
|
<span className="text-xs font-medium">컬럼 선택</span>
|
||||||
onClick={() =>
|
<Button
|
||||||
updatePopupConfig({
|
variant="ghost"
|
||||||
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
|
size="sm"
|
||||||
})
|
className="h-6 text-xs"
|
||||||
}
|
onClick={() =>
|
||||||
>
|
|
||||||
초기화
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
|
||||||
{/* 쿼리 결과 컬럼 목록 */}
|
|
||||||
{queryResult?.columns.map((col) => {
|
|
||||||
const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
|
|
||||||
const existingConfig = currentColumns.find((c) =>
|
|
||||||
typeof c === 'object' ? c.column === col : c === col
|
|
||||||
);
|
|
||||||
const isSelected = !!existingConfig;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={col}
|
|
||||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
|
|
||||||
onClick={() => {
|
|
||||||
const newColumns = isSelected
|
|
||||||
? currentColumns.filter((c) =>
|
|
||||||
typeof c === 'object' ? c.column !== col : c !== col
|
|
||||||
)
|
|
||||||
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
|
|
||||||
updatePopupConfig({
|
updatePopupConfig({
|
||||||
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
>
|
>
|
||||||
<Checkbox checked={isSelected} className="h-3 w-3" />
|
초기화
|
||||||
<span className="text-xs">{col}</span>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||||
})}
|
{/* 쿼리 결과 컬럼 목록 */}
|
||||||
{(!queryResult?.columns || queryResult.columns.length === 0) && (
|
{queryResult?.columns.map((col) => {
|
||||||
<p className="text-muted-foreground py-2 text-center text-xs">
|
const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
|
||||||
쿼리를 먼저 실행해주세요
|
const existingConfig = currentColumns.find((c) =>
|
||||||
</p>
|
typeof c === 'object' ? c.column === col : c === col
|
||||||
|
);
|
||||||
|
const isSelected = !!existingConfig;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = isSelected
|
||||||
|
? currentColumns.filter((c) =>
|
||||||
|
typeof c === 'object' ? c.column !== col : c !== col
|
||||||
|
)
|
||||||
|
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={isSelected} className="h-3 w-3" />
|
||||||
|
<span className="text-xs">{col}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(!queryResult?.columns || queryResult.columns.length === 0) && (
|
||||||
|
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||||
|
쿼리를 먼저 실행해주세요
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">비워두면 모든 컬럼이 표시됩니다</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 커스텀 쿼리 모드: 직접 입력 방식 */}
|
||||||
|
{popupConfig.additionalQuery?.queryMode === "custom" && (
|
||||||
|
<>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
커스텀 쿼리의 결과 컬럼이 자동으로 표시됩니다.
|
||||||
|
쿼리에서 AS "라벨명" 형태로 alias를 지정하면 해당 라벨로 표시됩니다.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || []), { column: "", label: "" }];
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
컬럼 추가 (선택사항)
|
||||||
|
</Button>
|
||||||
|
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</>
|
||||||
</Popover>
|
)}
|
||||||
<p className="text-muted-foreground mt-1 text-xs">비워두면 모든 컬럼이 표시됩니다</p>
|
|
||||||
|
|
||||||
{/* 선택된 컬럼 라벨 편집 */}
|
{/* 선택된 컬럼 라벨 편집 (테이블 모드) */}
|
||||||
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
|
{popupConfig.additionalQuery?.queryMode !== "custom" && (popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
<Label className="text-xs">컬럼 라벨 설정</Label>
|
<Label className="text-xs">컬럼 라벨 설정</Label>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
@ -321,6 +435,63 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 커스텀 쿼리 모드: 직접 입력 컬럼 편집 */}
|
||||||
|
{popupConfig.additionalQuery?.queryMode === "custom" && (popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<Label className="text-xs">표시할 컬럼 직접 입력</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">커스텀 쿼리 결과의 컬럼명을 직접 입력하세요</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{popupConfig.additionalQuery?.displayColumns?.map((colConfig, index) => {
|
||||||
|
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
||||||
|
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={column}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
|
||||||
|
newColumns[index] = { column: e.target.value, label: label || e.target.value };
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="컬럼명 (쿼리 결과)"
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
|
||||||
|
newColumns[index] = { column, label: e.target.value };
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="표시 라벨"
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = (popupConfig.additionalQuery?.displayColumns || []).filter(
|
||||||
|
(_, i) => i !== index
|
||||||
|
);
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -64,22 +64,35 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
|
|
||||||
// 추가 데이터 조회 설정이 있으면 실행
|
// 추가 데이터 조회 설정이 있으면 실행
|
||||||
const additionalQuery = config.rowDetailPopup?.additionalQuery;
|
const additionalQuery = config.rowDetailPopup?.additionalQuery;
|
||||||
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
|
if (additionalQuery?.enabled) {
|
||||||
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
const queryMode = additionalQuery.queryMode || "table";
|
||||||
const matchValue = row[sourceColumn];
|
|
||||||
|
|
||||||
if (matchValue !== undefined && matchValue !== null) {
|
// 커스텀 쿼리 모드
|
||||||
|
if (queryMode === "custom" && additionalQuery.customQuery) {
|
||||||
setDetailPopupLoading(true);
|
setDetailPopupLoading(true);
|
||||||
try {
|
try {
|
||||||
const query = `
|
// 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
|
||||||
SELECT *
|
let query = additionalQuery.customQuery;
|
||||||
FROM ${additionalQuery.tableName}
|
// console.log("🔍 [ListWidget] 커스텀 쿼리 파라미터 치환 시작");
|
||||||
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
// console.log("🔍 [ListWidget] 클릭한 행 데이터:", row);
|
||||||
LIMIT 1;
|
// console.log("🔍 [ListWidget] 행 컬럼 목록:", Object.keys(row));
|
||||||
`;
|
|
||||||
|
Object.keys(row).forEach((key) => {
|
||||||
|
const value = row[key];
|
||||||
|
const placeholder = new RegExp(`\\{${key}\\}`, "g");
|
||||||
|
// SQL 인젝션 방지를 위해 값 이스케이프
|
||||||
|
const safeValue = typeof value === "string"
|
||||||
|
? value.replace(/'/g, "''")
|
||||||
|
: value;
|
||||||
|
query = query.replace(placeholder, String(safeValue ?? ""));
|
||||||
|
// console.log(`🔍 [ListWidget] 치환: {${key}} → ${safeValue}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log("🔍 [ListWidget] 최종 쿼리:", query);
|
||||||
|
|
||||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
const result = await dashboardApi.executeQuery(query);
|
const result = await dashboardApi.executeQuery(query);
|
||||||
|
// console.log("🔍 [ListWidget] 쿼리 결과:", result);
|
||||||
|
|
||||||
if (result.success && result.rows.length > 0) {
|
if (result.success && result.rows.length > 0) {
|
||||||
setAdditionalDetailData(result.rows[0]);
|
setAdditionalDetailData(result.rows[0]);
|
||||||
|
|
@ -87,12 +100,43 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
setAdditionalDetailData({});
|
setAdditionalDetailData({});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("추가 데이터 로드 실패:", error);
|
console.error("커스텀 쿼리 실행 실패:", error);
|
||||||
setAdditionalDetailData({});
|
setAdditionalDetailData({});
|
||||||
} finally {
|
} finally {
|
||||||
setDetailPopupLoading(false);
|
setDetailPopupLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 테이블 조회 모드
|
||||||
|
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
|
||||||
|
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
||||||
|
const matchValue = row[sourceColumn];
|
||||||
|
|
||||||
|
if (matchValue !== undefined && matchValue !== null) {
|
||||||
|
setDetailPopupLoading(true);
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT *
|
||||||
|
FROM ${additionalQuery.tableName}
|
||||||
|
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
||||||
|
LIMIT 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
|
const result = await dashboardApi.executeQuery(query);
|
||||||
|
|
||||||
|
if (result.success && result.rows.length > 0) {
|
||||||
|
setAdditionalDetailData(result.rows[0]);
|
||||||
|
} else {
|
||||||
|
setAdditionalDetailData({});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("추가 데이터 로드 실패:", error);
|
||||||
|
setAdditionalDetailData({});
|
||||||
|
} finally {
|
||||||
|
setDetailPopupLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[config.rowDetailPopup],
|
[config.rowDetailPopup],
|
||||||
|
|
@ -190,22 +234,34 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
|
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
|
||||||
const groups: FieldGroup[] = [];
|
const groups: FieldGroup[] = [];
|
||||||
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
|
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
|
||||||
|
const queryMode = config.rowDetailPopup?.additionalQuery?.queryMode || "table";
|
||||||
|
|
||||||
|
// 커스텀 쿼리 모드일 때는 additional 데이터를 우선 사용
|
||||||
|
// row와 additional을 병합하되, 커스텀 쿼리 결과(additional)가 우선
|
||||||
|
const mergedData = queryMode === "custom" && additional && Object.keys(additional).length > 0
|
||||||
|
? { ...row, ...additional } // additional이 row를 덮어씀
|
||||||
|
: row;
|
||||||
|
|
||||||
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
|
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
|
||||||
let basicFields: { column: string; label: string }[] = [];
|
let basicFields: { column: string; label: string }[] = [];
|
||||||
|
|
||||||
if (displayColumns && displayColumns.length > 0) {
|
if (displayColumns && displayColumns.length > 0) {
|
||||||
// DisplayColumnConfig 형식 지원
|
// DisplayColumnConfig 형식 지원
|
||||||
|
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
|
||||||
basicFields = displayColumns
|
basicFields = displayColumns
|
||||||
.map((colConfig) => {
|
.map((colConfig) => {
|
||||||
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
||||||
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
|
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
|
||||||
return { column, label };
|
return { column, label };
|
||||||
})
|
})
|
||||||
.filter((item) => item.column in row);
|
.filter((item) => item.column in mergedData);
|
||||||
} else {
|
} else {
|
||||||
// 전체 컬럼
|
// 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
|
||||||
basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
|
if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
|
||||||
|
basicFields = Object.keys(additional).map((key) => ({ column: key, label: key }));
|
||||||
|
} else {
|
||||||
|
basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
|
|
@ -220,8 +276,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
|
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
|
||||||
if (additional && Object.keys(additional).length > 0) {
|
if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
|
||||||
// 운행 정보
|
// 운행 정보
|
||||||
if (additional.last_trip_start || additional.last_trip_end) {
|
if (additional.last_trip_start || additional.last_trip_end) {
|
||||||
groups.push({
|
groups.push({
|
||||||
|
|
|
||||||
|
|
@ -96,22 +96,35 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
|
|
||||||
// 추가 데이터 조회 설정이 있으면 실행
|
// 추가 데이터 조회 설정이 있으면 실행
|
||||||
const additionalQuery = config.rowDetailPopup?.additionalQuery;
|
const additionalQuery = config.rowDetailPopup?.additionalQuery;
|
||||||
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
|
if (additionalQuery?.enabled) {
|
||||||
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
const queryMode = additionalQuery.queryMode || "table";
|
||||||
const matchValue = row[sourceColumn];
|
|
||||||
|
|
||||||
if (matchValue !== undefined && matchValue !== null) {
|
// 커스텀 쿼리 모드
|
||||||
|
if (queryMode === "custom" && additionalQuery.customQuery) {
|
||||||
setDetailPopupLoading(true);
|
setDetailPopupLoading(true);
|
||||||
try {
|
try {
|
||||||
const query = `
|
// 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
|
||||||
SELECT *
|
let query = additionalQuery.customQuery;
|
||||||
FROM ${additionalQuery.tableName}
|
// console.log("🔍 [ListTestWidget] 커스텀 쿼리 파라미터 치환 시작");
|
||||||
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
// console.log("🔍 [ListTestWidget] 클릭한 행 데이터:", row);
|
||||||
LIMIT 1;
|
// console.log("🔍 [ListTestWidget] 행 컬럼 목록:", Object.keys(row));
|
||||||
`;
|
|
||||||
|
Object.keys(row).forEach((key) => {
|
||||||
|
const value = row[key];
|
||||||
|
const placeholder = new RegExp(`\\{${key}\\}`, "g");
|
||||||
|
// SQL 인젝션 방지를 위해 값 이스케이프
|
||||||
|
const safeValue = typeof value === "string"
|
||||||
|
? value.replace(/'/g, "''")
|
||||||
|
: value;
|
||||||
|
query = query.replace(placeholder, String(safeValue ?? ""));
|
||||||
|
// console.log(`🔍 [ListTestWidget] 치환: {${key}} → ${safeValue}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log("🔍 [ListTestWidget] 최종 쿼리:", query);
|
||||||
|
|
||||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
const result = await dashboardApi.executeQuery(query);
|
const result = await dashboardApi.executeQuery(query);
|
||||||
|
// console.log("🔍 [ListTestWidget] 쿼리 결과:", result);
|
||||||
|
|
||||||
if (result.success && result.rows.length > 0) {
|
if (result.success && result.rows.length > 0) {
|
||||||
setAdditionalDetailData(result.rows[0]);
|
setAdditionalDetailData(result.rows[0]);
|
||||||
|
|
@ -119,12 +132,43 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
setAdditionalDetailData({});
|
setAdditionalDetailData({});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("추가 데이터 로드 실패:", err);
|
console.error("커스텀 쿼리 실행 실패:", err);
|
||||||
setAdditionalDetailData({});
|
setAdditionalDetailData({});
|
||||||
} finally {
|
} finally {
|
||||||
setDetailPopupLoading(false);
|
setDetailPopupLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 테이블 조회 모드
|
||||||
|
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
|
||||||
|
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
||||||
|
const matchValue = row[sourceColumn];
|
||||||
|
|
||||||
|
if (matchValue !== undefined && matchValue !== null) {
|
||||||
|
setDetailPopupLoading(true);
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT *
|
||||||
|
FROM ${additionalQuery.tableName}
|
||||||
|
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
||||||
|
LIMIT 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
|
const result = await dashboardApi.executeQuery(query);
|
||||||
|
|
||||||
|
if (result.success && result.rows.length > 0) {
|
||||||
|
setAdditionalDetailData(result.rows[0]);
|
||||||
|
} else {
|
||||||
|
setAdditionalDetailData({});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("추가 데이터 로드 실패:", err);
|
||||||
|
setAdditionalDetailData({});
|
||||||
|
} finally {
|
||||||
|
setDetailPopupLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[config.rowDetailPopup],
|
[config.rowDetailPopup],
|
||||||
|
|
@ -222,13 +266,21 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
|
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
|
||||||
const groups: FieldGroup[] = [];
|
const groups: FieldGroup[] = [];
|
||||||
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
|
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
|
||||||
|
const queryMode = config.rowDetailPopup?.additionalQuery?.queryMode || "table";
|
||||||
|
|
||||||
|
// 커스텀 쿼리 모드일 때는 additional 데이터를 우선 사용
|
||||||
|
// row와 additional을 병합하되, 커스텀 쿼리 결과(additional)가 우선
|
||||||
|
const mergedData = queryMode === "custom" && additional && Object.keys(additional).length > 0
|
||||||
|
? { ...row, ...additional } // additional이 row를 덮어씀
|
||||||
|
: row;
|
||||||
|
|
||||||
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
|
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
|
||||||
const allKeys = Object.keys(row).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
|
const allKeys = Object.keys(mergedData).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
|
||||||
let basicFields: { column: string; label: string }[] = [];
|
let basicFields: { column: string; label: string }[] = [];
|
||||||
|
|
||||||
if (displayColumns && displayColumns.length > 0) {
|
if (displayColumns && displayColumns.length > 0) {
|
||||||
// DisplayColumnConfig 형식 지원
|
// DisplayColumnConfig 형식 지원
|
||||||
|
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
|
||||||
basicFields = displayColumns
|
basicFields = displayColumns
|
||||||
.map((colConfig) => {
|
.map((colConfig) => {
|
||||||
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
||||||
|
|
@ -237,8 +289,14 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
})
|
})
|
||||||
.filter((item) => allKeys.includes(item.column));
|
.filter((item) => allKeys.includes(item.column));
|
||||||
} else {
|
} else {
|
||||||
// 전체 컬럼
|
// 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
|
||||||
basicFields = allKeys.map((key) => ({ column: key, label: key }));
|
if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
|
||||||
|
basicFields = Object.keys(additional)
|
||||||
|
.filter((key) => !key.startsWith("_"))
|
||||||
|
.map((key) => ({ column: key, label: key }));
|
||||||
|
} else {
|
||||||
|
basicFields = allKeys.map((key) => ({ column: key, label: key }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
|
|
@ -253,8 +311,8 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
|
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
|
||||||
if (additional && Object.keys(additional).length > 0) {
|
if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
|
||||||
// 운행 정보
|
// 운행 정보
|
||||||
if (additional.last_trip_start || additional.last_trip_end) {
|
if (additional.last_trip_start || additional.last_trip_end) {
|
||||||
groups.push({
|
groups.push({
|
||||||
|
|
|
||||||
|
|
@ -203,11 +203,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
setTripInfoLoading(identifier);
|
setTripInfoLoading(identifier);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// user_id 또는 vehicle_number로 조회
|
// user_id 또는 vehicle_number로 조회 (시간은 KST로 변환)
|
||||||
const query = `SELECT
|
const query = `SELECT
|
||||||
id, vehicle_number, user_id,
|
id, vehicle_number, user_id,
|
||||||
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
|
(last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start,
|
||||||
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
|
(last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end,
|
||||||
|
last_trip_distance, last_trip_time,
|
||||||
|
(last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start,
|
||||||
|
(last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end,
|
||||||
|
last_empty_distance, last_empty_time,
|
||||||
departure, arrival, status
|
departure, arrival, status
|
||||||
FROM vehicles
|
FROM vehicles
|
||||||
WHERE user_id = '${identifier}'
|
WHERE user_id = '${identifier}'
|
||||||
|
|
@ -277,12 +281,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
if (identifiers.length === 0) return;
|
if (identifiers.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 모든 마커의 운행/공차 정보를 한 번에 조회
|
// 모든 마커의 운행/공차 정보를 한 번에 조회 (시간은 KST로 변환)
|
||||||
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
const query = `SELECT
|
const query = `SELECT
|
||||||
id, vehicle_number, user_id,
|
id, vehicle_number, user_id,
|
||||||
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
|
(last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start,
|
||||||
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
|
(last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end,
|
||||||
|
last_trip_distance, last_trip_time,
|
||||||
|
(last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start,
|
||||||
|
(last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end,
|
||||||
|
last_empty_distance, last_empty_time,
|
||||||
departure, arrival, status
|
departure, arrival, status
|
||||||
FROM vehicles
|
FROM vehicles
|
||||||
WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")})
|
WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")})
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수주 등록 전용 거래처 검색 컴포넌트
|
|
||||||
*
|
|
||||||
* 이 컴포넌트는 수주 등록 화면 전용이며, 설정이 고정되어 있습니다.
|
|
||||||
* 범용 AutocompleteSearchInput과 달리 customer_mng 테이블만 조회합니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface OrderCustomerSearchProps {
|
|
||||||
/** 현재 선택된 거래처 코드 */
|
|
||||||
value: string;
|
|
||||||
/** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */
|
|
||||||
onChange: (customerCode: string | null, fullData?: any) => void;
|
|
||||||
/** 비활성화 여부 */
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OrderCustomerSearch({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled = false,
|
|
||||||
}: OrderCustomerSearchProps) {
|
|
||||||
return (
|
|
||||||
<AutocompleteSearchInputComponent
|
|
||||||
// 고정 설정 (수주 등록 전용)
|
|
||||||
tableName="customer_mng"
|
|
||||||
displayField="customer_name"
|
|
||||||
valueField="customer_code"
|
|
||||||
searchFields={[
|
|
||||||
"customer_name",
|
|
||||||
"customer_code",
|
|
||||||
"business_number",
|
|
||||||
]}
|
|
||||||
placeholder="거래처명 입력하여 검색"
|
|
||||||
showAdditionalInfo
|
|
||||||
additionalFields={["customer_code", "address", "contact_phone"]}
|
|
||||||
|
|
||||||
// 외부에서 제어 가능한 prop
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { ModalRepeaterTableComponent } from "@/lib/registry/components/modal-repeater-table";
|
|
||||||
import type {
|
|
||||||
RepeaterColumnConfig,
|
|
||||||
CalculationRule,
|
|
||||||
} from "@/lib/registry/components/modal-repeater-table";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수주 등록 전용 품목 반복 테이블 컴포넌트
|
|
||||||
*
|
|
||||||
* 이 컴포넌트는 수주 등록 화면 전용이며, 설정이 고정되어 있습니다.
|
|
||||||
* 범용 ModalRepeaterTable과 달리 item_info 테이블만 조회하며,
|
|
||||||
* 수주 등록에 필요한 컬럼과 계산 공식이 미리 설정되어 있습니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface OrderItemRepeaterTableProps {
|
|
||||||
/** 현재 선택된 품목 목록 */
|
|
||||||
value: any[];
|
|
||||||
/** 품목 목록 변경 시 콜백 */
|
|
||||||
onChange: (items: any[]) => void;
|
|
||||||
/** 비활성화 여부 */
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 수주 등록 전용 컬럼 설정 (고정)
|
|
||||||
const ORDER_COLUMNS: RepeaterColumnConfig[] = [
|
|
||||||
{
|
|
||||||
field: "item_number",
|
|
||||||
label: "품번",
|
|
||||||
editable: false,
|
|
||||||
width: "120px",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "item_name",
|
|
||||||
label: "품명",
|
|
||||||
editable: false,
|
|
||||||
width: "180px",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "specification",
|
|
||||||
label: "규격",
|
|
||||||
editable: false,
|
|
||||||
width: "150px",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "material",
|
|
||||||
label: "재질",
|
|
||||||
editable: false,
|
|
||||||
width: "120px",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "quantity",
|
|
||||||
label: "수량",
|
|
||||||
type: "number",
|
|
||||||
editable: true,
|
|
||||||
required: true,
|
|
||||||
defaultValue: 1,
|
|
||||||
width: "100px",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "selling_price",
|
|
||||||
label: "단가",
|
|
||||||
type: "number",
|
|
||||||
editable: true,
|
|
||||||
required: true,
|
|
||||||
width: "120px",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "amount",
|
|
||||||
label: "금액",
|
|
||||||
type: "number",
|
|
||||||
editable: false,
|
|
||||||
calculated: true,
|
|
||||||
width: "120px",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "order_date",
|
|
||||||
label: "수주일",
|
|
||||||
type: "date",
|
|
||||||
editable: true,
|
|
||||||
width: "130px",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "delivery_date",
|
|
||||||
label: "납기일",
|
|
||||||
type: "date",
|
|
||||||
editable: true,
|
|
||||||
width: "130px",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 수주 등록 전용 계산 공식 (고정)
|
|
||||||
const ORDER_CALCULATION_RULES: CalculationRule[] = [
|
|
||||||
{
|
|
||||||
result: "amount",
|
|
||||||
formula: "quantity * selling_price",
|
|
||||||
dependencies: ["quantity", "selling_price"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function OrderItemRepeaterTable({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled = false,
|
|
||||||
}: OrderItemRepeaterTableProps) {
|
|
||||||
return (
|
|
||||||
<ModalRepeaterTableComponent
|
|
||||||
// 고정 설정 (수주 등록 전용)
|
|
||||||
sourceTable="item_info"
|
|
||||||
sourceColumns={[
|
|
||||||
"item_number",
|
|
||||||
"item_name",
|
|
||||||
"specification",
|
|
||||||
"material",
|
|
||||||
"unit",
|
|
||||||
"selling_price",
|
|
||||||
]}
|
|
||||||
sourceSearchFields={["item_name", "item_number", "specification"]}
|
|
||||||
modalTitle="품목 검색 및 선택"
|
|
||||||
modalButtonText="품목 검색"
|
|
||||||
multiSelect={true}
|
|
||||||
columns={ORDER_COLUMNS}
|
|
||||||
calculationRules={ORDER_CALCULATION_RULES}
|
|
||||||
uniqueField="item_number"
|
|
||||||
|
|
||||||
// 외부에서 제어 가능한 prop
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,572 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { OrderCustomerSearch } from "./OrderCustomerSearch";
|
|
||||||
import { OrderItemRepeaterTable } from "./OrderItemRepeaterTable";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { apiClient } from "@/lib/api/client";
|
|
||||||
|
|
||||||
interface OrderRegistrationModalProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OrderRegistrationModal({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onSuccess,
|
|
||||||
}: OrderRegistrationModalProps) {
|
|
||||||
// 입력 방식
|
|
||||||
const [inputMode, setInputMode] = useState<string>("customer_first");
|
|
||||||
|
|
||||||
// 판매 유형 (국내/해외)
|
|
||||||
const [salesType, setSalesType] = useState<string>("domestic");
|
|
||||||
|
|
||||||
// 단가 기준 (기준단가/거래처별단가)
|
|
||||||
const [priceType, setPriceType] = useState<string>("standard");
|
|
||||||
|
|
||||||
// 폼 데이터
|
|
||||||
const [formData, setFormData] = useState<any>({
|
|
||||||
customerCode: "",
|
|
||||||
customerName: "",
|
|
||||||
contactPerson: "",
|
|
||||||
deliveryDestination: "",
|
|
||||||
deliveryAddress: "",
|
|
||||||
deliveryDate: "",
|
|
||||||
memo: "",
|
|
||||||
// 무역 정보 (해외 판매 시)
|
|
||||||
incoterms: "",
|
|
||||||
paymentTerms: "",
|
|
||||||
currency: "KRW",
|
|
||||||
portOfLoading: "",
|
|
||||||
portOfDischarge: "",
|
|
||||||
hsCode: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 선택된 품목 목록
|
|
||||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
|
||||||
|
|
||||||
// 납기일 일괄 적용 플래그 (딱 한 번만 실행)
|
|
||||||
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
|
|
||||||
|
|
||||||
// 저장 중
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
// 저장 처리
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
// 유효성 검사
|
|
||||||
if (!formData.customerCode) {
|
|
||||||
toast.error("거래처를 선택해주세요");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedItems.length === 0) {
|
|
||||||
toast.error("품목을 추가해주세요");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
|
|
||||||
// 수주 등록 API 호출
|
|
||||||
const orderData: any = {
|
|
||||||
inputMode,
|
|
||||||
salesType,
|
|
||||||
priceType,
|
|
||||||
customerCode: formData.customerCode,
|
|
||||||
contactPerson: formData.contactPerson,
|
|
||||||
deliveryDestination: formData.deliveryDestination,
|
|
||||||
deliveryAddress: formData.deliveryAddress,
|
|
||||||
deliveryDate: formData.deliveryDate,
|
|
||||||
items: selectedItems,
|
|
||||||
memo: formData.memo,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 해외 판매 시 무역 정보 추가
|
|
||||||
if (salesType === "export") {
|
|
||||||
orderData.tradeInfo = {
|
|
||||||
incoterms: formData.incoterms,
|
|
||||||
paymentTerms: formData.paymentTerms,
|
|
||||||
currency: formData.currency,
|
|
||||||
portOfLoading: formData.portOfLoading,
|
|
||||||
portOfDischarge: formData.portOfDischarge,
|
|
||||||
hsCode: formData.hsCode,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await apiClient.post("/orders", orderData);
|
|
||||||
|
|
||||||
if (response.data.success) {
|
|
||||||
toast.success("수주가 등록되었습니다");
|
|
||||||
onOpenChange(false);
|
|
||||||
onSuccess?.();
|
|
||||||
|
|
||||||
// 폼 초기화
|
|
||||||
resetForm();
|
|
||||||
} else {
|
|
||||||
toast.error(response.data.message || "수주 등록에 실패했습니다");
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("수주 등록 오류:", error);
|
|
||||||
toast.error(
|
|
||||||
error.response?.data?.message || "수주 등록 중 오류가 발생했습니다"
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 취소 처리
|
|
||||||
const handleCancel = () => {
|
|
||||||
onOpenChange(false);
|
|
||||||
resetForm();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 폼 초기화
|
|
||||||
const resetForm = () => {
|
|
||||||
setInputMode("customer_first");
|
|
||||||
setSalesType("domestic");
|
|
||||||
setPriceType("standard");
|
|
||||||
setFormData({
|
|
||||||
customerCode: "",
|
|
||||||
customerName: "",
|
|
||||||
contactPerson: "",
|
|
||||||
deliveryDestination: "",
|
|
||||||
deliveryAddress: "",
|
|
||||||
deliveryDate: "",
|
|
||||||
memo: "",
|
|
||||||
incoterms: "",
|
|
||||||
paymentTerms: "",
|
|
||||||
currency: "KRW",
|
|
||||||
portOfLoading: "",
|
|
||||||
portOfDischarge: "",
|
|
||||||
hsCode: "",
|
|
||||||
});
|
|
||||||
setSelectedItems([]);
|
|
||||||
setIsDeliveryDateApplied(false); // 플래그 초기화
|
|
||||||
};
|
|
||||||
|
|
||||||
// 품목 목록 변경 핸들러 (납기일 일괄 적용 로직 포함)
|
|
||||||
const handleItemsChange = (newItems: any[]) => {
|
|
||||||
// 1️⃣ 플래그가 이미 true면 그냥 업데이트만 (일괄 적용 완료 상태)
|
|
||||||
if (isDeliveryDateApplied) {
|
|
||||||
setSelectedItems(newItems);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2️⃣ 품목이 없으면 그냥 업데이트
|
|
||||||
if (newItems.length === 0) {
|
|
||||||
setSelectedItems(newItems);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3️⃣ 현재 상태: 납기일이 있는 행과 없는 행 개수 체크
|
|
||||||
const itemsWithDate = newItems.filter((item) => item.delivery_date);
|
|
||||||
const itemsWithoutDate = newItems.filter((item) => !item.delivery_date);
|
|
||||||
|
|
||||||
// 4️⃣ 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용
|
|
||||||
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
|
|
||||||
// 5️⃣ 전체 일괄 적용
|
|
||||||
const selectedDate = itemsWithDate[0].delivery_date;
|
|
||||||
const updatedItems = newItems.map((item) => ({
|
|
||||||
...item,
|
|
||||||
delivery_date: selectedDate, // 모든 행에 동일한 납기일 적용
|
|
||||||
}));
|
|
||||||
|
|
||||||
setSelectedItems(updatedItems);
|
|
||||||
setIsDeliveryDateApplied(true); // 플래그 활성화 (다음부터는 일괄 적용 안 함)
|
|
||||||
|
|
||||||
console.log("✅ 납기일 일괄 적용 완료:", selectedDate);
|
|
||||||
console.log(` - 대상: ${itemsWithoutDate.length}개 행에 ${selectedDate} 적용`);
|
|
||||||
} else {
|
|
||||||
// 그냥 업데이트
|
|
||||||
setSelectedItems(newItems);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 전체 금액 계산
|
|
||||||
const totalAmount = selectedItems.reduce(
|
|
||||||
(sum, item) => sum + (item.amount || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] max-h-[90vh] overflow-hidden">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-base sm:text-lg">수주 등록</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
|
||||||
새로운 수주를 등록합니다
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 상단 셀렉트 박스 3개 */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
{/* 입력 방식 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="inputMode" className="text-xs sm:text-sm flex items-center gap-1">
|
|
||||||
<span className="text-amber-500">📝</span> 입력 방식
|
|
||||||
</Label>
|
|
||||||
<Select value={inputMode} onValueChange={setInputMode}>
|
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
||||||
<SelectValue placeholder="입력 방식 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="customer_first">거래처 우선</SelectItem>
|
|
||||||
<SelectItem value="quotation">견대 방식</SelectItem>
|
|
||||||
<SelectItem value="unit_price">단가 방식</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 판매 유형 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="salesType" className="text-xs sm:text-sm flex items-center gap-1">
|
|
||||||
<span className="text-blue-500">🌏</span> 판매 유형
|
|
||||||
</Label>
|
|
||||||
<Select value={salesType} onValueChange={setSalesType}>
|
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
||||||
<SelectValue placeholder="판매 유형 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="domestic">국내 판매</SelectItem>
|
|
||||||
<SelectItem value="export">해외 판매</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 단가 기준 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="priceType" className="text-xs sm:text-sm flex items-center gap-1">
|
|
||||||
<span className="text-green-500">💰</span> 단가 방식
|
|
||||||
</Label>
|
|
||||||
<Select value={priceType} onValueChange={setPriceType}>
|
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
||||||
<SelectValue placeholder="단가 방식 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="standard">기준 단가</SelectItem>
|
|
||||||
<SelectItem value="customer">거래처별 단가</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 거래처 정보 (항상 표시) */}
|
|
||||||
{inputMode === "customer_first" && (
|
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 space-y-4">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-gray-700">
|
|
||||||
<span>🏢</span>
|
|
||||||
<span>거래처 정보</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{/* 거래처 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs sm:text-sm">거래처 *</Label>
|
|
||||||
<OrderCustomerSearch
|
|
||||||
value={formData.customerCode}
|
|
||||||
onChange={(code, fullData) => {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
customerCode: code || "",
|
|
||||||
customerName: fullData?.customer_name || "",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 담당자 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="contactPerson" className="text-xs sm:text-sm">
|
|
||||||
담당자
|
|
||||||
</Label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="contactPerson"
|
|
||||||
placeholder="담당자"
|
|
||||||
value={formData.contactPerson}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, contactPerson: e.target.value })
|
|
||||||
}
|
|
||||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 납품처 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="deliveryDestination" className="text-xs sm:text-sm">
|
|
||||||
납품처
|
|
||||||
</Label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="deliveryDestination"
|
|
||||||
placeholder="납품처"
|
|
||||||
value={formData.deliveryDestination}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, deliveryDestination: e.target.value })
|
|
||||||
}
|
|
||||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 납품장소 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="deliveryAddress" className="text-xs sm:text-sm">
|
|
||||||
납품장소
|
|
||||||
</Label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="deliveryAddress"
|
|
||||||
placeholder="납품장소"
|
|
||||||
value={formData.deliveryAddress}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, deliveryAddress: e.target.value })
|
|
||||||
}
|
|
||||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{inputMode === "quotation" && (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs sm:text-sm">견대 번호 *</Label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="견대 번호를 입력하세요"
|
|
||||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{inputMode === "unit_price" && (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs sm:text-sm">단가 방식 설정</Label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="단가 정보 입력"
|
|
||||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 추가된 품목 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs sm:text-sm">추가된 품목</Label>
|
|
||||||
<OrderItemRepeaterTable
|
|
||||||
value={selectedItems}
|
|
||||||
onChange={handleItemsChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 전체 금액 표시 */}
|
|
||||||
{selectedItems.length > 0 && (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<div className="text-sm sm:text-base font-semibold">
|
|
||||||
전체 금액: {totalAmount.toLocaleString()}원
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 무역 정보 (해외 판매 시에만 표시) */}
|
|
||||||
{salesType === "export" && (
|
|
||||||
<div className="rounded-lg border border-blue-200 bg-blue-50/50 p-4 space-y-4">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-blue-700">
|
|
||||||
<span>🌏</span>
|
|
||||||
<span>무역 정보</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
{/* 인코텀즈 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="incoterms" className="text-xs sm:text-sm">
|
|
||||||
인코텀즈
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.incoterms}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({ ...formData, incoterms: value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
||||||
<SelectValue placeholder="선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="EXW">EXW</SelectItem>
|
|
||||||
<SelectItem value="FOB">FOB</SelectItem>
|
|
||||||
<SelectItem value="CIF">CIF</SelectItem>
|
|
||||||
<SelectItem value="DDP">DDP</SelectItem>
|
|
||||||
<SelectItem value="DAP">DAP</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 결제 조건 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="paymentTerms" className="text-xs sm:text-sm">
|
|
||||||
결제 조건
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.paymentTerms}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({ ...formData, paymentTerms: value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
||||||
<SelectValue placeholder="선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="advance">선결제</SelectItem>
|
|
||||||
<SelectItem value="cod">착불</SelectItem>
|
|
||||||
<SelectItem value="lc">신용장(L/C)</SelectItem>
|
|
||||||
<SelectItem value="net30">NET 30</SelectItem>
|
|
||||||
<SelectItem value="net60">NET 60</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 통화 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="currency" className="text-xs sm:text-sm">
|
|
||||||
통화
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.currency}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({ ...formData, currency: value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
||||||
<SelectValue placeholder="통화 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="KRW">KRW (원)</SelectItem>
|
|
||||||
<SelectItem value="USD">USD (달러)</SelectItem>
|
|
||||||
<SelectItem value="EUR">EUR (유로)</SelectItem>
|
|
||||||
<SelectItem value="JPY">JPY (엔)</SelectItem>
|
|
||||||
<SelectItem value="CNY">CNY (위안)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
{/* 선적항 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="portOfLoading" className="text-xs sm:text-sm">
|
|
||||||
선적항
|
|
||||||
</Label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="portOfLoading"
|
|
||||||
placeholder="선적항"
|
|
||||||
value={formData.portOfLoading}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, portOfLoading: e.target.value })
|
|
||||||
}
|
|
||||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 도착항 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="portOfDischarge" className="text-xs sm:text-sm">
|
|
||||||
도착항
|
|
||||||
</Label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="portOfDischarge"
|
|
||||||
placeholder="도착항"
|
|
||||||
value={formData.portOfDischarge}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, portOfDischarge: e.target.value })
|
|
||||||
}
|
|
||||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HS Code */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="hsCode" className="text-xs sm:text-sm">
|
|
||||||
HS Code
|
|
||||||
</Label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="hsCode"
|
|
||||||
placeholder="HS Code"
|
|
||||||
value={formData.hsCode}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, hsCode: e.target.value })
|
|
||||||
}
|
|
||||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 메모 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="memo" className="text-xs sm:text-sm">
|
|
||||||
메모
|
|
||||||
</Label>
|
|
||||||
<textarea
|
|
||||||
id="memo"
|
|
||||||
placeholder="메모를 입력하세요"
|
|
||||||
value={formData.memo}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, memo: e.target.value })
|
|
||||||
}
|
|
||||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
||||||
>
|
|
||||||
{isSaving ? "저장 중..." : "저장"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,374 +0,0 @@
|
||||||
# 수주 등록 컴포넌트
|
|
||||||
|
|
||||||
## 개요
|
|
||||||
|
|
||||||
수주 등록 기능을 위한 전용 컴포넌트들입니다. 이 컴포넌트들은 범용 컴포넌트를 래핑하여 수주 등록에 최적화된 고정 설정을 제공합니다.
|
|
||||||
|
|
||||||
## 컴포넌트 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/components/order/
|
|
||||||
├── OrderRegistrationModal.tsx # 수주 등록 메인 모달
|
|
||||||
├── OrderCustomerSearch.tsx # 거래처 검색 (전용)
|
|
||||||
├── OrderItemRepeaterTable.tsx # 품목 반복 테이블 (전용)
|
|
||||||
└── README.md # 문서 (현재 파일)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 1. OrderRegistrationModal
|
|
||||||
|
|
||||||
수주 등록 메인 모달 컴포넌트입니다.
|
|
||||||
|
|
||||||
### Props
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface OrderRegistrationModalProps {
|
|
||||||
/** 모달 열림/닫힘 상태 */
|
|
||||||
open: boolean;
|
|
||||||
/** 모달 상태 변경 핸들러 */
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
/** 수주 등록 성공 시 콜백 */
|
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 사용 예시
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal";
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button onClick={() => setIsOpen(true)}>수주 등록</Button>
|
|
||||||
|
|
||||||
<OrderRegistrationModal
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={setIsOpen}
|
|
||||||
onSuccess={() => {
|
|
||||||
console.log("수주 등록 완료!");
|
|
||||||
// 목록 새로고침 등
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 기능
|
|
||||||
|
|
||||||
- **입력 방식 선택**: 거래처 우선, 견적 방식, 단가 방식
|
|
||||||
- **거래처 검색**: 자동완성 드롭다운으로 거래처 검색 및 선택
|
|
||||||
- **품목 관리**: 모달에서 품목 검색 및 추가, 수량/단가 입력, 금액 자동 계산
|
|
||||||
- **전체 금액 표시**: 추가된 품목들의 총 금액 계산
|
|
||||||
- **유효성 검사**: 거래처 및 품목 필수 입력 체크
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. OrderCustomerSearch
|
|
||||||
|
|
||||||
수주 등록 전용 거래처 검색 컴포넌트입니다.
|
|
||||||
|
|
||||||
### 특징
|
|
||||||
|
|
||||||
- `customer_mng` 테이블만 조회 (고정)
|
|
||||||
- 거래처명, 거래처코드, 사업자번호로 검색 (고정)
|
|
||||||
- 추가 정보 표시 (주소, 연락처)
|
|
||||||
|
|
||||||
### Props
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface OrderCustomerSearchProps {
|
|
||||||
/** 현재 선택된 거래처 코드 */
|
|
||||||
value: string;
|
|
||||||
/** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */
|
|
||||||
onChange: (customerCode: string | null, fullData?: any) => void;
|
|
||||||
/** 비활성화 여부 */
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 사용 예시
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { OrderCustomerSearch } from "@/components/order/OrderCustomerSearch";
|
|
||||||
|
|
||||||
function MyForm() {
|
|
||||||
const [customerCode, setCustomerCode] = useState("");
|
|
||||||
const [customerName, setCustomerName] = useState("");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OrderCustomerSearch
|
|
||||||
value={customerCode}
|
|
||||||
onChange={(code, fullData) => {
|
|
||||||
setCustomerCode(code || "");
|
|
||||||
setCustomerName(fullData?.customer_name || "");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 고정 설정
|
|
||||||
|
|
||||||
| 설정 | 값 | 설명 |
|
|
||||||
|------|-----|------|
|
|
||||||
| `tableName` | `customer_mng` | 거래처 테이블 |
|
|
||||||
| `displayField` | `customer_name` | 표시 필드 |
|
|
||||||
| `valueField` | `customer_code` | 값 필드 |
|
|
||||||
| `searchFields` | `["customer_name", "customer_code", "business_number"]` | 검색 대상 필드 |
|
|
||||||
| `additionalFields` | `["customer_code", "address", "contact_phone"]` | 추가 표시 필드 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. OrderItemRepeaterTable
|
|
||||||
|
|
||||||
수주 등록 전용 품목 반복 테이블 컴포넌트입니다.
|
|
||||||
|
|
||||||
### 특징
|
|
||||||
|
|
||||||
- `item_info` 테이블만 조회 (고정)
|
|
||||||
- 수주에 필요한 컬럼만 표시 (품번, 품명, 수량, 단가, 금액 등)
|
|
||||||
- 금액 자동 계산 (`수량 * 단가`)
|
|
||||||
|
|
||||||
### Props
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface OrderItemRepeaterTableProps {
|
|
||||||
/** 현재 선택된 품목 목록 */
|
|
||||||
value: any[];
|
|
||||||
/** 품목 목록 변경 시 콜백 */
|
|
||||||
onChange: (items: any[]) => void;
|
|
||||||
/** 비활성화 여부 */
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 사용 예시
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { OrderItemRepeaterTable } from "@/components/order/OrderItemRepeaterTable";
|
|
||||||
|
|
||||||
function MyForm() {
|
|
||||||
const [items, setItems] = useState([]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OrderItemRepeaterTable
|
|
||||||
value={items}
|
|
||||||
onChange={setItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 고정 컬럼 설정
|
|
||||||
|
|
||||||
| 필드 | 라벨 | 타입 | 편집 | 필수 | 계산 | 설명 |
|
|
||||||
|------|------|------|------|------|------|------|
|
|
||||||
| `id` | 품번 | text | ❌ | - | - | 품목 ID |
|
|
||||||
| `item_name` | 품명 | text | ❌ | - | - | 품목명 |
|
|
||||||
| `item_number` | 품목번호 | text | ❌ | - | - | 품목 번호 |
|
|
||||||
| `quantity` | 수량 | number | ✅ | ✅ | - | 주문 수량 (기본값: 1) |
|
|
||||||
| `selling_price` | 단가 | number | ✅ | ✅ | - | 판매 단가 |
|
|
||||||
| `amount` | 금액 | number | ❌ | - | ✅ | 자동 계산 (수량 * 단가) |
|
|
||||||
| `delivery_date` | 납품일 | date | ✅ | - | - | 납품 예정일 |
|
|
||||||
| `note` | 비고 | text | ✅ | - | - | 추가 메모 |
|
|
||||||
|
|
||||||
### 계산 규칙
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
amount = quantity * selling_price
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 범용 컴포넌트 vs 전용 컴포넌트
|
|
||||||
|
|
||||||
### 왜 전용 컴포넌트를 만들었나?
|
|
||||||
|
|
||||||
| 항목 | 범용 컴포넌트 | 전용 컴포넌트 |
|
|
||||||
|------|--------------|--------------|
|
|
||||||
| **목적** | 화면 편집기에서 다양한 용도로 사용 | 수주 등록 전용 |
|
|
||||||
| **설정** | ConfigPanel에서 자유롭게 변경 가능 | 하드코딩으로 고정 |
|
|
||||||
| **유연성** | 높음 (모든 테이블/필드 지원) | 낮음 (수주에 최적화) |
|
|
||||||
| **안정성** | 사용자 실수 가능 | 설정 변경 불가로 안전 |
|
|
||||||
| **위치** | `lib/registry/components/` | `components/order/` |
|
|
||||||
|
|
||||||
### 범용 컴포넌트 (화면 편집기용)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// ❌ 수주 등록에서 사용 금지
|
|
||||||
<AutocompleteSearchInputComponent
|
|
||||||
tableName="???" // ConfigPanel에서 변경 가능
|
|
||||||
displayField="???" // 다른 테이블로 바꿀 수 있음
|
|
||||||
valueField="???" // 필드가 맞지 않으면 에러
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**문제점:**
|
|
||||||
- 사용자가 `tableName`을 `item_info`로 변경하면 거래처가 아닌 품목이 조회됨
|
|
||||||
- `valueField`를 변경하면 `formData.customerCode`에 잘못된 값 저장
|
|
||||||
- 수주 로직이 깨짐
|
|
||||||
|
|
||||||
### 전용 컴포넌트 (수주 등록용)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// ✅ 수주 등록에서 사용
|
|
||||||
<OrderCustomerSearch
|
|
||||||
value={customerCode} // 외부에서 제어 가능
|
|
||||||
onChange={handleChange} // 값 변경만 처리
|
|
||||||
// 나머지 설정은 내부에서 고정
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**장점:**
|
|
||||||
- 설정이 하드코딩되어 있어 변경 불가
|
|
||||||
- 수주 등록 로직에 최적화
|
|
||||||
- 안전하고 예측 가능
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API 엔드포인트
|
|
||||||
|
|
||||||
### 거래처 검색
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/entity-search/customer_mng
|
|
||||||
Query Parameters:
|
|
||||||
- searchText: 검색어
|
|
||||||
- searchFields: customer_name,customer_code,business_number
|
|
||||||
- page: 페이지 번호
|
|
||||||
- limit: 페이지 크기
|
|
||||||
```
|
|
||||||
|
|
||||||
### 품목 검색
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/entity-search/item_info
|
|
||||||
Query Parameters:
|
|
||||||
- searchText: 검색어
|
|
||||||
- searchFields: item_name,id,item_number
|
|
||||||
- page: 페이지 번호
|
|
||||||
- limit: 페이지 크기
|
|
||||||
```
|
|
||||||
|
|
||||||
### 수주 등록
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/orders
|
|
||||||
Body:
|
|
||||||
{
|
|
||||||
inputMode: "customer_first" | "quotation" | "unit_price",
|
|
||||||
customerCode: string,
|
|
||||||
deliveryDate?: string,
|
|
||||||
items: Array<{
|
|
||||||
id: string,
|
|
||||||
item_name: string,
|
|
||||||
quantity: number,
|
|
||||||
selling_price: number,
|
|
||||||
amount: number,
|
|
||||||
delivery_date?: string,
|
|
||||||
note?: string
|
|
||||||
}>,
|
|
||||||
memo?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
Response:
|
|
||||||
{
|
|
||||||
success: boolean,
|
|
||||||
data?: {
|
|
||||||
orderNumber: string,
|
|
||||||
orderId: number
|
|
||||||
},
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 멀티테넌시 (Multi-Tenancy)
|
|
||||||
|
|
||||||
모든 API 호출은 자동으로 `company_code` 필터링이 적용됩니다.
|
|
||||||
|
|
||||||
- 거래처 검색: 현재 로그인한 사용자의 회사에 속한 거래처만 조회
|
|
||||||
- 품목 검색: 현재 로그인한 사용자의 회사에 속한 품목만 조회
|
|
||||||
- 수주 등록: 자동으로 현재 사용자의 `company_code` 추가
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 트러블슈팅
|
|
||||||
|
|
||||||
### 1. 거래처가 검색되지 않음
|
|
||||||
|
|
||||||
**원인**: `customer_mng` 테이블에 데이터가 없거나 `company_code`가 다름
|
|
||||||
|
|
||||||
**해결**:
|
|
||||||
```sql
|
|
||||||
-- 거래처 데이터 확인
|
|
||||||
SELECT * FROM customer_mng WHERE company_code = 'YOUR_COMPANY_CODE';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 품목이 검색되지 않음
|
|
||||||
|
|
||||||
**원인**: `item_info` 테이블에 데이터가 없거나 `company_code`가 다름
|
|
||||||
|
|
||||||
**해결**:
|
|
||||||
```sql
|
|
||||||
-- 품목 데이터 확인
|
|
||||||
SELECT * FROM item_info WHERE company_code = 'YOUR_COMPANY_CODE';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 수주 등록 실패
|
|
||||||
|
|
||||||
**원인**: 필수 필드 누락 또는 백엔드 API 오류
|
|
||||||
|
|
||||||
**해결**:
|
|
||||||
1. 브라우저 개발자 도구 콘솔 확인
|
|
||||||
2. 네트워크 탭에서 API 응답 확인
|
|
||||||
3. 백엔드 로그 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 개발 참고 사항
|
|
||||||
|
|
||||||
### 새로운 전용 컴포넌트 추가 시
|
|
||||||
|
|
||||||
1. **범용 컴포넌트 활용**: 기존 범용 컴포넌트를 래핑
|
|
||||||
2. **설정 고정**: 비즈니스 로직에 필요한 설정을 하드코딩
|
|
||||||
3. **Props 최소화**: 외부에서 제어 가능한 최소한의 prop만 노출
|
|
||||||
4. **문서 작성**: README에 사용법 및 고정 설정 명시
|
|
||||||
|
|
||||||
### 예시: 견적 등록 전용 컴포넌트
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// QuotationCustomerSearch.tsx
|
|
||||||
export function QuotationCustomerSearch({ value, onChange }: Props) {
|
|
||||||
return (
|
|
||||||
<AutocompleteSearchInputComponent
|
|
||||||
tableName="customer_mng" // 고정
|
|
||||||
displayField="customer_name" // 고정
|
|
||||||
valueField="customer_code" // 고정
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 관련 파일
|
|
||||||
|
|
||||||
- 범용 컴포넌트:
|
|
||||||
- `lib/registry/components/autocomplete-search-input/`
|
|
||||||
- `lib/registry/components/entity-search-input/`
|
|
||||||
- `lib/registry/components/modal-repeater-table/`
|
|
||||||
|
|
||||||
- 백엔드 API:
|
|
||||||
- `backend-node/src/controllers/entitySearchController.ts`
|
|
||||||
- `backend-node/src/controllers/orderController.ts`
|
|
||||||
|
|
||||||
- 계획서:
|
|
||||||
- `수주등록_화면_개발_계획서.md`
|
|
||||||
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
export const INPUT_MODE = {
|
|
||||||
CUSTOMER_FIRST: "customer_first",
|
|
||||||
QUOTATION: "quotation",
|
|
||||||
UNIT_PRICE: "unit_price",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type InputMode = (typeof INPUT_MODE)[keyof typeof INPUT_MODE];
|
|
||||||
|
|
||||||
export const SALES_TYPE = {
|
|
||||||
DOMESTIC: "domestic",
|
|
||||||
EXPORT: "export",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type SalesType = (typeof SALES_TYPE)[keyof typeof SALES_TYPE];
|
|
||||||
|
|
||||||
export const PRICE_TYPE = {
|
|
||||||
STANDARD: "standard",
|
|
||||||
CUSTOMER: "customer",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type PriceType = (typeof PRICE_TYPE)[keyof typeof PRICE_TYPE];
|
|
||||||
|
|
@ -468,7 +468,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
return rendererInstance.render();
|
return rendererInstance.render();
|
||||||
} else {
|
} else {
|
||||||
// 함수형 컴포넌트
|
// 함수형 컴포넌트
|
||||||
return <NewComponentRenderer {...rendererProps} />;
|
// refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제
|
||||||
|
return <NewComponentRenderer key={refreshKey} {...rendererProps} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ import "./entity-search-input/EntitySearchInputRenderer";
|
||||||
import "./modal-repeater-table/ModalRepeaterTableRenderer";
|
import "./modal-repeater-table/ModalRepeaterTableRenderer";
|
||||||
import "./simple-repeater-table/SimpleRepeaterTableRenderer"; // 🆕 단순 반복 테이블
|
import "./simple-repeater-table/SimpleRepeaterTableRenderer"; // 🆕 단순 반복 테이블
|
||||||
import "./repeat-screen-modal/RepeatScreenModalRenderer"; // 🆕 반복 화면 모달 (카드 형태)
|
import "./repeat-screen-modal/RepeatScreenModalRenderer"; // 🆕 반복 화면 모달 (카드 형태)
|
||||||
import "./order-registration-modal/OrderRegistrationModalRenderer";
|
|
||||||
|
|
||||||
// 🆕 조건부 컨테이너 컴포넌트
|
// 🆕 조건부 컨테이너 컴포넌트
|
||||||
import "./conditional-container/ConditionalContainerRenderer";
|
import "./conditional-container/ConditionalContainerRenderer";
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,18 @@ async function fetchReferenceValue(
|
||||||
|
|
||||||
// 연산자가 "=" 인 경우만 지원 (확장 가능)
|
// 연산자가 "=" 인 경우만 지원 (확장 가능)
|
||||||
if (operator === "=") {
|
if (operator === "=") {
|
||||||
whereConditions[targetField] = value;
|
// 숫자형 ID인 경우 숫자로 변환 (문자열 '189' → 숫자 189)
|
||||||
|
// 백엔드에서 entity 타입 컬럼 검색 시 문자열이면 ILIKE 검색을 수행하므로
|
||||||
|
// 정확한 ID 매칭을 위해 숫자로 변환해야 함
|
||||||
|
let convertedValue = value;
|
||||||
|
if (targetField.endsWith('_id') || targetField === 'id') {
|
||||||
|
const numValue = Number(value);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
convertedValue = numValue;
|
||||||
|
console.log(` 🔢 ID 타입 변환: ${targetField} = "${value}" → ${numValue}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
whereConditions[targetField] = convertedValue;
|
||||||
} else {
|
} else {
|
||||||
console.warn(`⚠️ 연산자 "${operator}"는 아직 지원되지 않습니다.`);
|
console.warn(`⚠️ 연산자 "${operator}"는 아직 지원되지 않습니다.`);
|
||||||
}
|
}
|
||||||
|
|
@ -198,14 +209,43 @@ export function ModalRepeaterTableComponent({
|
||||||
const columnName = component?.columnName;
|
const columnName = component?.columnName;
|
||||||
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
||||||
|
|
||||||
|
// 빈 객체 판단 함수 (수정 모달의 실제 데이터는 유지)
|
||||||
|
const isEmptyRow = (item: any): boolean => {
|
||||||
|
if (!item || typeof item !== 'object') return true;
|
||||||
|
|
||||||
|
// id가 있으면 실제 데이터 (수정 모달)
|
||||||
|
if (item.id) return false;
|
||||||
|
|
||||||
|
// 모든 값이 비어있는지 확인 (계산 필드 제외)
|
||||||
|
const hasValue = Object.entries(item).some(([key, value]) => {
|
||||||
|
// 계산 필드나 메타데이터는 제외
|
||||||
|
if (key.startsWith('_') || key === 'total_amount') return false;
|
||||||
|
|
||||||
|
// 실제 값이 있는지 확인
|
||||||
|
return value !== undefined &&
|
||||||
|
value !== null &&
|
||||||
|
value !== '' &&
|
||||||
|
value !== 0 &&
|
||||||
|
value !== '0' &&
|
||||||
|
value !== '0.00';
|
||||||
|
});
|
||||||
|
|
||||||
|
return !hasValue;
|
||||||
|
};
|
||||||
|
|
||||||
// 🆕 내부 상태로 데이터 관리 (즉시 UI 반영을 위해)
|
// 🆕 내부 상태로 데이터 관리 (즉시 UI 반영을 위해)
|
||||||
const [localValue, setLocalValue] = useState<any[]>(externalValue);
|
const [localValue, setLocalValue] = useState<any[]>(() => {
|
||||||
|
return externalValue.filter((item) => !isEmptyRow(item));
|
||||||
|
});
|
||||||
|
|
||||||
// 🆕 외부 값(formData, propValue) 변경 시 내부 상태 동기화
|
// 🆕 외부 값(formData, propValue) 변경 시 내부 상태 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 빈 객체 필터링
|
||||||
|
const filteredValue = externalValue.filter((item) => !isEmptyRow(item));
|
||||||
|
|
||||||
// 외부 값이 변경되었고, 내부 값과 다른 경우에만 동기화
|
// 외부 값이 변경되었고, 내부 값과 다른 경우에만 동기화
|
||||||
if (JSON.stringify(externalValue) !== JSON.stringify(localValue)) {
|
if (JSON.stringify(filteredValue) !== JSON.stringify(localValue)) {
|
||||||
setLocalValue(externalValue);
|
setLocalValue(filteredValue);
|
||||||
}
|
}
|
||||||
}, [externalValue]);
|
}, [externalValue]);
|
||||||
|
|
||||||
|
|
@ -475,11 +515,18 @@ export function ModalRepeaterTableComponent({
|
||||||
|
|
||||||
const whereConditions: Record<string, any> = {};
|
const whereConditions: Record<string, any> = {};
|
||||||
for (const cond of joinConditions) {
|
for (const cond of joinConditions) {
|
||||||
const value = rowData[cond.sourceField];
|
let value = rowData[cond.sourceField];
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
console.warn(`⚠️ 조인 조건의 소스 필드 "${cond.sourceField}" 값이 없음`);
|
console.warn(`⚠️ 조인 조건의 소스 필드 "${cond.sourceField}" 값이 없음`);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
// 숫자형 ID인 경우 숫자로 변환
|
||||||
|
if (cond.targetField.endsWith('_id') || cond.targetField === 'id') {
|
||||||
|
const numValue = Number(value);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
value = numValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
whereConditions[cond.targetField] = value;
|
whereConditions[cond.targetField] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,8 +579,16 @@ export function ModalRepeaterTableComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블 조회
|
// 테이블 조회
|
||||||
|
// 숫자형 ID인 경우 숫자로 변환
|
||||||
|
let convertedFromValue = fromValue;
|
||||||
|
if (joinCondition.toField.endsWith('_id') || joinCondition.toField === 'id') {
|
||||||
|
const numValue = Number(fromValue);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
convertedFromValue = numValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
const whereConditions: Record<string, any> = {
|
const whereConditions: Record<string, any> = {
|
||||||
[joinCondition.toField]: fromValue
|
[joinCondition.toField]: convertedFromValue
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(` 🔍 단계 ${i + 1}: ${tableName} 조회`, whereConditions);
|
console.log(` 🔍 단계 ${i + 1}: ${tableName} 조회`, whereConditions);
|
||||||
|
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
|
|
||||||
interface OrderRegistrationModalConfig {
|
|
||||||
buttonText?: string;
|
|
||||||
buttonVariant?: "default" | "secondary" | "outline" | "ghost";
|
|
||||||
buttonSize?: "default" | "sm" | "lg";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderRegistrationModalConfigPanelProps {
|
|
||||||
config: OrderRegistrationModalConfig;
|
|
||||||
onConfigChange: (config: OrderRegistrationModalConfig) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OrderRegistrationModalConfigPanel({
|
|
||||||
config,
|
|
||||||
onConfigChange,
|
|
||||||
}: OrderRegistrationModalConfigPanelProps) {
|
|
||||||
const [localConfig, setLocalConfig] = useState(config);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalConfig(config);
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const updateConfig = (updates: Partial<OrderRegistrationModalConfig>) => {
|
|
||||||
const newConfig = { ...localConfig, ...updates };
|
|
||||||
setLocalConfig(newConfig);
|
|
||||||
onConfigChange(newConfig);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 p-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs sm:text-sm">버튼 텍스트</Label>
|
|
||||||
<Input
|
|
||||||
value={localConfig.buttonText || "수주 등록"}
|
|
||||||
onChange={(e) => updateConfig({ buttonText: e.target.value })}
|
|
||||||
placeholder="수주 등록"
|
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs sm:text-sm">버튼 스타일</Label>
|
|
||||||
<Select
|
|
||||||
value={localConfig.buttonVariant || "default"}
|
|
||||||
onValueChange={(value: any) => updateConfig({ buttonVariant: value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="default">기본</SelectItem>
|
|
||||||
<SelectItem value="secondary">보조</SelectItem>
|
|
||||||
<SelectItem value="outline">외곽선</SelectItem>
|
|
||||||
<SelectItem value="ghost">고스트</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs sm:text-sm">버튼 크기</Label>
|
|
||||||
<Select
|
|
||||||
value={localConfig.buttonSize || "default"}
|
|
||||||
onValueChange={(value: any) => updateConfig({ buttonSize: value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="sm">작게</SelectItem>
|
|
||||||
<SelectItem value="default">기본</SelectItem>
|
|
||||||
<SelectItem value="lg">크게</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-muted rounded-md text-xs text-muted-foreground">
|
|
||||||
<p className="font-medium mb-2">💡 참고사항:</p>
|
|
||||||
<ul className="space-y-1 list-disc list-inside">
|
|
||||||
<li>버튼 클릭 시 수주 등록 모달이 열립니다</li>
|
|
||||||
<li>거래처 검색, 품목 선택 기능 포함</li>
|
|
||||||
<li>입력 방식: 거래처 우선/견적서/단가</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Plus } from "lucide-react";
|
|
||||||
import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal";
|
|
||||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
|
||||||
import OrderRegistrationModalDefinition from "./index";
|
|
||||||
import { OrderRegistrationModalConfigPanel } from "./OrderRegistrationModalConfigPanel";
|
|
||||||
|
|
||||||
interface OrderRegistrationModalRendererProps {
|
|
||||||
buttonText?: string;
|
|
||||||
buttonVariant?: "default" | "secondary" | "outline" | "ghost" | "destructive";
|
|
||||||
buttonSize?: "default" | "sm" | "lg";
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OrderRegistrationModalRenderer({
|
|
||||||
buttonText = "수주 등록",
|
|
||||||
buttonVariant = "default",
|
|
||||||
buttonSize = "default",
|
|
||||||
style,
|
|
||||||
}: OrderRegistrationModalRendererProps) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
// style에서 width, height 제거 (h-full w-full로 제어)
|
|
||||||
const { width, height, ...restStyle } = style || {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant={buttonVariant}
|
|
||||||
size={buttonSize}
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
className="h-full w-full"
|
|
||||||
style={restStyle}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{buttonText}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<OrderRegistrationModal open={isOpen} onOpenChange={setIsOpen} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컴포넌트 자동 등록
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
ComponentRegistry.registerComponent({
|
|
||||||
...OrderRegistrationModalDefinition,
|
|
||||||
component: OrderRegistrationModalRenderer,
|
|
||||||
renderer: OrderRegistrationModalRenderer,
|
|
||||||
configPanel: OrderRegistrationModalConfigPanel,
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
/**
|
|
||||||
* 수주등록 모달 컴포넌트
|
|
||||||
* 거래처 검색, 품목 선택, 수주 정보 입력을 한 번에 처리하는 모달
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
|
||||||
|
|
||||||
export const OrderRegistrationModalDefinition: Omit<ComponentDefinition, "renderer" | "configPanel" | "component"> = {
|
|
||||||
id: "order-registration-modal",
|
|
||||||
name: "수주등록 모달",
|
|
||||||
category: ComponentCategory.ACTION,
|
|
||||||
webType: "button" as const,
|
|
||||||
description: "거래처, 품목을 선택하여 수주를 등록하는 모달",
|
|
||||||
icon: "FileText",
|
|
||||||
version: "1.0.0",
|
|
||||||
author: "WACE",
|
|
||||||
tags: ["수주", "주문", "영업", "모달"],
|
|
||||||
|
|
||||||
defaultSize: {
|
|
||||||
width: 120,
|
|
||||||
height: 40,
|
|
||||||
},
|
|
||||||
|
|
||||||
defaultConfig: {
|
|
||||||
buttonText: "수주 등록",
|
|
||||||
buttonVariant: "default",
|
|
||||||
buttonSize: "default",
|
|
||||||
},
|
|
||||||
|
|
||||||
defaultProps: {
|
|
||||||
style: {
|
|
||||||
width: "120px",
|
|
||||||
height: "40px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
configSchema: {
|
|
||||||
buttonText: {
|
|
||||||
type: "string",
|
|
||||||
label: "버튼 텍스트",
|
|
||||||
defaultValue: "수주 등록",
|
|
||||||
},
|
|
||||||
buttonVariant: {
|
|
||||||
type: "select",
|
|
||||||
label: "버튼 스타일",
|
|
||||||
options: [
|
|
||||||
{ label: "기본", value: "default" },
|
|
||||||
{ label: "보조", value: "secondary" },
|
|
||||||
{ label: "외곽선", value: "outline" },
|
|
||||||
{ label: "고스트", value: "ghost" },
|
|
||||||
],
|
|
||||||
defaultValue: "default",
|
|
||||||
},
|
|
||||||
buttonSize: {
|
|
||||||
type: "select",
|
|
||||||
label: "버튼 크기",
|
|
||||||
options: [
|
|
||||||
{ label: "작게", value: "sm" },
|
|
||||||
{ label: "기본", value: "default" },
|
|
||||||
{ label: "크게", value: "lg" },
|
|
||||||
],
|
|
||||||
defaultValue: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OrderRegistrationModalDefinition;
|
|
||||||
|
|
||||||
|
|
@ -468,8 +468,17 @@ export function RepeatScreenModalComponent({
|
||||||
// 조인 조건 생성
|
// 조인 조건 생성
|
||||||
const filters: Record<string, any> = {};
|
const filters: Record<string, any> = {};
|
||||||
for (const condition of dataSourceConfig.joinConditions) {
|
for (const condition of dataSourceConfig.joinConditions) {
|
||||||
const refValue = representativeData[condition.referenceKey];
|
let refValue = representativeData[condition.referenceKey];
|
||||||
if (refValue !== undefined && refValue !== null) {
|
if (refValue !== undefined && refValue !== null) {
|
||||||
|
// 숫자형 ID인 경우 숫자로 변환 (문자열 '189' → 숫자 189)
|
||||||
|
// 백엔드에서 entity 타입 컬럼 검색 시 문자열이면 ILIKE 검색을 수행하므로
|
||||||
|
// 정확한 ID 매칭을 위해 숫자로 변환해야 함
|
||||||
|
if (condition.sourceKey.endsWith('_id') || condition.sourceKey === 'id') {
|
||||||
|
const numValue = Number(refValue);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
refValue = numValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
filters[condition.sourceKey] = refValue;
|
filters[condition.sourceKey] = refValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -479,6 +488,14 @@ export function RepeatScreenModalComponent({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[RepeatScreenModal] 외부 테이블 API 호출:`, {
|
||||||
|
sourceTable: dataSourceConfig.sourceTable,
|
||||||
|
filters,
|
||||||
|
joinConditions: dataSourceConfig.joinConditions,
|
||||||
|
representativeDataId: representativeData.id,
|
||||||
|
representativeDataIdType: typeof representativeData.id,
|
||||||
|
});
|
||||||
|
|
||||||
// API 호출 - 메인 테이블 데이터
|
// API 호출 - 메인 테이블 데이터
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/table-management/tables/${dataSourceConfig.sourceTable}/data`,
|
`/table-management/tables/${dataSourceConfig.sourceTable}/data`,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2 } from "lucide
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { generateNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
import { generateNumberingCode, allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
|
||||||
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
|
|
@ -279,8 +279,11 @@ export function UniversalFormModalComponent({
|
||||||
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
||||||
// (UniversalFormModal이 해당 필드의 주인이므로)
|
// (UniversalFormModal이 해당 필드의 주인이므로)
|
||||||
for (const [key, value] of Object.entries(formData)) {
|
for (const [key, value] of Object.entries(formData)) {
|
||||||
// 설정에 정의된 필드만 병합
|
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
|
||||||
if (configuredFields.has(key)) {
|
const isConfiguredField = configuredFields.has(key);
|
||||||
|
const isNumberingRuleId = key.endsWith("_numberingRuleId");
|
||||||
|
|
||||||
|
if (isConfiguredField || isNumberingRuleId) {
|
||||||
if (value !== undefined && value !== null && value !== "") {
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
event.detail.formData[key] = value;
|
event.detail.formData[key] = value;
|
||||||
console.log(`[UniversalFormModal] 필드 병합: ${key} =`, value);
|
console.log(`[UniversalFormModal] 필드 병합: ${key} =`, value);
|
||||||
|
|
@ -446,17 +449,34 @@ export function UniversalFormModalComponent({
|
||||||
!updatedData[field.columnName]
|
!updatedData[field.columnName]
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
console.log(`[채번 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`);
|
console.log(`[채번 미리보기 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`);
|
||||||
// generateOnOpen: 모달 열 때 실제 순번 할당 (DB 시퀀스 즉시 증가)
|
// generateOnOpen: 미리보기만 표시 (DB 시퀀스 증가 안 함)
|
||||||
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
const response = await previewNumberingCode(field.numberingRule.ruleId);
|
||||||
if (response.success && response.data?.generatedCode) {
|
if (response.success && response.data?.generatedCode) {
|
||||||
updatedData[field.columnName] = response.data.generatedCode;
|
updatedData[field.columnName] = response.data.generatedCode;
|
||||||
|
|
||||||
|
// 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
|
||||||
|
const ruleIdKey = `${field.columnName}_numberingRuleId`;
|
||||||
|
updatedData[ruleIdKey] = field.numberingRule.ruleId;
|
||||||
|
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
numberingGeneratedRef.current = true; // 생성 완료 표시
|
numberingGeneratedRef.current = true; // 생성 완료 표시
|
||||||
console.log(`[채번 완료] ${field.columnName} = ${response.data.generatedCode}`);
|
console.log(
|
||||||
|
`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
|
||||||
|
);
|
||||||
|
console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
|
||||||
|
|
||||||
|
// 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
|
||||||
|
if (onChange) {
|
||||||
|
onChange({
|
||||||
|
...updatedData,
|
||||||
|
[ruleIdKey]: field.numberingRule.ruleId,
|
||||||
|
});
|
||||||
|
console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`채번규칙 생성 실패 (${field.columnName}):`, error);
|
console.error(`채번규칙 미리보기 실패 (${field.columnName}):`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -468,7 +488,7 @@ export function UniversalFormModalComponent({
|
||||||
setFormData(updatedData);
|
setFormData(updatedData);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[config],
|
[config, onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 필드 값 변경 핸들러
|
// 필드 값 변경 핸들러
|
||||||
|
|
@ -688,9 +708,9 @@ export function UniversalFormModalComponent({
|
||||||
const saveSingleRow = useCallback(async () => {
|
const saveSingleRow = useCallback(async () => {
|
||||||
const dataToSave = { ...formData };
|
const dataToSave = { ...formData };
|
||||||
|
|
||||||
// 메타데이터 필드 제거
|
// 메타데이터 필드 제거 (채번 규칙 ID는 유지 - buttonActions.ts에서 사용)
|
||||||
Object.keys(dataToSave).forEach((key) => {
|
Object.keys(dataToSave).forEach((key) => {
|
||||||
if (key.startsWith("_")) {
|
if (key.startsWith("_") && !key.includes("_numberingRuleId")) {
|
||||||
delete dataToSave[key];
|
delete dataToSave[key];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,6 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||||
import("@/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel"),
|
import("@/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel"),
|
||||||
"entity-search-input": () => import("@/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel"),
|
"entity-search-input": () => import("@/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel"),
|
||||||
"modal-repeater-table": () => import("@/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel"),
|
"modal-repeater-table": () => import("@/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel"),
|
||||||
"order-registration-modal": () =>
|
|
||||||
import("@/lib/registry/components/order-registration-modal/OrderRegistrationModalConfigPanel"),
|
|
||||||
// 🆕 조건부 컨테이너
|
// 🆕 조건부 컨테이너
|
||||||
"conditional-container": () =>
|
"conditional-container": () =>
|
||||||
import("@/lib/registry/components/conditional-container/ConditionalContainerConfigPanel"),
|
import("@/lib/registry/components/conditional-container/ConditionalContainerConfigPanel"),
|
||||||
|
|
@ -401,7 +399,6 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
||||||
"autocomplete-search-input",
|
"autocomplete-search-input",
|
||||||
"entity-search-input",
|
"entity-search-input",
|
||||||
"modal-repeater-table",
|
"modal-repeater-table",
|
||||||
"order-registration-modal",
|
|
||||||
"conditional-container",
|
"conditional-container",
|
||||||
].includes(componentId);
|
].includes(componentId);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue