공용 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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user