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

39 KiB
Raw Permalink Blame History

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

📌 프로젝트 개요

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

기간: 약 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 });
  }}
/>

컴포넌트 동작 흐름

  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

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

📝 참고 자료


🚀 다음 단계

  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 추가)

왜 전용 컴포넌트를 만들었나?

문제점: 범용 컴포넌트를 수주 등록 모달에서 직접 사용하면, 화면 편집기에서 설정을 변경했을 때 수주 등록 로직이 깨질 수 있습니다.

예시:

  • 사용자가 AutocompleteSearchInputtableNamecustomer_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

개발 원칙

  1. 비즈니스 로직이 고정된 경우: 전용 컴포넌트 생성
  2. 화면 편집기에서 사용: 범용 컴포넌트 사용
  3. 전용 컴포넌트는 범용 컴포넌트를 래핑: 중복 코드 최소화
  4. Props 최소화: 외부에서 제어 가능한 최소한의 prop만 노출

참고 문서

  • 전용 컴포넌트 상세 문서: frontend/components/order/README.md
  • 범용 컴포넌트 문서: 각 컴포넌트 폴더의 README.md

작성일: 2025-01-14 최종 수정일: 2025-01-15 작성자: AI Assistant 버전: 1.1