From 3f53594d3f2e60ae9029c18552cb5c74239b91da Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 21 Mar 2026 10:48:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=AC=EC=A0=81=EC=84=9C=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=ED=99=94,=20=EB=A7=88=EC=BC=80=ED=8C=85=20=EC=97=90?= =?UTF-8?q?=EC=85=8B,=20=EC=A0=84=EC=B2=B4=20=EC=B9=B4=ED=94=BC=20?= =?UTF-8?q?=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관리자 견적서 CRUD (WBS/항목/향후관리/특이사항 5탭 편집기) - 고객용 공개 견적서 페이지 (optional 항목 선택 + 실시간 총액 + 수락) - 마케팅 SVG 에셋 6종 (썸네일 5개 + 배너 1개) + 관리자 에셋 페이지 - 전체 카피 강화: 크레덴셜 제거 → URL증거/환불보장/계약서/납기패널티 중심 Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 5 +- app/admin/components/AdminSidebar.tsx | 20 + app/admin/marketing/page.tsx | 182 +++++++++ app/admin/quotes/[id]/page.tsx | 510 ++++++++++++++++++++++++++ app/admin/quotes/page.tsx | 200 ++++++++++ app/api/admin/quotes/[id]/route.ts | 47 +++ app/api/admin/quotes/route.ts | 55 +++ app/api/quote/[token]/route.ts | 48 +++ app/freelance/page.tsx | 20 +- app/page.tsx | 34 +- app/quote/[token]/page.tsx | 466 +++++++++++++++++++++++ app/services/automation/page.tsx | 2 +- app/services/stock/page.tsx | 4 +- app/services/website/page.tsx | 10 +- public/marketing/banner-homepage.svg | 125 +++++++ public/marketing/thumb-automation.svg | 158 ++++++++ public/marketing/thumb-homepage-A.svg | 186 ++++++++++ public/marketing/thumb-homepage-B.svg | 88 +++++ public/marketing/thumb-prompt.svg | 124 +++++++ public/marketing/thumb-stock.svg | 151 ++++++++ 20 files changed, 2399 insertions(+), 36 deletions(-) create mode 100644 app/admin/marketing/page.tsx create mode 100644 app/admin/quotes/[id]/page.tsx create mode 100644 app/admin/quotes/page.tsx create mode 100644 app/api/admin/quotes/[id]/route.ts create mode 100644 app/api/admin/quotes/route.ts create mode 100644 app/api/quote/[token]/route.ts create mode 100644 app/quote/[token]/page.tsx create mode 100644 public/marketing/banner-homepage.svg create mode 100644 public/marketing/thumb-automation.svg create mode 100644 public/marketing/thumb-homepage-A.svg create mode 100644 public/marketing/thumb-homepage-B.svg create mode 100644 public/marketing/thumb-prompt.svg create mode 100644 public/marketing/thumb-stock.svg diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c01124d..b8db06d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,10 @@ { "permissions": { "allow": [ - "Bash(mkdir:*)" + "Bash(mkdir:*)", + "Bash(npx tsc:*)", + "Bash(git add:*)", + "Bash(git commit:*)" ] } } diff --git a/app/admin/components/AdminSidebar.tsx b/app/admin/components/AdminSidebar.tsx index 1d97496..77f15be 100644 --- a/app/admin/components/AdminSidebar.tsx +++ b/app/admin/components/AdminSidebar.tsx @@ -46,6 +46,26 @@ const NAV_ITEMS = [ ), }, + { + href: '/admin/quotes', + label: '견적서 관리', + icon: ( + + + + ), + }, + { + href: '/admin/marketing', + label: '마케팅 에셋', + icon: ( + + + + ), + }, ]; export default function AdminSidebar() { diff --git a/app/admin/marketing/page.tsx b/app/admin/marketing/page.tsx new file mode 100644 index 0000000..be7ca38 --- /dev/null +++ b/app/admin/marketing/page.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; + +const ASSETS = [ + { + file: '/marketing/thumb-homepage-A.svg', + name: '홈페이지 제작 썸네일 A', + desc: '신뢰형 — 브라우저 목업 포함 다크 테마', + size: '1200 × 675', + platform: '크몽 메인', + color: '#2563eb', + }, + { + file: '/marketing/thumb-homepage-B.svg', + name: '홈페이지 제작 썸네일 B', + desc: '스펙 강조형 — 3플랜 카드 비교', + size: '1200 × 675', + platform: '크몽 서브', + color: '#7c3aed', + }, + { + file: '/marketing/thumb-automation.svg', + name: '업무 자동화 썸네일', + desc: '시간 절약형 — 자동화 플로우 다이어그램', + size: '1200 × 675', + platform: '크몽 메인', + color: '#10b981', + }, + { + file: '/marketing/thumb-prompt.svg', + name: '프롬프트 엔지니어링 썸네일', + desc: 'Before/After 말풍선 비교형', + size: '1200 × 675', + platform: '크몽 메인', + color: '#7c3aed', + }, + { + file: '/marketing/thumb-stock.svg', + name: '주식 자동매매 썸네일', + desc: '폰 목업 + 텔레그램 알림 UI', + size: '1200 × 675', + platform: '크몽 메인', + color: '#22c55e', + }, + { + file: '/marketing/banner-homepage.svg', + name: '홈페이지 제작 배너', + desc: '가로형 배너 — 블로그/SNS 활용', + size: '1200 × 400', + platform: '블로그/SNS', + color: '#2563eb', + }, +]; + +export default function MarketingPage() { + const [preview, setPreview] = useState<(typeof ASSETS)[0] | null>(null); + const [copied, setCopied] = useState(null); + + function copyPath(file: string) { + const url = `${window.location.origin}${file}`; + navigator.clipboard.writeText(url); + setCopied(file); + setTimeout(() => setCopied(null), 2000); + } + + function download(file: string, name: string) { + const a = document.createElement('a'); + a.href = file; + a.download = name.replace(/\s/g, '_') + '.svg'; + a.click(); + } + + return ( +
+
+
+

마케팅 에셋

+

크몽/숨고 등록용 썸네일 및 배너 — SVG 파일 다운로드 가능

+
+ + ← 대시보드 + +
+ + {/* 안내 */} +
+ ℹ️ +
+

SVG → PNG 변환 방법

+

브라우저에서 파일 열기 → 우클릭 → "이미지 다른 이름으로 저장" (PNG), 또는 Figma에 SVG 드래그 후 PNG Export를 추천합니다. 크몽은 JPG/PNG만 허용합니다.

+
+
+ + {/* 그리드 */} +
+ {ASSETS.map((asset) => ( +
+ {/* 미리보기 */} + + + {/* 정보 */} +
+
+
+

{asset.name}

+

{asset.desc}

+
+ + {asset.platform} + +
+

{asset.size}px

+ +
+ + +
+
+
+ ))} +
+ + {/* 크게 보기 모달 */} + {preview && ( +
setPreview(null)} + > +
e.stopPropagation()}> +
+
+

{preview.name}

+

{preview.size}px · {preview.desc}

+
+
+ + +
+
+ {preview.name} +
+
+ )} +
+ ); +} diff --git a/app/admin/quotes/[id]/page.tsx b/app/admin/quotes/[id]/page.tsx new file mode 100644 index 0000000..777478e --- /dev/null +++ b/app/admin/quotes/[id]/page.tsx @@ -0,0 +1,510 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; + +/* ─── 타입 ─────────────────────────────────────────────── */ +interface WBSTask { id: string; name: string; duration: string; description: string; } +interface WBSPhase { id: string; phase: string; tasks: WBSTask[]; } +interface QuoteItem { + id: string; category: string; name: string; description: string; + quantity: number; unitPrice: number; optional: boolean; +} +interface MaintenancePlan { + id: string; name: string; period: string; monthlyFee: number; + includes: string[]; recommended: boolean; +} +interface QuoteForm { + title: string; client_name: string; client_email: string; + valid_until: string; status: string; + wbs: WBSPhase[]; items: QuoteItem[]; maintenance: MaintenancePlan[]; notes: string; +} + +const newId = () => Math.random().toString(36).slice(2, 9); + +const STATUS_OPTIONS = [ + { value: 'draft', label: '초안' }, + { value: 'sent', label: '발송됨' }, + { value: 'accepted', label: '수락됨' }, + { value: 'rejected', label: '거절됨' }, +]; + +const ITEM_CATEGORIES = ['기획', '디자인', '개발', '인프라', '유지보수', '기타']; + +const TABS = ['기본정보', 'WBS', '견적항목', '향후관리', '특이사항'] as const; +type Tab = typeof TABS[number]; + +/* ─── 컴포넌트 ─────────────────────────────────────────── */ +export default function QuoteEditorPage() { + const params = useParams(); + const router = useRouter(); + const id = params.id as string; + + const [tab, setTab] = useState('기본정보'); + const [form, setForm] = useState({ + title: '새 견적서', client_name: '', client_email: '', + valid_until: '', status: 'draft', + wbs: [], items: [], maintenance: [], notes: '', + }); + const [publicToken, setPublicToken] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [copied, setCopied] = useState(false); + + useEffect(() => { + fetch(`/api/admin/quotes/${id}`) + .then((r) => r.json()) + .then((d) => { + if (d.quote) { + const q = d.quote; + setForm({ + title: q.title, client_name: q.client_name, client_email: q.client_email, + valid_until: q.valid_until?.slice(0, 10) ?? '', status: q.status, + wbs: q.wbs ?? [], items: q.items ?? [], + maintenance: q.maintenance ?? [], notes: q.notes ?? '', + }); + setPublicToken(q.public_token); + } + }) + .finally(() => setLoading(false)); + }, [id]); + + const save = useCallback(async (silent = false) => { + if (!silent) setSaving(true); + await fetch(`/api/admin/quotes/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }); + if (!silent) { setSaving(false); setSaved(true); setTimeout(() => setSaved(false), 2000); } + }, [id, form]); + + // ── helpers ──────────────────────────── + const setField = (k: keyof QuoteForm, v: unknown) => setForm((f) => ({ ...f, [k]: v })); + + const totalPrice = form.items.reduce((s, i) => s + i.unitPrice * i.quantity, 0); + + function copyLink() { + navigator.clipboard.writeText(`${window.location.origin}/quote/${publicToken}`); + setCopied(true); setTimeout(() => setCopied(false), 2000); + } + + // ── WBS ──────────────────────────────── + function addPhase() { + setField('wbs', [...form.wbs, { id: newId(), phase: '새 단계', tasks: [] }]); + } + function updatePhase(phaseId: string, k: string, v: string) { + setField('wbs', form.wbs.map((p) => p.id === phaseId ? { ...p, [k]: v } : p)); + } + function removePhase(phaseId: string) { + setField('wbs', form.wbs.filter((p) => p.id !== phaseId)); + } + function addTask(phaseId: string) { + setField('wbs', form.wbs.map((p) => p.id === phaseId + ? { ...p, tasks: [...p.tasks, { id: newId(), name: '새 작업', duration: '1일', description: '' }] } + : p)); + } + function updateTask(phaseId: string, taskId: string, k: string, v: string) { + setField('wbs', form.wbs.map((p) => p.id === phaseId + ? { ...p, tasks: p.tasks.map((t) => t.id === taskId ? { ...t, [k]: v } : t) } + : p)); + } + function removeTask(phaseId: string, taskId: string) { + setField('wbs', form.wbs.map((p) => p.id === phaseId + ? { ...p, tasks: p.tasks.filter((t) => t.id !== taskId) } + : p)); + } + + // ── Items ─────────────────────────────── + function addItem() { + setField('items', [...form.items, { + id: newId(), category: '개발', name: '', description: '', + quantity: 1, unitPrice: 0, optional: false, + }]); + } + function updateItem(itemId: string, k: string, v: unknown) { + setField('items', form.items.map((i) => i.id === itemId ? { ...i, [k]: v } : i)); + } + function removeItem(itemId: string) { + setField('items', form.items.filter((i) => i.id !== itemId)); + } + + // ── Maintenance ───────────────────────── + function addPlan() { + setField('maintenance', [...form.maintenance, { + id: newId(), name: '기본 유지보수', period: '3개월', + monthlyFee: 0, includes: ['버그 수정', '소소한 변경'], recommended: false, + }]); + } + function updatePlan(planId: string, k: string, v: unknown) { + setField('maintenance', form.maintenance.map((p) => p.id === planId ? { ...p, [k]: v } : p)); + } + function removePlan(planId: string) { + setField('maintenance', form.maintenance.filter((p) => p.id !== planId)); + } + function updatePlanInclude(planId: string, idx: number, v: string) { + setField('maintenance', form.maintenance.map((p) => p.id === planId + ? { ...p, includes: p.includes.map((inc, i) => i === idx ? v : inc) } + : p)); + } + function addPlanInclude(planId: string) { + setField('maintenance', form.maintenance.map((p) => p.id === planId + ? { ...p, includes: [...p.includes, ''] } + : p)); + } + function removePlanInclude(planId: string, idx: number) { + setField('maintenance', form.maintenance.map((p) => p.id === planId + ? { ...p, includes: p.includes.filter((_, i) => i !== idx) } + : p)); + } + + if (loading) { + return
불러오는 중...
; + } + + return ( +
+ {/* 상단 바 */} +
+
+ + + + + +
+

{form.title || '견적서 편집'}

+

{form.client_name || '고객 미지정'} · 합계 {totalPrice.toLocaleString()}원

+
+
+ +
+ {/* 공개 링크 */} + {publicToken && ( + + )} + {/* 미리보기 */} + {publicToken && ( + + + + + 미리보기 + + )} + {/* 저장 */} + +
+
+ + {/* 탭 */} +
+
+ {TABS.map((t) => ( + + ))} +
+
+ + {/* 콘텐츠 */} +
+ + {/* ── 기본정보 ── */} + {tab === '기본정보' && ( +
+
+ + setField('title', e.target.value)} placeholder="예: 쇼핑몰 개발 견적서 v1.0" /> + +
+ + setField('client_name', e.target.value)} placeholder="홍길동" /> + + + setField('client_email', e.target.value)} placeholder="client@example.com" /> + +
+
+ + setField('valid_until', e.target.value)} /> + + + + +
+
+ + {/* 요약 카드 */} +
+

견적 요약

+
+
+
{form.items.length}
+
총 항목
+
+
+
{form.items.filter(i => !i.optional).length}
+
필수 항목
+
+
+
{form.items.filter(i => i.optional).length}
+
선택 항목
+
+
+
+ 총 견적 금액 + {totalPrice.toLocaleString()}원 +
+
+
+ )} + + {/* ── WBS ── */} + {tab === 'WBS' && ( +
+
+

작업 분류 체계(WBS)를 단계별로 작성합니다

+ +
+ {form.wbs.length === 0 && ( + + )} + {form.wbs.map((phase, pi) => ( +
+
+ {pi + 1} + updatePhase(phase.id, 'phase', e.target.value)} + placeholder="단계명 (예: 기획, 디자인, 개발)" + /> + + +
+ {phase.tasks.length > 0 && ( +
+ {phase.tasks.map((task) => ( +
+
+ updateTask(phase.id, task.id, 'name', e.target.value)} placeholder="작업명" /> +
+
+ updateTask(phase.id, task.id, 'duration', e.target.value)} placeholder="기간 (예: 3일)" /> +
+
+ updateTask(phase.id, task.id, 'description', e.target.value)} placeholder="작업 설명" /> +
+
+ +
+
+ ))} +
+ )} + {phase.tasks.length === 0 && ( +

작업 없음 — 위 버튼으로 추가하세요

+ )} +
+ ))} +
+ )} + + {/* ── 견적항목 ── */} + {tab === '견적항목' && ( +
+
+

선택 항목(optional)은 고객이 직접 선택/해제할 수 있습니다

+ +
+ + {/* 헤더 */} + {form.items.length > 0 && ( +
+
카테고리
+
항목명
+
설명
+
수량
+
단가
+
선택
+
+
+ )} + + {form.items.length === 0 && } + + {form.items.map((item) => ( +
+
+ +
+
+ updateItem(item.id, 'name', e.target.value)} placeholder="항목명" /> +
+
+ updateItem(item.id, 'description', e.target.value)} placeholder="상세 설명" /> +
+
+ updateItem(item.id, 'quantity', Number(e.target.value))} /> +
+
+ updateItem(item.id, 'unitPrice', Number(e.target.value))} /> +
+
+ +
+
+ +
+
+ ))} + + {/* 합계 */} + {form.items.length > 0 && ( +
+
+
+ 필수 합계 + {form.items.filter(i => !i.optional).reduce((s, i) => s + i.unitPrice * i.quantity, 0).toLocaleString()}원 +
+
+ 선택 합계 + {form.items.filter(i => i.optional).reduce((s, i) => s + i.unitPrice * i.quantity, 0).toLocaleString()}원 +
+
+ 전체 합계 + {totalPrice.toLocaleString()}원 +
+
+
+ )} +
+ )} + + {/* ── 향후관리 ── */} + {tab === '향후관리' && ( +
+
+

납품 후 유지보수 플랜을 구성합니다 (고객이 하나를 선택)

+ +
+ {form.maintenance.length === 0 && } + {form.maintenance.map((plan) => ( +
+
+
+ + updatePlan(plan.id, 'name', e.target.value)} placeholder="기본 유지보수" /> + + + updatePlan(plan.id, 'period', e.target.value)} placeholder="3개월" /> + + + updatePlan(plan.id, 'monthlyFee', Number(e.target.value))} /> + +
+
+ 추천 + +
+ +
+ +
+
+ 포함 사항 + +
+
+ {plan.includes.map((inc, idx) => ( +
+ updatePlanInclude(plan.id, idx, e.target.value)} placeholder="포함 사항 입력" /> + +
+ ))} +
+
+
+ ))} +
+ )} + + {/* ── 특이사항 ── */} + {tab === '특이사항' && ( +
+ +