ERP-node/수주등록_화면_개발_계획서.md

1354 lines
39 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 📋 수주 등록 화면 개발 계획서
## 📌 프로젝트 개요
**목표**: 수주 등록 모달 화면 구현을 위한 범용 컴포넌트 개발 및 전용 화면 구성
**기간**: 약 4-5일
**전략**: 핵심 범용 컴포넌트 우선 개발 → 전용 화면 구성 → 점진적 확장
---
## 🎯 Phase 1: 범용 컴포넌트 개발 (2-3일)
### 1.1 EntitySearchInput 컴포넌트 ⭐⭐⭐
**목적**: 엔티티 테이블(거래처, 품목, 사용자 등)에서 데이터를 검색하고 선택하는 범용 입력 컴포넌트
#### 주요 기능
- 자동완성 검색 (타이핑 시 실시간 검색)
- 모달 검색 (버튼 클릭 → 전체 목록 + 검색)
- 콤보 모드 (입력 필드 + 검색 버튼)
- 다중 필드 검색 지원
- 선택된 항목 표시 및 초기화
#### 인터페이스 설계
```typescript
// 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 엔드포인트 (백엔드)
```typescript
// 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 });
});
```
#### 사용 예시
```tsx
// 거래처 검색 - 콤보 모드 (입력 + 버튼)
<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 테이블에 추가
- 추가된 행의 필드 편집 가능 (수량, 단가 등)
- 계산 필드 지원 (수량 × 단가 = 금액)
- 행 삭제 기능
- 중복 방지 (이미 추가된 항목은 선택 불가)
#### 인터페이스 설계
```typescript
// 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 # 속성 편집 패널
```
#### 사용 예시
```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 });
}}
/>
```
#### 컴포넌트 동작 흐름
1. **초기 렌더링**
- "품목 검색" 버튼 표시
- 현재 추가된 항목들을 테이블로 표시
2. **모달 열기**
- 버튼 클릭 → `ItemSelectionModal` 열림
- `sourceTable`에서 데이터 조회 (페이징, 검색 지원)
- 이미 추가된 항목은 체크박스 비활성화 (중복 방지)
3. **항목 선택 및 추가**
- 체크박스로 다중 선택
- "추가" 버튼 클릭 → 선택된 항목들이 `value` 배열에 추가
- `onChange` 콜백 호출
4. **편집**
- 추가된 행의 편집 가능한 필드 클릭 → 인라인 편집
- `editable: true`인 필드만 편집 가능
- 값 변경 시 → 계산 필드 자동 업데이트
5. **계산 필드 업데이트**
- `calculationRules`에 따라 자동 계산
- 예: `quantity` 또는 `unit_price` 변경 시 → `amount` 자동 계산
6. **행 삭제**
- 각 행의 삭제 버튼 클릭 → 해당 항목 제거
- `onChange` 콜백 호출
---
## 🎯 Phase 2: 백엔드 API 개발 (1일)
### 2.1 엔티티 검색 API
**파일**: `backend-node/src/controllers/entitySearchController.ts`
```typescript
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`
```typescript
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`
```typescript
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`
```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`
```typescript
// 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
---
## 📝 참고 자료
- [shadcn/ui 모달 가이드](.cursor/rules/admin-page-style-guide.mdc)
- [멀티테넌시 가이드](.cursor/rules/multi-tenancy-guide.mdc)
- [API 클라이언트 사용 규칙](.cursor/rules/)
- [화면관리 시스템 문서](./화면관리_시스템_구현_계획서.md)
---
## 🚀 다음 단계
1. **즉시 개발**: EntitySearchInput 컴포넌트부터 시작
2. **병렬 작업**: 프론트엔드와 백엔드 API 동시 개발 가능
3. **점진적 확장**: 견적서, 발주서 등 유사한 화면에 재사용
4. **피드백 수집**: 실제 사용자 테스트 후 개선사항 반영
---
## ❓ 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
```tsx
// 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
```tsx
// 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/` |
| **사용처** | - 화면 편집기 드래그앤드롭<br/>- 범용 데이터 입력 | - 수주 등록 모달<br/>- 특정 비즈니스 로직 |
### 사용 패턴
#### ❌ 잘못된 방법 (범용 컴포넌트 직접 사용)
```tsx
// OrderRegistrationModal.tsx
<AutocompleteSearchInputComponent
tableName="customer_mng" // 화면 편집기에서 변경 가능
displayField="customer_name" // 변경 가능
valueField="customer_code" // 변경 가능
// ... 설정이 바뀌면 수주 등록 로직 깨짐!
/>
```
#### ✅ 올바른 방법 (전용 컴포넌트 사용)
```tsx
// 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
```
### 개발 원칙
1. **비즈니스 로직이 고정된 경우**: 전용 컴포넌트 생성
2. **화면 편집기에서 사용**: 범용 컴포넌트 사용
3. **전용 컴포넌트는 범용 컴포넌트를 래핑**: 중복 코드 최소화
4. **Props 최소화**: 외부에서 제어 가능한 최소한의 prop만 노출
### 참고 문서
- 전용 컴포넌트 상세 문서: `frontend/components/order/README.md`
- 범용 컴포넌트 문서: 각 컴포넌트 폴더의 README.md
---
**작성일**: 2025-01-14
**최종 수정일**: 2025-01-15
**작성자**: AI Assistant
**버전**: 1.1