본문 바로가기

바이브코딩

21. 항목 관리와 이메일 발송 - 견적서 생성 시스템

견적서 생성 시스템 - 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 관리를 다룹니다.

견적을 보냈습니다.
이제 고객이 수락하기를 기다릴 뿐입니다.