
견적서 생성 시스템 - 21편. 항목 관리와 이메일 발송
견적서 생성 시스템으로 고객에게 정식 견적을 보냅니다.
이 글에서는 DB 설계, 폼 구현, 금액 계산, 발송 기능을 다룹니다.
📄 견적서가 필요했다
문의를 받았습니다.
"이 제품 얼마예요?"
답장을 보내야 하는데, 그냥 이메일로 "10만원이요"라고 보낼 순 없었습니다.
정식 견적서가 필요했습니다.
Claude에게 물었습니다.
견적서 작성 기능을 만들어줘.
문의에서 바로 견적서 작성으로 넘어갈 수 있게.
항목을 여러 개 추가할 수 있어야 하고,
부가세도 자동 계산되게.🗄️ 견적 데이터 구조
Claude가 두 개의 테이블을 제안했습니다.
견적 메인과 견적 항목입니다.
-- 견적 메인 테이블
create table quotations (
id uuid primary key default gen_random_uuid(),
inquiry_id uuid references inquiries(id),
user_id uuid references users(id),
-- 금액
subtotal integer not null default 0, -- 공급가
vat_amount integer not null default 0, -- 부가세
total_amount integer not null default 0, -- 합계
-- 상태
status text default 'draft',
-- draft, sent, accepted, rejected, expired
-- 기타
valid_until date,
note text,
created_at timestamptz default now(),
updated_at timestamptz default now(),
sent_at timestamptz
);
-- 견적 항목 테이블
create table quotation_items (
id uuid primary key default gen_random_uuid(),
quotation_id uuid references quotations(id) on delete cascade,
product_id uuid references products(id),
name text not null, -- 제품명 (스냅샷)
quantity integer not null,
unit_price integer not null,
amount integer not null, -- quantity * unit_price
display_order integer default 0,
created_at timestamptz default now()
);
on delete cascade로 견적이 삭제되면 항목도 같이 삭제됩니다.
제품명을 별도로 저장하는 이유가 있었습니다.
나중에 제품 이름이 바뀌어도 견적서는 발송 당시 이름을 유지해야 하기 때문입니다.
📝 견적서 작성 폼
페이지 구조
// app/admin/(protected)/quotations/new/page.tsx
import { getProducts } from '@/lib/queries/products'
import { getInquiryById } from '@/lib/queries/admin-inquiries'
import { QuotationForm } from '@/components/admin/QuotationForm'
interface Props {
searchParams: { inquiry?: string }
}
export default async function NewQuotationPage({ searchParams }: Props) {
const products = await getProducts()
// 문의에서 온 경우
let inquiry = null
if (searchParams.inquiry) {
inquiry = await getInquiryById(searchParams.inquiry)
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">견적서 작성</h1>
<QuotationForm
products={products}
inquiry={inquiry}
/>
</div>
)
}
문의 상세에서 "견적서 작성" 버튼을 누르면 ?inquiry=xxx로 넘어옵니다.
그러면 고객 정보가 자동으로 채워집니다.
견적 폼 컴포넌트
이게 좀 복잡했습니다.
항목을 동적으로 추가/삭제해야 하고, 금액도 실시간으로 계산해야 합니다.
// components/admin/QuotationForm.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useFieldArray, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { quotationSchema, QuotationFormData } from '@/lib/validations/quotation'
import { createQuotation } from '@/app/actions/admin-quotation'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Plus, Trash2 } from 'lucide-react'
export function QuotationForm({ products, inquiry }) {
const router = useRouter()
const form = useForm<QuotationFormData>({
resolver: zodResolver(quotationSchema),
defaultValues: {
inquiryId: inquiry?.id || '',
userId: inquiry?.user_id || '',
customerName: inquiry?.name || '',
customerEmail: inquiry?.email || '',
items: [{ productId: '', name: '', quantity: 1, unitPrice: 0 }],
validUntil: getDefaultValidDate(),
note: '',
},
})
// 동적 항목 관리
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'items',
})
// 금액 자동 계산
const watchItems = form.watch('items')
const subtotal = watchItems.reduce((sum, item) => {
return sum + (item.quantity || 0) * (item.unitPrice || 0)
}, 0)
const vatAmount = Math.floor(subtotal * 0.1)
const totalAmount = subtotal + vatAmount
const onSubmit = async (data: QuotationFormData) => {
const result = await createQuotation({
...data,
subtotal,
vatAmount,
totalAmount,
})
if (result.error) {
alert(result.error)
return
}
router.push(`/admin/quotations/${result.quotationId}`)
}
// 제품 선택 시 이름 자동 입력
const handleProductSelect = (index: number, productId: string) => {
const product = products.find((p) => p.id === productId)
if (product) {
form.setValue(`items.${index}.name`, product.name_ko)
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* 고객 정보 */}
<div className="bg-white p-6 rounded-xl border">
<h2 className="font-bold mb-4">고객 정보</h2>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">이름</label>
<Input {...form.register('customerName')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">이메일</label>
<Input {...form.register('customerEmail')} />
</div>
</div>
</div>
{/* 견적 항목 */}
<div className="bg-white p-6 rounded-xl border">
<div className="flex justify-between items-center mb-4">
<h2 className="font-bold">견적 항목</h2>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ productId: '', name: '', quantity: 1, unitPrice: 0 })}
>
<Plus className="w-4 h-4 mr-1" /> 항목 추가
</Button>
</div>
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="flex gap-4 items-start">
{/* 제품 선택 */}
<div className="flex-1">
<select
{...form.register(`items.${index}.productId`)}
onChange={(e) => handleProductSelect(index, e.target.value)}
className="w-full border rounded-lg p-2"
>
<option value="">직접 입력</option>
{products.map((product) => (
<option key={product.id} value={product.id}>
{product.name_ko}
</option>
))}
</select>
</div>
{/* 품명 */}
<div className="flex-1">
<Input
{...form.register(`items.${index}.name`)}
placeholder="품명"
/>
</div>
{/* 수량 */}
<div className="w-24">
<Input
type="number"
{...form.register(`items.${index}.quantity`, { valueAsNumber: true })}
placeholder="수량"
/>
</div>
{/* 단가 */}
<div className="w-32">
<Input
type="number"
{...form.register(`items.${index}.unitPrice`, { valueAsNumber: true })}
placeholder="단가"
/>
</div>
{/* 금액 */}
<div className="w-32 text-right py-2">
{formatCurrency(
(watchItems[index]?.quantity || 0) *
(watchItems[index]?.unitPrice || 0)
)}
</div>
{/* 삭제 */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
))}
</div>
{/* 합계 */}
<div className="border-t mt-6 pt-6">
<div className="flex justify-end">
<div className="w-64 space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">공급가액</span>
<span>{formatCurrency(subtotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">부가세 (10%)</span>
<span>{formatCurrency(vatAmount)}</span>
</div>
<div className="flex justify-between text-lg font-bold border-t pt-2">
<span>합계</span>
<span className="text-primary">{formatCurrency(totalAmount)}</span>
</div>
</div>
</div>
</div>
</div>
{/* 추가 정보 */}
<div className="bg-white p-6 rounded-xl border">
<h2 className="font-bold mb-4">추가 정보</h2>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">유효 기간</label>
<Input type="date" {...form.register('validUntil')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">비고</label>
<textarea
{...form.register('note')}
className="w-full border rounded-lg p-2"
rows={3}
/>
</div>
</div>
</div>
{/* 제출 */}
<div className="flex gap-4">
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? '저장 중...' : '견적서 저장'}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
취소
</Button>
</div>
</form>
)
}
useFieldArray가 핵심입니다.
항목을 동적으로 추가/삭제할 수 있게 해줍니다.
🔧 Server Action
// app/actions/admin-quotation.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
interface QuotationItem {
productId: string
name: string
quantity: number
unitPrice: number
}
interface CreateQuotationData {
inquiryId?: string
userId?: string
items: QuotationItem[]
subtotal: number
vatAmount: number
totalAmount: number
validUntil: string
note?: string
}
export async function createQuotation(data: CreateQuotationData) {
const supabase = await createClient()
// 1. 견적 생성
const { data: quotation, error: quotationError } = await supabase
.from('quotations')
.insert({
inquiry_id: data.inquiryId || null,
user_id: data.userId || null,
subtotal: data.subtotal,
vat_amount: data.vatAmount,
total_amount: data.totalAmount,
valid_until: data.validUntil,
note: data.note,
status: 'draft',
})
.select()
.single()
if (quotationError) {
return { error: quotationError.message }
}
// 2. 견적 항목 생성
const items = data.items.map((item, index) => ({
quotation_id: quotation.id,
product_id: item.productId || null,
name: item.name,
quantity: item.quantity,
unit_price: item.unitPrice,
amount: item.quantity * item.unitPrice,
display_order: index,
}))
const { error: itemsError } = await supabase
.from('quotation_items')
.insert(items)
if (itemsError) {
// 롤백 - 견적 삭제
await supabase.from('quotations').delete().eq('id', quotation.id)
return { error: itemsError.message }
}
revalidatePath('/admin/quotations')
return { success: true, quotationId: quotation.id }
}
항목 저장이 실패하면 견적도 삭제합니다.
수동 롤백입니다.
Supabase에는 트랜잭션이 없어서 이렇게 처리했습니다.
📧 견적서 발송
저장만 하면 끝이 아닙니다.
고객에게 보내야 합니다.
// app/actions/admin-quotation.ts
export async function sendQuotation(quotationId: string) {
const supabase = await createClient()
// 1. 견적 정보 조회
const { data: quotation } = await supabase
.from('quotations')
.select(`
*,
items:quotation_items(*),
user:users(email, name)
`)
.eq('id', quotationId)
.single()
if (!quotation) {
return { error: '견적을 찾을 수 없습니다' }
}
// 2. 이메일 발송
try {
await sendQuotationEmail({
to: quotation.user?.email,
quotation,
})
} catch (error) {
return { error: '이메일 발송에 실패했습니다' }
}
// 3. 상태 업데이트
await supabase
.from('quotations')
.update({
status: 'sent',
sent_at: new Date().toISOString(),
})
.eq('id', quotationId)
// 4. 문의 상태도 업데이트
if (quotation.inquiry_id) {
await supabase
.from('inquiries')
.update({ status: 'completed' })
.eq('id', quotation.inquiry_id)
}
revalidatePath('/admin/quotations')
return { success: true }
}
이메일을 보내고, 견적 상태를 'sent'로 바꿉니다.
연결된 문의가 있으면 그것도 'completed'로 바꿉니다.
⚠️ 삽질했던 것들
금액 계산이 이상함
부동 소수점 문제였습니다.
// 이렇게 하면 안 됨
const price = 100.5 * 3 // 301.49999999...
모든 금액을 정수(원 단위)로 계산했습니다.
const vatAmount = Math.floor(subtotal * 0.1)
Math.floor로 소수점을 버립니다.
항목 순서가 바뀜
DB에서 가져올 때 순서가 섞였습니다.
// display_order로 정렬
.order('display_order')
저장할 때 인덱스를 display_order에 넣고, 조회할 때 정렬합니다.
트랜잭션이 없음
견적은 저장됐는데 항목이 실패하면?
견적만 덩그러니 남습니다.
if (itemsError) {
// 수동 롤백
await supabase.from('quotations').delete().eq('id', quotation.id)
return { error: itemsError.message }
}
직접 삭제해서 롤백합니다.
완벽하진 않지만 대부분의 경우를 커버합니다.
💡 견적 작성 효율화
Claude가 알려준 것들입니다.
템플릿 기능
자주 쓰는 항목 세트를 저장해둡니다.
const templates = [
{ name: '기본 세트', items: [...] },
{ name: '프리미엄 세트', items: [...] },
]
버튼 하나로 항목을 채울 수 있습니다.
이전 견적 복사
<Button onClick={() => copyFromPrevious(previousQuotationId)}>
이전 견적 복사
</Button>
같은 고객에게 비슷한 견적을 보낼 때 유용합니다.
할인 기능
interface QuotationItem {
// ...
discount?: number // 할인율 (%)
discountAmount?: number // 할인 금액
}
나중에 추가할 수 있습니다.
📋 이번 편 요약
| 항목 | 내용 |
|---|---|
| 다룬 기능 | 견적서 작성, 항목 추가/삭제, 금액 계산, 발송 |
| 핵심 기술 | React Hook Form, useFieldArray, Server Action |
| 핵심 파일 | app/admin/(protected)/quotations/ |
| 핵심 포인트 | 정수 금액 계산, 수동 롤백 |
다음 편에서는 제품/회원/FAQ 관리를 다룹니다.
견적을 보냈습니다.
이제 고객이 수락하기를 기다릴 뿐입니다.
'바이브코딩' 카테고리의 다른 글
| 23. 담당자 배정과 알림 - B2B 업체 관리 시스템 (0) | 2026.01.30 |
|---|---|
| 22. CRUD 패턴과 이미지 업로드 - 제품/회원/FAQ 관리 (0) | 2026.01.29 |
| 20. CRUD와 상태 변경 - 문의 관리 시스템 구축 (0) | 2026.01.27 |
| 19. 통계와 현황 조회 - 관리자 대시보드 만들기 (0) | 2026.01.26 |
| 18. 문의와 견적 조회 구현 - Next.js 마이페이지 대시보드 (0) | 2026.01.25 |