From 2654507ba1d649d9092f372c66403eb72e53becd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A4=80=EC=84=B1=EC=9D=98=20Macbook=20Pro?= Date: Wed, 26 Aug 2026 12:14:47 +0900 Subject: [PATCH] =?UTF-8?q?fix(=EA=B2=AC=EC=A0=81):=20=EC=88=AB=EC=9E=90?= =?UTF-8?q?=20=EC=B9=B8=EC=97=90=EC=84=9C=20=ED=95=9C=20=EA=B8=80=EC=9E=90?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EC=BB=A4=EC=84=9C=EA=B0=80=20=ED=92=80?= =?UTF-8?q?=EB=A6=AC=EB=8D=98=20=EA=B2=83=20=C2=B7=20=EB=B9=84=EC=9C=A8=20?= =?UTF-8?q?=EC=83=81=ED=95=9C=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **① 커서가 풀렸다** 견적서 생성 팝업의 선수금 비율·금액 칸에서 한 글자 칠 때마다 포커스가 날아가 연속 입력이 안 됐다. 원인은 `Field` 를 **렌더 안에서 만든 것**이다: Field={({ label, children }) => (...)} React 는 컴포넌트 «타입»이 바뀌면 그 자리를 통째로 헐고 다시 짓는다. 렌더마다 새 함수는 새 타입이라, 안에 있던 `` 이 사라졌다 새로 생겼다. 모듈 바깥으로 뺐다. **② 전면 조사** 입력칸 51개가 있는 화면 전체를 훑었다 — 같은 부류(렌더 안 컴포넌트 선언, 값 기반 key)는 **이 한 곳뿐**이었다. 리스트의 `key={q.id}`·`key={o.value}` 는 안정적이라 무관하다. **③ 비율 상한이 빠져 있었다** 예전 팝업의 `max={100}` 이 공용 조각으로 옮기면서 사라져 **2468% 가 그대로 먹혔다** (선수금이 몫을 넘어 할부원금이 음수가 된다). 금액 칸과 같은 규칙으로 0~100 으로 누른다. 회귀 가드는 **화면 전체를 훑는다** — 이런 실수는 타입 오류도 콘솔 경고도 아니라 한 곳만 보게 두면 다음에 또 샌다. Closes #297 --- backend/src/__tests__/input-focus.test.ts | 93 +++++++++++++++++++ frontend/src/components/ConfirmQuoteModal.tsx | 23 +++-- frontend/src/components/DownPaymentFields.tsx | 13 ++- 3 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 backend/src/__tests__/input-focus.test.ts diff --git a/backend/src/__tests__/input-focus.test.ts b/backend/src/__tests__/input-focus.test.ts new file mode 100644 index 0000000..ec9194d --- /dev/null +++ b/backend/src/__tests__/input-focus.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; + +/** + * **입력칸에서 한 글자 칠 때마다 커서가 풀리지 않아야 한다.** + * + * React 는 컴포넌트 «타입»이 바뀌면 그 자리를 통째로 헐고 다시 짓는다. + * 렌더 함수 안에서 컴포넌트를 만들면 렌더마다 새 함수 = 새 타입이 되어, + * 안에 있던 `` 이 사라졌다 새로 생긴다 — **한 글자마다 포커스가 날아간다.** + * + * 실제로 견적서 생성 팝업에서 났다. 원인은 이런 한 줄이었다: + * + * Field={({ label, children }) => (...)} // 렌더마다 새 컴포넌트 + * + * 눈으로는 잡기 어렵다(타입 오류도 아니고 콘솔 경고도 없다). 그래서 훑는다. + */ +const SRC = path.resolve(__dirname, '../../../frontend/src'); + +function tsxFiles(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const p = path.join(dir, name); + if (statSync(p).isDirectory()) out.push(...tsxFiles(p)); + else if (name.endsWith('.tsx')) out.push(p); + } + return out; +} + +const FILES = tsxFiles(SRC); +const rel = (p: string) => path.relative(SRC, p); + +describe('입력칸 포커스', () => { + it('🔴 컴포넌트를 prop 으로 넘길 때 그 자리에서 만들지 않는다', () => { + /* + * `Foo={({ a, b }) => (...)}` 처럼 대문자 prop 에 인라인 컴포넌트를 넘기면, + * 받는 쪽이 `` 로 그리는 순간 렌더마다 다시 만들어진다. + */ + const bad: string[] = []; + for (const f of FILES) { + const src = readFileSync(f, 'utf8'); + for (const [i, line] of src.split('\n').entries()) { + const m = /(\w+)=\{\(\s*\{[^}]*\}\s*\)\s*=>\s*\(/.exec(line); + if (m && /^[A-Z]/.test(m[1]!)) bad.push(`${rel(f)}:${i + 1} — ${m[1]}`); + } + } + expect(bad, `렌더 안에서 만들어지는 컴포넌트:\n${bad.join('\n')}`).toEqual([]); + }); + + it('🔴 컴포넌트를 다른 컴포넌트 **안에서** 선언하고 JSX 로 쓰지 않는다', () => { + const bad: string[] = []; + for (const f of FILES) { + const src = readFileSync(f, 'utf8'); + for (const [i, line] of src.split('\n').entries()) { + // 들여쓰기된(=함수 안) 대문자 선언 + const m = /^(\s+)(?:const ([A-Z]\w*)\s*[:=]|function ([A-Z]\w*)\s*\()/.exec(line); + if (!m?.[1]) continue; + const name = m[2] ?? m[3]!; + if (new RegExp(`<${name}[\\s/>]`).test(src)) bad.push(`${rel(f)}:${i + 1} — ${name}`); + } + } + expect(bad, `함수 안에서 선언한 컴포넌트:\n${bad.join('\n')}`).toEqual([]); + }); + + it('입력칸에 값 기반 key 를 주지 않는다 — 값이 바뀔 때마다 새로 만들어진다', () => { + const bad: string[] = []; + for (const f of FILES) { + const src = readFileSync(f, 'utf8'); + for (const [i, line] of src.split('\n').entries()) { + if (/<(input|textarea)\b/.test(line) && /key=/.test(line)) bad.push(`${rel(f)}:${i + 1}`); + } + } + expect(bad, `key 가 붙은 입력칸:\n${bad.join('\n')}`).toEqual([]); + }); + + it('훑을 파일이 실제로 있다 — 검사가 빈손으로 통과하지 않게', () => { + expect(FILES.length).toBeGreaterThan(30); + }); +}); + +describe('선수금 입력 상한', () => { + const DP = readFileSync(path.join(SRC, 'components/DownPaymentFields.tsx'), 'utf8'); + + it('🔴 비율은 0~100 으로 눌러 준다', () => { + // 넘기면 선수금이 몫을 넘어 할부원금이 음수가 된다. + // (예전 팝업의 max={100} 이 이 조각으로 옮기면서 빠져 실제로 2468% 가 먹혔다) + expect(DP).toMatch(/Math\.min\(Math\.max\(Number\(v\) \|\| 0, 0\), 100\)/); + }); + + it('금액도 몫으로 눌러 준다', () => { + expect(DP).toMatch(/Math\.min\(Math\.max\(Number\(v\) \|\| 0, 0\), base\)/); + }); +}); diff --git a/frontend/src/components/ConfirmQuoteModal.tsx b/frontend/src/components/ConfirmQuoteModal.tsx index aaaa737..0e96bbe 100644 --- a/frontend/src/components/ConfirmQuoteModal.tsx +++ b/frontend/src/components/ConfirmQuoteModal.tsx @@ -33,6 +33,22 @@ interface Props { onDone: () => void } +/** + * 라벨 + 입력칸 한 벌. + * + * ⚠️ **컴포넌트 바깥에 둔다.** 렌더 함수 안에서 만들면 렌더마다 «다른 타입»이 되어, + * React 가 그 자리를 통째로 헐고 다시 짓는다. 안에 있던 `` 이 사라졌다 + * 새로 생기므로 **한 글자 칠 때마다 커서가 풀린다**(실제 제보). + */ +function PlainField({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <> + + {children} + + ) +} + export function ConfirmQuoteModal({ quoteId, customerName, status, initialInputs, bodyOnly = false, onClose, onDone }: Props) { const init = initialInputs ?? {} const isConfirmed = status !== 'draft' @@ -106,12 +122,7 @@ export function ConfirmQuoteModal({ quoteId, customerName, status, initialInputs ( - <> - - {children} - - )} + Field={PlainField} inputStyle={s.input} /> diff --git a/frontend/src/components/DownPaymentFields.tsx b/frontend/src/components/DownPaymentFields.tsx index c028c5a..fa60d0d 100644 --- a/frontend/src/components/DownPaymentFields.tsx +++ b/frontend/src/components/DownPaymentFields.tsx @@ -52,10 +52,17 @@ export function DownPaymentFields({ base, rate, amount, disabled, onChange, Fiel }, [mode, pct, won, base]) function editRate(v: string) { - setPct(v) - if (v.trim() === '') { setMode(null); setWon(''); onChange({ rate: 0 }); return } + if (v.trim() === '') { setPct(''); setMode(null); setWon(''); onChange({ rate: 0 }); return } + /* + * 0~100% 로 눌러 준다 — 금액 칸과 같은 규칙이다. + * + * 넘겨 두면 선수금이 나눠 가질 몫을 넘어 **할부원금이 음수**가 된다. + * (예전 팝업에는 `max={100}` 이 있었는데 이 조각으로 옮기면서 빠졌다) + */ + const n = Math.min(Math.max(Number(v) || 0, 0), 100) + setPct(String(n)) setMode('rate') - onChange({ rate: (Number(v) || 0) / 100 }) // 금액은 보내지 않는다 — 비율이 기준 + onChange({ rate: n / 100 }) // 금액은 보내지 않는다 — 비율이 기준 } function editAmount(v: string) {