feat: 수주등록 모달 및 범용 컴포넌트 개발

- 범용 컴포넌트 3종 개발 및 레지스트리 등록:
  * AutocompleteSearchInput: 자동완성 검색 입력 컴포넌트
  * EntitySearchInput: 엔티티 검색 모달 컴포넌트
  * ModalRepeaterTable: 모달 기반 반복 테이블 컴포넌트

- 수주등록 전용 컴포넌트:
  * OrderCustomerSearch: 거래처 검색 (AutocompleteSearchInput 래퍼)
  * OrderItemRepeaterTable: 품목 관리 (ModalRepeaterTable 래퍼)
  * OrderRegistrationModal: 수주등록 메인 모달

- 백엔드 API:
  * Entity 검색 API (멀티테넌시 지원)
  * 수주 등록 API (자동 채번)

- 화면 편집기 통합:
  * 컴포넌트 레지스트리에 등록
  * ConfigPanel을 통한 설정 기능
  * 드래그앤드롭으로 배치 가능

- 개발 문서:
  * 수주등록_화면_개발_계획서.md (상세 설계 문서)
This commit is contained in:
kjs 2025-11-14 14:43:53 +09:00
parent 075869c89c
commit 64e6fd1920
46 changed files with 6086 additions and 8 deletions

View File

@ -68,6 +68,8 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 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 orderRoutes from "./routes/orderRoutes"; // 수주 관리
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -230,6 +232,8 @@ app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리 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/orders", orderRoutes); // 수주 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes); // app.use('/api/users', userRoutes);

View File

@ -0,0 +1,109 @@
import { Request, Response } from "express";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
/**
* API
* GET /api/entity-search/:tableName
*/
export async function searchEntity(req: Request, res: Response) {
try {
const { tableName } = req.params;
const {
searchText = "",
searchFields = "",
filterCondition = "{}",
page = "1",
limit = "20",
} = req.query;
// 멀티테넌시
const companyCode = req.user!.companyCode;
// 검색 필드 파싱
const fields = searchFields
? (searchFields as string).split(",").map((f) => f.trim())
: [];
// WHERE 조건 생성
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 멀티테넌시 필터링
if (companyCode !== "*") {
whereConditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
// 검색 조건
if (searchText && fields.length > 0) {
const searchConditions = fields.map((field) => {
const condition = `${field}::text ILIKE $${paramIndex}`;
paramIndex++;
return condition;
});
whereConditions.push(`(${searchConditions.join(" OR ")})`);
// 검색어 파라미터 추가
fields.forEach(() => {
params.push(`%${searchText}%`);
});
}
// 추가 필터 조건
const additionalFilter = JSON.parse(filterCondition as string);
for (const [key, value] of Object.entries(additionalFilter)) {
whereConditions.push(`${key} = $${paramIndex}`);
params.push(value);
paramIndex++;
}
// 페이징
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 쿼리 실행
const pool = getPool();
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
const dataQuery = `
SELECT * FROM ${tableName} ${whereClause}
ORDER BY id DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(parseInt(limit as string));
params.push(offset);
const countResult = await pool.query(
countQuery,
params.slice(0, params.length - 2)
);
const dataResult = await pool.query(dataQuery, params);
logger.info("엔티티 검색 성공", {
tableName,
searchText,
companyCode,
rowCount: dataResult.rowCount,
});
res.json({
success: true,
data: dataResult.rows,
pagination: {
total: parseInt(countResult.rows[0].count),
page: parseInt(page as string),
limit: parseInt(limit as string),
},
});
} catch (error: any) {
logger.error("엔티티 검색 오류", { error: error.message, stack: error.stack });
res.status(500).json({ success: false, message: error.message });
}
}

View File

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

View File

@ -0,0 +1,14 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { searchEntity } from "../controllers/entitySearchController";
const router = Router();
/**
* API
* GET /api/entity-search/:tableName
*/
router.get("/:tableName", authenticateToken, searchEntity);
export default router;

View File

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

View File

@ -0,0 +1,138 @@
"use client";
import React, { useState } from "react";
import { EntitySearchInputComponent } from "@/lib/registry/components/entity-search-input";
import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
export default function TestEntitySearchPage() {
const [customerCode, setCustomerCode] = useState<string>("");
const [customerData, setCustomerData] = useState<any>(null);
const [itemCode, setItemCode] = useState<string>("");
const [itemData, setItemData] = useState<any>(null);
return (
<div className="container mx-auto p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold">EntitySearchInput </h1>
<p className="text-muted-foreground mt-2">
</p>
</div>
{/* 거래처 검색 테스트 - 자동완성 방식 */}
<Card>
<CardHeader>
<CardTitle> ( ) NEW</CardTitle>
<CardDescription>
-
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<AutocompleteSearchInputComponent
tableName="customer_mng"
displayField="customer_name"
valueField="customer_code"
searchFields={["customer_name", "customer_code", "business_number"]}
placeholder="거래처명 입력하여 검색"
showAdditionalInfo
additionalFields={["customer_code", "address", "contact_phone"]}
value={customerCode}
onChange={(code, fullData) => {
setCustomerCode(code || "");
setCustomerData(fullData);
}}
/>
</div>
{customerData && (
<div className="mt-4 p-4 bg-muted rounded-md">
<h3 className="font-semibold mb-2"> :</h3>
<pre className="text-xs">
{JSON.stringify(customerData, null, 2)}
</pre>
</div>
)}
</CardContent>
</Card>
{/* 거래처 검색 테스트 - 모달 방식 */}
<Card>
<CardHeader>
<CardTitle> ( )</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<EntitySearchInputComponent
tableName="customer_mng"
displayField="customer_name"
valueField="customer_code"
searchFields={["customer_name", "customer_code", "business_number"]}
mode="combo"
placeholder="거래처를 검색하세요"
modalTitle="거래처 검색 및 선택"
modalColumns={["customer_code", "customer_name", "address", "contact_phone"]}
showAdditionalInfo
additionalFields={["address", "contact_phone", "business_number"]}
value={customerCode}
onChange={(code, fullData) => {
setCustomerCode(code || "");
setCustomerData(fullData);
}}
/>
</div>
</CardContent>
</Card>
{/* 품목 검색 테스트 */}
<Card>
<CardHeader>
<CardTitle> (Modal )</CardTitle>
<CardDescription>
item_info
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<EntitySearchInputComponent
tableName="item_info"
displayField="item_name"
valueField="id"
searchFields={["item_name", "id", "item_number"]}
mode="modal"
placeholder="품목 선택"
modalTitle="품목 검색"
modalColumns={["id", "item_name", "item_number", "unit", "selling_price"]}
showAdditionalInfo
additionalFields={["item_number", "unit", "selling_price"]}
value={itemCode}
onChange={(code, fullData) => {
setItemCode(code || "");
setItemData(fullData);
}}
/>
</div>
{itemData && (
<div className="mt-4 p-4 bg-muted rounded-md">
<h3 className="font-semibold mb-2"> :</h3>
<pre className="text-xs">
{JSON.stringify(itemData, null, 2)}
</pre>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,87 @@
"use client";
import React, { useState } from "react";
import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function TestOrderRegistrationPage() {
const [modalOpen, setModalOpen] = useState(false);
const handleSuccess = () => {
console.log("수주 등록 성공!");
};
return (
<div className="container mx-auto p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold"> </h1>
<p className="text-muted-foreground mt-2">
EntitySearchInput + ModalRepeaterTable을
</p>
</div>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => setModalOpen(true)}>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span>EntitySearchInput: 거래처 ( )</span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span>ModalRepeaterTable: 품목 </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 계산: 수량 × = </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 편집: 수량, , , </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 방지: 이미 </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 삭제: 추가된 </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 표시: 모든 </span>
</div>
<div className="flex items-start gap-2">
<span className="text-primary"></span>
<span> 전환: 거래처 / / </span>
</div>
</CardContent>
</Card>
{/* 수주 등록 모달 */}
<OrderRegistrationModal
open={modalOpen}
onOpenChange={setModalOpen}
onSuccess={handleSuccess}
/>
</div>
);
}

View File

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

View File

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

View File

@ -0,0 +1,270 @@
"use client";
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { OrderCustomerSearch } from "./OrderCustomerSearch";
import { OrderItemRepeaterTable } from "./OrderItemRepeaterTable";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
interface OrderRegistrationModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function OrderRegistrationModal({
open,
onOpenChange,
onSuccess,
}: OrderRegistrationModalProps) {
// 입력 방식
const [inputMode, setInputMode] = useState<string>("customer_first");
// 폼 데이터
const [formData, setFormData] = useState<any>({
customerCode: "",
customerName: "",
deliveryDate: "",
memo: "",
});
// 선택된 품목 목록
const [selectedItems, setSelectedItems] = useState<any[]>([]);
// 저장 중
const [isSaving, setIsSaving] = useState(false);
// 저장 처리
const handleSave = async () => {
try {
// 유효성 검사
if (!formData.customerCode) {
toast.error("거래처를 선택해주세요");
return;
}
if (selectedItems.length === 0) {
toast.error("품목을 추가해주세요");
return;
}
setIsSaving(true);
// 수주 등록 API 호출
const response = await apiClient.post("/orders", {
inputMode,
customerCode: formData.customerCode,
deliveryDate: formData.deliveryDate,
items: selectedItems,
memo: formData.memo,
});
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");
setFormData({
customerCode: "",
customerName: "",
deliveryDate: "",
memo: "",
});
setSelectedItems([]);
};
// 전체 금액 계산
const totalAmount = selectedItems.reduce(
(sum, item) => sum + (item.amount || 0),
0
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 입력 방식 선택 */}
<div className="space-y-2">
<Label htmlFor="inputMode" className="text-xs sm:text-sm">
*
</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>
{/* 입력 방식에 따른 동적 폼 */}
{inputMode === "customer_first" && (
<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>
<OrderCustomerSearch
value={formData.customerCode}
onChange={(code, fullData) => {
setFormData({
...formData,
customerCode: code || "",
customerName: fullData?.customer_name || "",
});
}}
/>
</div>
{/* 납품일 */}
<div className="space-y-2">
<Label htmlFor="deliveryDate" className="text-xs sm:text-sm">
</Label>
<Input
id="deliveryDate"
type="date"
value={formData.deliveryDate}
onChange={(e) =>
setFormData({ ...formData, deliveryDate: e.target.value })
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
)}
{inputMode === "quotation" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Input
placeholder="견대 번호를 입력하세요"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
)}
{inputMode === "unit_price" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
placeholder="단가 정보 입력"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
)}
{/* 추가된 품목 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<OrderItemRepeaterTable
value={selectedItems}
onChange={setSelectedItems}
/>
</div>
{/* 전체 금액 표시 */}
{selectedItems.length > 0 && (
<div className="flex justify-end">
<div className="text-sm sm:text-base font-semibold">
: {totalAmount.toLocaleString()}
</div>
</div>
)}
{/* 메모 */}
<div className="space-y-2">
<Label htmlFor="memo" className="text-xs sm:text-sm">
</Label>
<Textarea
id="memo"
placeholder="메모를 입력하세요"
value={formData.memo}
onChange={(e) =>
setFormData({ ...formData, memo: e.target.value })
}
className="text-xs sm:text-sm"
rows={3}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={handleCancel}
disabled={isSaving}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={isSaving}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isSaving ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

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

View File

@ -70,6 +70,9 @@ import { toast } from "sonner";
import { MenuAssignmentModal } from "./MenuAssignmentModal"; import { MenuAssignmentModal } from "./MenuAssignmentModal";
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal"; import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
import { initializeComponents } from "@/lib/registry/components"; import { initializeComponents } from "@/lib/registry/components";
import { AutocompleteSearchInputRenderer } from "@/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputRenderer";
import { EntitySearchInputRenderer } from "@/lib/registry/components/entity-search-input/EntitySearchInputRenderer";
import { ModalRepeaterTableRenderer } from "@/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer";
import { ScreenFileAPI } from "@/lib/api/screenFile"; import { ScreenFileAPI } from "@/lib/api/screenFile";
import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan"; import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan";
@ -4935,6 +4938,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
)} )}
</div> </div>
</TableOptionsProvider> </TableOptionsProvider>
{/* 숨겨진 컴포넌트 렌더러들 (레지스트리 등록용) */}
<div style={{ display: "none" }}>
<AutocompleteSearchInputRenderer />
<EntitySearchInputRenderer />
<ModalRepeaterTableRenderer />
</div>
</ScreenPreviewProvider> </ScreenPreviewProvider>
); );
} }

View File

@ -63,8 +63,9 @@ export function ComponentsPanel({
), ),
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION), action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY), display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
data: allComponents.filter((c) => c.category === ComponentCategory.DATA), // 🆕 데이터 카테고리 추가
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT), layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY), // 🆕 유틸리티 카테고리 추가 utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY),
}; };
}, [allComponents]); }, [allComponents]);
@ -92,6 +93,8 @@ export function ComponentsPanel({
return <Palette className="h-6 w-6" />; return <Palette className="h-6 w-6" />;
case "action": case "action":
return <Zap className="h-6 w-6" />; return <Zap className="h-6 w-6" />;
case "data":
return <Database className="h-6 w-6" />;
case "layout": case "layout":
return <Layers className="h-6 w-6" />; return <Layers className="h-6 w-6" />;
case "utility": case "utility":
@ -185,7 +188,7 @@ export function ComponentsPanel({
{/* 카테고리 탭 */} {/* 카테고리 탭 */}
<Tabs defaultValue="input" className="flex min-h-0 flex-1 flex-col"> <Tabs defaultValue="input" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-6 gap-1 p-1"> <TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-7 gap-1 p-1">
<TabsTrigger <TabsTrigger
value="tables" value="tables"
className="flex items-center justify-center gap-0.5 px-0 text-[10px]" className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
@ -198,6 +201,14 @@ export function ComponentsPanel({
<Edit3 className="h-3 w-3" /> <Edit3 className="h-3 w-3" />
<span className="hidden"></span> <span className="hidden"></span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger
value="data"
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
title="데이터"
>
<Grid className="h-3 w-3" />
<span className="hidden"></span>
</TabsTrigger>
<TabsTrigger <TabsTrigger
value="action" value="action"
className="flex items-center justify-center gap-0.5 px-0 text-[10px]" className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
@ -260,6 +271,13 @@ export function ComponentsPanel({
: renderEmptyState()} : renderEmptyState()}
</TabsContent> </TabsContent>
{/* 데이터 컴포넌트 */}
<TabsContent value="data" className="mt-0 flex-1 space-y-2 overflow-y-auto">
{getFilteredComponents("data").length > 0
? getFilteredComponents("data").map(renderComponentCard)
: renderEmptyState()}
</TabsContent>
{/* 액션 컴포넌트 */} {/* 액션 컴포넌트 */}
<TabsContent value="action" className="mt-0 flex-1 space-y-2 overflow-y-auto"> <TabsContent value="action" className="mt-0 flex-1 space-y-2 overflow-y-auto">
{getFilteredComponents("action").length > 0 {getFilteredComponents("action").length > 0

View File

@ -36,6 +36,9 @@ import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
// 동적 컴포넌트 설정 패널 // 동적 컴포넌트 설정 패널
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel"; import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
interface DetailSettingsPanelProps { interface DetailSettingsPanelProps {
selectedComponent?: ComponentData; selectedComponent?: ComponentData;
onUpdateProperty: (componentId: string, path: string, value: any) => void; onUpdateProperty: (componentId: string, path: string, value: any) => void;
@ -859,6 +862,55 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
onUpdateProperty(selectedComponent.id, path, value); onUpdateProperty(selectedComponent.id, path, value);
}; };
const handleConfigChange = (newConfig: any) => {
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
};
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기
const componentId = selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.id;
if (componentId) {
const definition = ComponentRegistry.getComponent(componentId);
if (definition?.configPanel) {
const ConfigPanelComponent = definition.configPanel;
const currentConfig = selectedComponent.componentConfig || {};
console.log("✅ ConfigPanel 표시:", {
componentId,
definitionName: definition.name,
hasConfigPanel: !!definition.configPanel,
currentConfig,
});
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
const ConfigPanelWrapper = () => {
const config = currentConfig.config || definition.defaultConfig || {};
const handleConfigChange = (newConfig: any) => {
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<ConfigPanelComponent config={config} onConfigChange={handleConfigChange} />
</div>
);
};
return <ConfigPanelWrapper key={selectedComponent.id} />;
} else {
console.warn("⚠️ ConfigPanel 없음:", {
componentId,
definitionName: definition?.name,
hasDefinition: !!definition,
});
}
}
// 기존 하드코딩된 설정 패널들 (레거시)
switch (componentType) { switch (componentType) {
case "button": case "button":
case "button-primary": case "button-primary":
@ -904,8 +956,10 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
return ( return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center"> <div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" /> <Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3> <h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500"> "{componentType}" .</p> <p className="text-sm text-gray-500">
"{componentId || componentType}" .
</p>
</div> </div>
); );
} }

View File

@ -48,6 +48,9 @@ import { ButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
import { CardConfigPanel } from "../config-panels/CardConfigPanel"; import { CardConfigPanel } from "../config-panels/CardConfigPanel";
import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel"; import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel"; import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel"; import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel";
import { ChartConfigPanel } from "../config-panels/ChartConfigPanel"; import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel"; import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
@ -269,6 +272,55 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
onUpdateProperty(selectedComponent.id, path, value); onUpdateProperty(selectedComponent.id, path, value);
}; };
const handleConfigChange = (newConfig: any) => {
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
};
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기
const componentId = selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.id;
if (componentId) {
const definition = ComponentRegistry.getComponent(componentId);
if (definition?.configPanel) {
const ConfigPanelComponent = definition.configPanel;
const currentConfig = selectedComponent.componentConfig || {};
console.log("✅ ConfigPanel 표시:", {
componentId,
definitionName: definition.name,
hasConfigPanel: !!definition.configPanel,
currentConfig,
});
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
const ConfigPanelWrapper = () => {
const config = currentConfig.config || definition.defaultConfig || {};
const handleConfigChange = (newConfig: any) => {
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<ConfigPanelComponent config={config} onConfigChange={handleConfigChange} />
</div>
);
};
return <ConfigPanelWrapper key={selectedComponent.id} />;
} else {
console.warn("⚠️ ConfigPanel 없음:", {
componentId,
definitionName: definition?.name,
hasDefinition: !!definition,
});
}
}
// 기존 하드코딩된 설정 패널들 (레거시)
switch (componentType) { switch (componentType) {
case "button": case "button":
case "button-primary": case "button-primary":
@ -312,7 +364,16 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
return <BadgeConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />; return <BadgeConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
default: default:
return null; // ConfigPanel이 없는 경우 경고 표시
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-muted-foreground" />
<h3 className="mb-2 text-base font-medium"> </h3>
<p className="text-sm text-muted-foreground">
"{componentId || componentType}" .
</p>
</div>
);
} }
}; };

View File

@ -372,7 +372,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
return rendererInstance.render(); return rendererInstance.render();
} else { } else {
// 함수형 컴포넌트 // 함수형 컴포넌트
return <NewComponentRenderer {...rendererProps} />; // config 내부 속성도 펼쳐서 전달 (tableName, displayField 등)
const configProps = component.componentConfig?.config || component.componentConfig || {};
return <NewComponentRenderer {...rendererProps} {...configProps} />;
} }
} }
} catch (error) { } catch (error) {

View File

@ -0,0 +1,189 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
import { X, Loader2, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
import { EntitySearchResult } from "../entity-search-input/types";
import { cn } from "@/lib/utils";
import { AutocompleteSearchInputConfig } from "./types";
interface AutocompleteSearchInputProps extends Partial<AutocompleteSearchInputConfig> {
config?: AutocompleteSearchInputConfig;
filterCondition?: Record<string, any>;
disabled?: boolean;
value?: any;
onChange?: (value: any, fullData?: any) => void;
className?: string;
}
export function AutocompleteSearchInputComponent({
config,
tableName: propTableName,
displayField: propDisplayField,
valueField: propValueField,
searchFields: propSearchFields,
filterCondition = {},
placeholder: propPlaceholder,
disabled = false,
value,
onChange,
showAdditionalInfo: propShowAdditionalInfo,
additionalFields: propAdditionalFields,
className,
}: AutocompleteSearchInputProps) {
// config prop 우선, 없으면 개별 prop 사용
const tableName = config?.tableName || propTableName || "";
const displayField = config?.displayField || propDisplayField || "";
const valueField = config?.valueField || propValueField || "";
const searchFields = config?.searchFields || propSearchFields || [displayField];
const placeholder = config?.placeholder || propPlaceholder || "검색...";
const showAdditionalInfo = config?.showAdditionalInfo ?? propShowAdditionalInfo ?? false;
const additionalFields = config?.additionalFields || propAdditionalFields || [];
const [inputValue, setInputValue] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { searchText, setSearchText, results, loading, clearSearch } = useEntitySearch({
tableName,
searchFields,
filterCondition,
});
// value가 변경되면 표시값 업데이트
useEffect(() => {
if (value && selectedData) {
setInputValue(selectedData[displayField] || "");
} else if (!value) {
setInputValue("");
setSelectedData(null);
}
}, [value, displayField]);
// 외부 클릭 감지
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
setSearchText(newValue);
setIsOpen(true);
};
const handleSelect = (item: EntitySearchResult) => {
setSelectedData(item);
setInputValue(item[displayField] || "");
onChange?.(item[valueField], item);
setIsOpen(false);
};
const handleClear = () => {
setInputValue("");
setSelectedData(null);
onChange?.(null, null);
setIsOpen(false);
};
const handleInputFocus = () => {
// 포커스 시 항상 검색 실행 (빈 값이면 전체 목록)
if (!selectedData) {
setSearchText(inputValue || "");
setIsOpen(true);
}
};
return (
<div className={cn("relative", className)} ref={containerRef}>
{/* 입력 필드 */}
<div className="relative">
<Input
value={inputValue}
onChange={handleInputChange}
onFocus={handleInputFocus}
placeholder={placeholder}
disabled={disabled}
className="h-8 text-xs sm:h-10 sm:text-sm pr-16"
/>
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1">
{loading && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{inputValue && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
)}
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</div>
</div>
{/* 드롭다운 결과 */}
{isOpen && (results.length > 0 || loading) && (
<div className="absolute z-50 w-full mt-1 bg-background border rounded-md shadow-lg max-h-[300px] overflow-y-auto">
{loading && results.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
...
</div>
) : results.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="py-1">
{results.map((item, index) => (
<button
key={index}
type="button"
onClick={() => handleSelect(item)}
className="w-full text-left px-3 py-2 hover:bg-accent text-xs sm:text-sm transition-colors"
>
<div className="font-medium">{item[displayField]}</div>
{additionalFields.length > 0 && (
<div className="text-xs text-muted-foreground mt-1 space-y-0.5">
{additionalFields.map((field) => (
<div key={field}>
{field}: {item[field] || "-"}
</div>
))}
</div>
)}
</button>
))}
</div>
)}
</div>
)}
{/* 추가 정보 표시 */}
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground space-y-1 px-2">
{additionalFields.map((field) => (
<div key={field} className="flex gap-2">
<span className="font-medium">{field}:</span>
<span>{selectedData[field] || "-"}</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,381 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import { AutocompleteSearchInputConfig } from "./types";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
interface AutocompleteSearchInputConfigPanelProps {
config: AutocompleteSearchInputConfig;
onConfigChange: (config: AutocompleteSearchInputConfig) => void;
}
export function AutocompleteSearchInputConfigPanel({
config,
onConfigChange,
}: AutocompleteSearchInputConfigPanelProps) {
const [localConfig, setLocalConfig] = useState(config);
const [allTables, setAllTables] = useState<any[]>([]);
const [tableColumns, setTableColumns] = useState<any[]>([]);
const [isLoadingTables, setIsLoadingTables] = useState(false);
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
const [openTableCombo, setOpenTableCombo] = useState(false);
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
// 전체 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setIsLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setIsLoadingTables(false);
}
};
loadTables();
}, []);
// 선택된 테이블의 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!localConfig.tableName) {
setTableColumns([]);
return;
}
setIsLoadingColumns(true);
try {
const response = await tableManagementApi.getColumnList(localConfig.tableName);
if (response.success && response.data) {
setTableColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setTableColumns([]);
} finally {
setIsLoadingColumns(false);
}
};
loadColumns();
}, [localConfig.tableName]);
useEffect(() => {
setLocalConfig(config);
}, [config]);
const updateConfig = (updates: Partial<AutocompleteSearchInputConfig>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
const addSearchField = () => {
const fields = localConfig.searchFields || [];
updateConfig({ searchFields: [...fields, ""] });
};
const updateSearchField = (index: number, value: string) => {
const fields = [...(localConfig.searchFields || [])];
fields[index] = value;
updateConfig({ searchFields: fields });
};
const removeSearchField = (index: number) => {
const fields = [...(localConfig.searchFields || [])];
fields.splice(index, 1);
updateConfig({ searchFields: fields });
};
const addAdditionalField = () => {
const fields = localConfig.additionalFields || [];
updateConfig({ additionalFields: [...fields, ""] });
};
const updateAdditionalField = (index: number, value: string) => {
const fields = [...(localConfig.additionalFields || [])];
fields[index] = value;
updateConfig({ additionalFields: fields });
};
const removeAdditionalField = (index: number) => {
const fields = [...(localConfig.additionalFields || [])];
fields.splice(index, 1);
updateConfig({ additionalFields: fields });
};
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openTableCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoadingTables}
>
{localConfig.tableName
? allTables.find((t) => t.tableName === localConfig.tableName)?.displayName || localConfig.tableName
: isLoadingTables ? "로딩 중..." : "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => {
updateConfig({ tableName: table.tableName });
setOpenTableCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", localConfig.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openDisplayFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.tableName || isLoadingColumns}
>
{localConfig.displayField
? tableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{tableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => {
updateConfig({ displayField: column.columnName });
setOpenDisplayFieldCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", localConfig.displayField === column.columnName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openValueFieldCombo} onOpenChange={setOpenValueFieldCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openValueFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.tableName || isLoadingColumns}
>
{localConfig.valueField
? tableColumns.find((c) => c.columnName === localConfig.valueField)?.displayName || localConfig.valueField
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{tableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => {
updateConfig({ valueField: column.columnName });
setOpenValueFieldCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", localConfig.valueField === column.columnName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"></Label>
<Input
value={localConfig.placeholder || ""}
onChange={(e) => updateConfig({ placeholder: e.target.value })}
placeholder="검색..."
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addSearchField}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.searchFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={field}
onValueChange={(value) => updateSearchField(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeSearchField(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.showAdditionalInfo || false}
onCheckedChange={(checked) =>
updateConfig({ showAdditionalInfo: checked })
}
/>
</div>
</div>
{localConfig.showAdditionalInfo && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addAdditionalField}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.additionalFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={field}
onValueChange={(value) => updateAdditionalField(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeAdditionalField(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,19 @@
"use client";
import React, { useEffect } from "react";
import { ComponentRegistry } from "../../ComponentRegistry";
import { AutocompleteSearchInputDefinition } from "./index";
export function AutocompleteSearchInputRenderer() {
useEffect(() => {
ComponentRegistry.registerComponent(AutocompleteSearchInputDefinition);
console.log("✅ AutocompleteSearchInput 컴포넌트 등록 완료");
return () => {
// 컴포넌트 언마운트 시 해제하지 않음 (싱글톤 패턴)
};
}, []);
return null;
}

View File

@ -0,0 +1,40 @@
# AutocompleteSearchInput 컴포넌트
자동완성 드롭다운 방식의 엔티티 검색 입력 컴포넌트입니다.
## 특징
- 타이핑하면 즉시 드롭다운 표시
- 빈 값일 때 전체 목록 조회
- 추가 정보 표시 가능
- X 버튼으로 선택 초기화
- 외부 클릭 시 자동 닫힘
## 사용 예시
```tsx
<AutocompleteSearchInputComponent
tableName="customer_mng"
displayField="customer_name"
valueField="customer_code"
searchFields={["customer_name", "customer_code"]}
placeholder="거래처명 입력"
showAdditionalInfo
additionalFields={["customer_code", "address"]}
value={selectedCode}
onChange={(code, fullData) => {
console.log("선택됨:", code, fullData);
}}
/>
```
## 설정 옵션
- `tableName`: 검색할 테이블명
- `displayField`: 표시할 필드
- `valueField`: 값으로 사용할 필드
- `searchFields`: 검색 대상 필드들
- `placeholder`: 플레이스홀더
- `showAdditionalInfo`: 추가 정보 표시 여부
- `additionalFields`: 추가로 표시할 필드들

View File

@ -0,0 +1,43 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { AutocompleteSearchInputComponent } from "./AutocompleteSearchInputComponent";
import { AutocompleteSearchInputConfigPanel } from "./AutocompleteSearchInputConfigPanel";
/**
* AutocompleteSearchInput
*
*/
export const AutocompleteSearchInputDefinition = createComponentDefinition({
id: "autocomplete-search-input",
name: "자동완성 검색 입력",
nameEng: "Autocomplete Search Input",
description: "타이핑하면 드롭다운이 나타나는 엔티티 검색 입력 (거래처, 사용자 등)",
category: ComponentCategory.INPUT,
webType: "entity",
component: AutocompleteSearchInputComponent,
defaultConfig: {
tableName: "customer_mng",
displayField: "customer_name",
valueField: "customer_code",
searchFields: ["customer_name", "customer_code"],
placeholder: "검색...",
showAdditionalInfo: false,
additionalFields: [],
},
defaultSize: { width: 300, height: 40 },
configPanel: AutocompleteSearchInputConfigPanel,
icon: "Search",
tags: ["검색", "자동완성", "엔티티", "드롭다운", "거래처"],
version: "1.0.0",
author: "개발팀",
});
// 타입 내보내기
export type { AutocompleteSearchInputConfig } from "./types";
// 컴포넌트 내보내기
export { AutocompleteSearchInputComponent } from "./AutocompleteSearchInputComponent";
export { AutocompleteSearchInputRenderer } from "./AutocompleteSearchInputRenderer";

View File

@ -0,0 +1,11 @@
export interface AutocompleteSearchInputConfig {
tableName: string;
displayField: string;
valueField: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
placeholder?: string;
showAdditionalInfo?: boolean;
additionalFields?: string[];
}

View File

@ -0,0 +1,126 @@
"use client";
import React, { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, X } from "lucide-react";
import { EntitySearchModal } from "./EntitySearchModal";
import { EntitySearchInputProps, EntitySearchResult } from "./types";
import { cn } from "@/lib/utils";
export function EntitySearchInputComponent({
tableName,
displayField,
valueField,
searchFields = [displayField],
mode = "combo",
placeholder = "검색...",
disabled = false,
filterCondition = {},
value,
onChange,
modalTitle = "검색",
modalColumns = [],
showAdditionalInfo = false,
additionalFields = [],
className,
}: EntitySearchInputProps) {
const [modalOpen, setModalOpen] = useState(false);
const [displayValue, setDisplayValue] = useState("");
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
// value가 변경되면 표시값 업데이트
useEffect(() => {
if (value && selectedData) {
setDisplayValue(selectedData[displayField] || "");
} else {
setDisplayValue("");
setSelectedData(null);
}
}, [value, displayField]);
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
setSelectedData(fullData);
setDisplayValue(fullData[displayField] || "");
onChange?.(newValue, fullData);
};
const handleClear = () => {
setDisplayValue("");
setSelectedData(null);
onChange?.(null, null);
};
const handleOpenModal = () => {
if (!disabled) {
setModalOpen(true);
}
};
return (
<div className={cn("space-y-2", className)}>
{/* 입력 필드 */}
<div className="flex gap-2">
<div className="relative flex-1">
<Input
value={displayValue}
onChange={(e) => setDisplayValue(e.target.value)}
placeholder={placeholder}
disabled={disabled}
readOnly={mode === "modal" || mode === "combo"}
className="h-8 text-xs sm:h-10 sm:text-sm pr-8"
/>
{displayValue && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{(mode === "modal" || mode === "combo") && (
<Button
type="button"
onClick={handleOpenModal}
disabled={disabled}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Search className="h-4 w-4" />
</Button>
)}
</div>
{/* 추가 정보 표시 */}
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
<div className="text-xs text-muted-foreground space-y-1 px-2">
{additionalFields.map((field) => (
<div key={field} className="flex gap-2">
<span className="font-medium">{field}:</span>
<span>{selectedData[field] || "-"}</span>
</div>
))}
</div>
)}
{/* 검색 모달 */}
<EntitySearchModal
open={modalOpen}
onOpenChange={setModalOpen}
tableName={tableName}
displayField={displayField}
valueField={valueField}
searchFields={searchFields}
filterCondition={filterCondition}
modalTitle={modalTitle}
modalColumns={modalColumns}
onSelect={handleSelect}
/>
</div>
);
}

View File

@ -0,0 +1,498 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import { EntitySearchInputConfig } from "./config";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
interface EntitySearchInputConfigPanelProps {
config: EntitySearchInputConfig;
onConfigChange: (config: EntitySearchInputConfig) => void;
}
export function EntitySearchInputConfigPanel({
config,
onConfigChange,
}: EntitySearchInputConfigPanelProps) {
const [localConfig, setLocalConfig] = useState(config);
const [allTables, setAllTables] = useState<any[]>([]);
const [tableColumns, setTableColumns] = useState<any[]>([]);
const [isLoadingTables, setIsLoadingTables] = useState(false);
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
const [openTableCombo, setOpenTableCombo] = useState(false);
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
// 전체 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setIsLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setIsLoadingTables(false);
}
};
loadTables();
}, []);
// 선택된 테이블의 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!localConfig.tableName) {
setTableColumns([]);
return;
}
setIsLoadingColumns(true);
try {
const response = await tableManagementApi.getColumnList(localConfig.tableName);
if (response.success && response.data) {
setTableColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setTableColumns([]);
} finally {
setIsLoadingColumns(false);
}
};
loadColumns();
}, [localConfig.tableName]);
useEffect(() => {
setLocalConfig(config);
}, [config]);
const updateConfig = (updates: Partial<EntitySearchInputConfig>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
const addSearchField = () => {
const fields = localConfig.searchFields || [];
updateConfig({ searchFields: [...fields, ""] });
};
const updateSearchField = (index: number, value: string) => {
const fields = [...(localConfig.searchFields || [])];
fields[index] = value;
updateConfig({ searchFields: fields });
};
const removeSearchField = (index: number) => {
const fields = [...(localConfig.searchFields || [])];
fields.splice(index, 1);
updateConfig({ searchFields: fields });
};
const addModalColumn = () => {
const columns = localConfig.modalColumns || [];
updateConfig({ modalColumns: [...columns, ""] });
};
const updateModalColumn = (index: number, value: string) => {
const columns = [...(localConfig.modalColumns || [])];
columns[index] = value;
updateConfig({ modalColumns: columns });
};
const removeModalColumn = (index: number) => {
const columns = [...(localConfig.modalColumns || [])];
columns.splice(index, 1);
updateConfig({ modalColumns: columns });
};
const addAdditionalField = () => {
const fields = localConfig.additionalFields || [];
updateConfig({ additionalFields: [...fields, ""] });
};
const updateAdditionalField = (index: number, value: string) => {
const fields = [...(localConfig.additionalFields || [])];
fields[index] = value;
updateConfig({ additionalFields: fields });
};
const removeAdditionalField = (index: number) => {
const fields = [...(localConfig.additionalFields || [])];
fields.splice(index, 1);
updateConfig({ additionalFields: fields });
};
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openTableCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoadingTables}
>
{localConfig.tableName
? allTables.find((t) => t.tableName === localConfig.tableName)?.displayName || localConfig.tableName
: isLoadingTables ? "로딩 중..." : "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName || table.tableName}-${table.tableName}`}
onSelect={() => {
updateConfig({ tableName: table.tableName });
setOpenTableCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.tableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.displayName && table.displayName !== table.tableName && (
<span className="text-[10px] text-gray-500">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openDisplayFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.tableName || isLoadingColumns}
>
{localConfig.displayField
? tableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{tableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={`${column.displayName || column.columnName}-${column.columnName}`}
onSelect={() => {
updateConfig({ displayField: column.columnName });
setOpenDisplayFieldCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.displayField === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && column.displayName !== column.columnName && (
<span className="text-[10px] text-gray-500">{column.columnName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openValueFieldCombo} onOpenChange={setOpenValueFieldCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openValueFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.tableName || isLoadingColumns}
>
{localConfig.valueField
? tableColumns.find((c) => c.columnName === localConfig.valueField)?.displayName || localConfig.valueField
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{tableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={`${column.displayName || column.columnName}-${column.columnName}`}
onSelect={() => {
updateConfig({ valueField: column.columnName });
setOpenValueFieldCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.valueField === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && column.displayName !== column.columnName && (
<span className="text-[10px] text-gray-500">{column.columnName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">UI </Label>
<Select
value={localConfig.mode || "combo"}
onValueChange={(value: "autocomplete" | "modal" | "combo") =>
updateConfig({ mode: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="combo"> ( + )</SelectItem>
<SelectItem value="modal"></SelectItem>
<SelectItem value="autocomplete"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"></Label>
<Input
value={localConfig.placeholder || ""}
onChange={(e) => updateConfig({ placeholder: e.target.value })}
placeholder="검색..."
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{(localConfig.mode === "modal" || localConfig.mode === "combo") && (
<>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.modalTitle || ""}
onChange={(e) => updateConfig({ modalTitle: e.target.value })}
placeholder="검색 및 선택"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addModalColumn}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.modalColumns || []).map((column, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={column}
onValueChange={(value) => updateModalColumn(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeModalColumn(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
</>
)}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addSearchField}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.searchFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={field}
onValueChange={(value) => updateSearchField(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeSearchField(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.showAdditionalInfo || false}
onCheckedChange={(checked) =>
updateConfig({ showAdditionalInfo: checked })
}
/>
</div>
</div>
{localConfig.showAdditionalInfo && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addAdditionalField}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.additionalFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={field}
onValueChange={(value) => updateAdditionalField(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeAdditionalField(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,19 @@
"use client";
import React, { useEffect } from "react";
import { ComponentRegistry } from "../../ComponentRegistry";
import { EntitySearchInputDefinition } from "./index";
export function EntitySearchInputRenderer() {
useEffect(() => {
ComponentRegistry.registerComponent(EntitySearchInputDefinition);
console.log("✅ EntitySearchInput 컴포넌트 등록 완료");
return () => {
// 컴포넌트 언마운트 시 해제하지 않음 (싱글톤 패턴)
};
}, []);
return null;
}

View File

@ -0,0 +1,223 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Search, Loader2 } from "lucide-react";
import { useEntitySearch } from "./useEntitySearch";
import { EntitySearchResult } from "./types";
interface EntitySearchModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tableName: string;
displayField: string;
valueField: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
modalTitle?: string;
modalColumns?: string[];
onSelect: (value: any, fullData: EntitySearchResult) => void;
}
export function EntitySearchModal({
open,
onOpenChange,
tableName,
displayField,
valueField,
searchFields = [displayField],
filterCondition = {},
modalTitle = "검색",
modalColumns = [],
onSelect,
}: EntitySearchModalProps) {
const [localSearchText, setLocalSearchText] = useState("");
const {
results,
loading,
error,
pagination,
search,
clearSearch,
loadMore,
} = useEntitySearch({
tableName,
searchFields,
filterCondition,
});
// 모달 열릴 때 초기 검색
useEffect(() => {
if (open) {
search("", 1); // 빈 검색어로 전체 목록 조회
} else {
clearSearch();
setLocalSearchText("");
}
}, [open]);
const handleSearch = () => {
search(localSearchText, 1);
};
const handleSelect = (item: EntitySearchResult) => {
onSelect(item[valueField], item);
onOpenChange(false);
};
// 표시할 컬럼 결정
const displayColumns = modalColumns.length > 0 ? modalColumns : [displayField];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">{modalTitle}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
{/* 검색 입력 */}
<div className="flex gap-2">
<Input
placeholder="검색어를 입력하세요"
value={localSearchText}
onChange={(e) => setLocalSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<Button
onClick={handleSearch}
disabled={loading}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
<span className="ml-2"></span>
</Button>
</div>
{/* 오류 메시지 */}
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
{error}
</div>
)}
{/* 검색 결과 테이블 */}
<div className="border rounded-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted">
<tr>
{displayColumns.map((col) => (
<th
key={col}
className="px-4 py-2 text-left font-medium text-muted-foreground"
>
{col}
</th>
))}
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-24">
</th>
</tr>
</thead>
<tbody>
{loading && results.length === 0 ? (
<tr>
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
<p className="mt-2 text-muted-foreground"> ...</p>
</td>
</tr>
) : results.length === 0 ? (
<tr>
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center text-muted-foreground">
</td>
</tr>
) : (
results.map((item, index) => (
<tr
key={item[valueField] || index}
className="border-t hover:bg-accent cursor-pointer transition-colors"
onClick={() => handleSelect(item)}
>
{displayColumns.map((col, colIndex) => (
<td key={`${item[valueField] || index}-${col}-${colIndex}`} className="px-4 py-2">
{item[col] || "-"}
</td>
))}
<td className="px-4 py-2">
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
handleSelect(item);
}}
className="h-7 text-xs"
>
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* 페이지네이션 정보 */}
{results.length > 0 && (
<div className="flex justify-between items-center text-xs sm:text-sm text-muted-foreground">
<span>
{pagination.total} {results.length}
</span>
{pagination.page * pagination.limit < pagination.total && (
<Button
variant="outline"
size="sm"
onClick={loadMore}
disabled={loading}
className="h-7 text-xs"
>
</Button>
)}
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,14 @@
export interface EntitySearchInputConfig {
tableName: string;
displayField: string;
valueField: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
mode?: "autocomplete" | "modal" | "combo";
placeholder?: string;
modalTitle?: string;
modalColumns?: string[];
showAdditionalInfo?: boolean;
additionalFields?: string[];
}

View File

@ -0,0 +1,53 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { EntitySearchInputComponent } from "./EntitySearchInputComponent";
import { EntitySearchInputConfigPanel } from "./EntitySearchInputConfigPanel";
/**
* EntitySearchInput
*
*/
export const EntitySearchInputDefinition = createComponentDefinition({
id: "entity-search-input",
name: "엔티티 검색 입력 (모달)",
nameEng: "Entity Search Input",
description: "모달을 통한 엔티티 검색 및 선택 (거래처, 품목 등)",
category: ComponentCategory.INPUT,
webType: "entity",
component: EntitySearchInputComponent,
defaultConfig: {
tableName: "customer_mng",
displayField: "customer_name",
valueField: "customer_code",
searchFields: ["customer_name", "customer_code"],
mode: "combo",
placeholder: "검색...",
modalTitle: "검색 및 선택",
modalColumns: ["customer_code", "customer_name", "address"],
showAdditionalInfo: false,
additionalFields: [],
},
defaultSize: { width: 300, height: 40 },
configPanel: EntitySearchInputConfigPanel,
icon: "Search",
tags: ["검색", "모달", "엔티티", "거래처", "품목"],
version: "1.0.0",
author: "개발팀",
});
// 타입 내보내기
export type { EntitySearchInputConfig } from "./config";
// 컴포넌트 내보내기
export { EntitySearchInputComponent } from "./EntitySearchInputComponent";
export { EntitySearchInputRenderer } from "./EntitySearchInputRenderer";
export { EntitySearchModal } from "./EntitySearchModal";
export { useEntitySearch } from "./useEntitySearch";
export type {
EntitySearchInputProps,
EntitySearchResult,
EntitySearchResponse,
} from "./types";

View File

@ -0,0 +1,52 @@
/**
* EntitySearchInput
*
*/
export interface EntitySearchInputProps {
// 데이터 소스
tableName: string; // 검색할 테이블명 (예: "customer_mng")
displayField: string; // 표시할 필드 (예: "customer_name")
valueField: string; // 값으로 사용할 필드 (예: "customer_code")
searchFields?: string[]; // 검색 대상 필드들 (기본: [displayField])
// UI 모드
mode?: "autocomplete" | "modal" | "combo"; // 기본: "combo"
placeholder?: string;
disabled?: boolean;
// 필터링
filterCondition?: Record<string, any>; // 추가 WHERE 조건
companyCode?: string; // 멀티테넌시
// 선택된 값
value?: any;
onChange?: (value: any, fullData?: any) => void;
// 모달 설정 (mode가 "modal" 또는 "combo"일 때)
modalTitle?: string;
modalColumns?: string[]; // 모달에 표시할 컬럼들
// 추가 표시 정보
showAdditionalInfo?: boolean; // 선택 후 추가 정보 표시 (예: 주소)
additionalFields?: string[]; // 추가로 표시할 필드들
// 스타일
className?: string;
}
export interface EntitySearchResult {
[key: string]: any;
}
export interface EntitySearchResponse {
success: boolean;
data: EntitySearchResult[];
pagination?: {
total: number;
page: number;
limit: number;
};
error?: string;
}

View File

@ -0,0 +1,110 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { EntitySearchResult, EntitySearchResponse } from "./types";
interface UseEntitySearchProps {
tableName: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
}
export function useEntitySearch({
tableName,
searchFields = [],
filterCondition = {},
}: UseEntitySearchProps) {
const [searchText, setSearchText] = useState("");
const [results, setResults] = useState<EntitySearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState({
total: 0,
page: 1,
limit: 20,
});
// searchFields와 filterCondition을 ref로 관리하여 useCallback 의존성 문제 해결
const searchFieldsRef = useRef(searchFields);
const filterConditionRef = useRef(filterCondition);
useEffect(() => {
searchFieldsRef.current = searchFields;
filterConditionRef.current = filterCondition;
}, [searchFields, filterCondition]);
const search = useCallback(
async (text: string, page: number = 1) => {
try {
setLoading(true);
setError(null);
const params = new URLSearchParams({
searchText: text,
searchFields: searchFieldsRef.current.join(","),
filterCondition: JSON.stringify(filterConditionRef.current),
page: page.toString(),
limit: pagination.limit.toString(),
});
const response = await apiClient.get<EntitySearchResponse>(
`/entity-search/${tableName}?${params.toString()}`
);
if (response.data.success) {
setResults(response.data.data);
if (response.data.pagination) {
setPagination(response.data.pagination);
}
} else {
setError(response.data.error || "검색에 실패했습니다");
}
} catch (err: any) {
console.error("Entity search error:", err);
setError(err.response?.data?.message || "검색 중 오류가 발생했습니다");
} finally {
setLoading(false);
}
},
[tableName, pagination.limit]
);
// 디바운스된 검색
useEffect(() => {
// searchText가 명시적으로 설정되지 않은 경우(null/undefined)만 건너뛰기
if (searchText === null || searchText === undefined) {
return;
}
const timer = setTimeout(() => {
// 빈 문자열("")도 검색 (전체 목록 조회)
search(searchText.trim(), 1);
}, 300); // 300ms 디바운스
return () => clearTimeout(timer);
}, [searchText, search]);
const clearSearch = useCallback(() => {
setSearchText("");
setResults([]);
setError(null);
}, []);
const loadMore = useCallback(() => {
if (pagination.page * pagination.limit < pagination.total) {
search(searchText, pagination.page + 1);
}
}, [search, searchText, pagination]);
return {
searchText,
setSearchText,
results,
loading,
error,
pagination,
search,
clearSearch,
loadMore,
};
}

View File

@ -44,6 +44,12 @@ import "./numbering-rule/NumberingRuleRenderer";
import "./category-manager/CategoryManagerRenderer"; import "./category-manager/CategoryManagerRenderer";
import "./table-search-widget"; // 🆕 테이블 검색 필터 위젯 import "./table-search-widget"; // 🆕 테이블 검색 필터 위젯
// 🆕 수주 등록 관련 컴포넌트들
import { AutocompleteSearchInputRenderer } from "./autocomplete-search-input/AutocompleteSearchInputRenderer";
import { EntitySearchInputRenderer } from "./entity-search-input/EntitySearchInputRenderer";
import { ModalRepeaterTableRenderer } from "./modal-repeater-table/ModalRepeaterTableRenderer";
import "./order-registration-modal/OrderRegistrationModalRenderer";
/** /**
* *
*/ */

View File

@ -0,0 +1,265 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Search, Loader2 } from "lucide-react";
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
import { ItemSelectionModalProps } from "./types";
export function ItemSelectionModal({
open,
onOpenChange,
sourceTable,
sourceColumns,
sourceSearchFields = [],
multiSelect = true,
filterCondition = {},
modalTitle,
alreadySelected = [],
uniqueField,
onSelect,
}: ItemSelectionModalProps) {
const [localSearchText, setLocalSearchText] = useState("");
const [selectedItems, setSelectedItems] = useState<any[]>([]);
const { results, loading, error, search, clearSearch } = useEntitySearch({
tableName: sourceTable,
searchFields: sourceSearchFields.length > 0 ? sourceSearchFields : sourceColumns,
filterCondition,
});
// 모달 열릴 때 초기 검색
useEffect(() => {
if (open) {
search("", 1); // 빈 검색어로 전체 목록 조회
setSelectedItems([]);
} else {
clearSearch();
setLocalSearchText("");
setSelectedItems([]);
}
}, [open]);
const handleSearch = () => {
search(localSearchText, 1);
};
const handleToggleItem = (item: any) => {
if (!multiSelect) {
setSelectedItems([item]);
return;
}
const isSelected = selectedItems.some((selected) =>
uniqueField
? selected[uniqueField] === item[uniqueField]
: selected === item
);
if (isSelected) {
setSelectedItems(
selectedItems.filter((selected) =>
uniqueField
? selected[uniqueField] !== item[uniqueField]
: selected !== item
)
);
} else {
setSelectedItems([...selectedItems, item]);
}
};
const handleConfirm = () => {
onSelect(selectedItems);
onOpenChange(false);
};
// 이미 추가된 항목인지 확인
const isAlreadyAdded = (item: any): boolean => {
if (!uniqueField) return false;
return alreadySelected.some(
(selected) => selected[uniqueField] === item[uniqueField]
);
};
// 선택된 항목인지 확인
const isSelected = (item: any): boolean => {
return selectedItems.some((selected) =>
uniqueField
? selected[uniqueField] === item[uniqueField]
: selected === item
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">{modalTitle}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{multiSelect && " (다중 선택 가능)"}
</DialogDescription>
</DialogHeader>
{/* 검색 입력 */}
<div className="flex gap-2">
<Input
placeholder="검색어를 입력하세요"
value={localSearchText}
onChange={(e) => setLocalSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<Button
onClick={handleSearch}
disabled={loading}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
<span className="ml-2"></span>
</Button>
</div>
{/* 선택된 항목 수 */}
{selectedItems.length > 0 && (
<div className="text-sm text-primary">
{selectedItems.length}
</div>
)}
{/* 오류 메시지 */}
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
{error}
</div>
)}
{/* 검색 결과 테이블 */}
<div className="border rounded-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted">
<tr>
{multiSelect && (
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
</th>
)}
{sourceColumns.map((col) => (
<th
key={col}
className="px-4 py-2 text-left font-medium text-muted-foreground"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{loading && results.length === 0 ? (
<tr>
<td
colSpan={sourceColumns.length + (multiSelect ? 1 : 0)}
className="px-4 py-8 text-center"
>
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
<p className="mt-2 text-muted-foreground"> ...</p>
</td>
</tr>
) : results.length === 0 ? (
<tr>
<td
colSpan={sourceColumns.length + (multiSelect ? 1 : 0)}
className="px-4 py-8 text-center text-muted-foreground"
>
</td>
</tr>
) : (
results.map((item, index) => {
const alreadyAdded = isAlreadyAdded(item);
const selected = isSelected(item);
return (
<tr
key={index}
className={`border-t transition-colors ${
alreadyAdded
? "bg-muted/50 opacity-50"
: selected
? "bg-primary/10"
: "hover:bg-accent cursor-pointer"
}`}
onClick={() => {
if (!alreadyAdded) {
handleToggleItem(item);
}
}}
>
{multiSelect && (
<td className="px-4 py-2">
<Checkbox
checked={selected}
disabled={alreadyAdded}
onCheckedChange={() => {
if (!alreadyAdded) {
handleToggleItem(item);
}
}}
/>
</td>
)}
{sourceColumns.map((col) => (
<td key={col} className="px-4 py-2">
{item[col] || "-"}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleConfirm}
disabled={selectedItems.length === 0}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
({selectedItems.length})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,137 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { ItemSelectionModal } from "./ItemSelectionModal";
import { RepeaterTable } from "./RepeaterTable";
import { ModalRepeaterTableProps } from "./types";
import { useCalculation } from "./useCalculation";
import { cn } from "@/lib/utils";
interface ModalRepeaterTableComponentProps extends Partial<ModalRepeaterTableProps> {
config?: ModalRepeaterTableProps;
}
export function ModalRepeaterTableComponent({
config,
sourceTable: propSourceTable,
sourceColumns: propSourceColumns,
sourceSearchFields: propSourceSearchFields,
modalTitle: propModalTitle,
modalButtonText: propModalButtonText,
multiSelect: propMultiSelect,
columns: propColumns,
calculationRules: propCalculationRules,
value: propValue,
onChange: propOnChange,
uniqueField: propUniqueField,
filterCondition: propFilterCondition,
companyCode: propCompanyCode,
className,
}: ModalRepeaterTableComponentProps) {
// config prop 우선, 없으면 개별 prop 사용
const sourceTable = config?.sourceTable || propSourceTable || "";
const sourceColumns = config?.sourceColumns || propSourceColumns || [];
const sourceSearchFields = config?.sourceSearchFields || propSourceSearchFields || [];
const modalTitle = config?.modalTitle || propModalTitle || "항목 검색";
const modalButtonText = config?.modalButtonText || propModalButtonText || "품목 검색";
const multiSelect = config?.multiSelect ?? propMultiSelect ?? true;
const columns = config?.columns || propColumns || [];
const calculationRules = config?.calculationRules || propCalculationRules || [];
const value = config?.value || propValue || [];
const onChange = config?.onChange || propOnChange || (() => {});
const uniqueField = config?.uniqueField || propUniqueField;
const filterCondition = config?.filterCondition || propFilterCondition || {};
const companyCode = config?.companyCode || propCompanyCode;
const [modalOpen, setModalOpen] = useState(false);
const { calculateRow, calculateAll } = useCalculation(calculationRules);
// 초기 데이터에 계산 필드 적용
useEffect(() => {
if (value.length > 0 && calculationRules.length > 0) {
const calculated = calculateAll(value);
// 값이 실제로 변경된 경우만 업데이트
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
onChange(calculated);
}
}
}, []);
const handleAddItems = (items: any[]) => {
// 기본값 적용
const itemsWithDefaults = items.map((item) => {
const newItem = { ...item };
columns.forEach((col) => {
if (col.defaultValue !== undefined && newItem[col.field] === undefined) {
newItem[col.field] = col.defaultValue;
}
});
return newItem;
});
// 계산 필드 업데이트
const calculatedItems = calculateAll(itemsWithDefaults);
// 기존 데이터에 추가
onChange([...value, ...calculatedItems]);
};
const handleRowChange = (index: number, newRow: any) => {
// 계산 필드 업데이트
const calculatedRow = calculateRow(newRow);
// 데이터 업데이트
const newData = [...value];
newData[index] = calculatedRow;
onChange(newData);
};
const handleRowDelete = (index: number) => {
const newData = value.filter((_, i) => i !== index);
onChange(newData);
};
return (
<div className={cn("space-y-4", className)}>
{/* 추가 버튼 */}
<div className="flex justify-between items-center">
<div className="text-sm text-muted-foreground">
{value.length > 0 && `${value.length}개 항목`}
</div>
<Button
onClick={() => setModalOpen(true)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Plus className="h-4 w-4 mr-2" />
{modalButtonText}
</Button>
</div>
{/* Repeater 테이블 */}
<RepeaterTable
columns={columns}
data={value}
onDataChange={onChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
/>
{/* 항목 선택 모달 */}
<ItemSelectionModal
open={modalOpen}
onOpenChange={setModalOpen}
sourceTable={sourceTable}
sourceColumns={sourceColumns}
sourceSearchFields={sourceSearchFields}
multiSelect={multiSelect}
filterCondition={filterCondition}
modalTitle={modalTitle}
alreadySelected={value}
uniqueField={uniqueField}
onSelect={handleAddItems}
/>
</div>
);
}

View File

@ -0,0 +1,350 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import { ModalRepeaterTableProps } from "./types";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
interface ModalRepeaterTableConfigPanelProps {
config: Partial<ModalRepeaterTableProps>;
onConfigChange: (config: Partial<ModalRepeaterTableProps>) => void;
}
export function ModalRepeaterTableConfigPanel({
config,
onConfigChange,
}: ModalRepeaterTableConfigPanelProps) {
const [localConfig, setLocalConfig] = useState(config);
const [allTables, setAllTables] = useState<any[]>([]);
const [tableColumns, setTableColumns] = useState<any[]>([]);
const [isLoadingTables, setIsLoadingTables] = useState(false);
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
const [openTableCombo, setOpenTableCombo] = useState(false);
const [openUniqueFieldCombo, setOpenUniqueFieldCombo] = useState(false);
// 전체 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setIsLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setIsLoadingTables(false);
}
};
loadTables();
}, []);
// 선택된 테이블의 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!localConfig.sourceTable) {
setTableColumns([]);
return;
}
setIsLoadingColumns(true);
try {
const response = await tableManagementApi.getColumnList(localConfig.sourceTable);
if (response.success && response.data) {
setTableColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setTableColumns([]);
} finally {
setIsLoadingColumns(false);
}
};
loadColumns();
}, [localConfig.sourceTable]);
useEffect(() => {
setLocalConfig(config);
}, [config]);
const updateConfig = (updates: Partial<ModalRepeaterTableProps>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
const addSourceColumn = () => {
const columns = localConfig.sourceColumns || [];
updateConfig({ sourceColumns: [...columns, ""] });
};
const updateSourceColumn = (index: number, value: string) => {
const columns = [...(localConfig.sourceColumns || [])];
columns[index] = value;
updateConfig({ sourceColumns: columns });
};
const removeSourceColumn = (index: number) => {
const columns = [...(localConfig.sourceColumns || [])];
columns.splice(index, 1);
updateConfig({ sourceColumns: columns });
};
const addSearchField = () => {
const fields = localConfig.sourceSearchFields || [];
updateConfig({ sourceSearchFields: [...fields, ""] });
};
const updateSearchField = (index: number, value: string) => {
const fields = [...(localConfig.sourceSearchFields || [])];
fields[index] = value;
updateConfig({ sourceSearchFields: fields });
};
const removeSearchField = (index: number) => {
const fields = [...(localConfig.sourceSearchFields || [])];
fields.splice(index, 1);
updateConfig({ sourceSearchFields: fields });
};
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openTableCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoadingTables}
>
{localConfig.sourceTable
? allTables.find((t) => t.tableName === localConfig.sourceTable)?.displayName || localConfig.sourceTable
: isLoadingTables ? "로딩 중..." : "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => {
updateConfig({ sourceTable: table.tableName });
setOpenTableCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", localConfig.sourceTable === table.tableName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.modalTitle || ""}
onChange={(e) => updateConfig({ modalTitle: e.target.value })}
placeholder="항목 검색 및 선택"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.modalButtonText || ""}
onChange={(e) => updateConfig({ modalButtonText: e.target.value })}
placeholder="항목 검색"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Popover open={openUniqueFieldCombo} onOpenChange={setOpenUniqueFieldCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openUniqueFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.sourceTable || isLoadingColumns}
>
{localConfig.uniqueField
? tableColumns.find((c) => c.columnName === localConfig.uniqueField)?.displayName || localConfig.uniqueField
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{tableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => {
updateConfig({ uniqueField: column.columnName });
setOpenUniqueFieldCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", localConfig.uniqueField === column.columnName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.multiSelect ?? true}
onCheckedChange={(checked) =>
updateConfig({ multiSelect: checked })
}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addSourceColumn}
className="h-7 text-xs"
disabled={!localConfig.sourceTable || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.sourceColumns || []).map((column, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={column}
onValueChange={(value) => updateSourceColumn(index, value)}
disabled={!localConfig.sourceTable || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeSourceColumn(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addSearchField}
className="h-7 text-xs"
disabled={!localConfig.sourceTable || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.sourceSearchFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={field}
onValueChange={(value) => updateSearchField(index, value)}
disabled={!localConfig.sourceTable || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeSearchField(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
<div className="p-4 bg-muted rounded-md text-xs text-muted-foreground">
<p className="font-medium mb-2">💡 :</p>
<ul className="space-y-1 list-disc list-inside">
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
);
}

View File

@ -0,0 +1,19 @@
"use client";
import React, { useEffect } from "react";
import { ComponentRegistry } from "../../ComponentRegistry";
import { ModalRepeaterTableDefinition } from "./index";
export function ModalRepeaterTableRenderer() {
useEffect(() => {
ComponentRegistry.registerComponent(ModalRepeaterTableDefinition);
console.log("✅ ModalRepeaterTable 컴포넌트 등록 완료");
return () => {
// 컴포넌트 언마운트 시 해제하지 않음 (싱글톤 패턴)
};
}, []);
return null;
}

View File

@ -0,0 +1,179 @@
"use client";
import React, { useState } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { RepeaterColumnConfig } from "./types";
import { cn } from "@/lib/utils";
interface RepeaterTableProps {
columns: RepeaterColumnConfig[];
data: any[];
onDataChange: (newData: any[]) => void;
onRowChange: (index: number, newRow: any) => void;
onRowDelete: (index: number) => void;
}
export function RepeaterTable({
columns,
data,
onDataChange,
onRowChange,
onRowDelete,
}: RepeaterTableProps) {
const [editingCell, setEditingCell] = useState<{
rowIndex: number;
field: string;
} | null>(null);
const handleCellEdit = (rowIndex: number, field: string, value: any) => {
const newRow = { ...data[rowIndex], [field]: value };
onRowChange(rowIndex, newRow);
};
const renderCell = (
row: any,
column: RepeaterColumnConfig,
rowIndex: number
) => {
const isEditing =
editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
const value = row[column.field];
// 계산 필드는 편집 불가
if (column.calculated || !column.editable) {
return (
<div className="px-2 py-1">
{column.type === "number"
? typeof value === "number"
? value.toLocaleString()
: value || "0"
: value || "-"}
</div>
);
}
// 편집 가능한 필드
switch (column.type) {
case "number":
return (
<Input
type="number"
value={value || ""}
onChange={(e) =>
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
}
className="h-7 text-xs"
/>
);
case "date":
return (
<Input
type="date"
value={value || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
/>
);
case "select":
return (
<Select
value={value || ""}
onValueChange={(newValue) =>
handleCellEdit(rowIndex, column.field, newValue)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{column.selectOptions?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
default: // text
return (
<Input
type="text"
value={value || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
/>
);
}
};
return (
<div className="border rounded-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted">
<tr>
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
#
</th>
{columns.map((col) => (
<th
key={col.field}
className="px-4 py-2 text-left font-medium text-muted-foreground"
style={{ width: col.width }}
>
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
</th>
))}
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
</th>
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length + 2}
className="px-4 py-8 text-center text-muted-foreground"
>
</td>
</tr>
) : (
data.map((row, rowIndex) => (
<tr key={rowIndex} className="border-t hover:bg-accent/50">
<td className="px-4 py-2 text-center text-muted-foreground">
{rowIndex + 1}
</td>
{columns.map((col) => (
<td key={col.field} className="px-2 py-1">
{renderCell(row, col, rowIndex)}
</td>
))}
<td className="px-4 py-2 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => onRowDelete(rowIndex)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,52 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent";
import { ModalRepeaterTableConfigPanel } from "./ModalRepeaterTableConfigPanel";
/**
* ModalRepeaterTable
* + ()
*/
export const ModalRepeaterTableDefinition = createComponentDefinition({
id: "modal-repeater-table",
name: "모달 반복 테이블",
nameEng: "Modal Repeater Table",
description: "모달에서 항목을 검색하여 동적으로 추가/편집할 수 있는 테이블 (수주 품목 등)",
category: ComponentCategory.DATA,
webType: "table",
component: ModalRepeaterTableComponent,
defaultConfig: {
sourceTable: "item_info",
sourceColumns: ["item_code", "item_name", "size", "unit_price"],
sourceSearchFields: ["item_code", "item_name"],
modalTitle: "항목 검색 및 선택",
modalButtonText: "항목 검색",
multiSelect: true,
columns: [],
uniqueField: "item_code",
},
defaultSize: { width: 800, height: 400 },
configPanel: ModalRepeaterTableConfigPanel,
icon: "Table",
tags: ["테이블", "반복", "동적", "모달", "수주", "품목"],
version: "1.0.0",
author: "개발팀",
});
// 타입 내보내기
export type {
ModalRepeaterTableProps,
RepeaterColumnConfig,
CalculationRule,
ItemSelectionModalProps,
} from "./types";
// 컴포넌트 내보내기
export { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent";
export { ModalRepeaterTableRenderer } from "./ModalRepeaterTableRenderer";
export { ItemSelectionModal } from "./ItemSelectionModal";
export { RepeaterTable } from "./RepeaterTable";
export { useCalculation } from "./useCalculation";

View File

@ -0,0 +1,69 @@
/**
* ModalRepeaterTable
* , Repeater
*/
export interface ModalRepeaterTableProps {
// 소스 데이터 (모달에서 가져올 데이터)
sourceTable: string; // 검색할 테이블 (예: "item_info")
sourceColumns: string[]; // 모달에 표시할 컬럼들
sourceSearchFields?: string[]; // 검색 가능한 필드들
// 모달 설정
modalTitle: string; // 모달 제목 (예: "품목 검색 및 선택")
modalButtonText?: string; // 모달 열기 버튼 텍스트 (기본: "품목 검색")
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
// Repeater 테이블 설정
columns: RepeaterColumnConfig[]; // 테이블 컬럼 설정
// 계산 규칙
calculationRules?: CalculationRule[]; // 자동 계산 규칙
// 데이터
value: any[]; // 현재 추가된 항목들
onChange: (newData: any[]) => void; // 데이터 변경 콜백
// 중복 체크
uniqueField?: string; // 중복 체크할 필드 (예: "item_code")
// 필터링
filterCondition?: Record<string, any>;
companyCode?: string;
// 스타일
className?: string;
}
export interface RepeaterColumnConfig {
field: string; // 필드명
label: string; // 컬럼 헤더 라벨
type?: "text" | "number" | "date" | "select"; // 입력 타입
editable?: boolean; // 편집 가능 여부
calculated?: boolean; // 계산 필드 여부
width?: string; // 컬럼 너비
required?: boolean; // 필수 입력 여부
defaultValue?: any; // 기본값
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
}
export interface CalculationRule {
result: string; // 결과를 저장할 필드
formula: string; // 계산 공식 (예: "quantity * unit_price")
dependencies: string[]; // 의존하는 필드들
}
export interface ItemSelectionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceTable: string;
sourceColumns: string[];
sourceSearchFields?: string[];
multiSelect?: boolean;
filterCondition?: Record<string, any>;
modalTitle: string;
alreadySelected: any[]; // 이미 선택된 항목들 (중복 방지용)
uniqueField?: string;
onSelect: (items: any[]) => void;
}

View File

@ -0,0 +1,56 @@
import { useCallback } from "react";
import { CalculationRule } from "./types";
/**
*
*/
export function useCalculation(calculationRules: CalculationRule[] = []) {
/**
*
*/
const calculateRow = useCallback(
(row: any): any => {
if (calculationRules.length === 0) return row;
const updatedRow = { ...row };
for (const rule of calculationRules) {
try {
// formula에서 필드명 추출 및 값으로 대체
let formula = rule.formula;
for (const dep of rule.dependencies) {
const value = parseFloat(row[dep]) || 0;
formula = formula.replace(new RegExp(dep, "g"), value.toString());
}
// 계산 실행 (eval 대신 Function 사용)
const result = new Function(`return ${formula}`)();
updatedRow[rule.result] = result;
} catch (error) {
console.error(`계산 오류 (${rule.formula}):`, error);
updatedRow[rule.result] = 0;
}
}
return updatedRow;
},
[calculationRules]
);
/**
*
*/
const calculateAll = useCallback(
(data: any[]): any[] => {
return data.map((row) => calculateRow(row));
},
[calculateRow]
);
return {
calculateRow,
calculateAll,
};
}

View File

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

View File

@ -0,0 +1,53 @@
"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);
return (
<>
<Button
variant={buttonVariant}
size={buttonSize}
onClick={() => setIsOpen(true)}
style={style}
className="w-full h-full"
>
<Plus className="mr-2 h-4 w-4" />
{buttonText}
</Button>
<OrderRegistrationModal open={isOpen} onOpenChange={setIsOpen} />
</>
);
}
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent({
...OrderRegistrationModalDefinition,
component: OrderRegistrationModalRenderer,
renderer: OrderRegistrationModalRenderer,
configPanel: OrderRegistrationModalConfigPanel,
} as any);
}

View File

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

View File

@ -63,7 +63,9 @@ export function createComponentDefinition(options: CreateComponentDefinitionOpti
category, // 카테고리를 태그로 추가 category, // 카테고리를 태그로 추가
webType, // 웹타입을 태그로 추가 webType, // 웹타입을 태그로 추가
...name.split(" "), // 이름을 단어별로 분리하여 태그로 추가 ...name.split(" "), // 이름을 단어별로 분리하여 태그로 추가
].filter((tag, index, array) => array.indexOf(tag) === index); // 중복 제거 ]
.filter((tag) => tag && typeof tag === 'string' && tag.trim().length > 0) // undefined, null, 빈 문자열 제거
.filter((tag, index, array) => array.indexOf(tag) === index); // 중복 제거
// 컴포넌트 정의 생성 // 컴포넌트 정의 생성
const definition: ComponentDefinition = { const definition: ComponentDefinition = {
@ -163,7 +165,7 @@ export function validateComponentDefinition(definition: ComponentDefinition): {
warnings.push("태그가 너무 많습니다 (20개 초과)"); warnings.push("태그가 너무 많습니다 (20개 초과)");
} }
definition.tags.forEach((tag) => { definition.tags.forEach((tag) => {
if (tag.length > 30) { if (tag && typeof tag === 'string' && tag.length > 30) {
warnings.push(`태그가 너무 깁니다: ${tag}`); warnings.push(`태그가 너무 깁니다: ${tag}`);
} }
}); });

View File

@ -26,6 +26,11 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"split-panel-layout": () => import("@/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel"), "split-panel-layout": () => import("@/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel"),
"repeater-field-group": () => import("@/components/webtypes/config/RepeaterConfigPanel"), "repeater-field-group": () => import("@/components/webtypes/config/RepeaterConfigPanel"),
"flow-widget": () => import("@/components/screen/config-panels/FlowWidgetConfigPanel"), "flow-widget": () => import("@/components/screen/config-panels/FlowWidgetConfigPanel"),
// 🆕 수주 등록 관련 컴포넌트들
"autocomplete-search-input": () => import("@/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel"),
"entity-search-input": () => import("@/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel"),
"modal-repeater-table": () => import("@/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel"),
"order-registration-modal": () => import("@/lib/registry/components/order-registration-modal/OrderRegistrationModalConfigPanel"),
}; };
// ConfigPanel 컴포넌트 캐시 // ConfigPanel 컴포넌트 캐시
@ -251,6 +256,23 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
} }
}; };
// 🆕 수주 등록 관련 컴포넌트들은 간단한 인터페이스 사용
const isSimpleConfigPanel = [
"autocomplete-search-input",
"entity-search-input",
"modal-repeater-table",
"order-registration-modal"
].includes(componentId);
if (isSimpleConfigPanel) {
return (
<ConfigPanelComponent
config={config}
onConfigChange={onChange}
/>
);
}
return ( return (
<ConfigPanelComponent <ConfigPanelComponent
config={config} config={config}

View File

@ -16,6 +16,7 @@ export enum ComponentCategory {
DISPLAY = "display", // 표시 컴포넌트 (라벨, 이미지, 아이콘 등) DISPLAY = "display", // 표시 컴포넌트 (라벨, 이미지, 아이콘 등)
ACTION = "action", // 액션 컴포넌트 (버튼, 링크 등) ACTION = "action", // 액션 컴포넌트 (버튼, 링크 등)
LAYOUT = "layout", // 레이아웃 컴포넌트 (컨테이너, 그룹 등) LAYOUT = "layout", // 레이아웃 컴포넌트 (컨테이너, 그룹 등)
DATA = "data", // 데이터 컴포넌트 (테이블, 리스트 등)
CHART = "chart", // 차트 컴포넌트 (그래프, 차트 등) CHART = "chart", // 차트 컴포넌트 (그래프, 차트 등)
FORM = "form", // 폼 컴포넌트 (폼 그룹, 필드셋 등) FORM = "form", // 폼 컴포넌트 (폼 그룹, 필드셋 등)
MEDIA = "media", // 미디어 컴포넌트 (이미지, 비디오 등) MEDIA = "media", // 미디어 컴포넌트 (이미지, 비디오 등)

File diff suppressed because it is too large Load Diff