From 6a174786134f65e0d2bd66093679afd44733d405 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:05:08 +0900 Subject: [PATCH] =?UTF-8?q?fix(=EA=B2=AC=EC=A0=81):=20=EC=B0=A8=EB=9F=89?= =?UTF-8?q?=EB=A7=8C=20=EA=B2=AC=EC=A0=81=EC=9D=B4=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EA=B2=83=20=C2=B7=20?= =?UTF-8?q?=ED=83=AD=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=C2=B7=20?= =?UTF-8?q?=E3=80=8C=EC=96=B9=EB=8B=A4=E3=80=8D=E2=86=92=E3=80=8C=EC=9E=A5?= =?UTF-8?q?=EC=B0=A9=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **① 저장이 영영 막혔다** 저장 버튼은 「방문하지 않은 탭」이 있으면 잠긴다. 그런데 차량만 견적은 특장·옵션 탭에 **갈 수가 없어서** 확인이 끝나지 않았다 — 「특장·옵션 확인 필요」로 잠긴 채 남았다. 잠긴 탭은 확인 대상에서 뺀다. 판정을 `lockedTab` 한 곳에 모아 탭 표시와 저장 판정이 갈리지 않게 했다. **② 차량 트림 탭이 길어졌다** 차량만을 고르면 안내 박스가 끼어들어 탭 높이가 달라졌다. 잠그기만 하면 될 일에 칸을 더할 이유가 없다 — 안내를 걷어내고 레이아웃을 그대로 둔다. **③ 「얹다」 → 「장착」** 특장 문맥의 「얹다」를 전부 「장착」으로 바꿨다(30줄). 전문적인 표현이 아니다. 「그림자를 얹다」·「라우트에 얹다」 같은 코드 얘기는 그대로 뒀다 — 거기서 「장착」은 말이 되지 않는다. Closes #295 --- backend/src/__tests__/vehicle-only.test.ts | 42 ++++++++++++++++++- backend/src/routes/public.ts | 2 +- backend/src/routes/quotes.ts | 2 +- backend/src/services/quote-calc.ts | 4 +- backend/src/services/quote-pdf.ts | 2 +- doc-templates/quote-template.html | 2 +- frontend/src/api/quotes.ts | 2 +- frontend/src/components/BodyOnlyPanel.tsx | 41 ++---------------- frontend/src/components/InquiryModal.tsx | 2 +- frontend/src/components/OptionPanel.tsx | 24 +++++++---- frontend/src/components/QuoteExtras.tsx | 2 +- frontend/src/lib/liveQuote.ts | 2 +- frontend/src/lib/quoteCustomer.ts | 2 +- frontend/src/pages/PublicConfiguratorPage.tsx | 2 +- frontend/src/pages/SalesPage.tsx | 4 +- shared/pricing/body-only.test.ts | 2 +- shared/pricing/body-only.ts | 2 +- shared/pricing/quote.ts | 4 +- shared/pricing/vehicle-only.test.ts | 4 +- shared/pricing/vehicle-only.ts | 4 +- shared/process/steps.ts | 4 +- 21 files changed, 83 insertions(+), 72 deletions(-) diff --git a/backend/src/__tests__/vehicle-only.test.ts b/backend/src/__tests__/vehicle-only.test.ts index e5029f6..e944259 100644 --- a/backend/src/__tests__/vehicle-only.test.ts +++ b/backend/src/__tests__/vehicle-only.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { folderQuoteKind } from '../services/customer-folders.js'; /** - * **차량만 견적** — 특장을 얹지 않고 차량만 판다. 특장만 견적의 거울상이다. + * **차량만 견적** — 특장을 장착하지 않고 차량만 판다. 특장만 견적의 거울상이다. * * 이 견적은 **견적서까지**다. 계약서(「특장 매매 및 구조변경 계약서」)는 특장이 없는 * 거래에 맞지 않고, 주문 전환도 하지 않는다 — 특장 제작이 없어 특장사·구조변경·튜닝 @@ -53,7 +53,8 @@ describe('화면', () => { it('🔴 차량만이면 특장·옵션 탭을 누를 수 없다', () => { // 고를 수 있게 두면 고른 것이 금액에 안 잡혀 「왜 반영이 안 되냐」가 된다 - expect(PANEL).toMatch(/if \(vehicleOnly && tab\.key !== 'vehicle'\) return/); + // 잠금 판정은 lockedTab 한 곳에 있다 — 탭 표시와 저장 판정이 갈리지 않게 + expect(PANEL).toMatch(/if \(lockedTab\(tab\.key\)\) return/); expect(PANEL).toContain('styles.tabOff'); }); @@ -91,3 +92,40 @@ describe('목록에서 열어 보지 않고 가린다', () => { expect(read('frontend/src/components/CustomerFolders.tsx')).toMatch(/full:\s*null/); }); }); + +describe('차량만 견적도 저장할 수 있어야 한다', () => { + const PANEL = read('frontend/src/components/OptionPanel.tsx'); + + it('🔴 잠긴 탭을 「확인 필요」로 세지 않는다', () => { + /* + * 「방문하지 않은 탭」을 그대로 세면, 차량만 견적에서는 특장·옵션 탭에 **갈 수가 없어서** + * 확인이 영영 끝나지 않는다 — 저장 버튼이 「특장·옵션 확인 필요」로 잠긴 채 남는다(실제 제보). + */ + expect(PANEL).toMatch(/const unseen = TABS\.filter\(\(t\) => !visited\.has\(t\.key\) && !lockedTab\(t\.key\)\)/); + }); + + it('잠금 판정을 한 곳에서 한다 — 탭 표시와 저장 판정이 갈리면 안 된다', () => { + expect(PANEL).toMatch(/const lockedTab = /); + // 조건을 손으로 다시 쓴 곳이 없어야 한다 + expect(PANEL).not.toMatch(/vehicleOnly && tab\.key !== 'vehicle'/); + }); + + it('차량 트림 탭에 안내 박스를 끼워 넣지 않는다 — 레이아웃이 밀린다', () => { + // 잠그기만 하면 될 일에 칸을 더하면 탭 높이가 달라져 화면이 흔들린다 + expect(PANEL).not.toContain('VehicleOnlyNotice'); + expect(read('frontend/src/components/BodyOnlyPanel.tsx')).not.toContain('VehicleOnlyNotice'); + }); +}); + +describe('용어', () => { + it('🔴 특장은 「얹는다」가 아니라 「장착한다」', () => { + // 「얹다」는 전문적이지 않다(실제 지적). 특장 문맥에서는 쓰지 않는다. + const FILES = [ + 'shared/pricing/vehicle-only.ts', 'shared/pricing/body-only.ts', 'shared/pricing/quote.ts', + 'frontend/src/components/BodyOnlyPanel.tsx', 'frontend/src/components/OptionPanel.tsx', + 'doc-templates/quote-template.html', + ]; + const bad = FILES.filter(f => /특장[^\n]{0,40}얹|얹[^\n]{0,20}특장/.test(read(f))); + expect(bad, `「얹다」가 남은 곳: ${bad.join(', ')}`).toEqual([]); + }); +}); diff --git a/backend/src/routes/public.ts b/backend/src/routes/public.ts index 8f26a14..0617bb4 100644 --- a/backend/src/routes/public.ts +++ b/backend/src/routes/public.ts @@ -225,7 +225,7 @@ publicRouter.post('/inquiries', submitLimiter, async (req: Request, res: Respons // 특장만 견적 — 영업이 이어받을 때 같은 금액이 나와야 한다 body_only: body.body_only === true, /* - * 어떤 차에 얹는지 — 고객이 고른 값 그대로 남긴다. + * 어떤 차에 장착하는지 — 고객이 고른 값 그대로 남긴다. * 영업이 이어받아 견적을 열었을 때 여기서 되읽는다(다시 물어보지 않게). */ vehicle_owned: body.body_only === true diff --git a/backend/src/routes/quotes.ts b/backend/src/routes/quotes.ts index 2eaff72..fff5ba7 100644 --- a/backend/src/routes/quotes.ts +++ b/backend/src/routes/quotes.ts @@ -685,7 +685,7 @@ quotesRouter.post('/', rbac('SALES'), requirePermission('quote.create'), async ( * 보유 차량 정보는 고객이 적어 준 값 그대로(우리가 아는 제원이 아니다). */ body_only: body_only === true, - // 차량만 견적 — 특장을 얹지 않는다. 특장만과 동시에 참일 수 없다. + // 차량만 견적 — 특장을 장착하지 않는다. 특장만과 동시에 참일 수 없다. vehicle_only: body_only !== true && vehicle_only === true, vehicle_owned: body_only === true ? (vehicle_owned ?? {}) : {}, promotion_zeroed: promotion_zeroed ?? [], // 프로모션: 0원 처리한 특장옵션 그룹 diff --git a/backend/src/services/quote-calc.ts b/backend/src/services/quote-calc.ts index dd9f249..972b125 100644 --- a/backend/src/services/quote-calc.ts +++ b/backend/src/services/quote-calc.ts @@ -57,11 +57,11 @@ export type QuoteExtraInput = { promotion_discount?: number; local_subsidy_off?: boolean; // 견적별 지방보조금 미적용(영업 토글) /** - * **특장만 견적** — 고객이 차를 이미 갖고 있어 특장만 얹는다. + * **특장만 견적** — 고객이 차를 이미 갖고 있어 특장만 장착한다. * 차량에 딸린 입력을 통째로 0으로 만든다(shared 의 `bodyOnlyParams` 한 곳에서). */ body_only?: boolean; - /** 차량만 견적 — 특장을 얹지 않는다. body_only 와 동시에 참일 수 없다. */ + /** 차량만 견적 — 특장을 장착하지 않는다. body_only 와 동시에 참일 수 없다. */ vehicle_only?: boolean; }; diff --git a/backend/src/services/quote-pdf.ts b/backend/src/services/quote-pdf.ts index ddb1b04..3e0697b 100644 --- a/backend/src/services/quote-pdf.ts +++ b/backend/src/services/quote-pdf.ts @@ -205,7 +205,7 @@ export async function generateQuotePdf(quoteId: number): Promise const bodyOnly = inp['body_only'] === true; - /** 차량만 견적 — 특장을 얹지 않는다. 특장만과 동시에 참일 수 없다. */ + /** 차량만 견적 — 특장을 장착하지 않는다. 특장만과 동시에 참일 수 없다. */ const vehicleOnly = !bodyOnly && inp['vehicle_only'] === true; const data = { diff --git a/doc-templates/quote-template.html b/doc-templates/quote-template.html index dfbf37a..5f63a52 100644 --- a/doc-templates/quote-template.html +++ b/doc-templates/quote-template.html @@ -180,7 +180,7 @@

특장 정보

diff --git a/frontend/src/api/quotes.ts b/frontend/src/api/quotes.ts index c971f6a..618da4e 100644 --- a/frontend/src/api/quotes.ts +++ b/frontend/src/api/quotes.ts @@ -9,7 +9,7 @@ export interface SaveQuoteRequest extends Partial { memo?: string // 메모/안내문 /** 특장만 견적 — 고객이 차를 이미 갖고 있다(차량 금액·보조금이 전부 빠진다) */ body_only?: boolean - /** 차량만 견적 — 특장을 얹지 않는다 */ + /** 차량만 견적 — 특장을 장착하지 않는다 */ vehicle_only?: boolean /** 특장만일 때 고객이 적어 주는 보유 차량 정보 */ vehicle_owned?: Record diff --git a/frontend/src/components/BodyOnlyPanel.tsx b/frontend/src/components/BodyOnlyPanel.tsx index 31634bc..7a31220 100644 --- a/frontend/src/components/BodyOnlyPanel.tsx +++ b/frontend/src/components/BodyOnlyPanel.tsx @@ -1,7 +1,7 @@ import { BTN } from '../styles/buttons' /** - * **특장만 견적** — 고객이 차를 이미 갖고 있어 특장만 얹는다. + * **특장만 견적** — 고객이 차를 이미 갖고 있어 특장만 장착한다. * * 차량을 우리가 팔지 않으므로 차량가·탁송료·구매혜택·EV보조금·차량 등록비가 전부 빠진다. * 보유 차량의 **차종**은 계약서 생성 단계에서 받는다 — 견적 단계에서는 금액만 보면 되고, @@ -28,7 +28,7 @@ export const OWNED_MODELS = [ * 냉동기는 주행 중에도 **차량 전원**으로 돌아간다 — V2L 포트가 없으면 설치 자체가 불가능하다. * * 우리가 파는 차(기본·플러스)는 모두 V2L 이 있어 확인할 것이 없다. - * **고객 차에 얹을 때만** 물어야 한다. + * **고객 차에 장착할 때만** 물어야 한다. */ export const V2L_NOTICE = '냉동 사양은 차량에서 전원을 받습니다 — 고객 차량에 사용 가능한 V2L 포트가 있어야 설치할 수 있습니다.' export const V2L_CONFIRM = 'V2L 모듈이 있어야 냉동기 설치가 가능합니다. 고객 차량에 사용 가능한 V2L 포트가 있음을 확인했습니다.' @@ -55,7 +55,7 @@ export function BodyOnlyToggle({ on, onToggle, disabled }: { } /** - * **차량만 견적** — 특장을 얹지 않고 차량만 판다. [[BodyOnlyToggle]] 의 거울상이다. + * **차량만 견적** — 특장을 장착하지 않고 차량만 판다. [[BodyOnlyToggle]] 의 거울상이다. * * 특장만과 **동시에 고를 수 없다.** 둘 다 켜면 팔 것이 아무것도 남지 않는다 — * 한쪽을 켜면 다른 쪽 버튼이 눌리지 않게 막는다. @@ -75,39 +75,6 @@ export function VehicleOnlyToggle({ on, onToggle, disabled }: { ) } -/** - * 차량만을 골랐을 때만 뜨는 안내 — 무엇이 빠지는지 그 자리에서 알린다. - * 견적서를 뽑고 나서 「특장이 왜 없냐」를 묻게 두면 안 된다. - */ -export function VehicleOnlyNotice() { - return ( -
-
차량만 판매하는 견적입니다
-
- 특장을 얹지 않아 특장·옵션 탭과 프로모션이 잠깁니다. - 구조변경이 없어 특장 취득세·등록부가수수료·구조변경 비용도 빠집니다. -
-
- 보조금과 할부(캐피탈)는 그대로 적용됩니다 — 차를 사는 거래이기 때문입니다. -
-
- 이 견적은 견적서까지입니다. 계약서·주문 전환은 특장 매매 계약이라 해당하지 않습니다. -
-
- ) -} - -const vo: Record = { - box: { - marginTop: 'var(--sp-4)', padding: '12px 14px', - border: '0.5px solid var(--line)', borderRadius: 8, background: 'var(--card)', - display: 'flex', flexDirection: 'column', gap: 6, - }, - title: { fontSize: 13, fontWeight: 700, color: 'var(--dark)' }, - body: { fontSize: 12, color: 'var(--muted)', lineHeight: 1.6 }, - warn: { fontSize: 12, color: 'var(--dark)', lineHeight: 1.6, marginTop: 2 }, -} - /** * 특장만을 **골랐을 때만** 뜨는 안내. 평소에는 보이지 않는다 — * 차량을 사는 대부분의 견적에는 해당 없는 이야기라, 늘 띄워 두면 읽지 않게 된다. @@ -115,7 +82,7 @@ const vo: Record = { * 여기서는 **알리기만** 한다. 실제 확인(체크)은 특장 탭에서 냉동을 고르는 그 자리에서 받는다. */ export function BodyOnlyNotice({ model, onModelChange }: { - /** 보유 차종 — **임시저장 단계**에서 받는다. 어떤 차에 얹는지는 견적의 전제다. */ + /** 보유 차종 — **임시저장 단계**에서 받는다. 어떤 차에 장착하는지는 견적의 전제다. */ model: string /** * 없으면 **안내만** 하고 보유 차종은 묻지 않는다 — 공개 창구가 그렇다. diff --git a/frontend/src/components/InquiryModal.tsx b/frontend/src/components/InquiryModal.tsx index 27d94d0..a9da5a2 100644 --- a/frontend/src/components/InquiryModal.tsx +++ b/frontend/src/components/InquiryModal.tsx @@ -103,7 +103,7 @@ export function InquiryModal({ modelCode, selections, subsidy, onSubsidyChange, [filled(name), '성명'], [phone.replace(/\D/g, '').length >= 10, '휴대폰'], [/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.trim()), '이메일'], - // 특장만이면 어떤 차에 얹는지가 견적의 전제다 — 「차량 트림」 화면에서 고른다 + // 특장만이면 어떤 차에 장착하는지가 견적의 전제다 — 「차량 트림」 화면에서 고른다 [!bodyOnly || !!ownedModel, '보유 차종'], /* * 보조금 조건은 **전부** 필수 — 하나라도 비면 화면에 보인 실구매가가 달라진다. diff --git a/frontend/src/components/OptionPanel.tsx b/frontend/src/components/OptionPanel.tsx index e6c10d0..2d4d60f 100644 --- a/frontend/src/components/OptionPanel.tsx +++ b/frontend/src/components/OptionPanel.tsx @@ -3,7 +3,7 @@ import type { ApiPricingBundle } from '@shared/types/index' import type { PricingOk } from '@shared/pricing/core' import { VehicleOptionsTab } from './tabs/VehicleOptionsTab' import { BodyOptionsTab } from './tabs/BodyOptionsTab' -import { BodyOnlyToggle, BodyOnlyNotice, VehicleOnlyToggle, VehicleOnlyNotice } from './BodyOnlyPanel' +import { BodyOnlyToggle, BodyOnlyNotice, VehicleOnlyToggle } from './BodyOnlyPanel' import { InteriorOptionsTab } from './tabs/InteriorOptionsTab' import { groupsByCategory, OPTION_CATEGORY } from '../lib/optionRules' import { QuoteExtras } from './QuoteExtras' @@ -36,7 +36,7 @@ interface Props { * 공개 화면에는 주지 않는다(영업이 판단할 성격의 견적이다). */ bodyOnly?: boolean - /** 차량만 견적 — 특장을 얹지 않는다. 특장·옵션 탭이 잠긴다. */ + /** 차량만 견적 — 특장을 장착하지 않는다. 특장·옵션 탭이 잠긴다. */ vehicleOnly?: boolean onToggleVehicleOnly?: (v: boolean) => void onToggleBodyOnly?: (v: boolean) => void @@ -116,7 +116,14 @@ export function OptionPanel({ }, [vehicleOnly, activeTab]) // 견적 저장 전에 모든 단계를 확인하게 강제한다. 기본 화면인 트림(vehicle)은 이미 본 것으로 친다. const [visited, setVisited] = useState>(new Set(['vehicle'])) - const unseen = TABS.filter((t) => !visited.has(t.key)) + /* + * ⚠️ **잠긴 탭은 확인 대상이 아니다.** + * + * 차량만 견적이면 특장·옵션 탭을 누를 수 없다. 그런데 「방문하지 않은 탭」을 그대로 + * 세면 **갈 수도 없는 곳을 확인하라며 저장이 영영 막힌다**(실제 제보). + */ + const lockedTab = (k: TabKey) => !!vehicleOnly && k !== 'vehicle' + const unseen = TABS.filter((t) => !visited.has(t.key) && !lockedTab(t.key)) const btnLabel = isSaving ? '저장 중…' : saveLabel && !unseen.length && !isUnsupported @@ -136,7 +143,7 @@ export function OptionPanel({