공용 NumberInput + 숫자 포맷 정책 적용 (수량 1,234 / 금액 1,234.00)

- 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) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-15 14:52:50 +09:00
parent 1fb438bdcb
commit 0fe71298d2
4 changed files with 143 additions and 20 deletions
@@ -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
+119
View File
@@ -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<string>("");
const inputRef = useRef<HTMLInputElement>(null);
// 외부 value 변경 시 draft 동기화 (focus 중 외부 강제 갱신 시도는 무시 — 자연스러운 편집)
useEffect(() => {
if (!focused) setDraft("");
}, [num, focused]);
const displayValue = focused
? draft
: (num === "" ? "" : formatFor(num, decimals));
const onFocus = (e: React.FocusEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
// 편집 중엔 자유 입력 허용 (콤마·소수점·- 모두 허용 — blur 시 정규화)
setDraft(e.target.value);
};
return (
<Input
ref={inputRef}
type="text"
inputMode={decimals > 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,
)}
/>
);
}
@@ -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);
@@ -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 }:
</td>
<td className="px-2 py-1">{r.part_name || ""}</td>
<td className="px-2 py-1">
<Input type="number" min={0} value={r.qty ?? ""} className="h-7 text-right"
onChange={(e) => updateRow(r.rowKey, { qty: e.target.value })} />
<NumberInput value={r.qty} decimals={0} min={0} className="h-7"
onChange={(v) => updateRow(r.rowKey, { qty: v === "" ? "" : String(v) })} />
</td>
<td className="px-2 py-1">
<SmartSelect options={supplierOpts} value={r.partner_objid ?? ""}
onValueChange={(v) => updateRow(r.rowKey, { partner_objid: v })} />
</td>
<td className="px-2 py-1">
<Input type="number" min={0} step="0.01" value={r.partner_price ?? ""} className="h-7 text-right"
onChange={(e) => updateRow(r.rowKey, { partner_price: e.target.value })} />
<NumberInput value={r.partner_price} decimals={2} min={0} className="h-7"
onChange={(v) => updateRow(r.rowKey, { partner_price: v === "" ? "" : String(v) })} />
</td>
<td className="px-2 py-1 text-center">
<Button size="icon" variant="ghost" className="h-6 w-6"