feat(portal): 의뢰 상태 머신(TDD) + 의뢰/견적 메일
This commit is contained in:
24
lib/__tests__/request-status.test.ts
Normal file
24
lib/__tests__/request-status.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { REQUEST_STATUS, TIMELINE_STEPS, timelineIndex, isRequestStatus } from '@/lib/request-status';
|
||||
|
||||
describe('request-status', () => {
|
||||
it('8개 상태 라벨 정의', () => {
|
||||
expect(Object.keys(REQUEST_STATUS)).toHaveLength(8);
|
||||
expect(REQUEST_STATUS.quoted.label).toBe('견적 발송');
|
||||
});
|
||||
it('타임라인은 정주행 6단계', () => {
|
||||
expect(TIMELINE_STEPS).toEqual(['pending','reviewing','quoted','accepted','in_progress','completed']);
|
||||
});
|
||||
it('timelineIndex — 정주행 상태는 해당 인덱스', () => {
|
||||
expect(timelineIndex('pending')).toBe(0);
|
||||
expect(timelineIndex('completed')).toBe(5);
|
||||
});
|
||||
it('timelineIndex — on_hold는 quoted 위치(2), cancelled는 -1', () => {
|
||||
expect(timelineIndex('on_hold')).toBe(2);
|
||||
expect(timelineIndex('cancelled')).toBe(-1);
|
||||
});
|
||||
it('isRequestStatus 가드', () => {
|
||||
expect(isRequestStatus('quoted')).toBe(true);
|
||||
expect(isRequestStatus('nope')).toBe(false);
|
||||
});
|
||||
});
|
||||
68
lib/request-emails.ts
Normal file
68
lib/request-emails.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Resend } from 'resend';
|
||||
import { escapeHtml } from '@/lib/security';
|
||||
|
||||
const FROM = '쟁승메이드 <noreply@jaengseung-made.com>';
|
||||
const ADMIN_EMAIL = 'bgg8988@gmail.com';
|
||||
const SITE = 'https://jaengseung-made.com';
|
||||
|
||||
function resend() {
|
||||
return new Resend(process.env.RESEND_API_KEY);
|
||||
}
|
||||
|
||||
/** 의뢰 접수 확인 — 고객에게 추적 링크 발송 */
|
||||
export async function sendRequestReceivedEmail(opts: {
|
||||
name: string; email: string; service: string; publicToken: string;
|
||||
}) {
|
||||
const { name, email, service, publicToken } = opts;
|
||||
await resend().emails.send({
|
||||
from: FROM,
|
||||
to: [email],
|
||||
subject: '[쟁승메이드] 의뢰가 접수되었습니다',
|
||||
html: `
|
||||
<h2>의뢰가 접수되었습니다</h2>
|
||||
<p>${escapeHtml(name)}님, <strong>${escapeHtml(service)}</strong> 의뢰가 정상 접수되었습니다.</p>
|
||||
<p>영업일 2일 내에 회신드리며, 아래 링크에서 진행 상태를 언제든 확인하실 수 있습니다.</p>
|
||||
<p><a href="${SITE}/track/${publicToken}">의뢰 진행 상태 확인하기</a></p>
|
||||
<hr />
|
||||
<p style="color:#666;font-size:12px;">이 링크는 본인 확인용입니다. 타인과 공유하지 마세요.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
/** 견적 발송 — 고객에게 견적 링크 */
|
||||
export async function sendQuoteSentEmail(opts: {
|
||||
clientName: string; clientEmail: string; quoteTitle: string; quoteToken: string; validUntil: string | null;
|
||||
}) {
|
||||
const { clientName, clientEmail, quoteTitle, quoteToken, validUntil } = opts;
|
||||
await resend().emails.send({
|
||||
from: FROM,
|
||||
to: [clientEmail],
|
||||
subject: `[쟁승메이드] 견적서가 도착했습니다 — ${escapeHtml(quoteTitle)}`,
|
||||
html: `
|
||||
<h2>견적서를 보내드립니다</h2>
|
||||
<p>${escapeHtml(clientName)}님, 요청하신 건의 견적서가 준비되었습니다.</p>
|
||||
<p><a href="${SITE}/quote/${quoteToken}">견적서 확인하기</a></p>
|
||||
${validUntil ? `<p style="color:#666;font-size:13px;">유효기간: ${escapeHtml(validUntil.slice(0, 10))}</p>` : ''}
|
||||
<p>견적서 페이지에서 바로 수락하시거나, 회신으로 문의 주세요.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
/** 견적 수락/거절 — 관리자 알림 */
|
||||
export async function sendQuoteDecisionEmail(opts: {
|
||||
decision: 'accepted' | 'rejected'; quoteTitle: string; clientName: string; total?: number;
|
||||
}) {
|
||||
const { decision, quoteTitle, clientName, total } = opts;
|
||||
const label = decision === 'accepted' ? '수락' : '거절';
|
||||
await resend().emails.send({
|
||||
from: FROM,
|
||||
to: [ADMIN_EMAIL],
|
||||
subject: `[쟁승메이드] 견적 ${label} — ${escapeHtml(quoteTitle)}`,
|
||||
html: `
|
||||
<h2>고객이 견적을 ${label}했습니다</h2>
|
||||
<p>견적: ${escapeHtml(quoteTitle)} / 고객: ${escapeHtml(clientName)}</p>
|
||||
${typeof total === 'number' ? `<p>수락 금액: ₩${total.toLocaleString('ko-KR')}</p>` : ''}
|
||||
<p><a href="${SITE}/admin/quotes">견적 관리로 이동</a></p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
31
lib/request-status.ts
Normal file
31
lib/request-status.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/** 외주 의뢰 상태 머신 — DB CHECK(2026-06-12-client-portal.sql)와 단일 동기 소스 */
|
||||
export type RequestStatus =
|
||||
| 'pending' | 'reviewing' | 'quoted' | 'accepted'
|
||||
| 'on_hold' | 'in_progress' | 'completed' | 'cancelled';
|
||||
|
||||
export const REQUEST_STATUS: Record<RequestStatus, { label: string }> = {
|
||||
pending: { label: '접수' },
|
||||
reviewing: { label: '검토중' },
|
||||
quoted: { label: '견적 발송' },
|
||||
accepted: { label: '수주 확정' },
|
||||
on_hold: { label: '보류' },
|
||||
in_progress: { label: '진행중' },
|
||||
completed: { label: '완료' },
|
||||
cancelled: { label: '취소' },
|
||||
};
|
||||
|
||||
/** 고객 타임라인 정주행 단계 (on_hold/cancelled는 별도 표기) */
|
||||
export const TIMELINE_STEPS: RequestStatus[] = [
|
||||
'pending', 'reviewing', 'quoted', 'accepted', 'in_progress', 'completed',
|
||||
];
|
||||
|
||||
/** 타임라인에서 현재 위치. on_hold→quoted 위치, cancelled→-1 */
|
||||
export function timelineIndex(status: RequestStatus): number {
|
||||
if (status === 'cancelled') return -1;
|
||||
if (status === 'on_hold') return TIMELINE_STEPS.indexOf('quoted');
|
||||
return TIMELINE_STEPS.indexOf(status);
|
||||
}
|
||||
|
||||
export function isRequestStatus(v: unknown): v is RequestStatus {
|
||||
return typeof v === 'string' && v in REQUEST_STATUS;
|
||||
}
|
||||
Reference in New Issue
Block a user