Files
wace_rps/frontend/components/common/NumberInput.tsx
T
hjjeong 0fe71298d2 공용 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>
2026-05-15 14:52:50 +09:00

120 lines
3.3 KiB
TypeScript

"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,
)}
/>
);
}