diff --git a/backend/src/__tests__/quote-template.test.ts b/backend/src/__tests__/quote-template.test.ts index 0fdf637..ebe05f7 100644 --- a/backend/src/__tests__/quote-template.test.ts +++ b/backend/src/__tests__/quote-template.test.ts @@ -41,19 +41,30 @@ describe('견적서 양식 — 반복 블록과 렌더 코드가 짝이 맞는 expect(missing, `코드에만 있는 블록: ${missing.join(', ')}`).toEqual([]); }); - it('열마다 갈래가 짝을 이룬다 — 차량·특장·고객 셋 다', () => { - // 한 열만 갈라 두면 특장만 견적서에서 그 열만 옛 모양으로 남는다 - for (const [a, b] of [['carSection', 'ownedSection'], ['topNormal', 'topOnly'], ['custNormal', 'custOnly']]) { - expect(inTemplate.has(a!), a).toBe(true); - expect(inTemplate.has(b!), b).toBe(true); + /** 열마다 어떤 갈래들이 있는지 — 견적 종류가 늘면 여기에 더한다. */ + const COLUMNS = { + car: ['carSection', 'ownedSection'], // 차량 열: 파는 경우 / 고객 보유 + top: ['topNormal', 'topOnly', 'topNone'], // 특장 열: 함께 / 특장만 / 특장 없음 + cust: ['custNormal', 'custOnly'], // 고객 열: 할부 있음 / 없음 + } as const; + + it('열마다 갈래가 다 있다 — 하나라도 빠지면 그 열만 옛 모양으로 남는다', () => { + for (const [col, branches] of Object.entries(COLUMNS)) { + for (const b of branches) expect(inTemplate.has(b), `${col} 의 ${b}`).toBe(true); } }); - it('갈래마다 정렬용 pad 가 하나씩 들어 있다', () => { - // pad 는 세 열의 마지막 줄을 같은 가로선에 맞춘다. 갈래에 없으면 그 열만 어긋난다. - for (const col of ['car', 'top', 'cust']) { + it('갈래마다 정렬용 pad 가 **하나씩** 들어 있다', () => { + /* + * pad 는 세 열의 마지막 줄을 같은 가로선에 맞춘다. 갈래에 없으면 그 열만 어긋난다. + * + * 개수를 숫자로 박아 두지 않는다 — 견적 종류가 늘 때마다 정당한 변경이 막히고, + * 통과시키려면 숫자를 고쳐 적게 되어 검사가 받아쓰기가 된다. + * **갈래 수와 같은지**를 본다. + */ + for (const [col, branches] of Object.entries(COLUMNS)) { const n = (BODY.match(new RegExp(``, 'g')) ?? []).length; - expect(n, `pad:${col}`).toBe(2); // 일반 갈래 1 + 특장만 갈래 1 + expect(n, `pad:${col} (갈래 ${branches.length}개)`).toBe(branches.length); } }); }); diff --git a/backend/src/__tests__/vehicle-only.test.ts b/backend/src/__tests__/vehicle-only.test.ts new file mode 100644 index 0000000..e5029f6 --- /dev/null +++ b/backend/src/__tests__/vehicle-only.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { folderQuoteKind } from '../services/customer-folders.js'; + +/** + * **차량만 견적** — 특장을 얹지 않고 차량만 판다. 특장만 견적의 거울상이다. + * + * 이 견적은 **견적서까지**다. 계약서(「특장 매매 및 구조변경 계약서」)는 특장이 없는 + * 거래에 맞지 않고, 주문 전환도 하지 않는다 — 특장 제작이 없어 특장사·구조변경·튜닝 + * 단계가 통째로 빈다. + */ +const ROOT = path.resolve(__dirname, '../../..'); +const read = (rel: string) => readFileSync(path.join(ROOT, rel), 'utf8'); + +describe('견적 종류 판정', () => { + it('둘은 동시에 참일 수 없다 — 특장만이 이긴다', () => { + // 그러면 팔 것이 아무것도 남지 않는다. 먼저 있던 기능을 지켜 기존 견적을 보호한다. + expect(folderQuoteKind({ body_only: true, vehicle_only: true })).toBe('body'); + }); + + it('각각을 알아본다', () => { + expect(folderQuoteKind({ body_only: true })).toBe('body'); + expect(folderQuoteKind({ vehicle_only: true })).toBe('vehicle'); + expect(folderQuoteKind({})).toBe('full'); + expect(folderQuoteKind(null)).toBe('full'); + }); +}); + +describe('견적서 양식', () => { + const TPL = read('doc-templates/quote-template.html'); + const PDF = read('backend/src/services/quote-pdf.ts'); + + it('특장 열에 「특장 없음」 갈래가 있다', () => { + expect(TPL).toContain(''); + expect(PDF).toContain("renderEach(html, 'topNone'"); + }); + + it('🔴 차량만이면 특장 옵션 행을 그리지 않는다', () => { + // 0원 줄이 줄줄이 서면 「특장을 샀는데 공짜」로 읽힌다 + expect(PDF).toMatch(/renderEach\(html, 'topOptions', vehicleOnly \? \[\] : topOptions\)/); + }); + + it('세 갈래가 서로 배타적이다', () => { + // topNormal 은 둘 중 어느 쪽도 아닐 때만 나온다 + expect(PDF).toMatch(/renderEach\(html, 'topNormal', \(bodyOnly \|\| vehicleOnly\) \? \[\] : \[\{\}\]\)/); + }); +}); + +describe('화면', () => { + const PANEL = read('frontend/src/components/OptionPanel.tsx'); + const EXTRAS = read('frontend/src/components/QuoteExtras.tsx'); + + it('🔴 차량만이면 특장·옵션 탭을 누를 수 없다', () => { + // 고를 수 있게 두면 고른 것이 금액에 안 잡혀 「왜 반영이 안 되냐」가 된다 + expect(PANEL).toMatch(/if \(vehicleOnly && tab\.key !== 'vehicle'\) return/); + expect(PANEL).toContain('styles.tabOff'); + }); + + it('잠긴 탭에 머물러 있으면 차량 탭으로 되돌린다 — 빈 화면을 막는다', () => { + expect(PANEL).toMatch(/vehicleOnly && activeTab !== 'vehicle'\) setActiveTab\('vehicle'\)/); + }); + + it('🔴 차량만이면 프로모션이 잠긴다 — 특장 옵션에 붙는 할인이다', () => { + expect(EXTRAS).toMatch(/disabled=\{vehicleOnly\}/); + expect(EXTRAS).toMatch(/\{showPromo && !vehicleOnly &&/); + }); + + it('지방보조금 소진은 잠기지 않는다 — 차량 보조금이라 그대로 쓴다', () => { + const i = EXTRAS.indexOf('지방보조금 소진'); + const around = EXTRAS.slice(Math.max(0, i - 700), i); + expect(around).not.toContain('vehicleOnly'); + }); + + it('두 종류를 동시에 고를 수 없다', () => { + expect(PANEL).toMatch(/BodyOnlyToggle[\s\S]{0,120}disabled=\{!!vehicleOnly\}/); + expect(PANEL).toMatch(/VehicleOnlyToggle[\s\S]{0,120}disabled=\{!!bodyOnly\}/); + }); +}); + +describe('목록에서 열어 보지 않고 가린다', () => { + it('영업 목록·관리자 목록·고객 서류함 셋 다 표시한다', () => { + expect(read('frontend/src/pages/SalesPage.tsx')).toContain(''); + expect(read('frontend/src/pages/AdminPage.tsx')).toContain(''); + expect(read('frontend/src/components/CustomerFolders.tsx')).toContain('KIND_LABEL[q.kind]'); + }); + + it('일반 견적에는 아무것도 붙이지 않는다', () => { + // 대부분이 일반이라 다 붙이면 표가 배지로 뒤덮여 정작 다른 건이 묻힌다 + expect(read('frontend/src/lib/quoteCustomer.ts')).toMatch(/full:\s*null/); + expect(read('frontend/src/components/CustomerFolders.tsx')).toMatch(/full:\s*null/); + }); +}); diff --git a/backend/src/routes/customer-folders.ts b/backend/src/routes/customer-folders.ts index 1bf4d92..da6f3df 100644 --- a/backend/src/routes/customer-folders.ts +++ b/backend/src/routes/customer-folders.ts @@ -16,7 +16,7 @@ import { prisma } from '../lib/prisma.js'; import { rbac, ownQuotesOnly, scopedToMine } from '../middleware/rbac.js'; import { VISIBLE } from '../lib/visibility.js'; import { - groupCustomers, collectDocs, resolveDocId, optionChips, groupDocsByQuote, + groupCustomers, collectDocs, resolveDocId, optionChips, groupDocsByQuote, folderQuoteKind, type CustomerGroup, type FolderCustomer, type PinnedInput, type FolderQuote, } from '../services/customer-folders.js'; @@ -137,6 +137,8 @@ customerFoldersRouter.get('/:key', rbac('ADMIN', 'SALES'), guard(async (req, res id: true, quote_no: true, status: true, created_at: true, final_price: true, selections: true, docs_frozen_at: true, docs_frozen_quote_path: true, docs_frozen_contract_path: true, + // 견적 종류(특장만/차량만)를 카드에 표시하려면 필요하다 + inputs: true, }, orderBy: { id: 'desc' }, }); @@ -175,6 +177,7 @@ customerFoldersRouter.get('/:key', rbac('ADMIN', 'SALES'), guard(async (req, res finalPrice: q.final_price, options: optionChips((q.selections ?? {}) as Record, c => nameOf.get(c)), frozenAt: q.docs_frozen_at?.toISOString() ?? null, + kind: folderQuoteKind(q.inputs), docs: byQuote.get(q.quote_no ?? null) ?? [], })); diff --git a/backend/src/routes/quotes.ts b/backend/src/routes/quotes.ts index 41bd672..2eaff72 100644 --- a/backend/src/routes/quotes.ts +++ b/backend/src/routes/quotes.ts @@ -41,7 +41,7 @@ async function buildParams( selections: Record, customer: CustomerInput | undefined, calcYear: number, - extra?: { promotion_zeroed?: string[]; promotion_discount?: number; local_subsidy_off?: boolean; body_only?: boolean }, + extra?: { promotion_zeroed?: string[]; promotion_discount?: number; local_subsidy_off?: boolean; body_only?: boolean; vehicle_only?: boolean }, ): Promise { if (!prisma) throw new Error('DB_UNAVAILABLE'); @@ -245,17 +245,17 @@ quotesRouter.post('/calculate', rbac('SALES'), async (req: Request, res): Promis res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결 필요' } }); return; } - const { model_code, year, selections, customer, promotion_zeroed, promotion_discount, local_subsidy_off, body_only } = req.body as { + const { model_code, year, selections, customer, promotion_zeroed, promotion_discount, local_subsidy_off, body_only, vehicle_only } = req.body as { model_code?: string; year?: number; selections?: Record; customer?: CustomerInput; - promotion_zeroed?: string[]; promotion_discount?: number; local_subsidy_off?: boolean; body_only?: boolean; + promotion_zeroed?: string[]; promotion_discount?: number; local_subsidy_off?: boolean; body_only?: boolean; vehicle_only?: boolean; }; if (!model_code || !selections) { res.status(400).json({ error: { code: 'BAD_INPUT', message: 'model_code, selections 필수' } }); return; } try { - const params = await buildParams(model_code, selections, customer, year ?? new Date().getFullYear(), { promotion_zeroed, promotion_discount, local_subsidy_off, body_only }); + const params = await buildParams(model_code, selections, customer, year ?? new Date().getFullYear(), { promotion_zeroed, promotion_discount, local_subsidy_off, body_only, vehicle_only }); const result = calcPrice(params); if (result.status === 'unsupported') { res.status(422).json({ error: { code: 'UNSUPPORTED', message: result.reason } }); @@ -275,10 +275,10 @@ quotesRouter.post('/calculate-total', rbac('SALES', 'ADMIN'), async (req: Reques res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결 필요' } }); return; } - const { model_code, year, selections, customer, down_payment_rate, installment_months, promotion_zeroed, promotion_discount, local_subsidy_off, body_only } = req.body as { + const { model_code, year, selections, customer, down_payment_rate, installment_months, promotion_zeroed, promotion_discount, local_subsidy_off, body_only, vehicle_only } = req.body as { model_code?: string; year?: number; selections?: Record; customer?: CustomerInput; - down_payment_rate?: number; installment_months?: number; promotion_zeroed?: string[]; promotion_discount?: number; local_subsidy_off?: boolean; body_only?: boolean; + down_payment_rate?: number; installment_months?: number; promotion_zeroed?: string[]; promotion_discount?: number; local_subsidy_off?: boolean; body_only?: boolean; vehicle_only?: boolean; }; if (!model_code || !selections) { res.status(400).json({ error: { code: 'BAD_INPUT', message: 'model_code, selections 필수' } }); @@ -287,7 +287,7 @@ quotesRouter.post('/calculate-total', rbac('SALES', 'ADMIN'), async (req: Reques try { const params = await buildQuoteParams( model_code, selections, customer, - { down_payment_rate, installment_months, promotion_zeroed, promotion_discount, local_subsidy_off, body_only }, + { down_payment_rate, installment_months, promotion_zeroed, promotion_discount, local_subsidy_off, body_only, vehicle_only }, year ?? new Date().getFullYear(), ); res.json({ data: calcQuote(params) }); @@ -335,6 +335,7 @@ quotesRouter.get('/:id/total', rbac('SALES', 'ADMIN'), async (req: Request, res) local_subsidy_off: inp['local_subsidy_off'] as boolean | undefined, // 특장만 견적 — 빠뜨리면 다시 열 때 차량 금액이 되살아난다 body_only: inp['body_only'] === true, + vehicle_only: inp['vehicle_only'] === true, }, quote.created_at.getFullYear(), ); @@ -379,7 +380,7 @@ quotesRouter.patch('/:id/inputs', rbac('SALES', 'ADMIN'), requirePermission('quo if (await isFrozen(id)) { res.status(409).json({ error: { code: 'DOCS_FROZEN', message: FROZEN_MESSAGE } }); return; } // 허용 필드만 병합(입력시트 값). 임의 키 오염 방지. const ALLOWED = ['down_payment_rate', 'down_payment_amount', 'installment_months', 'tax_exempt_type', 'has_biz_plate', - 'biz_type', 'is_sosang', 'region', 'has_transport_license', 'diesel_conversion', 'diesel_status', 'promotion_zeroed', 'promotion_discount', 'memo', 'local_subsidy_off', 'body_only', 'vehicle_owned', + 'biz_type', 'is_sosang', 'region', 'has_transport_license', 'diesel_conversion', 'diesel_status', 'promotion_zeroed', 'promotion_discount', 'memo', 'local_subsidy_off', 'body_only', 'vehicle_only', 'vehicle_owned', // 매매계약서 전용 입력(견적서 생성 팝업에서 함께 받음). 전부 선택 — 비워두면 계약서에 공란으로 나간다. 'contract_party', 'buyer_agent', 'buyer_relation', 'buyer_regno', 'buyer_tel', // 대표이사 — 법인 계약서 서명블록. 저장 후 사업자구분을 고칠 때 함께 고칠 수 있어야 한다. @@ -640,12 +641,13 @@ quotesRouter.post('/', rbac('SALES'), requirePermission('quote.create'), async ( res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결 필요' } }); return; } - const { model_code, year, selections, customer, down_payment_rate, installment_months, promotion_zeroed, promotion_discount, memo, local_subsidy_off, body_only, vehicle_owned } = req.body as { + const { model_code, year, selections, customer, down_payment_rate, installment_months, promotion_zeroed, promotion_discount, memo, local_subsidy_off, body_only, vehicle_only, vehicle_owned } = req.body as { model_code?: string; year?: number; selections?: Record; customer?: CustomerInput; down_payment_rate?: number; installment_months?: number; promotion_zeroed?: string[]; promotion_discount?: number; memo?: string; local_subsidy_off?: boolean; /** 특장만 견적 — 고객이 차를 이미 갖고 있다 */ body_only?: boolean; + vehicle_only?: boolean; /** 특장만일 때 고객이 적어 주는 보유 차량 정보(견적서에 그대로 실린다) */ vehicle_owned?: Record; }; @@ -655,13 +657,13 @@ quotesRouter.post('/', rbac('SALES'), requirePermission('quote.create'), async ( } const calcYear = year ?? new Date().getFullYear(); - const params = await buildParams(model_code, selections, customer, calcYear, { promotion_zeroed, promotion_discount, local_subsidy_off, body_only }); + const params = await buildParams(model_code, selections, customer, calcYear, { promotion_zeroed, promotion_discount, local_subsidy_off, body_only, vehicle_only }); const result = calcPrice(params); // 저장되는 실구매가는 **총견적서 기준**(견적서 PDF·화면과 동일 규칙). // calcPrice(Ver1.21)는 공급가액 산출과 하위호환 응답용으로만 유지한다. const totalParams = await buildQuoteParams(model_code, selections, customer, - { down_payment_rate, installment_months, promotion_zeroed, promotion_discount, local_subsidy_off, body_only }, calcYear); + { down_payment_rate, installment_months, promotion_zeroed, promotion_discount, local_subsidy_off, body_only, vehicle_only }, calcYear); const total = calcQuote(totalParams); // 총견적서 입력시트 스냅샷(견적별 입력값 — 나중에 총견적서 재출력·재계산용) @@ -683,6 +685,8 @@ 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원 처리한 특장옵션 그룹 promotion_discount: Math.max(0, Math.round(promotion_discount ?? 0)), // 프로모션 할인액(VAT 포함) diff --git a/backend/src/services/customer-folders.ts b/backend/src/services/customer-folders.ts index d056d44..ad494c1 100644 --- a/backend/src/services/customer-folders.ts +++ b/backend/src/services/customer-folders.ts @@ -278,9 +278,22 @@ export interface FolderQuote { options: OptionChip[]; /** 서명 요청 때 굳힌 정본이 있는 건인가 */ frozenAt: string | null; + /** + * 견적 종류 — 서류함에서 **열어 보지 않고** 특장만·차량만을 가리게 한다. + * 금액만으로는 구분되지 않는다(특장만 2천만원과 차량만 3천만원이 나란히 있으면 알 길이 없다). + */ + kind: 'full' | 'body' | 'vehicle'; docs: FolderDoc[]; } +/** 저장된 입력에서 견적 종류를 읽는다. 둘은 동시에 참일 수 없다. */ +export function folderQuoteKind(inputs: unknown): FolderQuote['kind'] { + const inp = (inputs ?? {}) as Record; + if (inp['body_only'] === true) return 'body'; + if (inp['vehicle_only'] === true) return 'vehicle'; + return 'full'; +} + /** * 서류를 **견적별로 나눈다** — 화면이 견적 카드 하나에 그 건의 서류를 모아 보여 준다. * diff --git a/backend/src/services/quote-calc.ts b/backend/src/services/quote-calc.ts index 7836df3..dd9f249 100644 --- a/backend/src/services/quote-calc.ts +++ b/backend/src/services/quote-calc.ts @@ -8,6 +8,7 @@ import { assembleOptionSum, TAKBAE_RATE, DEFAULT_TAX_EXEMPT_TYPE, dieselDeducts, toDieselStatus, type QuoteParams, bodyOnlyParams, + vehicleOnlyParams, } from '@buildup-ev/shared/pricing'; export type CustomerInput = { @@ -60,6 +61,8 @@ export type QuoteExtraInput = { * 차량에 딸린 입력을 통째로 0으로 만든다(shared 의 `bodyOnlyParams` 한 곳에서). */ body_only?: boolean; + /** 차량만 견적 — 특장을 얹지 않는다. body_only 와 동시에 참일 수 없다. */ + vehicle_only?: boolean; }; export async function buildQuoteParams( @@ -151,5 +154,11 @@ export async function buildQuoteParams( * 여기서 하나씩 0을 채우지 않고 shared 한 곳에 맡긴다 — 화면도 같은 함수를 쓰므로 * 견적서와 화면이 다른 값을 말할 수 없다. */ - return extra?.body_only ? bodyOnlyParams(params) : params; + /* + * 둘은 **동시에 참일 수 없다** — 그러면 팔 것이 아무것도 남지 않는다. + * 어쩌다 둘 다 들어오면 특장만을 우선한다(먼저 있던 기능이라 기존 견적을 지킨다). + */ + if (extra?.body_only) return bodyOnlyParams(params); + if (extra?.vehicle_only) return vehicleOnlyParams(params); + return params; } diff --git a/backend/src/services/quote-pdf.ts b/backend/src/services/quote-pdf.ts index 79493a8..ddb1b04 100644 --- a/backend/src/services/quote-pdf.ts +++ b/backend/src/services/quote-pdf.ts @@ -205,6 +205,8 @@ export async function generateQuotePdf(quoteId: number): Promise const bodyOnly = inp['body_only'] === true; + /** 차량만 견적 — 특장을 얹지 않는다. 특장만과 동시에 참일 수 없다. */ + const vehicleOnly = !bodyOnly && inp['vehicle_only'] === true; const data = { vehicleModel: modelName, @@ -268,15 +270,18 @@ export async function generateQuotePdf(quoteId: number): Promise let html = TEMPLATE.replace(//, ''); html = renderEach(html, 'benefitRows', benefitRows); html = renderEach(html, 'subsidyRows', subsidyRows); - html = renderEach(html, 'topOptions', topOptions); + // 차량만 견적에는 특장 옵션 행 자체가 없다 — 0원 줄이 늘어서면 「샀는데 공짜」로 읽힌다 + html = renderEach(html, 'topOptions', vehicleOnly ? [] : topOptions); /* * 특장·고객 칸도 두 갈래다 — 차를 파는 견적이면 인도금·할부까지, 특장만이면 총액만. * ⚠️ topOptions 를 **먼저** 편 뒤에 갈래를 가른다. 옵션 행이 두 갈래 바깥에 있어 * 순서가 뒤바뀌면 갈래 안의 `{{ item.* }}` 을 옵션 item 으로 먹어 버린다. * renderPad 보다도 앞이어야 한다 — 갈래마다 pad 가 하나씩 들어 있다. */ - html = renderEach(html, 'topNormal', bodyOnly ? [] : [{}]); + html = renderEach(html, 'topNormal', (bodyOnly || vehicleOnly) ? [] : [{}]); html = renderEach(html, 'topOnly', bodyOnly ? [{}] : []); + // 차량만 — 특장 칸을 비우고 왜 비었는지만 적는다 + html = renderEach(html, 'topNone', vehicleOnly ? [{}] : []); html = renderEach(html, 'custNormal', bodyOnly ? [] : [{}]); html = renderEach(html, 'custOnly', bodyOnly ? [{}] : []); // 탁송료·보조금 안내는 차를 살 때만 해당한다 diff --git a/doc-templates/quote-template.html b/doc-templates/quote-template.html index c3b8b66..dfbf37a 100644 --- a/doc-templates/quote-template.html +++ b/doc-templates/quote-template.html @@ -178,6 +178,17 @@

특장 정보

특장 총 금액 (①+②){{ top.bodyTotal }} + + + + 특장 없음 — 차량만 구매하는 견적입니다. + 특장 제작·구조변경이 없어 관련 비용이 발생하지 않습니다. + + diff --git a/frontend/src/api/customerFolders.ts b/frontend/src/api/customerFolders.ts index 33de767..bf76fd7 100644 --- a/frontend/src/api/customerFolders.ts +++ b/frontend/src/api/customerFolders.ts @@ -39,6 +39,8 @@ export interface ApiFolderQuote { options: ApiOptionChip[] /** 서명 요청 때 굳힌 정본이 있는 건인가 — 실제로 고객에게 나간 판이다 */ frozenAt: string | null + /** 견적 종류 — 열어 보지 않고 특장만·차량만을 가린다 */ + kind: 'full' | 'body' | 'vehicle' docs: ApiFolderDoc[] } diff --git a/frontend/src/api/quotes.ts b/frontend/src/api/quotes.ts index a667d43..c971f6a 100644 --- a/frontend/src/api/quotes.ts +++ b/frontend/src/api/quotes.ts @@ -9,6 +9,8 @@ export interface SaveQuoteRequest extends Partial { memo?: string // 메모/안내문 /** 특장만 견적 — 고객이 차를 이미 갖고 있다(차량 금액·보조금이 전부 빠진다) */ body_only?: boolean + /** 차량만 견적 — 특장을 얹지 않는다 */ + vehicle_only?: boolean /** 특장만일 때 고객이 적어 주는 보유 차량 정보 */ vehicle_owned?: Record // 금액을 바꾸는 입력(프로모션·지방보조금 토글)은 shared 가 이름의 단일 소스다. diff --git a/frontend/src/components/BodyOnlyPanel.tsx b/frontend/src/components/BodyOnlyPanel.tsx index c8c355b..c80de32 100644 --- a/frontend/src/components/BodyOnlyPanel.tsx +++ b/frontend/src/components/BodyOnlyPanel.tsx @@ -39,15 +39,75 @@ export const V2L_CONFIRM = 'V2L 모듈이 있어야 냉동기 설치가 가능 * 트림 옵션값으로 만들지 않았다. 그러면 옵션DB·단가표에 「차를 안 산다」는 항목이 생겨 * 가격 조립이 그걸 알아야 한다. 차량 구매 여부는 **가격 항목이 아니라 견적의 성격**이다. */ -export function BodyOnlyToggle({ on, onToggle }: { on: boolean; onToggle: (v: boolean) => void }) { +export function BodyOnlyToggle({ on, onToggle, disabled }: { + on: boolean; onToggle: (v: boolean) => void + /** 차량만 견적을 고른 상태 — 둘은 동시에 될 수 없다 */ + disabled?: boolean +}) { return ( ) } +/** + * **차량만 견적** — 특장을 얹지 않고 차량만 판다. [[BodyOnlyToggle]] 의 거울상이다. + * + * 특장만과 **동시에 고를 수 없다.** 둘 다 켜면 팔 것이 아무것도 남지 않는다 — + * 한쪽을 켜면 다른 쪽 버튼이 눌리지 않게 막는다. + */ +export function VehicleOnlyToggle({ on, onToggle, disabled }: { + on: boolean; onToggle: (v: boolean) => void + /** 특장만 견적을 고른 상태 */ + disabled?: boolean +}) { + return ( + + ) +} + +/** + * 차량만을 골랐을 때만 뜨는 안내 — 무엇이 빠지는지 그 자리에서 알린다. + * 견적서를 뽑고 나서 「특장이 왜 없냐」를 묻게 두면 안 된다. + */ +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 }, +} + /** * 특장만을 **골랐을 때만** 뜨는 안내. 평소에는 보이지 않는다 — * 차량을 사는 대부분의 견적에는 해당 없는 이야기라, 늘 띄워 두면 읽지 않게 된다. diff --git a/frontend/src/components/CustomerFolders.tsx b/frontend/src/components/CustomerFolders.tsx index 809d0bc..3b59346 100644 --- a/frontend/src/components/CustomerFolders.tsx +++ b/frontend/src/components/CustomerFolders.tsx @@ -154,11 +154,16 @@ function FolderBody({ folderKey, mine }: { folderKey: number; mine?: boolean }) ) } +/** 종류 표시 — 일반 견적(차량+특장)은 대부분이라 붙이지 않는다 */ +const KIND_LABEL: Record = { full: null, body: '특장만', vehicle: '차량만' } + function QuoteCard({ q, folderKey, mine }: { q: ApiFolderQuote; folderKey: number; mine?: boolean }) { return (
{q.quoteNo ?? `#${q.id}`} + {/* 특장만·차량만은 여기서 바로 갈린다 — 견적서를 열어 볼 필요가 없다 */} + {KIND_LABEL[q.kind] && {KIND_LABEL[q.kind]}} {STATUS_KO[q.status] ?? q.status} {q.finalPrice != null && {fmtPrice(q.finalPrice)}} @@ -284,6 +289,15 @@ const s: Record = { qDate: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontVariantNumeric: 'tabular-nums' }, chips: { display: 'flex', flexWrap: 'wrap', gap: 4 }, + /* 일반 견적에는 안 붙인다 — 대부분이 그것이라 다 붙이면 다른 건이 묻힌다 */ + kindBody: { + fontSize: 'var(--fs-caption)', fontWeight: 700, padding: '0 6px', borderRadius: 3, + color: 'var(--dark)', background: 'var(--lime-bg)', border: '0.5px solid var(--lime)', whiteSpace: 'nowrap', + }, + kindVehicle: { + fontSize: 'var(--fs-caption)', fontWeight: 700, padding: '0 6px', borderRadius: 3, + color: 'var(--dark)', background: 'var(--card)', border: '0.5px solid var(--line-firm)', whiteSpace: 'nowrap', + }, chip: { fontSize: 'var(--fs-caption)', color: 'var(--dark)', background: 'var(--lime-bg)', borderRadius: 999, padding: '1px 9px', diff --git a/frontend/src/components/OptionPanel.tsx b/frontend/src/components/OptionPanel.tsx index adce507..e6c10d0 100644 --- a/frontend/src/components/OptionPanel.tsx +++ b/frontend/src/components/OptionPanel.tsx @@ -1,9 +1,9 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' 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 } from './BodyOnlyPanel' +import { BodyOnlyToggle, BodyOnlyNotice, VehicleOnlyToggle, VehicleOnlyNotice } from './BodyOnlyPanel' import { InteriorOptionsTab } from './tabs/InteriorOptionsTab' import { groupsByCategory, OPTION_CATEGORY } from '../lib/optionRules' import { QuoteExtras } from './QuoteExtras' @@ -36,6 +36,9 @@ interface Props { * 공개 화면에는 주지 않는다(영업이 판단할 성격의 견적이다). */ bodyOnly?: boolean + /** 차량만 견적 — 특장을 얹지 않는다. 특장·옵션 탭이 잠긴다. */ + vehicleOnly?: boolean + onToggleVehicleOnly?: (v: boolean) => void onToggleBodyOnly?: (v: boolean) => void /** 보유 차종 — 특장만 견적의 전제라 여기서 받는다 */ ownedModel?: string @@ -82,6 +85,8 @@ export function OptionPanel({ publicMode = false, saveLabel, bodyOnly, + vehicleOnly, + onToggleVehicleOnly, onToggleBodyOnly, ownedModel, onOwnedModelChange, @@ -101,6 +106,14 @@ export function OptionPanel({ onToggleLocalSubsidy, }: Props) { const [activeTab, setActiveTab] = useState('vehicle') + + /* + * 차량만으로 바꿨는데 특장·옵션 탭에 머물러 있으면 **빈 화면**이 된다. + * 잠긴 탭에 있으면 차량 탭으로 되돌린다. + */ + useEffect(() => { + if (vehicleOnly && activeTab !== 'vehicle') setActiveTab('vehicle') + }, [vehicleOnly, activeTab]) // 견적 저장 전에 모든 단계를 확인하게 강제한다. 기본 화면인 트림(vehicle)은 이미 본 것으로 친다. const [visited, setVisited] = useState>(new Set(['vehicle'])) const unseen = TABS.filter((t) => !visited.has(t.key)) @@ -122,11 +135,22 @@ export function OptionPanel({ return (