From 6531f64c2fc59e81a11b62f4769538495c883635 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:48:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EB=A9=94=EC=9D=BC):=20=ED=8C=9D=EC=97=85?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=A9=94=EC=9D=BC=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=C2=B7=EC=88=98=EC=A0=95=20=C2=B7=20=EB=AC=B4=EC=97=87=EC=9D=84?= =?UTF-8?q?=20=EB=B3=B4=EB=83=88=EB=8A=94=EC=A7=80=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **① 메일이 없어도 보낼 수 있다** 견적서는 메일 없이도 만들어진다. 그런데 「메일 전달」 버튼이 **고객 메일이 없으면 잠겨** 보낼 방법이 없었다. 버튼을 열고 **팝업에서 바로 적어** 보내게 한다. 고객정보에 메일이 있으면 **채워서 연다** — 그대로 보내도 되고 고쳐도 된다. 받는 사람이 비면 발송 버튼만 잠근다. **② 무엇을 보냈는지 남긴다** 「그 고객에게 계약서까지 보냈던가?」를 사람 기억에 맡기면 두 번 보내거나 안 보낸다. 새 표 `quote_email_log` 에 **견적번호·받는 사람·계약서 포함 여부·첨부·보낸 사람·시각**을 남긴다. 어느 판을 보냈는지 가리도록 견적번호와 날짜를 함께 적는다. ⚠️ **기록이 실패해도 발송은 성공으로 응답한다.** 발송은 이미 끝난 일이라, 여기서 던지면 「보냈는데 실패했다」로 보여 다시 보내게 되고 고객이 같은 메일을 두 번 받는다. **③ 어디서 보나** - 영업 — 「메일 전달」 팝업 안(보낼 때 궁금한 것이라 그 자리에서 본다) - 관리자 — 「고객정보」 조회 팝업 안(발송은 영업 업무라 조회만 한다) **목록 화면은 건드리지 않았다.** 열마다 배지를 더하면 표만 복잡해진다. migration 은 표 하나를 더하기만 한다(기존 표·열 불변). Closes #303 --- .../migration.sql | 17 ++++ backend/prisma/schema.prisma | 26 ++++++ backend/src/__tests__/email-log.test.ts | 88 +++++++++++++++++++ backend/src/__tests__/vehicle-only.test.ts | 7 +- backend/src/routes/email.ts | 55 ++++++++++++ frontend/src/api/email.ts | 17 ++++ frontend/src/components/CustomerViewModal.tsx | 7 ++ frontend/src/components/EmailLog.tsx | 50 +++++++++++ frontend/src/components/EmailSendModal.tsx | 34 ++++++- frontend/src/pages/SalesPage.tsx | 22 +++-- 10 files changed, 308 insertions(+), 15 deletions(-) create mode 100644 backend/prisma/migrations/20260826000000_add_quote_email_log/migration.sql create mode 100644 backend/src/__tests__/email-log.test.ts create mode 100644 frontend/src/components/EmailLog.tsx diff --git a/backend/prisma/migrations/20260826000000_add_quote_email_log/migration.sql b/backend/prisma/migrations/20260826000000_add_quote_email_log/migration.sql new file mode 100644 index 0000000..54013ec --- /dev/null +++ b/backend/prisma/migrations/20260826000000_add_quote_email_log/migration.sql @@ -0,0 +1,17 @@ +-- 메일로 무엇을 보냈는지 남기는 표 — 견적서만인지, 계약서까지인지. +-- +-- ⚠️ **더하기만 한다.** 기존 표·열을 건드리지 않는다. +CREATE TABLE IF NOT EXISTS "quote_email_log" ( + "id" SERIAL PRIMARY KEY, + "quote_id" INTEGER NOT NULL, + "quote_no" VARCHAR(30), + "to_email" VARCHAR(200) NOT NULL, + "with_contract" BOOLEAN NOT NULL DEFAULT false, + "attachments" VARCHAR(500) NOT NULL, + "sent_by" VARCHAR(120) NOT NULL, + "sent_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "quote_email_log_quote_id_fkey" + FOREIGN KEY ("quote_id") REFERENCES "quote"("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +CREATE INDEX IF NOT EXISTS "quote_email_log_quote_id_idx" ON "quote_email_log"("quote_id"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e45bbe3..720a1f7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -270,6 +270,31 @@ model TaxConfig { /// 견적서·계약서가 오간 뒤에 "왜 금액이 다르냐"는 이야기가 나온다. 그때 되짚을 수 /// 있어야 하므로 옵션·고객정보·할부조건 변경을 전부 같은 표에 필드 단위로 남긴다. /// (기준데이터 변경이력 option_db_change_log 와 같은 구조 — 익숙한 모양을 유지) +/// 메일로 무엇을 보냈는지 남긴다 — 견적서만인지, 계약서까지인지. +/// +/// 「그 고객에게 계약서까지 보냈던가?」를 사람 기억에 맡기면 두 번 보내거나 안 보낸다. +/// 어느 견적을 언제 어디로 보냈는지가 함께 있어야 골라볼 수 있다. +/// +/// ⚠️ 지우지 않는다 — 보낸 것은 되돌릴 수 없는 사실이라 기록도 되돌리지 않는다. +model QuoteEmailLog { + id Int @id @default(autoincrement()) + quote_id Int + /// 보낸 시점의 견적번호 — 나중에 번호가 붙거나 바뀌어도 그때 무엇을 보냈는지가 남는다 + quote_no String? @db.VarChar(30) + to_email String @db.VarChar(200) + /// 계약서를 함께 보냈는가. false = 견적서만 + with_contract Boolean @default(false) + /// 실제로 붙인 파일 이름들 — 「보냈다」와 「무엇이 갔다」가 어긋나지 않게 + attachments String @db.VarChar(500) + sent_by String @db.VarChar(120) + sent_at DateTime @default(now()) + + quote Quote @relation(fields: [quote_id], references: [id]) + + @@index([quote_id]) + @@map("quote_email_log") +} + model QuoteChangeLog { id Int @id @default(autoincrement()) quote_id Int @@ -450,6 +475,7 @@ model Quote { org Org? @relation("QuoteOrg", fields: [org_id], references: [code]) order Order? contracts PurchaseContract[] + email_logs QuoteEmailLog[] @@map("quote") } diff --git a/backend/src/__tests__/email-log.test.ts b/backend/src/__tests__/email-log.test.ts new file mode 100644 index 0000000..205b751 --- /dev/null +++ b/backend/src/__tests__/email-log.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** + * **메일로 무엇을 보냈는지 남긴다** — 견적서만인지, 계약서까지인지. + * + * 「그 고객에게 계약서까지 보냈던가?」를 사람 기억에 맡기면 두 번 보내거나 안 보낸다. + * 어느 판을 보냈는지 가리도록 **견적번호와 날짜**를 함께 남긴다. + */ +const ROOT = path.resolve(__dirname, '../../..'); +const read = (rel: string) => readFileSync(path.join(ROOT, rel), 'utf8'); + +describe('발송 기록', () => { + const ROUTE = read('backend/src/routes/email.ts'); + + it('보낼 때마다 남긴다 — 무엇을·어디로·언제·누가', () => { + expect(ROUTE).toContain('quoteEmailLog.create'); + for (const f of ['quote_no', 'to_email', 'with_contract', 'attachments', 'sent_by']) { + expect(ROUTE, f).toContain(f); + } + }); + + it('🔴 기록이 실패해도 발송은 성공으로 응답한다', () => { + /* + * 발송은 이미 끝난 일이다. 여기서 던지면 「보냈는데 실패했다」로 보여 다시 보내게 되고, + * 고객이 같은 메일을 두 번 받는다. + */ + const i = ROUTE.indexOf('quoteEmailLog.create'); + const around = ROUTE.slice(Math.max(0, i - 600), i + 700); + expect(around).toContain('catch'); + expect(around).toMatch(/발송은 완료/); + // 기록 뒤에 응답이 나가야 한다 + expect(ROUTE.indexOf('res.json({ data: { to: r.to')).toBeGreaterThan(i); + }); + + it('조회는 최신순으로, 발송과 같은 권한을 요구한다', () => { + const i = ROUTE.indexOf("'/:id/email-log'"); + expect(i).toBeGreaterThan(-1); + const decl = ROUTE.slice(i, i + 220); + expect(decl).toContain("rbac('ADMIN', 'SALES')"); + expect(decl).toContain("requirePermission('doc.send.email')"); + expect(ROUTE.slice(i, i + 600)).toContain("orderBy: { sent_at: 'desc' }"); + }); + + it('표는 더하기만 한다 — 기존 것을 건드리지 않는다', () => { + const sql = read('backend/prisma/migrations/20260826000000_add_quote_email_log/migration.sql'); + expect(sql).toMatch(/CREATE TABLE IF NOT EXISTS "quote_email_log"/); + /* + * 「구문의 시작」만 본다. 외래키의 `ON DELETE RESTRICT` 는 지우는 명령이 아니라 + * **지우지 못하게 막는 선언**이다 — 단어만 보면 그것까지 걸린다. + */ + const statements = sql.replace(/--.*$/gm, '').split(';').map(x => x.trim()).filter(Boolean); + const destructive = statements.filter(x => /^(DROP|DELETE|TRUNCATE|UPDATE|ALTER)\b/i.test(x)); + expect(destructive, `파괴적 구문: ${destructive.join(' | ')}`).toEqual([]); + }); +}); + +describe('메일 전달 팝업', () => { + const SALES = read('frontend/src/pages/SalesPage.tsx'); + const MODAL = read('frontend/src/components/EmailSendModal.tsx'); + + it('🔴 고객 메일이 없어도 버튼이 열린다 — 팝업에서 적어 보낸다', () => { + // 견적서는 메일 없이도 만들어진다. 메일이 없다고 발송 자체를 막을 이유가 없다. + const i = SALES.indexOf('메일 전달'); + const around = SALES.slice(Math.max(0, i - 900), i); + expect(around).not.toMatch(/disabled=\{!q\.customer\?\.email\}/); + expect(around).toMatch(/defaultTo: q\.customer\?\.email/); + }); + + it('받는 사람이 비면 발송이 잠긴다', () => { + expect(MODAL).toMatch(/disabled=\{sending \|\| !to\.trim\(\)\}/); + }); + + it('이력에 견적번호와 날짜가 함께 나온다 — 어느 판을 보냈는지 가린다', () => { + const LOG = read('frontend/src/components/EmailLog.tsx'); + expect(LOG).toContain('r.quoteNo'); + expect(LOG).toContain('r.sentAt'); + expect(LOG).toMatch(/견적서\+계약서/); + expect(LOG).toMatch(/견적서만/); + }); + + it('영업은 발송 팝업에서, 관리자는 조회 팝업에서 같은 이력을 본다', () => { + // 목록에 열을 더하지 않는다 — 「보냈나」는 보낼 때 궁금한 것이다 + expect(MODAL).toContain(''); + expect(read('frontend/src/components/CustomerViewModal.tsx')).toContain(''); + }); +}); diff --git a/backend/src/__tests__/vehicle-only.test.ts b/backend/src/__tests__/vehicle-only.test.ts index e8de282..65b360a 100644 --- a/backend/src/__tests__/vehicle-only.test.ts +++ b/backend/src/__tests__/vehicle-only.test.ts @@ -157,10 +157,13 @@ describe('차량만 견적에는 계약서가 없다', () => { }); it('메일 전달은 **막지 않는다** — 견적서는 보낼 수 있어야 한다', () => { - // 계약서만 없는 것이지 견적서가 없는 것이 아니다 + /* + * 계약서만 없는 것이지 견적서가 없는 것이 아니다. + * 고객 메일이 없어도 막지 않는다 — 팝업에서 적어 보낸다(email-log.test 가 따로 지킨다). + */ const i = SALES.indexOf('메일 전달'); const around = SALES.slice(Math.max(0, i - 900), i); - expect(around).toMatch(/disabled=\{!q\.customer\?\.email\}/); + expect(around).not.toMatch(/disabled=\{[^}]*noContract[^}]*\}/); }); it('메일 팝업에서 계약서 첨부칸을 아예 띄우지 않는다', () => { diff --git a/backend/src/routes/email.ts b/backend/src/routes/email.ts index 274d336..131c949 100644 --- a/backend/src/routes/email.ts +++ b/backend/src/routes/email.ts @@ -5,6 +5,7 @@ import { Router } from 'express'; import type { Request, Response } from 'express'; import { rbac, requirePermission } from '../middleware/rbac.js'; +import { prisma } from '../lib/prisma.js'; import { sendQuoteDocsEmail, EmailConfigError, EmailError } from '../services/email.js'; import { ContractError } from '../services/contract.js'; import { QuotePdfError } from '../services/quote-pdf.js'; @@ -12,6 +13,36 @@ import { SofficeUnavailableError } from '../lib/soffice.js'; export const emailRouter = Router(); +/** + * 지금까지 무엇을 보냈나 — **메일 전달 팝업이 띄운다.** + * + * 목록 화면은 건드리지 않는다. 「보냈나 안 보냈나」는 보낼 때 궁금한 것이지 + * 목록을 훑을 때 궁금한 것이 아니다 — 열마다 배지를 더하면 표만 복잡해진다. + */ +emailRouter.get('/:id/email-log', rbac('ADMIN', 'SALES'), requirePermission('doc.send.email'), async (req: Request, res: Response): Promise => { + const id = Number(req.params['id']); + if (!Number.isInteger(id)) { res.status(400).json({ error: { code: 'BAD_INPUT', message: '잘못된 견적 id' } }); return; } + try { + const rows = await prisma?.quoteEmailLog.findMany({ + where: { quote_id: id }, + orderBy: { sent_at: 'desc' }, + take: 20, + }) ?? []; + res.json({ data: rows.map(r => ({ + id: r.id, + quoteNo: r.quote_no, + to: r.to_email, + withContract: r.with_contract, + attachments: r.attachments, + sentBy: r.sent_by, + sentAt: r.sent_at.toISOString(), + })) }); + } catch (e) { + console.error('[GET quotes/:id/email-log]', e); + res.status(500).json({ error: { code: 'INTERNAL', message: '발송 기록 조회 실패' } }); + } +}); + emailRouter.post('/:id/email', rbac('ADMIN', 'SALES'), requirePermission('doc.send.email'), async (req: Request, res: Response): Promise => { const id = Number(req.params['id']); if (!Number.isInteger(id)) { res.status(400).json({ error: { code: 'BAD_INPUT', message: '잘못된 견적 id' } }); return; } @@ -22,6 +53,30 @@ emailRouter.post('/:id/email', rbac('ADMIN', 'SALES'), requirePermission('doc.se try { const r = await sendQuoteDocsEmail(id, { to, cc, subject, message, includeContract: include_contract !== false }); + + /* + * 무엇을 보냈는지 남긴다 — **견적서만인지, 계약서까지인지.** + * 사람 기억에 맡기면 두 번 보내거나 안 보낸다. + * + * ⚠️ 기록이 실패해도 **발송은 이미 끝난 일**이다. 여기서 던지면 「보냈는데 실패했다」로 + * 보여 다시 보내게 된다 — 고객이 같은 메일을 두 번 받는다. 로그만 남기고 넘어간다. + */ + try { + const q = await prisma?.quote.findUnique({ where: { id }, select: { quote_no: true } }); + await prisma?.quoteEmailLog.create({ + data: { + quote_id: id, + quote_no: q?.quote_no ?? null, + to_email: r.to, + with_contract: include_contract !== false, + attachments: r.attachments.join(', ').slice(0, 500), + sent_by: req.auth?.email ?? 'unknown', + }, + }); + } catch (e) { + console.error('[POST quotes/:id/email] 발송 기록 실패(발송은 완료)', { quoteId: id, err: e }); + } + res.json({ data: { to: r.to, attachments: r.attachments } }); } catch (e) { if (e instanceof EmailConfigError) { res.status(503).json({ error: { code: 'EMAIL_UNCONFIGURED', message: e.message } }); return; } diff --git a/frontend/src/api/email.ts b/frontend/src/api/email.ts index 8dbf777..1a4e200 100644 --- a/frontend/src/api/email.ts +++ b/frontend/src/api/email.ts @@ -25,3 +25,20 @@ export interface SendQuoteEmailOpts { export async function sendQuoteEmail(quoteId: number, opts: SendQuoteEmailOpts): Promise { return apiFetch(`/api/v1/quotes/${quoteId}/email`, { method: 'POST', body: JSON.stringify(opts) }) } + +/** 지금까지 이 견적으로 무엇을 보냈나 — 메일 전달 팝업이 띄운다. */ +export interface EmailLogRow { + id: number + /** 보낸 시점의 견적번호 — 어느 판을 보냈는지 가린다 */ + quoteNo: string | null + to: string + /** true = 견적서+계약서 · false = 견적서만 */ + withContract: boolean + attachments: string + sentBy: string + sentAt: string +} + +export async function fetchEmailLog(quoteId: number): Promise { + return apiFetch(`/api/v1/quotes/${quoteId}/email-log`) +} diff --git a/frontend/src/components/CustomerViewModal.tsx b/frontend/src/components/CustomerViewModal.tsx index ff7df1c..73ac39d 100644 --- a/frontend/src/components/CustomerViewModal.tsx +++ b/frontend/src/components/CustomerViewModal.tsx @@ -1,4 +1,5 @@ import type { ApiQuote } from '@shared/types/index' +import { EmailLogFor } from './EmailLog' // ── 고객정보 조회 (읽기 전용) ───────────────────────────────────────────── // @@ -90,6 +91,12 @@ export function CustomerViewModal({ quote, onClose }: { quote: ApiQuote; onClose ))} + {/* + 메일로 무엇을 보냈는지 — 관리자는 **조회만** 한다(발송은 영업 업무). + 목록에 열을 더하지 않고 여기서 본다. + */} + +
diff --git a/frontend/src/components/EmailLog.tsx b/frontend/src/components/EmailLog.tsx new file mode 100644 index 0000000..acc5eb4 --- /dev/null +++ b/frontend/src/components/EmailLog.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react' +import { fetchEmailLog, type EmailLogRow } from '../api/email' + +/** + * 발송 이력 — **무엇을 보냈는지**가 핵심이다. + * + * 「보냈다」만으로는 부족하다. 견적서만 보낸 건과 계약서까지 보낸 건이 섞이면 + * 「그 고객에게 계약서까지 보냈던가」를 사람 기억에 맡기게 된다. + * 어느 판을 보냈는지 가리도록 **견적번호와 날짜**를 함께 적는다. + */ +export function EmailLog({ rows }: { rows: EmailLogRow[] | null }) { + if (rows === null) return null + if (rows.length === 0) return
아직 보낸 적이 없습니다.
+ return ( +
+
보낸 기록
+ {rows.map(r => ( +
+ + {r.withContract ? '견적서+계약서' : '견적서만'} + + {r.quoteNo ?? '번호 없음'} + {r.sentAt.slice(0, 16).replace('T', ' ')} + {r.to} +
+ ))} +
+ ) +} + + +const e: Record = { + logBox: { marginTop: 14, borderTop: '0.5px solid var(--line)', paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 5 }, + logTitle: { fontSize: 'var(--fs-caption)', fontWeight: 700, color: 'var(--muted)' }, + logEmpty: { marginTop: 14, borderTop: '0.5px solid var(--line)', paddingTop: 10, fontSize: 11, color: 'var(--muted)' }, + logRow: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--muted)' }, + /* 무엇을 보냈는지가 한눈에 갈려야 한다 — 계약서까지 보낸 건은 더 무겁게 */ + tagBoth: { fontWeight: 700, color: 'var(--dark)', background: 'var(--lime-bg)', border: '0.5px solid var(--lime)', borderRadius: 3, padding: '0 5px', whiteSpace: 'nowrap' }, + tagQuote: { fontWeight: 700, color: 'var(--muted)', background: 'var(--card)', border: '0.5px solid var(--line)', borderRadius: 3, padding: '0 5px', whiteSpace: 'nowrap' }, + logNo: { fontVariantNumeric: 'tabular-nums', color: 'var(--dark)', whiteSpace: 'nowrap' }, + logDate: { fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, + logTo: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, +} + +/** 스스로 불러오는 판 — 관리자 「고객정보」처럼 이력만 필요한 곳에서 쓴다. */ +export function EmailLogFor({ quoteId }: { quoteId: number }) { + const [rows, setRows] = useState(null) + useEffect(() => { fetchEmailLog(quoteId).then(setRows).catch(() => setRows([])) }, [quoteId]) + return +} diff --git a/frontend/src/components/EmailSendModal.tsx b/frontend/src/components/EmailSendModal.tsx index 1788d54..f68d5a7 100644 --- a/frontend/src/components/EmailSendModal.tsx +++ b/frontend/src/components/EmailSendModal.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { BTN } from '../styles/buttons' -import { sendQuoteEmail } from '../api/email' +import { sendQuoteEmail, fetchEmailLog, type EmailLogRow } from '../api/email' +import { EmailLog } from './EmailLog' /** 견적서(+계약서) 이메일 발송 모달. to 비우면 등록된 고객 이메일로 발송. */ export function EmailSendModal({ quoteId, customerName, defaultTo, onClose, noContract}: { @@ -26,6 +27,15 @@ export function EmailSendModal({ quoteId, customerName, defaultTo, onClose, noCo const [sending, setSending] = useState(false) const [done, setDone] = useState(null) const [err, setErr] = useState('') + /* + * 지금까지 무엇을 보냈나 — **여기서만 보여 준다.** + * 목록 화면에는 두지 않는다. 「보냈나 안 보냈나」는 보낼 때 궁금한 것이지 + * 목록을 훑을 때 궁금한 것이 아니다 — 열마다 배지를 더하면 표만 복잡해진다. + */ + const [log, setLog] = useState(null) + + function loadLog() { fetchEmailLog(quoteId).then(setLog).catch(() => setLog([])) } + useEffect(loadLog, [quoteId]) // eslint-disable-line react-hooks/exhaustive-deps async function handleSend() { setSending(true); setErr('') @@ -36,6 +46,7 @@ export function EmailSendModal({ quoteId, customerName, defaultTo, onClose, noCo include_contract: attachContract, }) setDone(`${r.to} 로 발송됨 (${r.attachments.join(', ')})`) + loadLog() // 방금 보낸 것이 이력에 바로 보이게 } catch (e) { setErr(e instanceof Error ? e.message : '발송 실패') } finally { @@ -55,12 +66,23 @@ export function EmailSendModal({ quoteId, customerName, defaultTo, onClose, noCo {done ? (
✓ {done}
+
) : (
+ {/* + 고객정보에 메일이 있으면 채워서 연다 — 그대로 보내도 되고 고쳐도 된다. + 없으면 빈칸으로 열려 **여기서 바로 적어** 보낼 수 있다. + 견적서는 메일 없이도 만들어지므로, 메일이 없다고 발송 자체를 막지 않는다. + */} - setTo(e.target.value)} /> + setTo(e.target.value)} + /> + {!defaultTo &&
고객정보에 등록된 이메일이 없습니다 — 여기에 적으면 그대로 발송됩니다.
} {canAttachContract && (
)} @@ -96,5 +120,7 @@ const s: Record = { note: { fontSize: 11, color: 'var(--muted)', marginTop: 4 }, primary: { marginTop: 10, padding: '9px 16px', border: 'none', borderRadius: 8, background: 'var(--dark)', color: '#fff', fontSize: 13, fontWeight: 700, cursor: 'pointer', alignSelf: 'flex-start' }, ok: { background: 'var(--lime-bg)', color: 'var(--dark)', fontSize: 13, padding: '10px 12px', borderRadius: 8, marginBottom: 12 }, + hint: { fontSize: 11, color: 'var(--muted)' }, + /* 무엇을 보냈는지가 한눈에 갈려야 한다 — 계약서까지 보낸 건은 더 무겁게 */ err: { background: 'var(--warnbg)', border: '0.5px solid var(--warn)', color: 'var(--warn)', fontSize: 12.5, padding: '8px 12px', borderRadius: 8, marginTop: 4 }, } diff --git a/frontend/src/pages/SalesPage.tsx b/frontend/src/pages/SalesPage.tsx index 4d87194..690af43 100644 --- a/frontend/src/pages/SalesPage.tsx +++ b/frontend/src/pages/SalesPage.tsx @@ -136,7 +136,7 @@ function MyListView() { // 확인 팝업 안에서 지역을 고칠 수 있어야 한다(보조금이 걸린 값) const [regions, setRegions] = useState([]) useEffect(() => { fetchRegions().then(setRegions).catch(() => setRegions([])) }, []) - const [emailQuote, setEmailQuote] = useState<{ id: number; customerName?: string; noContract?: boolean } | null>(null) + const [emailQuote, setEmailQuote] = useState<{ id: number; customerName?: string; defaultTo?: string; noContract?: boolean } | null>(null) const [confirmQuoteModal, setConfirmQuoteModal] = useState< { id: number; customerName?: string; status: string; inputs?: Record; customer?: ApiQuote['customer'] } | null >(null) @@ -302,6 +302,7 @@ function MyListView() { setEmailQuote(null)} /> @@ -584,14 +585,17 @@ function MyListView() { */} {canEmail && ( )} {canSign && (