# 📋 수주 등록 화면 개발 계획서

## 📌 프로젝트 개요

**목표**: 수주 등록 모달 화면 구현을 위한 범용 컴포넌트 개발 및 전용 화면 구성

**기간**: 약 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
