Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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");
26 changes: 26 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -450,6 +475,7 @@ model Quote {
org Org? @relation("QuoteOrg", fields: [org_id], references: [code])
order Order?
contracts PurchaseContract[]
email_logs QuoteEmailLog[]

@@map("quote")
}
Expand Down
88 changes: 88 additions & 0 deletions backend/src/__tests__/email-log.test.ts
Original file line number Diff line number Diff line change
@@ -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('<EmailLog rows={log} />');
expect(read('frontend/src/components/CustomerViewModal.tsx')).toContain('<EmailLogFor quoteId={quote.id} />');
});
});
7 changes: 5 additions & 2 deletions backend/src/__tests__/vehicle-only.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('메일 팝업에서 계약서 첨부칸을 아예 띄우지 않는다', () => {
Expand Down
55 changes: 55 additions & 0 deletions backend/src/routes/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,44 @@
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';
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<void> => {
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<void> => {
const id = Number(req.params['id']);
if (!Number.isInteger(id)) { res.status(400).json({ error: { code: 'BAD_INPUT', message: '잘못된 견적 id' } }); return; }
Expand All @@ -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; }
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/api/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,20 @@ export interface SendQuoteEmailOpts {
export async function sendQuoteEmail(quoteId: number, opts: SendQuoteEmailOpts): Promise<EmailResult> {
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<EmailLogRow[]> {
return apiFetch(`/api/v1/quotes/${quoteId}/email-log`)
}
7 changes: 7 additions & 0 deletions frontend/src/components/CustomerViewModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ApiQuote } from '@shared/types/index'
import { EmailLogFor } from './EmailLog'

// ── 고객정보 조회 (읽기 전용) ─────────────────────────────────────────────
//
Expand Down Expand Up @@ -90,6 +91,12 @@ export function CustomerViewModal({ quote, onClose }: { quote: ApiQuote; onClose
</table>
</div>
))}
{/*
메일로 무엇을 보냈는지 — 관리자는 **조회만** 한다(발송은 영업 업무).
목록에 열을 더하지 않고 여기서 본다.
*/}
<EmailLogFor quoteId={quote.id} />

<div style={modal.actions}>
<button style={modal.confirmBtn} onClick={onClose}>닫기</button>
</div>
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/components/EmailLog.tsx
Original file line number Diff line number Diff line change
@@ -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 <div style={e.logEmpty}>아직 보낸 적이 없습니다.</div>
return (
<div style={e.logBox}>
<div style={e.logTitle}>보낸 기록</div>
{rows.map(r => (
<div key={r.id} style={e.logRow}>
<span style={r.withContract ? e.tagBoth : e.tagQuote}>
{r.withContract ? '견적서+계약서' : '견적서만'}
</span>
<span style={e.logNo}>{r.quoteNo ?? '번호 없음'}</span>
<span style={e.logDate}>{r.sentAt.slice(0, 16).replace('T', ' ')}</span>
<span style={e.logTo} title={`${r.to} · 보낸 사람 ${r.sentBy}`}>{r.to}</span>
</div>
))}
</div>
)
}


const e: Record<string, React.CSSProperties> = {
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<EmailLogRow[] | null>(null)
useEffect(() => { fetchEmailLog(quoteId).then(setRows).catch(() => setRows([])) }, [quoteId])
return <EmailLog rows={rows} />
}
Loading