From f7d26c4c3fdcac169bd30cbf1ec36e6bd662f860 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 12 Jun 2026 01:21:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(portal):=20=EC=9D=98=EB=A2=B0=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=A8=B8=EC=8B=A0(TDD)=20+=20=EC=9D=98=EB=A2=B0/?= =?UTF-8?q?=EA=B2=AC=EC=A0=81=20=EB=A9=94=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/__tests__/request-status.test.ts | 24 ++++++++++ lib/request-emails.ts | 68 ++++++++++++++++++++++++++++ lib/request-status.ts | 31 +++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 lib/__tests__/request-status.test.ts create mode 100644 lib/request-emails.ts create mode 100644 lib/request-status.ts diff --git a/lib/__tests__/request-status.test.ts b/lib/__tests__/request-status.test.ts new file mode 100644 index 0000000..9d92a6d --- /dev/null +++ b/lib/__tests__/request-status.test.ts @@ -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); + }); +}); diff --git a/lib/request-emails.ts b/lib/request-emails.ts new file mode 100644 index 0000000..36556d7 --- /dev/null +++ b/lib/request-emails.ts @@ -0,0 +1,68 @@ +import { Resend } from 'resend'; +import { escapeHtml } from '@/lib/security'; + +const FROM = '쟁승메이드 '; +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: ` +

의뢰가 접수되었습니다

+

${escapeHtml(name)}님, ${escapeHtml(service)} 의뢰가 정상 접수되었습니다.

+

영업일 2일 내에 회신드리며, 아래 링크에서 진행 상태를 언제든 확인하실 수 있습니다.

+

의뢰 진행 상태 확인하기

+
+

이 링크는 본인 확인용입니다. 타인과 공유하지 마세요.

+ `, + }); +} + +/** 견적 발송 — 고객에게 견적 링크 */ +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: ` +

견적서를 보내드립니다

+

${escapeHtml(clientName)}님, 요청하신 건의 견적서가 준비되었습니다.

+

견적서 확인하기

+ ${validUntil ? `

유효기간: ${escapeHtml(validUntil.slice(0, 10))}

` : ''} +

견적서 페이지에서 바로 수락하시거나, 회신으로 문의 주세요.

+ `, + }); +} + +/** 견적 수락/거절 — 관리자 알림 */ +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: ` +

고객이 견적을 ${label}했습니다

+

견적: ${escapeHtml(quoteTitle)} / 고객: ${escapeHtml(clientName)}

+ ${typeof total === 'number' ? `

수락 금액: ₩${total.toLocaleString('ko-KR')}

` : ''} +

견적 관리로 이동

+ `, + }); +} diff --git a/lib/request-status.ts b/lib/request-status.ts new file mode 100644 index 0000000..3846e63 --- /dev/null +++ b/lib/request-status.ts @@ -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 = { + 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; +}