- 범용 컴포넌트 3종 개발 및 레지스트리 등록: * AutocompleteSearchInput: 자동완성 검색 입력 컴포넌트 * EntitySearchInput: 엔티티 검색 모달 컴포넌트 * ModalRepeaterTable: 모달 기반 반복 테이블 컴포넌트 - 수주등록 전용 컴포넌트: * OrderCustomerSearch: 거래처 검색 (AutocompleteSearchInput 래퍼) * OrderItemRepeaterTable: 품목 관리 (ModalRepeaterTable 래퍼) * OrderRegistrationModal: 수주등록 메인 모달 - 백엔드 API: * Entity 검색 API (멀티테넌시 지원) * 수주 등록 API (자동 채번) - 화면 편집기 통합: * 컴포넌트 레지스트리에 등록 * ConfigPanel을 통한 설정 기능 * 드래그앤드롭으로 배치 가능 - 개발 문서: * 수주등록_화면_개발_계획서.md (상세 설계 문서)
39 KiB
📋 수주 등록 화면 개발 계획서
📌 프로젝트 개요
목표: 수주 등록 모달 화면 구현을 위한 범용 컴포넌트 개발 및 전용 화면 구성
기간: 약 4-5일
전략: 핵심 범용 컴포넌트 우선 개발 → 전용 화면 구성 → 점진적 확장
🎯 Phase 1: 범용 컴포넌트 개발 (2-3일)
1.1 EntitySearchInput 컴포넌트 ⭐⭐⭐
목적: 엔티티 테이블(거래처, 품목, 사용자 등)에서 데이터를 검색하고 선택하는 범용 입력 컴포넌트
주요 기능
- 자동완성 검색 (타이핑 시 실시간 검색)
- 모달 검색 (버튼 클릭 → 전체 목록 + 검색)
- 콤보 모드 (입력 필드 + 검색 버튼)
- 다중 필드 검색 지원
- 선택된 항목 표시 및 초기화
인터페이스 설계
// frontend/lib/registry/components/entity-search-input/types.ts
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[]; // 추가로 표시할 필드들
}
파일 구조
frontend/lib/registry/components/entity-search-input/
├── EntitySearchInputComponent.tsx # 메인 컴포넌트
├── EntitySearchModal.tsx # 검색 모달
├── types.ts # 타입 정의
├── useEntitySearch.ts # 검색 로직 훅
└── EntitySearchInputConfig.tsx # 속성 편집 패널
API 엔드포인트 (백엔드)
// backend-node/src/controllers/entitySearchController.ts
/**
* GET /api/entity-search/:tableName
* Query params:
* - searchText: 검색어
* - searchFields: 검색할 필드들 (콤마 구분)
* - filterCondition: JSON 형식의 추가 조건
* - page, limit: 페이징
*/
router.get("/api/entity-search/:tableName", async (req, res) => {
const { tableName } = req.params;
const {
searchText,
searchFields,
filterCondition,
page = 1,
limit = 20,
} = req.query;
// 멀티테넌시 자동 적용
const companyCode = req.user.companyCode;
// 검색 실행
const results = await entitySearchService.search({
tableName,
searchText,
searchFields: searchFields?.split(","),
filterCondition: filterCondition ? JSON.parse(filterCondition) : {},
companyCode,
page: parseInt(page),
limit: parseInt(limit),
});
res.json({ success: true, data: results });
});
사용 예시
// 거래처 검색 - 콤보 모드 (입력 + 버튼)
<EntitySearchInput
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", "tel"]}
showAdditionalInfo
additionalFields={["address", "tel", "business_number"]}
value={formData.customerCode}
onChange={(code, fullData) => {
setFormData({
...formData,
customerCode: code,
customerName: fullData.customer_name,
customerAddress: fullData.address,
});
}}
/>
// 품목 검색 - 모달 전용
<EntitySearchInput
tableName="item_info"
displayField="item_name"
valueField="item_code"
mode="modal"
placeholder="품목 선택"
modalTitle="품목 검색"
value={formData.itemCode}
onChange={(code, fullData) => {
setFormData({
...formData,
itemCode: code,
itemName: fullData.item_name,
unitPrice: fullData.unit_price,
});
}}
/>
// 사용자 검색 - 자동완성 전용
<EntitySearchInput
tableName="user_info"
displayField="user_name"
valueField="user_id"
searchFields={["user_name", "user_id", "email"]}
mode="autocomplete"
placeholder="사용자 검색"
value={formData.userId}
onChange={(userId, userData) => {
setFormData({
...formData,
userId,
userName: userData.user_name,
});
}}
/>
1.2 ModalRepeaterTable 컴포넌트 ⭐⭐
목적: 모달에서 데이터를 검색하여 선택하고, 선택된 항목들을 동적 테이블(Repeater)에 추가하는 범용 컴포넌트
주요 기능
- 모달 버튼 클릭 → 소스 테이블 검색 모달 열기
- 다중 선택 지원 (체크박스)
- 선택한 항목들을 Repeater 테이블에 추가
- 추가된 행의 필드 편집 가능 (수량, 단가 등)
- 계산 필드 지원 (수량 × 단가 = 금액)
- 행 삭제 기능
- 중복 방지 (이미 추가된 항목은 선택 불가)
인터페이스 설계
// frontend/lib/registry/components/modal-repeater-table/types.ts
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;
}
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[]; // 의존하는 필드들 (자동 추출 가능)
}
파일 구조
frontend/lib/registry/components/modal-repeater-table/
├── ModalRepeaterTableComponent.tsx # 메인 컴포넌트
├── ItemSelectionModal.tsx # 항목 선택 모달
├── RepeaterTable.tsx # 동적 테이블 (편집 가능)
├── types.ts # 타입 정의
├── useCalculation.ts # 계산 로직 훅
└── ModalRepeaterTableConfig.tsx # 속성 편집 패널
사용 예시
// 품목 추가 테이블 (수주 등록)
<ModalRepeaterTable
sourceTable="item_info"
sourceColumns={["item_code", "item_name", "spec", "unit", "unit_price"]}
sourceSearchFields={["item_code", "item_name", "spec"]}
modalTitle="품목 검색 및 선택"
modalButtonText="품목 검색"
multiSelect={true}
columns={[
{ field: "item_code", label: "품번", editable: false, width: "120px" },
{ field: "item_name", label: "품명", editable: false, width: "200px" },
{ field: "spec", label: "규격", editable: false, width: "150px" },
{
field: "quantity",
label: "수량",
type: "number",
editable: true,
required: true,
defaultValue: 1,
width: "100px",
},
{
field: "unit_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",
},
]}
calculationRules={[
{
result: "amount",
formula: "quantity * unit_price",
dependencies: ["quantity", "unit_price"],
},
]}
uniqueField="item_code"
value={selectedItems}
onChange={(newItems) => {
setSelectedItems(newItems);
// 전체 금액 재계산
const totalAmount = newItems.reduce(
(sum, item) => sum + (item.amount || 0),
0
);
setFormData({ ...formData, totalAmount });
}}
/>
컴포넌트 동작 흐름
-
초기 렌더링
- "품목 검색" 버튼 표시
- 현재 추가된 항목들을 테이블로 표시
-
모달 열기
- 버튼 클릭 →
ItemSelectionModal열림 sourceTable에서 데이터 조회 (페이징, 검색 지원)- 이미 추가된 항목은 체크박스 비활성화 (중복 방지)
- 버튼 클릭 →
-
항목 선택 및 추가
- 체크박스로 다중 선택
- "추가" 버튼 클릭 → 선택된 항목들이
value배열에 추가 onChange콜백 호출
-
편집
- 추가된 행의 편집 가능한 필드 클릭 → 인라인 편집
editable: true인 필드만 편집 가능- 값 변경 시 → 계산 필드 자동 업데이트
-
계산 필드 업데이트
calculationRules에 따라 자동 계산- 예:
quantity또는unit_price변경 시 →amount자동 계산
-
행 삭제
- 각 행의 삭제 버튼 클릭 → 해당 항목 제거
onChange콜백 호출
🎯 Phase 2: 백엔드 API 개발 (1일)
2.1 엔티티 검색 API
파일: backend-node/src/controllers/entitySearchController.ts
import { Request, Response } from "express";
import pool from "../database/pool";
import logger from "../utils/logger";
/**
* 엔티티 검색
* 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(",") : [];
// 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 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 });
res.status(500).json({ success: false, message: error.message });
}
}
라우트 등록: backend-node/src/routes/index.ts
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { searchEntity } from "../controllers/entitySearchController";
const router = Router();
// 엔티티 검색
router.get("/api/entity-search/:tableName", authenticateToken, searchEntity);
export default router;
2.2 수주 등록 API (기본)
파일: backend-node/src/controllers/orderController.ts
import { Request, Response } from "express";
import pool from "../database/pool";
import logger from "../utils/logger";
/**
* 수주 등록
* POST /api/orders
*/
export async function createOrder(req: Request, res: Response) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const {
inputMode, // 입력 방식
customerCode, // 거래처 코드
deliveryDate, // 납품일
items, // 품목 목록
memo, // 메모
} = req.body;
// 멀티테넌시
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
// 수주 마스터 생성
const orderQuery = `
INSERT INTO order_mng_master (
company_code,
order_no,
customer_code,
input_mode,
delivery_date,
total_amount,
memo,
created_by,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
RETURNING *
`;
// 수주 번호 자동 생성 (채번 규칙 활용 - 별도 구현 필요)
const orderNo = await generateOrderNumber(companyCode);
// 전체 금액 계산
const totalAmount = items.reduce(
(sum: number, item: any) => sum + (item.amount || 0),
0
);
const orderResult = await client.query(orderQuery, [
companyCode,
orderNo,
customerCode,
inputMode,
deliveryDate,
totalAmount,
memo,
userId,
]);
const orderId = orderResult.rows[0].id;
// 수주 상세 (품목) 생성
for (const item of items) {
const itemQuery = `
INSERT INTO order_mng_sub (
company_code,
order_id,
item_code,
item_name,
spec,
quantity,
unit_price,
amount,
delivery_date,
note
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`;
await client.query(itemQuery, [
companyCode,
orderId,
item.item_code,
item.item_name,
item.spec,
item.quantity,
item.unit_price,
item.amount,
item.delivery_date,
item.note,
]);
}
await client.query("COMMIT");
logger.info("수주 등록 성공", {
companyCode,
orderNo,
orderId,
itemCount: items.length,
});
res.json({
success: true,
data: {
orderId,
orderNo,
},
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("수주 등록 오류", { error: error.message });
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// 수주 번호 생성 함수 (예시 - 실제로는 채번 규칙 시스템 활용)
async function generateOrderNumber(companyCode: string): Promise<string> {
const today = new Date();
const year = today.getFullYear().toString().slice(2);
const month = String(today.getMonth() + 1).padStart(2, "0");
// 당일 수주 카운트 조회
const countQuery = `
SELECT COUNT(*) FROM order_mng_master
WHERE company_code = $1
AND DATE(created_at) = CURRENT_DATE
`;
const result = await pool.query(countQuery, [companyCode]);
const seq = parseInt(result.rows[0].count) + 1;
return `ORD${year}${month}${String(seq).padStart(4, "0")}`;
}
🎯 Phase 3: 수주 등록 전용 컴포넌트 (1일)
3.1 OrderRegistrationModal 컴포넌트
파일: frontend/components/order/OrderRegistrationModal.tsx
"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 { EntitySearchInput } from "@/lib/registry/components/entity-search-input/EntitySearchInputComponent";
import { ModalRepeaterTable } from "@/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent";
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("/api/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>
<EntitySearchInput
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",
"tel",
]}
showAdditionalInfo
additionalFields={["address", "tel"]}
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>
<ModalRepeaterTable
sourceTable="item_info"
sourceColumns={[
"item_code",
"item_name",
"spec",
"unit",
"unit_price",
]}
sourceSearchFields={["item_code", "item_name", "spec"]}
modalTitle="품목 검색 및 선택"
modalButtonText="품목 검색"
multiSelect={true}
columns={[
{
field: "item_code",
label: "품번",
editable: false,
width: "120px",
},
{
field: "item_name",
label: "품명",
editable: false,
width: "200px",
},
{
field: "spec",
label: "규격",
editable: false,
width: "150px",
},
{
field: "quantity",
label: "수량",
type: "number",
editable: true,
required: true,
defaultValue: 1,
width: "100px",
},
{
field: "unit_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",
},
]}
calculationRules={[
{
result: "amount",
formula: "quantity * unit_price",
dependencies: ["quantity", "unit_price"],
},
]}
uniqueField="item_code"
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>
);
}
🎯 Phase 4: 화면관리 시스템 통합 (선택 사항)
4.1 컴포넌트 레지스트리 등록
파일: frontend/lib/registry/component-registry.ts
// EntitySearchInput 등록
{
id: "entity-search-input",
name: "엔티티 검색 입력",
category: "input",
description: "데이터베이스 테이블에서 엔티티를 검색하고 선택하는 입력 필드",
component: EntitySearchInputComponent,
configPanel: EntitySearchInputConfig,
defaultProps: {
tableName: "",
displayField: "",
valueField: "",
mode: "combo",
placeholder: "검색...",
},
},
// ModalRepeaterTable 등록
{
id: "modal-repeater-table",
name: "모달 연동 Repeater",
category: "data",
description: "모달에서 데이터를 선택하여 동적 테이블에 추가하는 컴포넌트",
component: ModalRepeaterTableComponent,
configPanel: ModalRepeaterTableConfig,
defaultProps: {
sourceTable: "",
modalTitle: "항목 검색 및 선택",
multiSelect: true,
columns: [],
value: [],
},
},
📅 개발 일정
| Phase | 작업 내용 | 예상 기간 | 담당자 |
|---|---|---|---|
| Phase 1 | EntitySearchInput 컴포넌트 개발 | 1.5일 | Frontend |
| Phase 1 | ModalRepeaterTable 컴포넌트 개발 | 1.5일 | Frontend |
| Phase 2 | 백엔드 API 개발 (엔티티 검색 + 수주 등록) | 1일 | Backend |
| Phase 3 | 수주 등록 전용 컴포넌트 개발 | 1일 | Frontend |
| Phase 4 | 화면관리 시스템 통합 (선택) | 1일 | Frontend |
| 총계 | 4-5일 |
✅ 체크리스트
EntitySearchInput 컴포넌트
- 기본 인터페이스 및 타입 정의
- 자동완성 모드 구현
- 모달 모드 구현
- 콤보 모드 구현
- 백엔드 API 연동
- 멀티테넌시 적용
- 선택된 항목 표시 및 추가 정보 표시
- 속성 편집 패널 구현
- 테스트 및 디버깅
ModalRepeaterTable 컴포넌트
- 기본 인터페이스 및 타입 정의
- 모달 검색 구현
- 다중 선택 기능
- Repeater 테이블 렌더링
- 인라인 편집 기능
- 계산 필드 자동 업데이트
- 행 추가/삭제 기능
- 중복 방지 로직
- 속성 편집 패널 구현
- 테스트 및 디버깅
백엔드 API
- 엔티티 검색 API 구현
- 멀티테넌시 적용
- 페이징 지원
- 검색 필터링 로직
- 수주 등록 API 구현
- 트랜잭션 처리
- 채번 규칙 연동
- 오류 처리 및 로깅
수주 등록 컴포넌트
- 기본 폼 구조 구현
- 입력 방식별 동적 폼 전환
- EntitySearchInput 통합
- ModalRepeaterTable 통합
- 전체 금액 계산
- 저장 로직 구현
- 유효성 검사
- UI/UX 개선
🎨 UI/UX 가이드라인
모달 디자인
- 최대 너비:
max-w-[95vw] sm:max-w-[1200px] - 반응형 크기 적용
- 스크롤 가능하도록
overflow-y-auto적용 - 모바일에서는 전체 화면에 가까운 크기
입력 필드
- 높이:
h-8 sm:h-10(모바일 32px, 데스크톱 40px) - 텍스트 크기:
text-xs sm:text-sm - 필수 항목은 라벨에
*표시
버튼
- Footer 버튼:
gap-2 sm:gap-0 - 모바일에서는 같은 크기 (
flex-1) - 데스크톱에서는 자동 크기 (
flex-none)
테이블
- 고정 컬럼 너비 지정
- 스크롤 가능하도록 설계
- 모바일에서는 가로 스크롤 허용
🔧 기술 스택
- Frontend: Next.js 14, TypeScript, React 18
- UI 라이브러리: shadcn/ui, Tailwind CSS
- Backend: Node.js, Express, TypeScript
- Database: PostgreSQL
- 상태 관리: React Hooks (useState, useEffect)
- API 통신: Axios
📝 참고 자료
🚀 다음 단계
- 즉시 개발: EntitySearchInput 컴포넌트부터 시작
- 병렬 작업: 프론트엔드와 백엔드 API 동시 개발 가능
- 점진적 확장: 견적서, 발주서 등 유사한 화면에 재사용
- 피드백 수집: 실제 사용자 테스트 후 개선사항 반영
❓ FAQ
Q1: 다른 화면(견적서, 발주서)에도 사용할 수 있나요?
A: 네! EntitySearchInput과 ModalRepeaterTable은 범용 컴포넌트로 설계되어 있어서 다른 화면에서도 바로 사용 가능합니다.
Q2: 화면관리 시스템에 통합해야 하나요?
A: 필수는 아닙니다. Phase 3까지만 완료해도 수주 등록 기능은 정상 작동합니다. Phase 4는 향후 다른 화면을 더 쉽게 만들기 위한 선택사항입니다.
Q3: 계산 필드는 어떻게 작동하나요?
A: calculationRules에 정의된 공식에 따라 자동으로 계산됩니다. 예를 들어 quantity * unit_price는 수량이나 단가가 변경될 때마다 자동으로 금액을 계산합니다.
Q4: 중복된 품목을 추가하면 어떻게 되나요?
A: uniqueField에 지정된 필드(예: item_code)를 기준으로 중복을 체크하여, 이미 추가된 품목은 모달에서 선택할 수 없도록 비활성화됩니다.
📦 전용 컴포넌트 vs 범용 컴포넌트 (2025-01-15 추가)
왜 전용 컴포넌트를 만들었나?
문제점: 범용 컴포넌트를 수주 등록 모달에서 직접 사용하면, 화면 편집기에서 설정을 변경했을 때 수주 등록 로직이 깨질 수 있습니다.
예시:
- 사용자가
AutocompleteSearchInput의tableName을customer_mng에서item_info로 변경 - → 거래처 검색 대신 품목이 조회되어 수주 등록 실패
해결책: 수주 등록 전용 래퍼 컴포넌트 생성
전용 컴포넌트 목록
1. OrderCustomerSearch
// frontend/components/order/OrderCustomerSearch.tsx
// 범용 컴포넌트를 래핑하여 설정을 고정
<AutocompleteSearchInputComponent
tableName="customer_mng" // 고정 (변경 불가)
displayField="customer_name" // 고정
valueField="customer_code" // 고정
searchFields={["customer_name", "customer_code", "business_number"]} // 고정
// ... 기타 고정 설정
value={value} // 외부에서 제어 가능
onChange={onChange} // 외부에서 제어 가능
/>
특징:
customer_mng테이블만 조회 (고정)- 거래처명, 거래처코드, 사업자번호로 검색 (고정)
- 설정 변경 불가 → 안전
2. OrderItemRepeaterTable
// frontend/components/order/OrderItemRepeaterTable.tsx
// 수주 등록에 최적화된 컬럼 및 계산 규칙 고정
<ModalRepeaterTableComponent
sourceTable="item_info" // 고정
columns={ORDER_COLUMNS} // 고정 (품번, 품명, 수량, 단가, 금액 등)
calculationRules={ORDER_CALCULATION_RULES} // 고정 (수량 * 단가)
uniqueField="id" // 고정
// ... 기타 고정 설정
value={value} // 외부에서 제어 가능
onChange={onChange} // 외부에서 제어 가능
/>
특징:
item_info테이블만 조회 (고정)- 수주에 필요한 컬럼만 표시 (고정)
- 금액 자동 계산 공식 고정 (수량 * 단가)
- 설정 변경 불가 → 안전
비교표
| 항목 | 범용 컴포넌트 | 전용 컴포넌트 |
|---|---|---|
| 목적 | 화면 편집기에서 다양한 용도로 사용 | 수주 등록 전용 |
| 설정 | ConfigPanel에서 자유롭게 변경 가능 | 하드코딩으로 고정 |
| 유연성 | 높음 (모든 테이블/필드 지원) | 낮음 (수주에 최적화) |
| 안정성 | 사용자 실수 가능 | 설정 변경 불가로 안전 |
| 위치 | lib/registry/components/ |
components/order/ |
| 사용처 | - 화면 편집기 드래그앤드롭 - 범용 데이터 입력 |
- 수주 등록 모달 - 특정 비즈니스 로직 |
사용 패턴
❌ 잘못된 방법 (범용 컴포넌트 직접 사용)
// OrderRegistrationModal.tsx
<AutocompleteSearchInputComponent
tableName="customer_mng" // ⚠️ 화면 편집기에서 변경 가능
displayField="customer_name" // ⚠️ 변경 가능
valueField="customer_code" // ⚠️ 변경 가능
// ... 설정이 바뀌면 수주 등록 로직 깨짐!
/>
✅ 올바른 방법 (전용 컴포넌트 사용)
// OrderRegistrationModal.tsx
<OrderCustomerSearch
value={customerCode}
onChange={handleChange}
// 내부 설정은 고정되어 있어 안전!
/>
파일 구조
frontend/
├── lib/registry/components/ # 범용 컴포넌트 (화면 편집기용)
│ ├── autocomplete-search-input/
│ │ ├── AutocompleteSearchInputComponent.tsx
│ │ ├── AutocompleteSearchInputConfigPanel.tsx # 설정 변경 가능
│ │ └── types.ts
│ ├── entity-search-input/
│ │ ├── EntitySearchInputComponent.tsx
│ │ ├── EntitySearchInputConfigPanel.tsx # 설정 변경 가능
│ │ └── types.ts
│ └── modal-repeater-table/
│ ├── ModalRepeaterTableComponent.tsx
│ ├── ModalRepeaterTableConfigPanel.tsx # 설정 변경 가능
│ └── types.ts
│
└── components/order/ # 전용 컴포넌트 (수주 등록용)
├── OrderCustomerSearch.tsx # 설정 고정
├── OrderItemRepeaterTable.tsx # 설정 고정
├── OrderRegistrationModal.tsx # 메인 모달
└── README.md
개발 원칙
- 비즈니스 로직이 고정된 경우: 전용 컴포넌트 생성
- 화면 편집기에서 사용: 범용 컴포넌트 사용
- 전용 컴포넌트는 범용 컴포넌트를 래핑: 중복 코드 최소화
- Props 최소화: 외부에서 제어 가능한 최소한의 prop만 노출
참고 문서
- 전용 컴포넌트 상세 문서:
frontend/components/order/README.md - 범용 컴포넌트 문서: 각 컴포넌트 폴더의 README.md
작성일: 2025-01-14 최종 수정일: 2025-01-15 작성자: AI Assistant 버전: 1.1