From 0fe71298d23dd9c4f971484613894a1cbd44f032 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Fri, 15 May 2026 14:52:50 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20NumberInput=20+=20?= =?UTF-8?q?=EC=88=AB=EC=9E=90=20=ED=8F=AC=EB=A7=B7=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(=EC=88=98=EB=9F=89=201,234=20/=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=201,234.00)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NumberInput 공용 컴포넌트: blur 시 콤마+소수점 자릿수 강제, focus 시 raw 숫자로 전환되어 자유 편집, 잘못된 입력은 이전 값 유지. - 다이얼로그 수량/단가 input → NumberInput 으로 교체. - 백엔드 정규화 — M-BOM/detail/proposal-targets: qty=FLOOR()::INTEGER, unit_price/partner_price/total_price=NUMERIC(18,2) (운영 sales_request_part 는 정수 String 이지만 M-BOM production_qty NUMERIC(15,4) 가 흘러들어와 '4.0000' 노출되던 문제 차단). - ProposalCreateDialog fmt: Math.floor 후 ko-KR toLocaleString. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/salesPurchaseRequestService.ts | 20 +-- frontend/components/common/NumberInput.tsx | 119 ++++++++++++++++++ .../components/sales/ProposalCreateDialog.tsx | 5 +- .../sales/PurchaseRequestFormDialog.tsx | 19 +-- 4 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 frontend/components/common/NumberInput.tsx diff --git a/backend-node/src/services/salesPurchaseRequestService.ts b/backend-node/src/services/salesPurchaseRequestService.ts index f61c5388..bc6b35e0 100644 --- a/backend-node/src/services/salesPurchaseRequestService.ts +++ b/backend-node/src/services/salesPurchaseRequestService.ts @@ -321,10 +321,10 @@ export async function getPurchaseRequestDetail(srmObjid: string) { `SELECT SRP.OBJID, SRP.PART_OBJID, COALESCE(PM.PART_NO, '') AS PART_NO, COALESCE(PM.PART_NAME, '') AS PART_NAME, - COALESCE(SRP.QTY, '0') AS QTY, + COALESCE(FLOOR(NULLIF(SRP.QTY, '')::NUMERIC)::INTEGER, 0)::TEXT AS QTY, SRP.ORG_QTY, SRP.PARTNER_OBJID, - COALESCE(SRP.PARTNER_PRICE, '') AS PARTNER_PRICE, - COALESCE(SRP.UNIT_PRICE, 0) AS UNIT_PRICE, + COALESCE(NULLIF(SRP.PARTNER_PRICE, '')::NUMERIC, 0)::NUMERIC(18,2) AS PARTNER_PRICE, + COALESCE(SRP.UNIT_PRICE, 0)::NUMERIC(18,2) AS UNIT_PRICE, COALESCE(SRP.VENDOR_PM, '') AS VENDOR_PM, SRP.DELIVERY_REQUEST_DATE, SRP.STATUS, SRP.PROPOSAL_DATE FROM SALES_REQUEST_PART SRP @@ -346,7 +346,7 @@ export async function listVendorOptions(): Promise<{ code: string; label: string const r = await pool.query( `SELECT OBJID::VARCHAR AS code, CLIENT_NM AS label FROM CLIENT_MNG - WHERE COALESCE(STATUS, 'active') IN ('active', '활성', 'ACTIVE') + WHERE COALESCE(USE_YN, '1') IN ('1', 'Y', 'y') AND CLIENT_NM IS NOT NULL AND CLIENT_NM <> '' ORDER BY CLIENT_NM`, ); @@ -410,8 +410,8 @@ export async function listMbomPartsForProject(projectObjid: string) { COALESCE(PM.PART_NO, '') AS part_no, COALESCE(PM.PART_NAME, '') AS part_name, COALESCE(MD.UNIT, '') AS unit, - COALESCE(MD.PRODUCTION_QTY, MD.PO_QTY, 0) AS qty, - COALESCE(MD.UNIT_PRICE, 0) AS unit_price, + COALESCE(FLOOR(COALESCE(MD.PRODUCTION_QTY, MD.PO_QTY, 0))::INTEGER, 0) AS qty, + COALESCE(MD.UNIT_PRICE, 0)::NUMERIC(18,2) AS unit_price, COALESCE(MD.VENDOR, '') AS vendor_objid, CASE WHEN MD.VENDOR IS NULL OR MD.VENDOR = '' THEN '' @@ -558,9 +558,9 @@ export async function getProposalTargetParts(srmObjid: string) { SRP.OBJID, SRP.PART_OBJID, COALESCE(PM.PART_NO, '') AS PART_NO, COALESCE(PM.PART_NAME, '') AS PART_NAME, - COALESCE(SRP.QTY, '0') AS QTY, - COALESCE(SRP.UNIT_PRICE, NULLIF(SRP.PARTNER_PRICE,'')::NUMERIC, 0) AS UNIT_PRICE, - SRP.TOTAL_PRICE, + COALESCE(FLOOR(NULLIF(SRP.QTY, '')::NUMERIC)::INTEGER, 0)::TEXT AS QTY, + COALESCE(SRP.UNIT_PRICE, NULLIF(SRP.PARTNER_PRICE,'')::NUMERIC, 0)::NUMERIC(18,2) AS UNIT_PRICE, + SRP.TOTAL_PRICE::NUMERIC(18,2) AS TOTAL_PRICE, COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID, '') AS VENDOR_PM, CASE WHEN COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID) IS NULL THEN '' @@ -587,7 +587,7 @@ export async function getProposalTargetParts(srmObjid: string) { SRP.OBJID, SRP.PART_OBJID, COALESCE(PM.PART_NO,'') AS PART_NO, COALESCE(PM.PART_NAME,'') AS PART_NAME, - COALESCE(SRP.QTY,'0') AS QTY + COALESCE(FLOOR(NULLIF(SRP.QTY, '')::NUMERIC)::INTEGER, 0)::TEXT AS QTY FROM SALES_REQUEST_PART SRP LEFT JOIN PART_MNG PM ON SRP.PART_OBJID::VARCHAR = PM.OBJID::VARCHAR WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1 diff --git a/frontend/components/common/NumberInput.tsx b/frontend/components/common/NumberInput.tsx new file mode 100644 index 00000000..fd098ab9 --- /dev/null +++ b/frontend/components/common/NumberInput.tsx @@ -0,0 +1,119 @@ +"use client"; + +/** + * NumberInput — 숫자 표시 통일 공용 컴포넌트 (RPS 숫자 포맷 정책) + * + * - 금액(decimals=2): 1,234.00 + * - 수량(decimals=0): 1,234 + * - 표시: 콤마 + 소수점 자릿수 강제 (blur 시 정규화) + * - 편집: focus 시 raw 숫자 ("1234.5")로 전환되어 자유 입력 → blur 시 1,234.50 으로 재포맷 + * - onChange 는 항상 number(또는 빈 문자열) 만 부모로 전달 + */ + +import React, { useEffect, useRef, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +export interface NumberInputProps { + value: number | string | null | undefined; + onChange: (v: number | "") => void; + decimals?: number; // 기본 0 (수량). 금액은 2. + min?: number; + max?: number; + disabled?: boolean; + placeholder?: string; + className?: string; + /** "right"=금액·수량 기본, 그 외도 가능 */ + align?: "right" | "left" | "center"; +} + +function toNumOrEmpty(v: any): number | "" { + if (v === "" || v == null) return ""; + const n = Number(v); + return Number.isFinite(n) ? n : ""; +} + +function formatFor(v: number | "", decimals: number): string { + if (v === "") return ""; + return v.toLocaleString("ko-KR", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +} + +export function NumberInput({ + value, + onChange, + decimals = 0, + min, + max, + disabled, + placeholder, + className, + align = "right", +}: NumberInputProps) { + const num = toNumOrEmpty(value); + const [focused, setFocused] = useState(false); + const [draft, setDraft] = useState(""); + const inputRef = useRef(null); + + // 외부 value 변경 시 draft 동기화 (focus 중 외부 강제 갱신 시도는 무시 — 자연스러운 편집) + useEffect(() => { + if (!focused) setDraft(""); + }, [num, focused]); + + const displayValue = focused + ? draft + : (num === "" ? "" : formatFor(num, decimals)); + + const onFocus = (e: React.FocusEvent) => { + setFocused(true); + setDraft(num === "" ? "" : String(num)); // raw 숫자 (콤마 X) + // 전체 선택 — 빠른 재입력 편의 + requestAnimationFrame(() => e.target.select()); + }; + + const onBlur = () => { + setFocused(false); + if (draft.trim() === "") { + if (num !== "") onChange(""); + return; + } + // 콤마/공백 제거 후 숫자화 + const cleaned = draft.replace(/,/g, "").trim(); + let n = Number(cleaned); + if (!Number.isFinite(n)) { + // 잘못된 입력 → 이전 값 유지 + return; + } + if (decimals === 0) n = Math.floor(n); + else n = Number(n.toFixed(decimals)); + if (min != null && n < min) n = min; + if (max != null && n > max) n = max; + onChange(n); + }; + + const onChangeInner = (e: React.ChangeEvent) => { + // 편집 중엔 자유 입력 허용 (콤마·소수점·- 모두 허용 — blur 시 정규화) + setDraft(e.target.value); + }; + + return ( + 0 ? "decimal" : "numeric"} + disabled={disabled} + placeholder={placeholder} + value={displayValue} + onFocus={onFocus} + onBlur={onBlur} + onChange={onChangeInner} + className={cn( + align === "right" && "text-right", + align === "center" && "text-center", + className, + )} + /> + ); +} diff --git a/frontend/components/sales/ProposalCreateDialog.tsx b/frontend/components/sales/ProposalCreateDialog.tsx index 0aa6ed39..62cdfc19 100644 --- a/frontend/components/sales/ProposalCreateDialog.tsx +++ b/frontend/components/sales/ProposalCreateDialog.tsx @@ -173,9 +173,10 @@ function Section({ title, emptyMsg, children }: { title: React.ReactNode; emptyM ); } +// 수량: 자연수 1,234 / 금액: 1,234.00 (RPS 숫자 포맷 정책) function fmt(n: any) { - const v = Number(n ?? 0); - return v.toLocaleString(); + const v = Math.floor(Number(n ?? 0)); + return v.toLocaleString("ko-KR"); } function fmtMoney(n: any) { const v = Number(n ?? 0); diff --git a/frontend/components/sales/PurchaseRequestFormDialog.tsx b/frontend/components/sales/PurchaseRequestFormDialog.tsx index f3432364..7abddb6f 100644 --- a/frontend/components/sales/PurchaseRequestFormDialog.tsx +++ b/frontend/components/sales/PurchaseRequestFormDialog.tsx @@ -16,6 +16,7 @@ import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect" import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { CustomerSelect } from "@/components/common/CustomerSelect"; import { DateInput } from "@/components/common/DateInput"; +import { NumberInput } from "@/components/common/NumberInput"; import { purchaseApi, OptionItem } from "@/lib/api/purchase"; import { salesPurchaseRequestApi, @@ -116,9 +117,11 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved, srmObjid }: part_objid: p.part_objid, part_no: p.part_no, part_name: p.part_name, - qty: p.qty, + qty: String(Math.floor(Number(p.qty ?? 0))), partner_objid: p.partner_objid ?? "", - partner_price: p.partner_price ?? p.unit_price ?? "", + partner_price: p.partner_price != null && Number(p.partner_price) > 0 + ? String(Number(p.partner_price)) + : (p.unit_price != null && Number(p.unit_price) > 0 ? String(Number(p.unit_price)) : ""), }))); } } catch (e: any) { @@ -194,9 +197,9 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved, srmObjid }: part_objid: hit.part_objid, part_no: hit.part_no, part_name: hit.part_name, - qty: hit.qty > 0 ? String(hit.qty) : "1", + qty: String(Math.max(1, Math.floor(Number(hit.qty ?? 0)))), partner_objid: hit.vendor_objid || "", - partner_price: hit.unit_price > 0 ? String(hit.unit_price) : "", + partner_price: Number(hit.unit_price ?? 0) > 0 ? String(Number(hit.unit_price)) : "", }); }; @@ -326,16 +329,16 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved, srmObjid }: {r.part_name || ""} - updateRow(r.rowKey, { qty: e.target.value })} /> + updateRow(r.rowKey, { qty: v === "" ? "" : String(v) })} /> updateRow(r.rowKey, { partner_objid: v })} /> - updateRow(r.rowKey, { partner_price: e.target.value })} /> + updateRow(r.rowKey, { partner_price: v === "" ? "" : String(v) })} />