임동욱 임동욱 1 days ago
feat: 전화번호·계좌번호·사업자등록번호 입력을 숫자 전용으로 만든다
FoxInput에 digitsOnly를 둔다. 타이핑·붙여넣기·끌어놓기·IME 어느 경로든 input
이벤트에서 숫자만 남기고, 커서 앞에서 지운 글자 수만큼 커서를 당겨 제자리를
지킨다. 붙여넣기는 따로 받는다 — 브라우저가 maxLength를 붙여넣은 원문에 먼저
적용해 `123-45-67890`의 뒷자리를 잘라 버리기 때문이다. 초기값의 하이픈도 걷는다.

FoxPhoneNumber는 이미 숫자만 받았지만 번호 전체를 한 칸에 붙여넣으면 그 칸
자릿수만 남았다. 붙여넣은 숫자를 세 칸에 나눠 채우고 마지막 칸으로 포커스를 옮긴다.
onlyDigits는 두 컴포넌트가 같이 쓰도록 utils로 뺀다.

가맹몰 관리 모달(사업자등록번호·전화번호·입금계좌번호)과 가맹몰 상세(업체 연락처)에
켜고, 도메인 검증도 숫자만 허용하도록 조인다 — 사업자등록번호는 10자리.

Co-Authored-By: Claude Fable 5.1 
@deb800d309984388a3544f35d35f442abf90bd54
@fox/core/components/fox-input/fox-input.tsx
--- @fox/core/components/fox-input/fox-input.tsx
+++ @fox/core/components/fox-input/fox-input.tsx
@@ -6,12 +6,13 @@
   useRef,
   useState,
   type ChangeEvent,
+  type ClipboardEvent,
   type ComponentPropsWithRef,
   type FocusEvent,
   type FormEvent,
   type ReactNode,
 } from "react";
-import { cx } from "../../utils";
+import { cx, onlyDigits } from "../../utils";
 import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
 import {
   foxValidationMessage,
@@ -56,6 +57,12 @@
   invalid?: boolean;
   /** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */
   state?: FoxInputState;
+  /**
+   * 숫자만 받는다. 타이핑·붙여넣기·끌어놓기·IME 어느 경로로 들어와도 숫자가 아닌 글자는
+   * 그 자리에서 버리고 커서를 지킨다. `value`·`defaultValue`에 하이픈이 섞여 와도 숫자만
+   * 보인다. `inputMode`를 따로 주지 않으면 `numeric`이 된다.
+   */
+  digitsOnly?: boolean;
 
   validators?: FoxValidator[];
   validationMessages?: FoxValidationMessages;
@@ -100,6 +107,7 @@
   clearLabel = "입력 지우기",
   invalid = false,
   state,
+  digitsOnly = false,
   validators,
   validationMessages,
   validateOn = "blur",
@@ -114,11 +122,15 @@
 }: FoxInputProps) {
   const autoId = useId();
 
+  const fieldValue = digitsOnly && value !== undefined ? onlyDigits(String(value)) : value;
+  const fieldDefaultValue =
+    digitsOnly && defaultValue !== undefined ? onlyDigits(String(defaultValue)) : defaultValue;
+
   // 지우기 버튼은 값이 있을 때만 뜬다. 비제어 입력은 DOM만 알고 있으므로 직접 따라간다.
-  const [typedIn, setTypedIn] = useState(() => String(defaultValue ?? "").length > 0);
+  const [typedIn, setTypedIn] = useState(() => String(fieldDefaultValue ?? "").length > 0);
   const fieldRef = useRef<HTMLInputElement>(null);
 
-  const [draft, setDraft] = useState(() => String(defaultValue ?? ""));
+  const [draft, setDraft] = useState(() => String(fieldDefaultValue ?? ""));
   const [touched, setTouched] = useState(false);
 
   const validationErrors =
@@ -164,12 +176,33 @@
     }
   };
 
-  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
-    if (value === undefined) {
-      setTypedIn(event.target.value.length > 0);
+  // 숫자 전용이면 DOM 값을 그 자리에서 고쳐 쓴다. 커서 앞에서 지운 글자 수만큼 커서를 당겨
+  // 제자리를 지킨다 — 값을 통째로 바꾸면 브라우저가 커서를 끝으로 보내기 때문이다.
+  const readFieldValue = (field: HTMLInputElement): string => {
+    const raw = field.value;
+    if (!digitsOnly) {
+      return raw;
     }
-    setDraft(event.target.value);
-    onChange?.(event.target.value, event);
+    const clean = onlyDigits(raw);
+    if (clean === raw) {
+      return raw;
+    }
+    const caret = field.selectionStart;
+    field.value = clean;
+    if (caret !== null) {
+      const position = onlyDigits(raw.slice(0, caret)).length;
+      field.setSelectionRange(position, position);
+    }
+    return clean;
+  };
+
+  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
+    const next = readFieldValue(event.target);
+    if (value === undefined) {
+      setTypedIn(next.length > 0);
+    }
+    setDraft(next);
+    onChange?.(next, event);
   };
 
   const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
@@ -177,8 +210,39 @@
     rest.onBlur?.(event);
   };
 
+  // 숫자 전용 붙여넣기는 숫자만 추려 직접 넣는다. 브라우저는 maxLength를 붙여넣은 원문에 먼저
+  // 적용해 `123-45-67890`의 뒷자리를 잘라 버리므로 기본 동작에 맡길 수 없다.
+  const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => {
+    rest.onPaste?.(event);
+    if (!digitsOnly || event.defaultPrevented) {
+      return;
+    }
+    event.preventDefault();
+    const digits = onlyDigits(event.clipboardData.getData("text"));
+    if (digits === "") {
+      return;
+    }
+    const field = event.currentTarget;
+    if (document.execCommand("insertText", false, digits)) {
+      return;
+    }
+    // execCommand가 없는 환경 — 값을 직접 넣고 input 이벤트를 흘린다. React가 노드에 심어 둔
+    // 값 추적기를 피하려면 프로토타입의 setter를 써야 onChange가 돈다.
+    const start = field.selectionStart ?? field.value.length;
+    const end = field.selectionEnd ?? start;
+    let next = field.value.slice(0, start) + digits + field.value.slice(end);
+    if (field.maxLength > 0) {
+      next = next.slice(0, field.maxLength);
+    }
+    const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
+    setValue?.call(field, next);
+    const caret = Math.min(start + digits.length, next.length);
+    field.setSelectionRange(caret, caret);
+    field.dispatchEvent(new Event("input", { bubbles: true }));
+  };
+
   const handleInput = (event: FormEvent<HTMLInputElement>) => {
-    onInput?.((event.target as HTMLInputElement).value, event);
+    onInput?.(readFieldValue(event.target as HTMLInputElement), event);
   };
 
   const handleClear = () => {
@@ -207,16 +271,18 @@
       <div className="fox-input__box">
         <input
           {...rest}
+          inputMode={rest.inputMode ?? (digitsOnly ? "numeric" : undefined)}
           ref={attachField}
           id={fieldId}
           className="fox-input__field"
-          value={value}
-          defaultValue={defaultValue}
+          value={fieldValue}
+          defaultValue={fieldDefaultValue}
           aria-invalid={errored || undefined}
           aria-describedby={shownMessage ? messageId : undefined}
           onChange={handleChange}
           onInput={handleInput}
           onBlur={handleBlur}
+          onPaste={handlePaste}
         />
 
         {clearable && hasValue && (
@fox/core/components/fox-phone-number/fox-phone-number.tsx
--- @fox/core/components/fox-phone-number/fox-phone-number.tsx
+++ @fox/core/components/fox-phone-number/fox-phone-number.tsx
@@ -6,11 +6,12 @@
   useRef,
   useState,
   type ChangeEvent,
+  type ClipboardEvent,
   type KeyboardEvent,
   type ReactNode,
 } from "react";
 import { FoxFormLabel } from "../fox-form-label";
-import { cx } from "../../utils";
+import { cx, onlyDigits } from "../../utils";
 import { FoxInput, type FoxInputState } from "../fox-input";
 import { FoxSelect, type FoxSelectItem } from "../fox-select";
 
@@ -63,11 +64,6 @@
 const DEFAULT_PLACEHOLDERS: Parts = ["010", "1234", "5678"];
 const DEFAULT_MAX_LENGTHS: Lengths = [3, 4, 4];
 const SEGMENT_LABELS: Parts = ["전화번호 앞자리", "전화번호 가운데자리", "전화번호 뒷자리"];
-
-/** 숫자가 아닌 글자는 버린다 — 붙여넣기로 들어온 하이픈·공백도 여기서 걸린다. */
-function onlyDigits(text: string): string {
-  return text.replace(/\D/g, "");
-}
 
 /**
  * 숫자만 담긴 값을 화면의 세 칸으로 나눈다.
@@ -145,13 +141,17 @@
   const messageId = `${rootId}-message`;
   const errored = invalid || state === "error";
 
-  const commit = (index: number, next: string) => {
-    const nextParts = [...parts] as Parts;
-    nextParts[index] = next;
+  const commitParts = (nextParts: Parts) => {
     setParts(nextParts);
     const joined = nextParts.join("");
     setSeenValue(joined);
     onChange?.(joined);
+  };
+
+  const commit = (index: number, next: string) => {
+    const nextParts = [...parts] as Parts;
+    nextParts[index] = next;
+    commitParts(nextParts);
   };
 
   const focusSegment = (index: number) => {
@@ -165,6 +165,35 @@
     commit(index, digits);
     if (autoAdvance && index < 2 && digits.length >= maxLengths[index]) {
       focusSegment(index + 1);
+    }
+  };
+
+  // 번호 전체를 한 칸에 붙여넣으면 넘치는 숫자를 뒤 칸으로 흘려보낸다. 브라우저가 maxLength로
+  // 붙여넣은 글자를 먼저 잘라 버리므로 기본 동작을 막고 직접 나눈다.
+  const handleSegmentPaste = (index: number) => (event: ClipboardEvent<HTMLInputElement>) => {
+    event.preventDefault();
+    const pasted = onlyDigits(event.clipboardData.getData("text"));
+    if (pasted === "") {
+      return;
+    }
+    const field = event.currentTarget;
+    const start = field.selectionStart ?? field.value.length;
+    const end = field.selectionEnd ?? start;
+    let rest = onlyDigits(field.value.slice(0, start) + pasted + field.value.slice(end));
+
+    const nextParts = [...parts] as Parts;
+    let last = index;
+    for (let cursor = index; cursor < 3 && (cursor === index || rest !== ""); cursor += 1) {
+      nextParts[cursor] = rest.slice(0, maxLengths[cursor]);
+      rest = rest.slice(maxLengths[cursor]);
+      last = cursor;
+    }
+    commitParts(nextParts);
+
+    if (autoAdvance && last < 2 && nextParts[last].length >= maxLengths[last]) {
+      focusSegment(last + 1);
+    } else if (last !== index) {
+      focusSegment(last);
     }
   };
 
@@ -219,6 +248,7 @@
                   value={parts[index]}
                   onChange={handleSegmentChange(index)}
                   onKeyDown={handleSegmentKeyDown(index)}
+                  onPaste={handleSegmentPaste(index)}
                   placeholder={placeholders[index]}
                   maxLength={maxLengths[index]}
                   invalid={invalid}
@@ -258,6 +288,7 @@
                 handleSegmentChange(index)(event.target.value)
               }
               onKeyDown={handleSegmentKeyDown(index)}
+              onPaste={handleSegmentPaste(index)}
               ref={(node) => {
                 tailRefs.current[index] = node;
               }}
@fox/core/utils/index.ts
--- @fox/core/utils/index.ts
+++ @fox/core/utils/index.ts
@@ -1,1 +1,2 @@
 export { cx, type ClassValue } from "./cx";
+export { onlyDigits } from "./only-digits";
 
@fox/core/utils/only-digits.ts (added)
+++ @fox/core/utils/only-digits.ts
@@ -0,0 +1,4 @@
+/** 숫자가 아닌 글자를 버린다 — 붙여넣기로 들어온 하이픈·공백도 여기서 걸린다. */
+export function onlyDigits(text: string): string {
+  return text.replace(/\D/g, "");
+}
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -1519,9 +1519,22 @@
           </InputStage>
         ),
       },
-      
+      {
+        label: "숫자 전용 (digitsOnly) — 타이핑·붙여넣기 모두 숫자만 남고, 초기값의 하이픈도 걷힌다",
+        node: (
+          <InputStage>
+            <FoxInput
+              size="md"
+              label="사업자등록번호"
+              digitsOnly
+              maxLength={10}
+              defaultValue="123-45-67890"
+              placeholder="0000000000"
+            />
+          </InputStage>
+        ),
+      },
     ],
-    
   },
   {
     id: "fox-select",
@@ -2017,7 +2030,7 @@
     id: "fox-phone-number",
     name: "FoxPhoneNumber",
     description:
-      "Figma 시안(form-phonenumber 391:3156)을 옮겼습니다. unit은 상자 셋을 하이픈으로 잇고 앞자리가 선택이며, combine은 상자 하나에 세 칸을 둡니다. 화면은 세 칸이지만 값은 숫자만 담긴 문자열 하나입니다 — 하이픈은 값에 들어가지 않습니다. 칸을 다 채우면 다음 칸으로 넘어가고, 빈 칸에서 지우면 앞칸으로 돌아갑니다.",
+      "Figma 시안(form-phonenumber 391:3156)을 옮겼습니다. unit은 상자 셋을 하이픈으로 잇고 앞자리가 선택이며, combine은 상자 하나에 세 칸을 둡니다. 화면은 세 칸이지만 값은 숫자만 담긴 문자열 하나입니다 — 하이픈은 값에 들어가지 않습니다. 칸을 다 채우면 다음 칸으로 넘어가고, 빈 칸에서 지우면 앞칸으로 돌아갑니다. 번호 전체를 한 칸에 붙여넣으면 숫자만 추려 세 칸에 나눠 채웁니다.",
     variants: [
       {
         label: "직접 조작 — 값이 그대로 라벨에 보입니다",
app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
@@ -232,7 +232,8 @@
                 name="phoneNumber"
                 label="업체 연락처"
                 requirement="required"
-                placeholder="0507-1413-6625"
+                digitsOnly
+                placeholder="숫자만 입력하세요."
                 defaultValue={original.phoneNumber}
                 invalid={Boolean(errors.phoneNumber)}
                 message={errors.phoneNumber}
app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
--- app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
+++ app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
@@ -29,6 +29,7 @@
   MEMO_MAX_LENGTH,
   NAME_MAX_LENGTH,
   ACCOUNT_NUMBER_MAX_LENGTH,
+  BUSINESS_NUMBER_LENGTH,
   OWNER_MAX_LENGTH,
 } from '@/lib/domain/merchant-form';
 import { saveMerchantAction } from '../_actions';
@@ -222,8 +223,10 @@
                 size="md"
               name="businessNumber"
               label="사업자등록번호"
+              digitsOnly
+              maxLength={BUSINESS_NUMBER_LENGTH}
               defaultValue={merchant?.businessNumber ?? ''}
-              placeholder="000-00-00000"
+              placeholder="0000000000"
               invalid={Boolean(errors.businessNumber)}
               message={errors.businessNumber}
             />
@@ -244,6 +247,7 @@
             name="phoneNumber"
             label="전화번호"
             requirement="required"
+            digitsOnly
             defaultValue={merchant?.phoneNumber ?? ''}
             placeholder="가게 전화번호를 입력하세요."
             invalid={Boolean(errors.phoneNumber)}
@@ -340,7 +344,8 @@
             size="md"
             name="accountNumber"
             aria-label="입금계좌번호"
-            placeholder="입금계좌번호 (숫자와 -)"
+            digitsOnly
+            placeholder="입금계좌번호 (숫자만)"
             maxLength={ACCOUNT_NUMBER_MAX_LENGTH}
             invalid={Boolean(errors.accountNumber)}
             message={errors.accountNumber}
lib/domain/merchant-detail-form.ts
--- lib/domain/merchant-detail-form.ts
+++ lib/domain/merchant-detail-form.ts
@@ -15,7 +15,7 @@
 export const ADDRESS_MAX_LENGTH = 200;
 const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
 const POSTAL_CODE_PATTERN = /^\d{5}$/;
-const PHONE_PATTERN = /^[\d-]+$/;
+const PHONE_PATTERN = /^\d+$/;
 
 /** 상세 화면이 함께 다루는 업체(가맹점) 항목 — 백엔드 `MngrVocBaseVo`의 일부. */
 export type MerchantDetailMerchantValues = {
@@ -91,7 +91,7 @@
   if (!phoneNumber) {
     errors.phoneNumber = '업체 연락처를 입력해 주세요.';
   } else if (!PHONE_PATTERN.test(phoneNumber)) {
-    errors.phoneNumber = '연락처는 숫자와 -만 입력할 수 있습니다.';
+    errors.phoneNumber = '연락처는 숫자만 입력할 수 있습니다.';
   }
 
   const postalCode = values.postalCode.trim();
lib/domain/merchant-form.ts
--- lib/domain/merchant-form.ts
+++ lib/domain/merchant-form.ts
@@ -4,6 +4,7 @@
 export const ADDRESS_DETAIL_MAX_LENGTH = 200;
 export const MEMO_MAX_LENGTH = 500;
 export const ACCOUNT_NUMBER_MAX_LENGTH = 30;
+export const BUSINESS_NUMBER_LENGTH = 10;
 
 export interface MerchantValues {
   id: string;
@@ -45,8 +46,7 @@
 
 export const REQUIRED_MESSAGE = '필수입력 사항을 입력하세요.';
 
-/** 사업자등록번호는 선택이지만, 넣었다면 형태를 지킨다(기획 예시 `000-00-00000`). */
-const BUSINESS_NUMBER_PATTERN = /^\d{3}-?\d{2}-?\d{5}$/;
+const BUSINESS_NUMBER_PATTERN = new RegExp(`^\\d{${BUSINESS_NUMBER_LENGTH}}$`);
 
 type ValidationResult =
   | { ok: true; values: MerchantValues }
@@ -69,8 +69,8 @@
   const phoneNumber = values.phoneNumber.trim();
   if (!phoneNumber) {
     errors.phoneNumber = REQUIRED_MESSAGE;
-  } else if (!/^[\d-]+$/.test(phoneNumber)) {
-    errors.phoneNumber = '전화번호는 숫자와 -만 입력할 수 있습니다.';
+  } else if (!/^\d+$/.test(phoneNumber)) {
+    errors.phoneNumber = '전화번호는 숫자만 입력할 수 있습니다.';
   }
 
   if (!values.address.trim()) {
@@ -84,12 +84,12 @@
 
   const businessNumber = values.businessNumber.trim();
   if (businessNumber && !BUSINESS_NUMBER_PATTERN.test(businessNumber)) {
-    errors.businessNumber = '사업자등록번호는 000-00-00000 형식으로 입력해 주세요.';
+    errors.businessNumber = `사업자등록번호는 숫자 ${BUSINESS_NUMBER_LENGTH}자리로 입력해 주세요.`;
   }
 
   const accountNumber = values.accountNumber.trim();
-  if (accountNumber && !/^[\d-]+$/.test(accountNumber)) {
-    errors.accountNumber = '계좌번호는 숫자와 -만 입력할 수 있습니다.';
+  if (accountNumber && !/^\d+$/.test(accountNumber)) {
+    errors.accountNumber = '계좌번호는 숫자만 입력할 수 있습니다.';
   }
 
   if (Object.keys(errors).length > 0) {
Add a comment
List