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) {