From 2d9f30ebab07c657ed57b81a75ed98d339cfac1a Mon Sep 17 00:00:00 2001 From: hjjeong Date: Fri, 8 May 2026 14:48:13 +0900 Subject: [PATCH 01/21] =?UTF-8?q?=EC=98=81=EC=97=85=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=B2=A8=EB=B6=80=ED=8C=8C=EC=9D=BC=20=EB=AA=A8=EB=8B=AC=C2=B7?= =?UTF-8?q?=EC=A3=BC=EB=AC=B8=EC=84=9C=20=EB=B7=B0=20+=20DataGrid=20frozen?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공통 AttachmentDialog 컴포넌트 신설 (목록/업로드/다운로드/삭제, doc_type 단일·배열 지원) - 견적 add_est_cnt(클립) → AttachmentDialog (estimate02) - 주문 cu01_cnt(클립) → AttachmentDialog (FTC_ORDER,ORDER), has_order_data(폴더) → OrderFormViewDialog - OrderFormViewDialog 신설 — wace orderFormView 한국 표준 주문서 양식 + 인쇄 - 백엔드 fileController.getFileList docType 콤마 멀티값 지원, salesOrderMgmt.getOrderFormView API 추가 - DataGrid 확장: column.onClick / frozen prop / table-fixed / sticky-left + selected·hover 일관 처리 - 4개 메뉴 첫 컬럼 frozen 적용 (영업번호/프로젝트번호) - 주문서관리 발주일·발주번호·요청납기 컬럼 너비 확장 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/fileController.ts | 13 +- .../controllers/salesOrderMgmtController.ts | 9 + .../src/routes/salesOrderMgmtRoutes.ts | 1 + .../src/services/salesOrderMgmtService.ts | 74 ++++ .../(main)/COMPANY_16/sales/estimate/page.tsx | 54 ++- .../(main)/COMPANY_16/sales/order/page.tsx | 89 ++++- .../(main)/COMPANY_16/sales/revenue/page.tsx | 2 +- .../app/(main)/COMPANY_16/sales/sale/page.tsx | 2 +- .../components/common/AttachmentDialog.tsx | 286 +++++++++++++++ frontend/components/common/DataGrid.tsx | 86 ++++- .../components/sales/OrderFormViewDialog.tsx | 347 ++++++++++++++++++ frontend/lib/api/salesOrderMgmt.ts | 4 + 12 files changed, 936 insertions(+), 31 deletions(-) create mode 100644 frontend/components/common/AttachmentDialog.tsx create mode 100644 frontend/components/sales/OrderFormViewDialog.tsx diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index 03048d6d..df939714 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -601,8 +601,17 @@ export const getFileList = async ( } if (docType) { - whereConditions.push(`doc_type = $${paramIndex}`); - queryParams.push(docType as string); + const docTypes = String(docType) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (docTypes.length === 1) { + whereConditions.push(`doc_type = $${paramIndex}`); + queryParams.push(docTypes[0]); + } else if (docTypes.length > 1) { + whereConditions.push(`doc_type = ANY($${paramIndex}::text[])`); + queryParams.push(docTypes); + } paramIndex++; } diff --git a/backend-node/src/controllers/salesOrderMgmtController.ts b/backend-node/src/controllers/salesOrderMgmtController.ts index 2ca1850e..4e52540d 100644 --- a/backend-node/src/controllers/salesOrderMgmtController.ts +++ b/backend-node/src/controllers/salesOrderMgmtController.ts @@ -52,6 +52,15 @@ export async function remove(req: AuthenticatedRequest, res: Response) { } catch (e: any) { logger.error("주문서 삭제 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); } } +export async function getFormView(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const data = await svc.getOrderFormView(id); + if (!data) return res.status(404).json({ success: false, message: "주문서를 찾을 수 없습니다." }); + return res.json({ success: true, data }); + } catch (e: any) { logger.error("주문서 뷰 조회 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); } +} + export async function updateStatus(req: AuthenticatedRequest, res: Response) { try { const userId = req.user!.userId; diff --git a/backend-node/src/routes/salesOrderMgmtRoutes.ts b/backend-node/src/routes/salesOrderMgmtRoutes.ts index a9115502..6113c06e 100644 --- a/backend-node/src/routes/salesOrderMgmtRoutes.ts +++ b/backend-node/src/routes/salesOrderMgmtRoutes.ts @@ -7,6 +7,7 @@ router.use(authenticateToken); router.get("/list", ctrl.getList); router.get("/generate-number", ctrl.generateNumber); +router.get("/:id/form-view", ctrl.getFormView); router.get("/:id", ctrl.getById); router.post("/", ctrl.create); router.put("/:id", ctrl.update); diff --git a/backend-node/src/services/salesOrderMgmtService.ts b/backend-node/src/services/salesOrderMgmtService.ts index 8e5ba5af..a6a8100d 100644 --- a/backend-node/src/services/salesOrderMgmtService.ts +++ b/backend-node/src/services/salesOrderMgmtService.ts @@ -270,6 +270,80 @@ export async function getById(objid: string) { return { ...headerRes.rows[0], items }; } +// ─── 주문서 뷰 (wace orderFormView 대응) ────────────────────── +// wace 패턴: getOrderFormInfo(헤더 + 거래처) + getOrderFormItems(라인) 두 쿼리. + +export async function getOrderFormView(objid: string) { + const pool = getPool(); + const headerSql = ` + SELECT + T.objid AS objid, + T.contract_no AS contract_no, + T.po_no AS po_no, + T.order_date AS order_date, + T.customer_objid AS customer_objid, + CC_CAT.code_name AS category_name, + CC_CUR.code_name AS currency_name, + T.contract_currency AS currency_code, + T.exchange_rate AS exchange_rate, + T.order_supply_price AS order_supply_price, + T.order_vat AS order_vat, + T.order_total_amount AS order_total_amount, + CASE + WHEN T.paid_type = 'paid' THEN '부가세별도' + WHEN T.paid_type = 'free' THEN '부가세미포함' + ELSE '' + END AS vat_note, + T.customer_request AS customer_request, + C.customer_name AS client_nm, + C.business_number AS client_bus_reg_no, + COALESCE(C.ceo_name, C.representative_name) AS client_ceo_nm, + C.address AS client_addr, + C.biz_condition AS client_bus_type, + C.biz_item AS client_bus_item, + COALESCE(C.tel, C.phone, C.contact_phone) AS client_tel_no, + COALESCE(C.fax_no, C.fax) AS client_fax_no, + COALESCE(C.email, C.charge_email) AS client_email, + TRIM(BOTH FROM COALESCE(U_WR.dept_name, '') || ' ' || COALESCE(U_WR.user_name, T.writer)) AS writer_name, + U_WR.tel AS writer_contact, + TO_CHAR(T.regdate, 'YYYY-MM-DD HH24:MI:SS') AS reg_datetime + FROM contract_mgmt T + LEFT JOIN customer_mng C + ON C.customer_code = CASE WHEN T.customer_objid LIKE 'C_%' THEN substring(T.customer_objid, 3) ELSE T.customer_objid END + LEFT JOIN user_info U_WR ON U_WR.user_id = T.writer + LEFT JOIN comm_code CC_CAT ON CC_CAT.code_id = T.category_cd AND CC_CAT.status='active' + LEFT JOIN comm_code CC_CUR ON CC_CUR.code_id = T.contract_currency AND CC_CUR.status='active' + WHERE T.objid = $1 + `; + const headerRes = await pool.query(headerSql, [objid]); + if (headerRes.rowCount === 0) return null; + + const itemsSql = ` + SELECT + CI.seq, + COALESCE(IT.item_number, CI.part_no) AS part_no, + COALESCE(IT.item_name, CI.part_name) AS part_name, + IT.size AS spec, + CC_UNIT.code_name AS unit_name, + IT.unit AS unit_code, + CI.due_date AS due_date, + CI.order_quantity AS order_quantity, + CI.order_unit_price AS order_unit_price, + CI.order_supply_price AS order_supply_price, + CI.order_vat AS order_vat, + CI.order_total_amount AS order_total_amount + FROM contract_item CI + LEFT JOIN item_info IT ON IT.id = CI.part_objid + LEFT JOIN comm_code CC_UNIT ON CC_UNIT.code_id = IT.unit AND CC_UNIT.status='active' + WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE' + ORDER BY CI.seq + `; + const itemsRes = await pool.query(itemsSql, [objid]); + + logger.info("주문서 뷰 조회", { objid, itemCount: itemsRes.rowCount }); + return { info: headerRes.rows[0], items: itemsRes.rows }; +} + // ─── 영업번호 채번 ───────────────────────────────────────────── export async function generateContractNo(): Promise { diff --git a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx index 3aa985d0..e9616afb 100644 --- a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -20,6 +20,7 @@ import { CustomerSelect } from "@/components/common/CustomerSelect"; import { PartSelect } from "@/components/common/PartSelect"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog"; +import { AttachmentDialog } from "@/components/common/AttachmentDialog"; import { salesEstimateApi, EstimateRow, EstimateBody, EstimateItem } from "@/lib/api/salesEstimate"; import { salesOrderMgmtApi } from "@/lib/api/salesOrderMgmt"; @@ -27,7 +28,7 @@ import { salesOrderMgmtApi } from "@/lib/api/salesOrderMgmt"; // wace_plm 원본 견적관리 그리드 컬럼 순서를 그대로 따름 const GRID_COLUMNS: DataGridColumn[] = [ - { key: "contract_no", label: "영업번호", width: "w-[110px]" }, + { key: "contract_no", label: "영업번호", width: "w-[110px]", frozen: true }, { key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" }, { key: "receipt_date", label: "접수일", width: "w-[100px]", align: "center" }, { key: "earliest_due_date_label", label: "요청납기", width: "w-[110px]", align: "center" }, @@ -111,6 +112,39 @@ export default function SalesEstimatePage() { contents: "", }); + // 첨부파일 다이얼로그 (추가견적 클립 컬럼 클릭 시) + const [attachDialogOpen, setAttachDialogOpen] = useState(false); + const [attachContext, setAttachContext] = useState<{ + targetObjid: string; + docType: string | string[]; + uploadDocType: string; + uploadDocTypeName?: string; + title: string; + } | null>(null); + + // 클릭 핸들러를 주입한 그리드 컬럼 + const gridColumns = useMemo( + () => GRID_COLUMNS.map((col) => { + if (col.key === "add_est_cnt") { + return { + ...col, + onClick: (row) => { + setAttachContext({ + targetObjid: String(row.objid), + docType: "estimate02", + uploadDocType: "estimate02", + uploadDocTypeName: "추가견적", + title: `추가견적 첨부 — ${row.contract_no ?? ""}`, + }); + setAttachDialogOpen(true); + }, + }; + } + return col; + }), + [] + ); + // ─── 데이터 로드 ──────────────────────────────────────────── const fetchList = useCallback(async () => { @@ -452,7 +486,7 @@ export default function SalesEstimatePage() { {/* 그리드 */} { @@ -723,6 +757,20 @@ export default function SalesEstimatePage() { + {/* 첨부파일 다이얼로그 — 추가견적 등 클립 컬럼 클릭 시 */} + {attachContext && ( + + )} + {/* 품목 검색 — 등록 다이얼로그 */} (null); + + // 주문서 자동생성 뷰 다이얼로그 (has_order_data 폴더 컬럼 클릭) + const [orderFormOpen, setOrderFormOpen] = useState(false); + const [orderFormObjid, setOrderFormObjid] = useState(null); + + const gridColumns = useMemo( + () => GRID_COLUMNS.map((col) => { + if (col.key === "cu01_cnt") { + return { + ...col, + onClick: (row) => { + setAttachContext({ + targetObjid: String(row.objid), + docType: ["FTC_ORDER", "ORDER"], + uploadDocType: "ORDER", + uploadDocTypeName: "주문서", + title: `주문서 첨부 — ${row.contract_no ?? ""}`, + }); + setAttachDialogOpen(true); + }, + }; + } + if (col.key === "has_order_data") { + return { + ...col, + onClick: (row) => { + const cnt = Number(row.has_order_data ?? 0); + if (!cnt || cnt <= 0) { + toast.info("주문 라인이 입력되지 않았습니다."); + return; + } + setOrderFormObjid(String(row.objid)); + setOrderFormOpen(true); + }, + }; + } + return col; + }), + [] + ); + const fetchList = useCallback(async () => { if (!user) return; setLoading(true); @@ -336,7 +388,7 @@ export default function SalesOrderPage() { setSelected(id ? rows.find((r) => r.id === id) ?? null : null)} @@ -460,6 +512,27 @@ export default function SalesOrderPage() { + {/* 첨부파일 다이얼로그 — 주문서첨부 클립 컬럼 클릭 시 */} + {attachContext && ( + + )} + + {/* 주문서 자동생성 뷰 — 주문서 폴더 컬럼 클릭 시 (wace orderFormView 대응) */} + + {/* 품목 검색 — 등록 다이얼로그 (다중 선택) */} void; + /** attach_file_info.target_objid (보통 contract_mgmt.objid 등) */ + targetObjid: string | number | null | undefined; + /** 조회 시 doc_type 필터. 단일 문자열 또는 배열(예: ["FTC_ORDER","ORDER"]) */ + docType: string | string[]; + /** 새 업로드 시 INSERT할 doc_type. 미지정 시 docType 단일/배열의 첫 값 사용 */ + uploadDocType?: string; + /** docType_name 컬럼에 저장할 한글 라벨 (선택) */ + uploadDocTypeName?: string; + title?: string; + /** 업로드/삭제 버튼 비활성화 (조회 전용) */ + readOnly?: boolean; + /** 업로드/삭제 후 호출 — 그리드 카운트 갱신용 */ + onChanged?: () => void; +} + +export function AttachmentDialog({ + open, + onOpenChange, + targetObjid, + docType, + uploadDocType, + uploadDocTypeName, + title = "첨부파일", + readOnly = false, + onChanged, +}: AttachmentDialogProps) { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(false); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + + const docTypeQuery = Array.isArray(docType) ? docType.join(",") : docType; + const docTypeForUpload = + uploadDocType ?? (Array.isArray(docType) ? docType[0] : docType); + + const loadFiles = useCallback(async () => { + if (!targetObjid) { + setFiles([]); + return; + } + setLoading(true); + try { + const res = await apiClient.get("/files", { + params: { targetObjid: String(targetObjid), docType: docTypeQuery }, + }); + if (res.data?.success) { + setFiles(res.data.files || []); + } else { + setFiles([]); + } + } catch (e: any) { + toast.error("파일 목록 조회 실패: " + (e?.message ?? "")); + setFiles([]); + } finally { + setLoading(false); + } + }, [targetObjid, docTypeQuery]); + + useEffect(() => { + if (open) { + loadFiles(); + } + }, [open, loadFiles]); + + async function handleUpload(filesToUpload: FileList | null) { + if (!filesToUpload || filesToUpload.length === 0) return; + if (!targetObjid) { + toast.error("대상 ID(targetObjid)가 없습니다."); + return; + } + if (!docTypeForUpload) { + toast.error("업로드 doc_type을 결정할 수 없습니다."); + return; + } + + setUploading(true); + try { + const fd = new FormData(); + for (let i = 0; i < filesToUpload.length; i++) { + fd.append("files", filesToUpload[i]); + } + fd.append("targetObjid", String(targetObjid)); + fd.append("docType", docTypeForUpload); + if (uploadDocTypeName) fd.append("docTypeName", uploadDocTypeName); + + const res = await apiClient.post("/files/upload", fd, { + headers: { "Content-Type": "multipart/form-data" }, + }); + if (res.data?.success) { + toast.success(`${filesToUpload.length}건 업로드 완료`); + await loadFiles(); + onChanged?.(); + } else { + toast.error(res.data?.message || "업로드 실패"); + } + } catch (e: any) { + toast.error("업로드 실패: " + (e?.message ?? "")); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + } + + async function handleDelete(objid: string, name: string) { + if (!window.confirm(`'${name}' 파일을 삭제하시겠습니까?`)) return; + try { + await apiClient.delete(`/files/${objid}`); + toast.success("파일이 삭제되었습니다."); + await loadFiles(); + onChanged?.(); + } catch (e: any) { + toast.error("삭제 실패: " + (e?.message ?? "")); + } + } + + function handleDownload(objid: string) { + const baseURL = (apiClient.defaults.baseURL ?? "").replace(/\/$/, ""); + window.open(`${baseURL}/files/download/${objid}`, "_blank"); + } + + return ( + + + + + + {title} + + + +
+ + + + # + 파일명 + 크기 + 등록자 + 등록일 + 동작 + + + + {loading && ( + + + 로딩 중… + + + )} + {!loading && files.length === 0 && ( + + + 첨부파일이 없습니다. + + + )} + {!loading && + files.map((f, i) => ( + + {i + 1} + + {f.realFileName} + + {formatBytes(f.fileSize)} + {f.writer} + + {f.regdate ? f.regdate.replace("T", " ").slice(0, 16) : ""} + + + + {!readOnly && ( + + )} + + + ))} + +
+
+ + + {!readOnly ? ( +
+ handleUpload(e.target.files)} + className="hidden" + /> + +
+ ) : ( + + )} + +
+
+
+ ); +} + +function formatBytes(n: number) { + if (!n) return "0 B"; + const u = ["B", "KB", "MB", "GB"]; + let i = 0; + let v = n; + while (v >= 1024 && i < u.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`; +} diff --git a/frontend/components/common/DataGrid.tsx b/frontend/components/common/DataGrid.tsx index 346b913d..47f88a5d 100644 --- a/frontend/components/common/DataGrid.tsx +++ b/frontend/components/common/DataGrid.tsx @@ -46,6 +46,10 @@ export interface DataGridColumn { /** 이미지 타입 — 값을 /api/files/preview/{objid} 이미지로 렌더링 */ renderType?: "image" | "folder" | "clip"; selectOptions?: { value: string; label: string }[]; + /** 셀 클릭 핸들러 — folder/clip 등 인터랙션 컬럼에서 모달 오픈용. 행 클릭으로 전파되지 않음 */ + onClick?: (row: any) => void; + /** 좌측 고정 컬럼 (가로 스크롤 시 sticky-left). 첫 컬럼에만 사용 권장 */ + frozen?: boolean; } export interface DataGridProps { @@ -94,6 +98,7 @@ const fmtMoney = (val: any) => { function SortableHeaderCell({ col, sortKey, sortDir, onSort, headerFilterValues, uniqueValues, onToggleFilter, onClearFilter, + frozenLeftClass = "left-0", }: { col: DataGridColumn; sortKey: string | null; @@ -103,6 +108,7 @@ function SortableHeaderCell({ uniqueValues: string[]; onToggleFilter: (colKey: string, value: string) => void; onClearFilter: (colKey: string) => void; + frozenLeftClass?: string; }) { const [filterSearch, setFilterSearch] = useState(""); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key }); @@ -121,7 +127,10 @@ function SortableHeaderCell({
0; + const clickable = !!col.onClick; return ( - + { e.stopPropagation(); col.onClick!(row); } : undefined} + > ); @@ -516,9 +530,15 @@ export function DataGrid({ if (col.renderType === "clip") { const cnt = Number(val); const hasValue = !isNaN(cnt) && cnt > 0; + const clickable = !!col.onClick; return ( - - + { e.stopPropagation(); col.onClick!(row); } : undefined} + > + {hasValue && {cnt}} ); @@ -540,16 +560,23 @@ export function DataGrid({ ); }; + // 좌측 고정 보조: NO/체크박스 컬럼은 40px(=left-10), 첫 frozen 컬럼은 그 다음 위치 + const hasFrozen = columns.some((c) => c.frozen); + const hasFirstCol = showCheckbox || showRowNumber; + const stickyFirstColClass = "sticky left-0 z-20 bg-background"; + const stickyFirstColBodyClass = "sticky left-0 z-[6]"; + const frozenLeftClass = hasFirstCol ? "left-10" : "left-0"; + return (
- +
c.key)} strategy={horizontalListSortingStrategy}> {showCheckbox && ( - + 0 && checkedIds.length === processedData.length} onCheckedChange={(checked) => { @@ -558,7 +585,9 @@ export function DataGrid({ /> )} - {showRowNumber && !showCheckbox && No} + {showRowNumber && !showCheckbox && ( + No + )} {columns.map((col) => ( ))} @@ -588,12 +618,16 @@ export function DataGrid({ {emptyMessage} - ) : paginatedData.map((row, rowIdx) => ( + ) : paginatedData.map((row, rowIdx) => { + const isSelected = selectedId === row.id || (showCheckbox && checkedIds.includes(row.id)); + // sticky 셀에 alpha 없는 단색 배경 사용 (반투명이면 뒤 셀이 비침). + // selected → bg-accent / hover(non-selected) → group-hover로 muted 적용 / 기본 → bg-background + const stickyBgClass = isSelected ? "bg-accent" : "bg-background group-hover:bg-muted"; + return ( { onSelect?.(row.id); @@ -609,7 +643,14 @@ export function DataGrid({ onDoubleClick={() => onRowDoubleClick?.(row, rowIdx)} > {showCheckbox && ( - e.stopPropagation()}> + e.stopPropagation()} + > { @@ -621,11 +662,24 @@ export function DataGrid({ /> )} - {showRowNumber && !showCheckbox && {pageOffset + rowIdx + 1}} + {showRowNumber && !showCheckbox && ( + + {pageOffset + rowIdx + 1} + + )} {columns.map((col) => ( { if (col.editable) { e.stopPropagation(); @@ -637,7 +691,7 @@ export function DataGrid({ ))} - ))} + );})}
diff --git a/frontend/components/sales/OrderFormViewDialog.tsx b/frontend/components/sales/OrderFormViewDialog.tsx new file mode 100644 index 00000000..ec670d03 --- /dev/null +++ b/frontend/components/sales/OrderFormViewDialog.tsx @@ -0,0 +1,347 @@ +"use client"; + +/** + * OrderFormViewDialog — 주문서 자동생성 뷰 (wace orderFormView.jsp 대응) + * + * 주문관리 그리드 "주문서" 폴더 컬럼 클릭 시 표시. + * 한국 표준 주문서 양식 (공급받는자/공급자/품목/합계). + */ + +import { useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Printer } from "lucide-react"; +import { salesOrderMgmtApi } from "@/lib/api/salesOrderMgmt"; +import { toast } from "sonner"; + +// 공급자(우리 회사) 정보 — wace 원본 하드코딩값. 추후 회사 마스터 테이블로 이전. +const SUPPLIER = { + busRegNo: "314-81-75146", + name: "주식회사알피에스본사", + ceo: "이동헌", + address: "대전광역시 유성구 국제과학10로 8(둔곡동)", + busType: "제조업", + busItem: "금속절삭가공기계,반도체제조용기계", +}; + +interface OrderFormInfo { + objid: string; + contract_no: string; + po_no: string; + order_date: string; + client_nm?: string; + client_bus_reg_no?: string; + client_ceo_nm?: string; + client_addr?: string; + client_bus_type?: string; + client_bus_item?: string; + client_tel_no?: string; + client_fax_no?: string; + client_email?: string; + writer_name?: string; + writer_contact?: string; + order_supply_price?: string | number; + order_vat?: string | number; + order_total_amount?: string | number; + vat_note?: string; + reg_datetime?: string; +} + +interface OrderFormItem { + seq: number; + part_no?: string; + part_name?: string; + spec?: string; + unit_name?: string; + due_date?: string; + order_quantity?: string | number; + order_unit_price?: string | number; + order_supply_price?: string | number; + order_vat?: string | number; + order_total_amount?: string | number; +} + +export interface OrderFormViewDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + objid: string | null | undefined; +} + +export function OrderFormViewDialog({ open, onOpenChange, objid }: OrderFormViewDialogProps) { + const [info, setInfo] = useState(null); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open || !objid) { + setInfo(null); + setItems([]); + return; + } + setLoading(true); + salesOrderMgmtApi + .formView(String(objid)) + .then((data) => { + setInfo(data.info); + setItems(data.items ?? []); + }) + .catch((e: any) => { + toast.error("주문서 데이터 조회 실패: " + (e?.message ?? "")); + setInfo(null); + setItems([]); + }) + .finally(() => setLoading(false)); + }, [open, objid]); + + const orderDateText = formatOrderDate(info?.order_date); + const totalQty = items.reduce((acc, it) => acc + toNum(it.order_quantity), 0); + const totalSupply = items.reduce((acc, it) => acc + toNum(it.order_supply_price), 0); + + function handlePrint() { + const node = document.getElementById("order-form-print-area"); + if (!node) return; + const w = window.open("", "_blank", "width=950,height=800"); + if (!w) return; + w.document.write(` + 주문서 + ${node.innerHTML} + `); + w.document.close(); + w.focus(); + setTimeout(() => { w.print(); }, 250); + } + + return ( + + + + 주문서 — {info?.contract_no ?? ""} + + + {loading && ( +
로딩 중…
+ )} + + {!loading && !info && ( +
주문서 데이터가 없습니다.
+ )} + + {!loading && info && ( +
+
주 문 서
+
주문일자 : {orderDateText}
+
증빙번호 : {info.po_no ?? ""}
+ + {/* 공급받는자 / 공급자 */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
공급받는자등록번호{info.client_bus_reg_no ?? ""}공 급 자등록번호{SUPPLIER.busRegNo}
상 호{info.client_nm ?? ""}성명{info.client_ceo_nm ?? ""}상 호{SUPPLIER.name}성명{SUPPLIER.ceo}
주 소{info.client_addr ?? ""}주 소{SUPPLIER.address}
업 태{info.client_bus_type ?? ""}종목{info.client_bus_item ?? ""}업 태{SUPPLIER.busType}종목{SUPPLIER.busItem}
+ + {/* 납품처 / 담당자 */} + + + + + + + + + + + + + + + + + + + + + + + + + + + +
납 품 처{info.client_nm ?? ""}전화번호{info.client_tel_no ?? ""}팩스번호{info.client_fax_no ?? ""}
주 소{info.client_addr ?? ""}담 당 자{info.writer_name ?? ""}C.P.번호{info.writer_contact ?? ""}
+ + {/* 품목 테이블 */} + + + + + + + + + + + + + + + {["No.", "품번", "품명", "규격", "단위", "납기일", "수량", "단가", "금액"].map((h) => ( + + ))} + + + + {items.length === 0 && ( + + )} + {items.map((it, i) => ( + + + + + + + + + + + + ))} + + + + + + + + + + + + +
{h}
라인 없음
{i + 1}{it.part_no ?? ""}{it.part_name ?? ""}{it.spec ?? ""}{it.unit_name ?? ""}{it.due_date ?? ""}{fmt(it.order_quantity)}{fmt(it.order_unit_price)}{fmt(it.order_supply_price)}
합 계
{fmt(totalQty)}{fmt(totalSupply)}
+ + {/* 비고 / 합계 요약 */} + + + + + + + + + + + + + + + + + + + + + + + +
비 고공 급 가 액 합 계{fmt(info.order_supply_price)}
부 가 가 치 세{fmt(info.order_vat)}
총 계{fmt(info.order_total_amount)}
+ +
+ {info.vat_note ?? ""} + +
+
+ )} + + + + + +
+
+ ); +} + +function toNum(v: any): number { + if (v == null || v === "") return 0; + const n = Number(String(v).replace(/,/g, "")); + return isNaN(n) ? 0 : n; +} + +function fmt(v: any): string { + const n = toNum(v); + if (n === 0) return ""; + return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function formatOrderDate(s?: string): string { + if (!s) return ""; + const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!m) return s; + return `${m[1]}년 ${m[2]}월 ${m[3]}일`; +} diff --git a/frontend/lib/api/salesOrderMgmt.ts b/frontend/lib/api/salesOrderMgmt.ts index 3804b9a9..2dc0ba3e 100644 --- a/frontend/lib/api/salesOrderMgmt.ts +++ b/frontend/lib/api/salesOrderMgmt.ts @@ -123,4 +123,8 @@ export const salesOrderMgmtApi = { async setStatus(objid: string, contract_result: string) { return (await apiClient.patch(`/sales/order-mgmt/${objid}/status`, { contract_result })).data; }, + async formView(objid: string): Promise<{ info: any; items: any[] }> { + const res = await apiClient.get(`/sales/order-mgmt/${objid}/form-view`); + return res.data?.data ?? { info: null, items: [] }; + }, }; From b7a6816ef2aa2b8dd499ff15ecf9a426c3e829a9 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Fri, 8 May 2026 14:48:24 +0900 Subject: [PATCH 02/21] =?UTF-8?q?=EC=98=81=EC=97=85=EA=B4=80=EB=A6=AC=20GA?= =?UTF-8?q?P=20=EB=B6=84=EC=84=9D=20=EB=AC=B8=EC=84=9C=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A4=20+=20README=20=EB=8B=A4=EC=9D=8C=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 00-gap.md: wace_plm 원본 흐름 vs vexplor_rps 이식본 GAP 매트릭스. 다음 PR 우선순위(A: 수주확정→프로젝트 자동생성, B: 직접등록 통합폼, C: 결재·메일·PDF) 합의 문서. README.md: §7 다음 작업 항목을 완료 처리하고 00-gap.md 우선 합의로 재정렬. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/migration/sales/00-gap.md | 118 +++++++++++++++++++++++++++++++++ docs/migration/sales/README.md | 7 +- 2 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 docs/migration/sales/00-gap.md diff --git a/docs/migration/sales/00-gap.md b/docs/migration/sales/00-gap.md new file mode 100644 index 00000000..fd9ba0db --- /dev/null +++ b/docs/migration/sales/00-gap.md @@ -0,0 +1,118 @@ +# 영업관리 이식 GAP 분석 (원본 wace_plm 대비) + +> 작성: 2026-05-08 / 작성자: hjjeong +> 목적: vexplor_rps에 이식된 영업관리 4개 메뉴가 wace_plm 원본 흐름과 어디서 어긋나는지 정리하고, 다음 PR 우선순위를 합의하기 위한 단일 문서. +> 참고: [01-estimate.md](./01-estimate.md), [02-order.md](./02-order.md), [feedback_wace_jsp_columns](../../../../.claude/projects/-Users-jhj-vexplor-rps/memory/feedback_wace_jsp_columns.md) + +## 0. 한 문장 요약 + +견적/주문 list와 SQL은 잘 이식됐지만 **상태 전이 트리거**(수주확정 → 프로젝트 자동생성)와 **직접등록 통합폼**, **결재 자동판정**, **PDF·SMTP 실작업**이 통째로 빠져 있어, 사용자가 영업 흐름을 끝까지 돌릴 수 없는 상태. + +## 0.1 이식 원칙 (모든 GAP 작업 공통) + +> **JSP/Java/매퍼XML 안의 주석 블록(`/* */`, ``, `//`)은 비활성 옛 로직 보존 영역이다 — 절대 이식 대상이 아니다. 활성 코드만, 한 줄 한 줄 직접 따라가서 그대로 이식한다.** + +- **운영 화면이 진실의 기준**: waceplm.esgrin.com 운영 화면에 실제 보이는 항목/동작이 활성. 코드만 보면 활성/비활성 구분이 흐려짐. +- **컬럼 정의(`var columns = [...]`)**: `/* 주석처리된 컬럼 - 필요시 활성화 */` 블록 이하는 무시. +- **검색 폼(`#plmSearchZon`)**: `` 블록 이하는 무시. +- **서비스 메서드**: 주석된 옛 SQL/분기 무시. 호출 그래프(controller → service → mapper)를 한 줄씩 따라가서 활성 경로만 추출. +- **매퍼 XML**: `` 블록 안의 SQL fragment는 무시. ` -
-
- - setForm({ - ...form, - contract_context: { ...form.contract_context!, customer_objid: v }, - })} - /> -
-
- - setForm({ - ...form, - contract_context: { ...form.contract_context!, contract_currency: v }, - })} /> -
-
- - setForm({ - ...form, - contract_context: { ...form.contract_context!, receipt_date: e.target.value }, - })} /> -
-
- - -
-
- - setForm({ - ...form, - contract_context: { ...form.contract_context!, category_cd: v }, - })} /> -
-
- - )} - - {/* 견적 템플릿 */} -
- 견적 템플릿 -
+ {/* 견적요청 기본정보 — wace estimateRegistFormPopup.jsp 1행/2행 (8개) */} +
+ 견적요청 기본정보 +
- - +
+
+ + setForm({ ...form, category_cd: v })} /> +
+
+ + setForm({ ...form, area_cd: v })} /> +
+
+ + setForm({ ...form, customer_objid: v })} /> +
+
+ +
- - setForm({ ...form, estimate_no: e.target.value })} /> + + setForm({ ...form, receipt_date: e.target.value })} />
- - setForm({ ...form, validity_period: e.target.value })} - placeholder="예: 견적일로부터 30일" /> + + setForm({ ...form, contract_currency: v })} />
- - setForm({ ...form, executor: e.target.value })} /> + + setForm({ ...form, exchange_rate: e.target.value })} />
-
- - setForm({ ...form, recipient: e.target.value })} /> +
+ +
+ + +
-
- - setForm({ ...form, manager_name: e.target.value })} /> -
-
- - setForm({ ...form, manager_contact: e.target.value })} /> -
-
- - setForm({ ...form, model_name: e.target.value })} /> -
-
- - setForm({ ...form, model_code: e.target.value })} /> -
-
-
- -