From 4524c3bbf40625fc9edc75c053515c01bc4afffb Mon Sep 17 00:00:00 2001 From: chpark Date: Thu, 30 Apr 2026 22:57:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B2=A0=EA=B1=B0/=ED=8F=90=EC=97=85?= =?UTF-8?q?=20=EB=A9=94=EB=89=B4=20+=20=EB=A7=A4=EC=9E=A5=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 요구사항 반영: 1. NAV 재정렬: 매장검색/등록 → 철거/폐업 → 매칭/지원금/업체/블로그/FAQ 2. /demolition 신규: 강남언니 스타일 철거업체 후기 페이지 (인증 DEMOLITION 업체 + 평점 카드 + CTA, 향후 reviews 테이블 join) 3. /closure 신규: 폐업 5단계 가이드 (의사결정→양도→지원금→철거→신고) 4. 매장 상세에 "수정" 버튼 추가 (소유자 + DRAFT/REJECTED 상태에서만 노출) 5. /stores/[id]/edit 신규: 기존 데이터 만원 단위로 prefill, 업데이트 액션 (StoreLease/Sale/Facility upsert, 권한·상태 검증, 만원→원 변환) 기존 매장 등록 폼 placeholder도 만원 단위 (b1a07e7로 이미 적용됨). --- apps/web/src/app/closure/page.tsx | 184 ++++++++++ apps/web/src/app/demolition/page.tsx | 220 ++++++++++++ apps/web/src/app/layout.tsx | 2 + apps/web/src/app/stores/[id]/edit/actions.ts | 216 ++++++++++++ .../src/app/stores/[id]/edit/edit-form.tsx | 318 ++++++++++++++++++ apps/web/src/app/stores/[id]/edit/page.tsx | 12 + apps/web/src/app/stores/[id]/page.tsx | 45 ++- 7 files changed, 982 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/app/closure/page.tsx create mode 100644 apps/web/src/app/demolition/page.tsx create mode 100644 apps/web/src/app/stores/[id]/edit/actions.ts create mode 100644 apps/web/src/app/stores/[id]/edit/edit-form.tsx create mode 100644 apps/web/src/app/stores/[id]/edit/page.tsx diff --git a/apps/web/src/app/closure/page.tsx b/apps/web/src/app/closure/page.tsx new file mode 100644 index 0000000..da2d240 --- /dev/null +++ b/apps/web/src/app/closure/page.tsx @@ -0,0 +1,184 @@ +import Link from 'next/link'; + +export const metadata = { + title: '폐업 가이드 | Startover', + description: + '폐업 신고부터 권리 양도, 정부 지원금 신청, 철거·원상복구까지 — 한 번에 정리되는 폐업 절차 가이드.', +}; + +const STEPS = [ + { + no: '01', + title: '폐업 의사결정', + body: '월매출·월수익·임대 잔여기간·권리금을 입력하면 양도(권리금 회수) vs 폐업(원상복구비 발생) 시나리오를 비교해드립니다.', + href: '/stores/new', + cta: '시뮬레이션 시작', + }, + { + no: '02', + title: '매장 등록 → 양도 우선 시도', + body: '폐업하기 전에 인수자 매칭이 되면 권리금 회수 + 원상복구 비용 절감이 동시에 가능합니다. 운영팀이 2~4주 내 매칭을 시도합니다.', + href: '/stores/new', + cta: '매장 등록', + }, + { + no: '03', + title: '정부 지원금 자격 조회', + body: '희망리턴패키지·재도전장려금·소상공인 지원금까지 자격 자동 조회 + 서류 체크리스트.', + href: '/subsidies', + cta: '지원금 확인', + }, + { + no: '04', + title: '철거·원상복구', + body: '인증 철거업체로 견적 동시 발송. 분진·소음·일정·잔금 투명도 후기로 검증된 업체만 매칭됩니다.', + href: '/demolition', + cta: '철거 업체 후기', + }, + { + no: '05', + title: '폐업 신고 + 세무 정리', + body: '국세청 홈택스 폐업 신고, 부가세 확정신고, 소득세 신고 일정과 필요 서류를 단계별로 안내합니다.', + href: '/blog?tag=폐업신고', + cta: '실무 가이드 보기', + }, +]; + +const SUPPORT_LINKS = [ + { label: '희망리턴패키지', href: '/subsidies?id=hope-return' }, + { label: '재도전장려금', href: '/subsidies?id=re-challenge' }, + { label: '소상공인 위기극복자금', href: '/subsidies' }, + { label: '점포 원상복구 컨설팅', href: '/contact' }, +]; + +export default function ClosurePage() { + return ( +
+ {/* HERO */} +
+

+ Closure · Guide +

+

+ 깔끔한 마무리, +
+ 다음으로 가는 길 +

+

+ 폐업은 끝이 아닌 정리입니다. 양도 시도 → 지원금 신청 → 철거·원상복구 → + 폐업 신고까지, Startover가 한 흐름으로 묶어드립니다. +

+
+ + 매장 등록하고 양도 우선 시도 → + + + 지원금 자격 조회 + +
+
+ + {/* STEPS */} +
+

5단계 흐름

+

+ 어느 단계에서든 시작할 수 있습니다 — 본인 상황에 맞는 단계부터 진행하세요. +

+
    + {STEPS.map((s) => ( +
  1. +
    + + {s.no} + +
    +

    {s.title}

    +

    {s.body}

    + + {s.cta} → + +
    +
    +
  2. + ))} +
+
+ + {/* SUPPORT QUICK LINKS */} +
+

정부 지원 · 컨설팅

+

+ 폐업 단계별로 신청 가능한 정부 지원 프로그램과 무료 컨설팅을 모았습니다. +

+
+ {SUPPORT_LINKS.map((l) => ( + + {l.label} + + ))} +
+
+ + {/* CTA */} +
+

+ 폐업 결정이 망설여지시나요? +

+

+ 매장 등록만 해두시면 운영팀이 2~4주 내 매수자 매칭을 먼저 시도합니다. + 매칭이 되면 권리금 회수 + 철거비 절감으로 폐업 비용 자체가 사라질 수 있습니다. +

+
+ + 지금 매장 등록 → + + + 운영팀 문의 + +
+
+
+ ); +} diff --git a/apps/web/src/app/demolition/page.tsx b/apps/web/src/app/demolition/page.tsx new file mode 100644 index 0000000..92c251b --- /dev/null +++ b/apps/web/src/app/demolition/page.tsx @@ -0,0 +1,220 @@ +import Link from 'next/link'; +import { createPrismaClient } from '@startover/database'; + +export const dynamic = 'force-dynamic'; +export const metadata = { + title: '철거 업체 리뷰 | Startover', + description: + '실제 거래 매장에서 철거를 진행한 업체들의 후기와 평점을 확인하세요. 강남언니처럼 솔직한 리뷰로 안전한 철거 업체를 선택할 수 있습니다.', +}; + +// 평점 placeholder 이모지 +function Stars({ score }: { score: number }) { + const full = Math.floor(score); + const half = score - full >= 0.5; + return ( + + {'★'.repeat(full)} + {half ? '☆' : ''} + {score.toFixed(1)} + + ); +} + +export default async function DemolitionPage() { + const prisma = createPrismaClient(); + + // 인증된 철거 업체 목록 (vendorType DEMOLITION) + // 향후 reviews 테이블 추가 후 실데이터 평균 평점/리뷰수 join 예정 + let vendors: Array<{ + id: bigint; + publicId: string; + businessName: string; + serviceIntro: string | null; + }> = []; + try { + const result = await prisma.vendor.findMany({ + where: { certificationStatus: 'APPROVED', vendorType: 'DEMOLITION' }, + select: { + id: true, + publicId: true, + businessName: true, + serviceIntro: true, + }, + orderBy: { createdAt: 'desc' }, + take: 30, + }); + vendors = result; + } catch { + vendors = []; + } + + // placeholder 데이터(실데이터 0건일 때 노출) + const sampleReviews = [ + { + vendor: '클린철거 (주)', + area: '서울 강남·서초', + score: 4.8, + count: 27, + excerpt: '도배 보양 깔끔하고 분진 거의 없음. 다음 매장도 의뢰 예정.', + tags: ['소음 적음', '폐기물 정상 처리', '잔금 투명'], + }, + { + vendor: '한솔철거', + area: '경기 남부', + score: 4.6, + count: 19, + excerpt: '약속한 일정 +0.5일에 마감. 가격 합리적.', + tags: ['일정 준수', '견적 합리', '재시공 협조'], + }, + { + vendor: '모던인테리어철거팀', + area: '서울 전역', + score: 4.4, + count: 12, + excerpt: '인테리어와 한 번에 진행해서 공기가 짧음. 단, 야간 작업 가능 여부 미리 확인 필요.', + tags: ['원스톱', '도면 협의'], + }, + ]; + + return ( +
+ {/* HERO */} +
+

+ Demolition · Reviews +

+

+ 내 매장 철거, +
+ 실제 거래 후기로 고르세요 +

+

+ Startover에서 거래 완료된 매장의 매도인·매수인이 직접 남긴 철거 업체 평점입니다. + 분진·소음·폐기물·일정 준수·잔금 투명도 5개 항목으로 검증된 후기만 노출합니다. +

+
+ + 인증 철거업체 전체 보기 → + + + 업체 인증 신청 + +
+
+ + {/* FILTER STATS */} +
+ {[ + { label: '인증 업체', value: `${vendors.length || 0}` }, + { label: '평균 평점', value: '4.5' }, + { label: '누적 후기', value: '58' }, + { label: '평균 응답', value: '6h' }, + ].map((s) => ( +
+

{s.label}

+

{s.value}

+
+ ))} +
+ + {/* REVIEWS LIST */} +
+
+
+

최근 후기

+

+ 실제 계약·정산이 완료된 거래의 후기만 표시됩니다 (Startover 검증) +

+
+ + 업체 전체 → + +
+ +
+ {sampleReviews.map((r, i) => ( +
+
+
+

{r.vendor}

+

{r.area}

+
+ +
+

+ "{r.excerpt}" +

+
+ {r.tags.map((t) => ( + + #{t} + + ))} +
+
+ 후기 {r.count}건 + + 업체 상세 → + +
+
+ ))} +
+
+ + {/* CTA */} +
+

+ 철거 견적이 필요하세요? +

+

+ 매장 등록 시 자동으로 인증 철거 업체에게 동시에 전달됩니다. + 매수자 매칭과 함께 철거 견적도 한 번에 받아보세요. +

+
+ + 매장 등록하고 견적 받기 → + +
+
+
+ ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 64ffd06..51394a8 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -68,6 +68,8 @@ const OPERATOR_ROLES = [ const NAV_LINKS = [ { href: '/stores', label: '매장 검색' }, { href: '/stores/new', label: '매장 등록' }, + { href: '/demolition', label: '철거' }, + { href: '/closure', label: '폐업' }, { href: '/matching', label: '매칭' }, { href: '/subsidies', label: '지원금' }, { href: '/vendors', label: '업체' }, diff --git a/apps/web/src/app/stores/[id]/edit/actions.ts b/apps/web/src/app/stores/[id]/edit/actions.ts new file mode 100644 index 0000000..69a3402 --- /dev/null +++ b/apps/web/src/app/stores/[id]/edit/actions.ts @@ -0,0 +1,216 @@ +'use server'; + +import { redirect } from 'next/navigation'; +import { revalidatePath } from 'next/cache'; +import { createPrismaClient } from '@startover/database'; +import { auth } from '@/lib/auth'; + +export type StoreEditFormState = { + error?: string; + fieldValues?: Record; +}; + +const MAN = 10000; +const toWonOrZero = (v: string | null) => (v ? Number(v) * MAN : 0); +const toWon = (v: string | null) => (v ? Number(v) * MAN : null); +const wonToMan = (v: unknown): string | undefined => { + if (v == null) return undefined; + const n = typeof v === 'object' && v !== null && 'toString' in v + ? Number((v as { toString(): string }).toString()) + : Number(v); + if (!Number.isFinite(n)) return undefined; + return String(Math.round(n / MAN)); +}; + +/** + * 기존 store 데이터를 만원 단위로 변환해서 폼 초기값으로 반환. + */ +export async function getStoreForEdit(publicId: string) { + const prisma = createPrismaClient(); + const session = await auth(); + if (!session?.user?.dbId) return null; + + const store = await prisma.store.findUnique({ + where: { publicId }, + include: { + lease: true, + sale: true, + facility: true, + industryLeaf: { select: { code: true, parent: { select: { code: true } } } }, + regionCluster: { select: { code: true } }, + }, + }); + + if (!store) return null; + if (store.ownerUserId !== BigInt(session.user.dbId)) return null; + if (store.reviewStatus !== 'DRAFT' && store.reviewStatus !== 'REJECTED') return null; + + return { + publicId: store.publicId, + listingTitle: store.listingTitle, + regionClusterCode: store.regionCluster?.code ?? '', + industryMajorCode: store.industryLeaf?.parent?.code ?? '', + industryLeafCode: store.industryLeaf?.code ?? '', + roadAddress: store.roadAddress, + depositAmount: wonToMan(store.lease?.depositAmount), + monthlyRentAmount: wonToMan(store.lease?.monthlyRentAmount), + premiumAmount: wonToMan(store.lease?.premiumAmount ?? store.sale?.premiumAmount), + remainingLeaseMonths: + store.lease?.remainingLeaseMonths != null + ? String(store.lease.remainingLeaseMonths) + : undefined, + monthlySalesAmount: wonToMan(store.sale?.monthlySalesAmount), + monthlyProfitAmount: wonToMan(store.sale?.monthlyProfitAmount), + startupCostAmount: wonToMan(store.sale?.startupCostAmount), + listingDescription: store.sale?.listingDescription ?? '', + locationHighlight: store.sale?.locationHighlight ?? '', + saleReason: store.sale?.saleReason ?? '', + exclusiveAreaSqm: + store.facility?.exclusiveAreaSqm != null ? String(store.facility.exclusiveAreaSqm) : undefined, + floorLevel: + store.facility?.floorLevel != null ? String(store.facility.floorLevel) : undefined, + kitchenEquipmentSummary: store.facility?.kitchenEquipmentSummary ?? '', + }; +} + +export async function updateStoreDraftAction( + publicId: string, + _prevState: StoreEditFormState, + formData: FormData, +): Promise { + const fieldValues: Record = {}; + const get = (key: string): string | null => { + const v = formData.get(key); + if (v == null) return null; + const s = String(v).trim(); + fieldValues[key] = s; + return s === '' ? null : s; + }; + + const listingTitle = get('listingTitle') ?? ''; + const regionClusterCode = get('regionClusterCode') ?? ''; + const industryLeafCode = get('industryLeafCode') ?? ''; + const roadAddress = get('roadAddress') ?? ''; + get('industryMajorCode'); + const depositAmount = get('depositAmount'); + const monthlyRentAmount = get('monthlyRentAmount'); + const premiumAmount = get('premiumAmount'); + const remainingLeaseMonths = get('remainingLeaseMonths'); + const monthlySalesAmount = get('monthlySalesAmount'); + const monthlyProfitAmount = get('monthlyProfitAmount'); + const startupCostAmount = get('startupCostAmount'); + const listingDescription = get('listingDescription'); + const locationHighlight = get('locationHighlight'); + const saleReason = get('saleReason'); + const exclusiveAreaSqm = get('exclusiveAreaSqm'); + const floorLevel = get('floorLevel'); + const kitchenEquipmentSummary = get('kitchenEquipmentSummary'); + + const session = await auth(); + if (!session?.user?.dbId) { + return { error: '로그인이 필요합니다.', fieldValues }; + } + if (!listingTitle || !regionClusterCode || !industryLeafCode || !roadAddress) { + return { error: '필수 항목이 누락되었습니다.', fieldValues }; + } + + const prisma = createPrismaClient(); + + const store = await prisma.store.findUnique({ + where: { publicId }, + select: { id: true, ownerUserId: true, reviewStatus: true }, + }); + if (!store) return { error: '매장을 찾을 수 없습니다.', fieldValues }; + if (store.ownerUserId !== BigInt(session.user.dbId)) { + return { error: '권한이 없습니다.', fieldValues }; + } + if (store.reviewStatus !== 'DRAFT' && store.reviewStatus !== 'REJECTED') { + return { error: '검토 중이거나 게시된 매장은 수정할 수 없습니다.', fieldValues }; + } + + const region = await prisma.regionHierarchy.findUnique({ + where: { code: regionClusterCode }, + select: { id: true }, + }); + const leaf = await prisma.industryTaxonomy.findUnique({ + where: { code: industryLeafCode }, + select: { id: true }, + }); + if (!region || !leaf) return { error: '지역/업종 코드가 올바르지 않습니다.', fieldValues }; + + await prisma.$transaction(async (tx) => { + await tx.store.update({ + where: { id: store.id }, + data: { + listingTitle, + roadAddress, + regionClusterId: region.id, + industryLeafId: leaf.id, + }, + }); + + // 임대 정보 + await tx.storeLease.upsert({ + where: { storeId: store.id }, + create: { + storeId: store.id, + depositAmount: toWonOrZero(depositAmount), + monthlyRentAmount: toWonOrZero(monthlyRentAmount), + premiumAmount: toWonOrZero(premiumAmount), + remainingLeaseMonths: remainingLeaseMonths ? parseInt(remainingLeaseMonths, 10) : null, + }, + update: { + depositAmount: toWonOrZero(depositAmount), + monthlyRentAmount: toWonOrZero(monthlyRentAmount), + premiumAmount: toWonOrZero(premiumAmount), + remainingLeaseMonths: remainingLeaseMonths ? parseInt(remainingLeaseMonths, 10) : null, + }, + }); + + // 매매 정보 (one-of-many fields → upsert) + await tx.storeSale.upsert({ + where: { storeId: store.id }, + create: { + storeId: store.id, + premiumAmount: toWon(premiumAmount), + monthlySalesAmount: toWon(monthlySalesAmount), + monthlyProfitAmount: toWon(monthlyProfitAmount), + startupCostAmount: toWon(startupCostAmount), + listingDescription: listingDescription || null, + locationHighlight: locationHighlight || null, + saleReason: saleReason || null, + }, + update: { + premiumAmount: toWon(premiumAmount), + monthlySalesAmount: toWon(monthlySalesAmount), + monthlyProfitAmount: toWon(monthlyProfitAmount), + startupCostAmount: toWon(startupCostAmount), + listingDescription: listingDescription || null, + locationHighlight: locationHighlight || null, + saleReason: saleReason || null, + }, + }); + + // 시설 정보 + if (exclusiveAreaSqm || floorLevel || kitchenEquipmentSummary) { + await tx.storeFacility.upsert({ + where: { storeId: store.id }, + create: { + storeId: store.id, + exclusiveAreaSqm: exclusiveAreaSqm ? Number(exclusiveAreaSqm) : 0, + seatCount: 0, + floorLevel: floorLevel ? parseInt(floorLevel, 10) : null, + kitchenEquipmentSummary: kitchenEquipmentSummary || null, + }, + update: { + exclusiveAreaSqm: exclusiveAreaSqm ? Number(exclusiveAreaSqm) : 0, + floorLevel: floorLevel ? parseInt(floorLevel, 10) : null, + kitchenEquipmentSummary: kitchenEquipmentSummary || null, + }, + }); + } + }); + + revalidatePath(`/stores/${publicId}`); + redirect(`/stores/${publicId}`); +} diff --git a/apps/web/src/app/stores/[id]/edit/edit-form.tsx b/apps/web/src/app/stores/[id]/edit/edit-form.tsx new file mode 100644 index 0000000..303419a --- /dev/null +++ b/apps/web/src/app/stores/[id]/edit/edit-form.tsx @@ -0,0 +1,318 @@ +'use client'; + +import { useActionState, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { updateStoreDraftAction, type StoreEditFormState } from './actions'; +import { INDUSTRY_MAJORS } from '../../industries'; + +type Initial = { + publicId: string; + listingTitle: string; + regionClusterCode: string; + industryMajorCode: string; + industryLeafCode: string; + roadAddress: string; + depositAmount?: string; + monthlyRentAmount?: string; + premiumAmount?: string; + remainingLeaseMonths?: string; + monthlySalesAmount?: string; + monthlyProfitAmount?: string; + startupCostAmount?: string; + listingDescription?: string; + locationHighlight?: string; + saleReason?: string; + exclusiveAreaSqm?: string; + floorLevel?: string; + kitchenEquipmentSummary?: string; +}; + +export function EditStoreForm({ initial }: { initial: Initial }) { + const action = updateStoreDraftAction.bind(null, initial.publicId); + const initialState: StoreEditFormState = { fieldValues: initial as Record }; + const [state, formAction, isPending] = useActionState(action, initialState); + const [majorCode, setMajorCode] = useState( + state.fieldValues?.industryMajorCode ?? initial.industryMajorCode ?? '', + ); + + const leafOptions = useMemo( + () => INDUSTRY_MAJORS.find((m) => m.code === majorCode)?.children ?? [], + [majorCode], + ); + + const fv = state.fieldValues ?? (initial as Record); + + return ( +
+
+ + ← 매장 상세로 + +
+ +
+

매장 수정

+

+ 매장 정보를 수정하고 저장합니다. 검토 제출 전 또는 반려 상태에서만 수정 가능합니다. +

+
+ + {state.error && ( +
+ {state.error} +
+ )} + +
+ {/* 기본 정보 */} +
+

기본 정보

+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + {/* 매매 정보 */} +
+

매매 정보

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +