0fe71298d2
- 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>
120 lines
3.3 KiB
TypeScript
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,
|
|
)}
|
|
/>
|
|
);
|
|
}
|