Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4524c3bbf4 | |||
| 857d3f08a7 | |||
| 0f5a5a96c4 | |||
| b1a07e7a87 |
@@ -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 (
|
||||
<main className="mx-auto max-w-6xl px-6 py-10 font-body">
|
||||
{/* HERO */}
|
||||
<section className="rounded-3xl border border-ink/5 bg-white p-8 md:p-12">
|
||||
<p className="font-mono text-[11px] tracking-[0.25em] uppercase text-ink-muted">
|
||||
<span style={{ color: '#03C75A' }}>◆</span> Closure · Guide
|
||||
</p>
|
||||
<h1 className="mt-3 font-display text-3xl font-extrabold tracking-tight text-ink md:text-4xl">
|
||||
깔끔한 마무리,
|
||||
<br className="hidden md:block" />
|
||||
<span style={{ color: '#03C75A' }}>다음으로 가는 길</span>
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-ink-light md:text-base">
|
||||
폐업은 끝이 아닌 정리입니다. 양도 시도 → 지원금 신청 → 철거·원상복구 →
|
||||
폐업 신고까지, Startover가 한 흐름으로 묶어드립니다.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/stores/new"
|
||||
className="rounded-full px-5 py-2.5 text-sm font-semibold text-white"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg,#03C75A 0%,#02A149 55%,#018f40 100%)',
|
||||
boxShadow: '0 8px 20px -8px rgba(3,199,90,0.45)',
|
||||
}}
|
||||
>
|
||||
매장 등록하고 양도 우선 시도 →
|
||||
</Link>
|
||||
<Link
|
||||
href="/subsidies"
|
||||
className="rounded-full border border-ink/15 bg-white px-5 py-2.5 text-sm font-semibold text-ink hover:border-ink/40"
|
||||
>
|
||||
지원금 자격 조회
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* STEPS */}
|
||||
<section className="mt-10">
|
||||
<h2 className="font-display text-xl font-bold text-ink">5단계 흐름</h2>
|
||||
<p className="mt-1 text-xs text-ink-muted">
|
||||
어느 단계에서든 시작할 수 있습니다 — 본인 상황에 맞는 단계부터 진행하세요.
|
||||
</p>
|
||||
<ol className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{STEPS.map((s) => (
|
||||
<li
|
||||
key={s.no}
|
||||
className="card-lift rounded-2xl border border-ink/5 bg-white p-6"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<span
|
||||
className="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl font-mono text-sm font-bold text-white"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg,#03C75A 0%,#02A149 100%)',
|
||||
}}
|
||||
>
|
||||
{s.no}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-display text-lg font-bold text-ink">{s.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-ink-light">{s.body}</p>
|
||||
<Link
|
||||
href={s.href}
|
||||
className="mt-4 inline-flex items-center gap-1 text-sm font-semibold"
|
||||
style={{ color: '#02A149' }}
|
||||
>
|
||||
{s.cta} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* SUPPORT QUICK LINKS */}
|
||||
<section className="mt-10 rounded-3xl border border-ink/5 bg-white p-8">
|
||||
<h2 className="font-display text-lg font-bold text-ink">정부 지원 · 컨설팅</h2>
|
||||
<p className="mt-1 text-xs text-ink-muted">
|
||||
폐업 단계별로 신청 가능한 정부 지원 프로그램과 무료 컨설팅을 모았습니다.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{SUPPORT_LINKS.map((l) => (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className="rounded-full border border-ink/10 bg-white px-3.5 py-1.5 text-xs font-semibold text-ink hover:border-ink/30"
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section
|
||||
className="mt-10 rounded-3xl border border-ink/5 p-8 md:p-10"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg,rgba(3,199,90,0.08) 0%,rgba(2,161,73,0.04) 100%)',
|
||||
}}
|
||||
>
|
||||
<h2 className="font-display text-xl font-bold text-ink md:text-2xl">
|
||||
폐업 결정이 망설여지시나요?
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-ink-light">
|
||||
매장 등록만 해두시면 운영팀이 2~4주 내 매수자 매칭을 먼저 시도합니다.
|
||||
매칭이 되면 권리금 회수 + 철거비 절감으로 폐업 비용 자체가 사라질 수 있습니다.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/stores/new"
|
||||
className="rounded-full px-5 py-2.5 text-sm font-semibold text-white"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg,#03C75A 0%,#02A149 55%,#018f40 100%)',
|
||||
boxShadow: '0 8px 20px -8px rgba(3,199,90,0.45)',
|
||||
}}
|
||||
>
|
||||
지금 매장 등록 →
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="rounded-full border border-ink/15 bg-white px-5 py-2.5 text-sm font-semibold text-ink hover:border-ink/40"
|
||||
>
|
||||
운영팀 문의
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span className="font-mono text-sm">
|
||||
{'★'.repeat(full)}
|
||||
{half ? '☆' : ''}
|
||||
<span className="ml-1 text-ink-muted">{score.toFixed(1)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<main className="mx-auto max-w-6xl px-6 py-10 font-body">
|
||||
{/* HERO */}
|
||||
<section className="rounded-3xl border border-ink/5 bg-white p-8 md:p-12">
|
||||
<p className="font-mono text-[11px] tracking-[0.25em] uppercase text-ink-muted">
|
||||
<span style={{ color: '#03C75A' }}>◆</span> Demolition · Reviews
|
||||
</p>
|
||||
<h1 className="mt-3 font-display text-3xl font-extrabold tracking-tight text-ink md:text-4xl">
|
||||
내 매장 철거,
|
||||
<br className="hidden md:block" />
|
||||
<span style={{ color: '#03C75A' }}>실제 거래 후기</span>로 고르세요
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-ink-light md:text-base">
|
||||
Startover에서 거래 완료된 매장의 매도인·매수인이 직접 남긴 철거 업체 평점입니다.
|
||||
분진·소음·폐기물·일정 준수·잔금 투명도 5개 항목으로 검증된 후기만 노출합니다.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/vendors?serviceType=DEMOLITION"
|
||||
className="rounded-full px-5 py-2.5 text-sm font-semibold text-white"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg,#03C75A 0%,#02A149 55%,#018f40 100%)',
|
||||
boxShadow: '0 8px 20px -8px rgba(3,199,90,0.45)',
|
||||
}}
|
||||
>
|
||||
인증 철거업체 전체 보기 →
|
||||
</Link>
|
||||
<Link
|
||||
href="/vendors/apply"
|
||||
className="rounded-full border border-ink/15 bg-white px-5 py-2.5 text-sm font-semibold text-ink hover:border-ink/40"
|
||||
>
|
||||
업체 인증 신청
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FILTER STATS */}
|
||||
<section className="mt-8 grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{[
|
||||
{ label: '인증 업체', value: `${vendors.length || 0}` },
|
||||
{ label: '평균 평점', value: '4.5' },
|
||||
{ label: '누적 후기', value: '58' },
|
||||
{ label: '평균 응답', value: '6h' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="rounded-2xl border border-ink/5 bg-white p-5">
|
||||
<p className="text-xs text-ink-muted">{s.label}</p>
|
||||
<p className="mt-1 font-display text-2xl font-bold text-ink">{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* REVIEWS LIST */}
|
||||
<section className="mt-10">
|
||||
<div className="mb-5 flex items-end justify-between">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-bold text-ink">최근 후기</h2>
|
||||
<p className="mt-1 text-xs text-ink-muted">
|
||||
실제 계약·정산이 완료된 거래의 후기만 표시됩니다 (Startover 검증)
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/vendors?serviceType=DEMOLITION"
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: '#02A149' }}
|
||||
>
|
||||
업체 전체 →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{sampleReviews.map((r, i) => (
|
||||
<article
|
||||
key={i}
|
||||
className="card-lift rounded-2xl border border-ink/5 bg-white p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-display font-bold text-ink">{r.vendor}</h3>
|
||||
<p className="mt-0.5 text-xs text-ink-muted">{r.area}</p>
|
||||
</div>
|
||||
<Stars score={r.score} />
|
||||
</div>
|
||||
<p className="mt-4 text-sm leading-relaxed text-ink-light">
|
||||
"{r.excerpt}"
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{r.tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded-full bg-ink/5 px-2.5 py-0.5 text-[11px] text-ink-light"
|
||||
>
|
||||
#{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between border-t border-ink/5 pt-4">
|
||||
<span className="text-xs text-ink-muted">후기 {r.count}건</span>
|
||||
<Link
|
||||
href="/vendors"
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: '#02A149' }}
|
||||
>
|
||||
업체 상세 →
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section
|
||||
className="mt-12 rounded-3xl border border-ink/5 p-8 md:p-10"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg,rgba(3,199,90,0.08) 0%,rgba(2,161,73,0.04) 100%)',
|
||||
}}
|
||||
>
|
||||
<h2 className="font-display text-xl font-bold text-ink md:text-2xl">
|
||||
철거 견적이 필요하세요?
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-ink-light">
|
||||
매장 등록 시 자동으로 인증 철거 업체에게 동시에 전달됩니다.
|
||||
매수자 매칭과 함께 철거 견적도 한 번에 받아보세요.
|
||||
</p>
|
||||
<div className="mt-5">
|
||||
<Link
|
||||
href="/stores/new"
|
||||
className="rounded-full px-5 py-2.5 text-sm font-semibold text-white"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg,#03C75A 0%,#02A149 55%,#018f40 100%)',
|
||||
boxShadow: '0 8px 20px -8px rgba(3,199,90,0.45)',
|
||||
}}
|
||||
>
|
||||
매장 등록하고 견적 받기 →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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: '업체' },
|
||||
|
||||
@@ -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<string, string | undefined>;
|
||||
};
|
||||
|
||||
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<StoreEditFormState> {
|
||||
const fieldValues: Record<string, string | undefined> = {};
|
||||
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}`);
|
||||
}
|
||||
@@ -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<string, string | undefined> };
|
||||
const [state, formAction, isPending] = useActionState(action, initialState);
|
||||
const [majorCode, setMajorCode] = useState<string>(
|
||||
state.fieldValues?.industryMajorCode ?? initial.industryMajorCode ?? '',
|
||||
);
|
||||
|
||||
const leafOptions = useMemo(
|
||||
() => INDUSTRY_MAJORS.find((m) => m.code === majorCode)?.children ?? [],
|
||||
[majorCode],
|
||||
);
|
||||
|
||||
const fv = state.fieldValues ?? (initial as Record<string, string | undefined>);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-6 py-10 font-body">
|
||||
<div className="mb-6">
|
||||
<Link href={`/stores/${initial.publicId}`} className="text-sm text-warm-600 hover:text-warm-700">
|
||||
← 매장 상세로
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="animate-fade-up">
|
||||
<h1 className="font-display text-3xl font-bold text-ink">매장 수정</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
매장 정보를 수정하고 저장합니다. 검토 제출 전 또는 반려 상태에서만 수정 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<div className="mt-4 rounded-2xl border border-red-200/60 bg-red-50 px-5 py-4 text-sm text-red-700">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={formAction} className="mt-8 space-y-8 animate-fade-up">
|
||||
{/* 기본 정보 */}
|
||||
<section>
|
||||
<h2 className="font-display text-xl font-bold text-ink mb-4">기본 정보</h2>
|
||||
<div className="space-y-4 rounded-2xl border border-ink/5 bg-white/70 backdrop-blur-sm p-6">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">매장명 *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="listingTitle"
|
||||
required
|
||||
defaultValue={fv.listingTitle}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">지역 *</label>
|
||||
<select
|
||||
name="regionClusterCode"
|
||||
required
|
||||
defaultValue={fv.regionClusterCode}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
>
|
||||
<option value="">지역 선택</option>
|
||||
<option value="KR.BETA.GANGNAM_CORE">강남권 (역삼/선릉/논현)</option>
|
||||
<option value="KR.BETA.MAPO_CORE">마포권 (홍대/합정/연남)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">업종 대분류 *</label>
|
||||
<select
|
||||
name="industryMajorCode"
|
||||
required
|
||||
value={majorCode}
|
||||
onChange={(e) => setMajorCode(e.target.value)}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
>
|
||||
<option value="">대분류 선택</option>
|
||||
{INDUSTRY_MAJORS.map((m) => (
|
||||
<option key={m.code} value={m.code}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">업종 소분류 *</label>
|
||||
<select
|
||||
name="industryLeafCode"
|
||||
required
|
||||
defaultValue={fv.industryLeafCode}
|
||||
disabled={!majorCode}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="">소분류 선택</option>
|
||||
{leafOptions.map((c) => (
|
||||
<option key={c.code} value={c.code}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">주소 *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="roadAddress"
|
||||
required
|
||||
defaultValue={fv.roadAddress}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 매매 정보 */}
|
||||
<section>
|
||||
<h2 className="font-display text-xl font-bold text-ink mb-4">매매 정보</h2>
|
||||
<div className="space-y-4 rounded-2xl border border-ink/5 bg-white/70 backdrop-blur-sm p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">권리금 (만원)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="premiumAmount"
|
||||
placeholder="12000"
|
||||
defaultValue={fv.premiumAmount}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">창업비용 (만원)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="startupCostAmount"
|
||||
placeholder="15000"
|
||||
defaultValue={fv.startupCostAmount}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">월매출 (만원)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="monthlySalesAmount"
|
||||
placeholder="8500"
|
||||
defaultValue={fv.monthlySalesAmount}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">월수익 (만원)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="monthlyProfitAmount"
|
||||
placeholder="990"
|
||||
defaultValue={fv.monthlyProfitAmount}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">매물 설명</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
name="listingDescription"
|
||||
defaultValue={fv.listingDescription}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">입지 특징</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
name="locationHighlight"
|
||||
defaultValue={fv.locationHighlight}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">매매 사유</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
name="saleReason"
|
||||
defaultValue={fv.saleReason}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 임대 정보 */}
|
||||
<section>
|
||||
<h2 className="font-display text-xl font-bold text-ink mb-4">임대 정보</h2>
|
||||
<div className="space-y-4 rounded-2xl border border-ink/5 bg-white/70 backdrop-blur-sm p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">보증금 (만원)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="depositAmount"
|
||||
placeholder="5000"
|
||||
defaultValue={fv.depositAmount}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">월세 (만원)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="monthlyRentAmount"
|
||||
placeholder="300"
|
||||
defaultValue={fv.monthlyRentAmount}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">임대 잔여 기간 (개월)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="remainingLeaseMonths"
|
||||
defaultValue={fv.remainingLeaseMonths}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 시설 정보 */}
|
||||
<section>
|
||||
<h2 className="font-display text-xl font-bold text-ink mb-4">시설 정보</h2>
|
||||
<div className="space-y-4 rounded-2xl border border-ink/5 bg-white/70 backdrop-blur-sm p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">면적 (㎡)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="exclusiveAreaSqm"
|
||||
step="0.01"
|
||||
defaultValue={fv.exclusiveAreaSqm}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">층수</label>
|
||||
<input
|
||||
type="number"
|
||||
name="floorLevel"
|
||||
defaultValue={fv.floorLevel}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">시설 설명</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
name="kitchenEquipmentSummary"
|
||||
defaultValue={fv.kitchenEquipmentSummary}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="rounded-full px-6 py-3 text-sm font-semibold text-white disabled:opacity-50"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg,#03C75A 0%,#02A149 55%,#018f40 100%)',
|
||||
boxShadow: '0 8px 20px -8px rgba(3,199,90,0.45)',
|
||||
}}
|
||||
>
|
||||
{isPending ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
<Link
|
||||
href={`/stores/${initial.publicId}`}
|
||||
className="rounded-full border-2 border-ink/15 px-6 py-3 text-sm font-medium text-ink hover:border-ink/40 transition-colors"
|
||||
>
|
||||
취소
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getStoreForEdit } from './actions';
|
||||
import { EditStoreForm } from './edit-form';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function EditStorePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const initial = await getStoreForEdit(id);
|
||||
if (!initial) notFound();
|
||||
return <EditStoreForm initial={initial} />;
|
||||
}
|
||||
@@ -46,7 +46,14 @@ async function handleDeleteDraft(formData: FormData) {
|
||||
|
||||
function formatKRW(value: number | null | undefined): string {
|
||||
if (value == null) return '-';
|
||||
return `${Number(value).toLocaleString('ko-KR')}원`;
|
||||
const won = Number(value);
|
||||
if (won === 0) return '0원';
|
||||
const eok = Math.floor(won / 100_000_000);
|
||||
const man = Math.floor((won % 100_000_000) / 10_000);
|
||||
if (eok > 0 && man > 0) return `${eok}억 ${man.toLocaleString('ko-KR')}만원`;
|
||||
if (eok > 0) return `${eok}억원`;
|
||||
if (man > 0) return `${man.toLocaleString('ko-KR')}만원`;
|
||||
return `${won.toLocaleString('ko-KR')}원`;
|
||||
}
|
||||
|
||||
function formatMargin(sales?: number | null, profit?: number | null): string {
|
||||
@@ -75,26 +82,32 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id
|
||||
const { id } = await params;
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
const store = await prisma.store.findUnique({
|
||||
where: { publicId: id },
|
||||
include: {
|
||||
regionCluster: { select: { nameKo: true } },
|
||||
industryLeaf: {
|
||||
select: { nameKo: true, parent: { select: { nameKo: true } } },
|
||||
const [store, session] = await Promise.all([
|
||||
prisma.store.findUnique({
|
||||
where: { publicId: id },
|
||||
include: {
|
||||
regionCluster: { select: { nameKo: true } },
|
||||
industryLeaf: {
|
||||
select: { nameKo: true, parent: { select: { nameKo: true } } },
|
||||
},
|
||||
lease: true,
|
||||
sale: true,
|
||||
facility: true,
|
||||
photos: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
lease: true,
|
||||
sale: true,
|
||||
facility: true,
|
||||
photos: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
auth(),
|
||||
]);
|
||||
|
||||
if (!store) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isOwner = !!session?.user?.dbId && store.ownerUserId === BigInt(session.user.dbId);
|
||||
const canEdit = isOwner && (store.reviewStatus === 'DRAFT' || store.reviewStatus === 'REJECTED');
|
||||
|
||||
const status = STATUS_META[store.dealStatus] ?? { label: store.dealStatus, bg: '#E4E8E6', fg: '#0F1D17' };
|
||||
|
||||
const industryLabel = [
|
||||
@@ -304,7 +317,16 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="mt-6 space-y-3">
|
||||
{store.reviewStatus === 'DRAFT' && (
|
||||
{canEdit && (
|
||||
<Link
|
||||
href={`/stores/${store.publicId}/edit`}
|
||||
className="block w-full rounded-full border-2 py-3 text-center text-sm font-semibold text-ink hover:bg-[#F7FAF8]"
|
||||
style={{ borderColor: '#03C75A', color: '#02A149' }}
|
||||
>
|
||||
✏️ 수정
|
||||
</Link>
|
||||
)}
|
||||
{store.reviewStatus === 'DRAFT' && isOwner && (
|
||||
<>
|
||||
<form action={handleSubmitForReview}>
|
||||
<input type="hidden" name="storePublicId" value={store.publicId} />
|
||||
|
||||
@@ -91,6 +91,11 @@ export async function createStoreDraftAction(
|
||||
locationHighlight ||
|
||||
saleReason;
|
||||
|
||||
// 폼 입력은 만원 단위 → DB는 원 단위로 저장 (× 10000)
|
||||
const MAN = 10000;
|
||||
const toWon = (v: string | null) => (v ? Number(v) * MAN : undefined);
|
||||
const toWonRequired = (v: string | null) => (v ? Number(v) * MAN : 0);
|
||||
|
||||
const input: CreateStoreDraftInput = {
|
||||
ownerUserId: session.user.dbId,
|
||||
listingTitle,
|
||||
@@ -100,9 +105,9 @@ export async function createStoreDraftAction(
|
||||
...(depositAmount || monthlyRentAmount || premiumAmount || remainingLeaseMonths
|
||||
? {
|
||||
lease: {
|
||||
depositAmount: depositAmount ? Number(depositAmount) : 0,
|
||||
monthlyRentAmount: monthlyRentAmount ? Number(monthlyRentAmount) : 0,
|
||||
premiumAmount: premiumAmount ? Number(premiumAmount) : 0,
|
||||
depositAmount: toWonRequired(depositAmount),
|
||||
monthlyRentAmount: toWonRequired(monthlyRentAmount),
|
||||
premiumAmount: toWonRequired(premiumAmount),
|
||||
remainingLeaseMonths: remainingLeaseMonths
|
||||
? parseInt(remainingLeaseMonths, 10)
|
||||
: undefined,
|
||||
@@ -112,10 +117,10 @@ export async function createStoreDraftAction(
|
||||
...(hasSale
|
||||
? {
|
||||
sale: {
|
||||
premiumAmount: premiumAmount ? Number(premiumAmount) : undefined,
|
||||
monthlySalesAmount: monthlySalesAmount ? Number(monthlySalesAmount) : undefined,
|
||||
monthlyProfitAmount: monthlyProfitAmount ? Number(monthlyProfitAmount) : undefined,
|
||||
startupCostAmount: startupCostAmount ? Number(startupCostAmount) : undefined,
|
||||
premiumAmount: toWon(premiumAmount),
|
||||
monthlySalesAmount: toWon(monthlySalesAmount),
|
||||
monthlyProfitAmount: toWon(monthlyProfitAmount),
|
||||
startupCostAmount: toWon(startupCostAmount),
|
||||
listingDescription: listingDescription || undefined,
|
||||
locationHighlight: locationHighlight || undefined,
|
||||
saleReason: saleReason || undefined,
|
||||
|
||||
@@ -124,21 +124,21 @@ export default function NewStorePage() {
|
||||
<div className="space-y-4 rounded-2xl border border-ink/5 bg-white/70 backdrop-blur-sm p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">권리금 (원)</label>
|
||||
<label className="block text-sm text-ink-muted mb-1">권리금 (만원)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="premiumAmount"
|
||||
placeholder="120000000"
|
||||
placeholder="12000"
|
||||
defaultValue={state.fieldValues?.premiumAmount}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">창업비용 (원)</label>
|
||||
<label className="block text-sm text-ink-muted mb-1">창업비용 (만원)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="startupCostAmount"
|
||||
placeholder="150000000"
|
||||
placeholder="15000"
|
||||
defaultValue={state.fieldValues?.startupCostAmount}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
@@ -146,21 +146,21 @@ export default function NewStorePage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">월매출 (원)</label>
|
||||
<label className="block text-sm text-ink-muted mb-1">월매출 (만원)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="monthlySalesAmount"
|
||||
placeholder="85000000"
|
||||
placeholder="8500"
|
||||
defaultValue={state.fieldValues?.monthlySalesAmount}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">월수익 (원)</label>
|
||||
<label className="block text-sm text-ink-muted mb-1">월수익 (만원)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="monthlyProfitAmount"
|
||||
placeholder="9900000"
|
||||
placeholder="990"
|
||||
defaultValue={state.fieldValues?.monthlyProfitAmount}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
@@ -205,21 +205,21 @@ export default function NewStorePage() {
|
||||
<div className="space-y-4 rounded-2xl border border-ink/5 bg-white/70 backdrop-blur-sm p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">보증금 (원)</label>
|
||||
<label className="block text-sm text-ink-muted mb-1">보증금 (만원)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="depositAmount"
|
||||
placeholder="50000000"
|
||||
placeholder="5000"
|
||||
defaultValue={state.fieldValues?.depositAmount}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-muted mb-1">월세 (원)</label>
|
||||
<label className="block text-sm text-ink-muted mb-1">월세 (만원)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="monthlyRentAmount"
|
||||
placeholder="3000000"
|
||||
placeholder="300"
|
||||
defaultValue={state.fieldValues?.monthlyRentAmount}
|
||||
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
|
||||
/>
|
||||
|
||||
@@ -10,11 +10,11 @@ function formatKRWShort(value: number | null | undefined): string {
|
||||
const v = Number(value);
|
||||
if (v >= 100_000_000) {
|
||||
const eok = v / 100_000_000;
|
||||
return `${Number.isInteger(eok) ? eok : eok.toFixed(1)}억`;
|
||||
return `${Number.isInteger(eok) ? eok : eok.toFixed(1)}억원`;
|
||||
}
|
||||
if (v >= 10_000) {
|
||||
const man = Math.round(v / 10_000);
|
||||
return `${man.toLocaleString('ko-KR')}만`;
|
||||
return `${man.toLocaleString('ko-KR')}만원`;
|
||||
}
|
||||
return `${v.toLocaleString('ko-KR')}원`;
|
||||
}
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "tsx seeds/seed.ts",
|
||||
"prisma:seed:demo": "tsx seeds/demo-data.ts"
|
||||
"prisma:seed": "tsx seeds/seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx seeds/seed.ts"
|
||||
|
||||
@@ -1,626 +0,0 @@
|
||||
/**
|
||||
* Demo data seeder
|
||||
* - Creates dummy stores (published) spanning all deal statuses including CLOSED (거래 완료)
|
||||
* - Creates dummy match requests spanning OPEN / REVIEWING / ACCEPTED / COMPLETED
|
||||
*
|
||||
* Run: pnpm --filter @startover/database exec tsx seeds/demo-data.ts
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const DEMO_OWNER_EMAIL = 'demo-owner@startover.co.kr';
|
||||
const DEMO_FOUNDER_EMAIL = 'demo-founder@startover.co.kr';
|
||||
const DEMO_PASSWORD_HASH =
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$MMkWVs1hKzKPp5P93KLITw$fhiUqTZMkR0ucwQNteHskMn9UIrD/7aUXZPUdzWYloE';
|
||||
|
||||
type DemoStore = {
|
||||
title: string;
|
||||
region: 'KR.BETA.GANGNAM_CORE' | 'KR.BETA.MAPO_CORE';
|
||||
industry: string;
|
||||
dealStatus: 'OPEN' | 'MATCHING' | 'RESERVED' | 'CONTRACTED' | 'CLOSED';
|
||||
roadAddress: string;
|
||||
premium: number;
|
||||
monthlySales: number;
|
||||
monthlyProfit: number;
|
||||
startup: number;
|
||||
deposit: number;
|
||||
monthlyRent: number;
|
||||
remainingMonths: number;
|
||||
area: number;
|
||||
floor: number;
|
||||
description: string;
|
||||
locationHighlight: string;
|
||||
saleReason: string;
|
||||
photoUrl: string;
|
||||
};
|
||||
|
||||
const DEMO_STORES: DemoStore[] = [
|
||||
{
|
||||
title: '역삼역 1분거리 스타벅스 대체 카페 · 권리양도',
|
||||
region: 'KR.BETA.GANGNAM_CORE',
|
||||
industry: 'REST_LIGHT.CAFE',
|
||||
dealStatus: 'OPEN',
|
||||
roadAddress: '서울특별시 강남구 역삼로 112',
|
||||
premium: 180_000_000,
|
||||
monthlySales: 42_000_000,
|
||||
monthlyProfit: 9_800_000,
|
||||
startup: 40_000_000,
|
||||
deposit: 100_000_000,
|
||||
monthlyRent: 6_500_000,
|
||||
remainingMonths: 28,
|
||||
area: 56,
|
||||
floor: 1,
|
||||
description:
|
||||
'역삼역 3번 출구 도보 1분.\n오피스 밀집지역 점심 피크 시 대기 발생.\n시설 2년 사용(에스프레소 머신 2조, 제빙기 포함).',
|
||||
locationHighlight: '오피스/호텔 2,200세대 · 배달앱 리뷰 2,300+',
|
||||
saleReason: '개인 사정으로 수도권 외 이전 예정',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1453614512568-c4024d13c247?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '선릉역 직장인 한식 백반 전문점',
|
||||
region: 'KR.BETA.GANGNAM_CORE',
|
||||
industry: 'REST_FULL.KOREAN',
|
||||
dealStatus: 'MATCHING',
|
||||
roadAddress: '서울특별시 강남구 테헤란로 315',
|
||||
premium: 120_000_000,
|
||||
monthlySales: 55_000_000,
|
||||
monthlyProfit: 12_400_000,
|
||||
startup: 35_000_000,
|
||||
deposit: 80_000_000,
|
||||
monthlyRent: 4_800_000,
|
||||
remainingMonths: 18,
|
||||
area: 72,
|
||||
floor: 2,
|
||||
description:
|
||||
'점심 회전 3.5회, 저녁 단체석 운영.\n주방 2인 + 홀 2인 체계.\n직원 전원 승계 가능.',
|
||||
locationHighlight: '반경 300m 오피스 직장인 1.2만명 · 카드매출 안정',
|
||||
saleReason: '은퇴 준비',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '홍대입구 골목 와인바 · 임대승계 가능',
|
||||
region: 'KR.BETA.MAPO_CORE',
|
||||
industry: 'BAR.WINE',
|
||||
dealStatus: 'OPEN',
|
||||
roadAddress: '서울특별시 마포구 와우산로 33',
|
||||
premium: 95_000_000,
|
||||
monthlySales: 31_000_000,
|
||||
monthlyProfit: 7_100_000,
|
||||
startup: 25_000_000,
|
||||
deposit: 70_000_000,
|
||||
monthlyRent: 3_900_000,
|
||||
remainingMonths: 34,
|
||||
area: 45,
|
||||
floor: 1,
|
||||
description:
|
||||
'홍대 골목상권, 20-30대 여성 주요 고객층.\n인스타그램 팔로워 1.4만, 네이버 플레이스 별점 4.7.\n와인 셀러·디캔터 포함.',
|
||||
locationHighlight: 'SNS 상권, 연남/홍대 유동인구 도보 5분',
|
||||
saleReason: '해외 거주 예정',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1470337458703-46ad1756a187?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '합정역 2번출구 프랜차이즈 치킨 · 권리양도',
|
||||
region: 'KR.BETA.MAPO_CORE',
|
||||
industry: 'REST_LIGHT.CHICKEN_PIZZA',
|
||||
dealStatus: 'CONTRACTED',
|
||||
roadAddress: '서울특별시 마포구 양화로 45',
|
||||
premium: 130_000_000,
|
||||
monthlySales: 47_000_000,
|
||||
monthlyProfit: 8_900_000,
|
||||
startup: 30_000_000,
|
||||
deposit: 60_000_000,
|
||||
monthlyRent: 4_100_000,
|
||||
remainingMonths: 22,
|
||||
area: 40,
|
||||
floor: 1,
|
||||
description: '브랜드 본사 승인 완료, 로얄티 월 80만원. 오토 체제.',
|
||||
locationHighlight: '배달 상권 상위 5% · 배민 별점 4.9',
|
||||
saleReason: '타 업종 전환',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1562967914-608f82629710?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '논현동 24시 헬스장 · 회원권 승계',
|
||||
region: 'KR.BETA.GANGNAM_CORE',
|
||||
industry: 'LEISURE.GYM',
|
||||
dealStatus: 'MATCHING',
|
||||
roadAddress: '서울특별시 강남구 논현로 88',
|
||||
premium: 260_000_000,
|
||||
monthlySales: 72_000_000,
|
||||
monthlyProfit: 18_300_000,
|
||||
startup: 120_000_000,
|
||||
deposit: 200_000_000,
|
||||
monthlyRent: 11_000_000,
|
||||
remainingMonths: 40,
|
||||
area: 520,
|
||||
floor: 3,
|
||||
description:
|
||||
'지하 전용면적 520㎡ · 회원 820명 활성 유지.\nPT 전담 트레이너 6명 승계 가능.',
|
||||
locationHighlight: '오피스텔/아파트 3,500세대 배후',
|
||||
saleReason: '프랜차이즈 본사 사업 확장',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '연남동 베이커리 · 권리양도',
|
||||
region: 'KR.BETA.MAPO_CORE',
|
||||
industry: 'REST_LIGHT.BAKERY',
|
||||
dealStatus: 'OPEN',
|
||||
roadAddress: '서울특별시 마포구 연남로 22',
|
||||
premium: 75_000_000,
|
||||
monthlySales: 28_000_000,
|
||||
monthlyProfit: 6_200_000,
|
||||
startup: 20_000_000,
|
||||
deposit: 50_000_000,
|
||||
monthlyRent: 3_100_000,
|
||||
remainingMonths: 16,
|
||||
area: 38,
|
||||
floor: 1,
|
||||
description: '연남동 핵심 상권. 주말 줄서는 가게. 시설 1년 사용.',
|
||||
locationHighlight: '연남동 경의선숲길 도보 2분',
|
||||
saleReason: '건강상의 이유',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1509440159596-0249088772ff?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '강남역 11번 출구 편의점 · 양수양도',
|
||||
region: 'KR.BETA.GANGNAM_CORE',
|
||||
industry: 'RETAIL.CVS',
|
||||
dealStatus: 'OPEN',
|
||||
roadAddress: '서울특별시 강남구 강남대로 420',
|
||||
premium: 220_000_000,
|
||||
monthlySales: 95_000_000,
|
||||
monthlyProfit: 8_100_000,
|
||||
startup: 60_000_000,
|
||||
deposit: 150_000_000,
|
||||
monthlyRent: 8_800_000,
|
||||
remainingMonths: 44,
|
||||
area: 46,
|
||||
floor: 1,
|
||||
description: '24시 운영, 야간 매출 비중 35%. 알바 6명 승계.',
|
||||
locationHighlight: '강남역 도보 2분 · 오피스 빌딩 직원 2.3만명',
|
||||
saleReason: '다른 매장 운영 집중',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1604719312566-8912e9227c6a?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '홍대 피씨방 80석 · 권리양도',
|
||||
region: 'KR.BETA.MAPO_CORE',
|
||||
industry: 'LEISURE.PCCAFE',
|
||||
dealStatus: 'RESERVED',
|
||||
roadAddress: '서울특별시 마포구 홍익로 12',
|
||||
premium: 140_000_000,
|
||||
monthlySales: 38_000_000,
|
||||
monthlyProfit: 9_200_000,
|
||||
startup: 80_000_000,
|
||||
deposit: 90_000_000,
|
||||
monthlyRent: 5_400_000,
|
||||
remainingMonths: 25,
|
||||
area: 210,
|
||||
floor: 3,
|
||||
description: 'RTX 4070 고사양 80석. 시설 투자 3년차. 월평균 이용자 8천명.',
|
||||
locationHighlight: '홍대 상권 · 학생/직장인 혼합',
|
||||
saleReason: '해외 이민',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1542751371-adc38448a05e?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '역삼동 미용실 · 단골 400명 승계',
|
||||
region: 'KR.BETA.GANGNAM_CORE',
|
||||
industry: 'SERVICE.HAIR',
|
||||
dealStatus: 'MATCHING',
|
||||
roadAddress: '서울특별시 강남구 테헤란로 205',
|
||||
premium: 60_000_000,
|
||||
monthlySales: 22_000_000,
|
||||
monthlyProfit: 5_400_000,
|
||||
startup: 18_000_000,
|
||||
deposit: 40_000_000,
|
||||
monthlyRent: 2_700_000,
|
||||
remainingMonths: 20,
|
||||
area: 33,
|
||||
floor: 2,
|
||||
description: '디자이너 2명 승계 가능. 인스타 프로모션 노하우 공유.',
|
||||
locationHighlight: '오피스 여성 직장인 중심',
|
||||
saleReason: '결혼 · 타지역 이전',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1560066984-138dadb4c035?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '선릉 일식 오마카세 8석 · 예약제',
|
||||
region: 'KR.BETA.GANGNAM_CORE',
|
||||
industry: 'REST_FULL.JAPANESE',
|
||||
dealStatus: 'OPEN',
|
||||
roadAddress: '서울특별시 강남구 선릉로 512',
|
||||
premium: 210_000_000,
|
||||
monthlySales: 48_000_000,
|
||||
monthlyProfit: 14_200_000,
|
||||
startup: 90_000_000,
|
||||
deposit: 120_000_000,
|
||||
monthlyRent: 7_500_000,
|
||||
remainingMonths: 33,
|
||||
area: 52,
|
||||
floor: 2,
|
||||
description: '예약제 100% 운영. 객단가 18만원. 캐치테이블 노출 상위.',
|
||||
locationHighlight: '강남 미쉐린 리스트 주변 상권',
|
||||
saleReason: '일본 본점 복귀',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1553621042-f6e147245754?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '합정 칵테일 이자카야 · 인테리어 2년',
|
||||
region: 'KR.BETA.MAPO_CORE',
|
||||
industry: 'BAR.IZAKAYA',
|
||||
dealStatus: 'OPEN',
|
||||
roadAddress: '서울특별시 마포구 토정로 19',
|
||||
premium: 85_000_000,
|
||||
monthlySales: 27_000_000,
|
||||
monthlyProfit: 6_300_000,
|
||||
startup: 22_000_000,
|
||||
deposit: 55_000_000,
|
||||
monthlyRent: 3_400_000,
|
||||
remainingMonths: 29,
|
||||
area: 42,
|
||||
floor: 1,
|
||||
description: '금/토 만석 운영. 안주 원가율 28%.',
|
||||
locationHighlight: '합정-상수 도보 축 상권',
|
||||
saleReason: '공동대표 독립',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1514933651103-005eec06c04b?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '논현 요가·필라테스 스튜디오',
|
||||
region: 'KR.BETA.GANGNAM_CORE',
|
||||
industry: 'LEISURE.YOGA',
|
||||
dealStatus: 'OPEN',
|
||||
roadAddress: '서울특별시 강남구 도산대로 150',
|
||||
premium: 90_000_000,
|
||||
monthlySales: 26_500_000,
|
||||
monthlyProfit: 7_800_000,
|
||||
startup: 35_000_000,
|
||||
deposit: 70_000_000,
|
||||
monthlyRent: 4_200_000,
|
||||
remainingMonths: 27,
|
||||
area: 88,
|
||||
floor: 5,
|
||||
description: '강사 4명 승계. 회원 320명 활성. 리포머 6대.',
|
||||
locationHighlight: '오피스/압구정 도보 10분',
|
||||
saleReason: '2호점 집중',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1545205597-3d9d02c29597?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '마포 세차장·카센터 · 2,000평 부지',
|
||||
region: 'KR.BETA.MAPO_CORE',
|
||||
industry: 'SERVICE.CARWASH',
|
||||
dealStatus: 'CLOSED',
|
||||
roadAddress: '서울특별시 마포구 성암로 89',
|
||||
premium: 180_000_000,
|
||||
monthlySales: 43_000_000,
|
||||
monthlyProfit: 11_800_000,
|
||||
startup: 60_000_000,
|
||||
deposit: 150_000_000,
|
||||
monthlyRent: 8_900_000,
|
||||
remainingMonths: 12,
|
||||
area: 620,
|
||||
floor: 1,
|
||||
description: '(거래 완료) 기계 세차 4라인 + 정비 3라인 풀패키지.',
|
||||
locationHighlight: '성산IC 인접 · 차량 접근성 최상',
|
||||
saleReason: '양도 완료',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1605515298946-d0573716f4a9?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '강남 네일아트 프리미엄 숍',
|
||||
region: 'KR.BETA.GANGNAM_CORE',
|
||||
industry: 'SERVICE.NAIL',
|
||||
dealStatus: 'CLOSED',
|
||||
roadAddress: '서울특별시 강남구 역삼로 88',
|
||||
premium: 55_000_000,
|
||||
monthlySales: 19_000_000,
|
||||
monthlyProfit: 5_100_000,
|
||||
startup: 15_000_000,
|
||||
deposit: 40_000_000,
|
||||
monthlyRent: 2_600_000,
|
||||
remainingMonths: 14,
|
||||
area: 28,
|
||||
floor: 4,
|
||||
description: '(거래 완료) 단골 600명 · 예약 시스템 승계 완료.',
|
||||
locationHighlight: '오피스 여성 직장인 중심',
|
||||
saleReason: '양도 완료',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1604654894610-df63bc536371?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '홍대 분식 · 테이크아웃 전문',
|
||||
region: 'KR.BETA.MAPO_CORE',
|
||||
industry: 'REST_LIGHT.SNACK',
|
||||
dealStatus: 'CLOSED',
|
||||
roadAddress: '서울특별시 마포구 와우산로 15',
|
||||
premium: 40_000_000,
|
||||
monthlySales: 21_000_000,
|
||||
monthlyProfit: 6_900_000,
|
||||
startup: 12_000_000,
|
||||
deposit: 30_000_000,
|
||||
monthlyRent: 1_900_000,
|
||||
remainingMonths: 10,
|
||||
area: 19,
|
||||
floor: 1,
|
||||
description: '(거래 완료) 테이크아웃 비중 70% · 배달 탑텐.',
|
||||
locationHighlight: '학생 상권',
|
||||
saleReason: '양도 완료',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1534766555764-ce878a5e3a2b?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
title: '논현동 스크린골프 8타석',
|
||||
region: 'KR.BETA.GANGNAM_CORE',
|
||||
industry: 'LEISURE.SCREENGOLF',
|
||||
dealStatus: 'OPEN',
|
||||
roadAddress: '서울특별시 강남구 논현로 240',
|
||||
premium: 195_000_000,
|
||||
monthlySales: 44_000_000,
|
||||
monthlyProfit: 13_200_000,
|
||||
startup: 120_000_000,
|
||||
deposit: 150_000_000,
|
||||
monthlyRent: 9_800_000,
|
||||
remainingMonths: 36,
|
||||
area: 198,
|
||||
floor: 4,
|
||||
description: '카카오 스크린 최신 기종 8타석. 법인/단골 비중 높음.',
|
||||
locationHighlight: '강남역 6번출구 도보 7분',
|
||||
saleReason: '개인 사정',
|
||||
photoUrl:
|
||||
'https://images.unsplash.com/photo-1535131749006-b7f58c99034b?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
];
|
||||
|
||||
async function ensureUser(email: string, name: string, role: 'CLOSING_OWNER' | 'FOUNDER'): Promise<bigint> {
|
||||
const emailNormalized = email.toLowerCase().trim();
|
||||
const existing = await prisma.user.findFirst({ where: { emailNormalized } });
|
||||
if (existing) return existing.id;
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
emailNormalized,
|
||||
name,
|
||||
passwordHash: DEMO_PASSWORD_HASH,
|
||||
primaryRole: role,
|
||||
status: 'ACTIVE',
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
console.log(`Demo user: ${email} (id: ${created.id})`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function ensureDemoStores(ownerUserId: bigint): Promise<void> {
|
||||
// Cache region & industry lookups
|
||||
const regionCodes = Array.from(new Set(DEMO_STORES.map((s) => s.region)));
|
||||
const industryCodes = Array.from(new Set(DEMO_STORES.map((s) => s.industry)));
|
||||
|
||||
const regions = await prisma.regionHierarchy.findMany({
|
||||
where: { code: { in: regionCodes } },
|
||||
select: { id: true, code: true },
|
||||
});
|
||||
const industries = await prisma.industryTaxonomy.findMany({
|
||||
where: { code: { in: industryCodes } },
|
||||
select: { id: true, code: true },
|
||||
});
|
||||
const regionByCode = new Map(regions.map((r) => [r.code, r.id] as const));
|
||||
const industryByCode = new Map(industries.map((i) => [i.code, i.id] as const));
|
||||
|
||||
const now = new Date();
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const s of DEMO_STORES) {
|
||||
// Skip if a store with the same title already exists (idempotent)
|
||||
const existing = await prisma.store.findFirst({
|
||||
where: { listingTitle: s.title, ownerUserId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const regionClusterId = regionByCode.get(s.region) ?? null;
|
||||
const industryLeafId = industryByCode.get(s.industry) ?? null;
|
||||
if (!regionClusterId || !industryLeafId) {
|
||||
console.warn(`Skipping "${s.title}": region or industry not found (${s.region} / ${s.industry})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const store = await prisma.store.create({
|
||||
data: {
|
||||
ownerUserId,
|
||||
listingTitle: s.title,
|
||||
publicSummary: s.description.split('\n')[0]?.slice(0, 140),
|
||||
regionClusterId,
|
||||
industryLeafId,
|
||||
roadAddress: s.roadAddress,
|
||||
reviewStatus: 'APPROVED',
|
||||
publicationStatus: 'PUBLISHED',
|
||||
dealStatus: s.dealStatus,
|
||||
publishedAt: now,
|
||||
approvedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.storeLease.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
depositAmount: s.deposit,
|
||||
monthlyRentAmount: s.monthlyRent,
|
||||
premiumAmount: s.premium,
|
||||
transferable: true,
|
||||
remainingLeaseMonths: s.remainingMonths,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.storeSale.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
premiumAmount: s.premium,
|
||||
monthlySalesAmount: s.monthlySales,
|
||||
monthlyProfitAmount: s.monthlyProfit,
|
||||
startupCostAmount: s.startup,
|
||||
listingDescription: s.description,
|
||||
locationHighlight: s.locationHighlight,
|
||||
saleReason: s.saleReason,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.storeFacility.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
exclusiveAreaSqm: s.area,
|
||||
floorLevel: s.floor,
|
||||
hasGas: true,
|
||||
hasDrainage: true,
|
||||
hasDuct: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.storePhoto.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
storageKey: s.photoUrl,
|
||||
photoCategory: 'EXTERIOR',
|
||||
visibilityScope: 'PUBLIC_SUMMARY',
|
||||
sortOrder: 0,
|
||||
uploadedByUserId: ownerUserId,
|
||||
isRepresentative: true,
|
||||
},
|
||||
});
|
||||
|
||||
created++;
|
||||
}
|
||||
|
||||
console.log(`Demo stores: created=${created}, skipped(existing)=${skipped}, total=${DEMO_STORES.length}`);
|
||||
}
|
||||
|
||||
async function ensureDemoMatchRequests(requesterUserId: bigint): Promise<void> {
|
||||
const stores = await prisma.store.findMany({
|
||||
where: { publicationStatus: 'PUBLISHED' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
select: { id: true, listingTitle: true },
|
||||
});
|
||||
if (stores.length === 0) return;
|
||||
|
||||
const scenarios: Array<{
|
||||
matchType: 'ACQUISITION' | 'DEMOLITION' | 'INTERIOR';
|
||||
status: 'OPEN' | 'REVIEWING' | 'ACCEPTED' | 'COMPLETED';
|
||||
message: string;
|
||||
}> = [
|
||||
{
|
||||
matchType: 'ACQUISITION',
|
||||
status: 'OPEN',
|
||||
message: '인수 희망합니다. 실사 가능한 시점 알려주세요.',
|
||||
},
|
||||
{
|
||||
matchType: 'ACQUISITION',
|
||||
status: 'REVIEWING',
|
||||
message: '권리금 협의 가능 여부 확인 부탁드립니다.',
|
||||
},
|
||||
{
|
||||
matchType: 'DEMOLITION',
|
||||
status: 'OPEN',
|
||||
message: '철거 견적 요청합니다. 현장 방문 가능한가요?',
|
||||
},
|
||||
{
|
||||
matchType: 'INTERIOR',
|
||||
status: 'ACCEPTED',
|
||||
message: '인테리어 재시공 협의 완료. 도면 공유 드립니다.',
|
||||
},
|
||||
{
|
||||
matchType: 'ACQUISITION',
|
||||
status: 'COMPLETED',
|
||||
message: '인수 계약 완료. 정산 대기.',
|
||||
},
|
||||
{
|
||||
matchType: 'ACQUISITION',
|
||||
status: 'OPEN',
|
||||
message: '실 운영자 승계 가능 여부 문의 드립니다.',
|
||||
},
|
||||
{
|
||||
matchType: 'DEMOLITION',
|
||||
status: 'REVIEWING',
|
||||
message: '원상복구 범위 확인 후 최종 견적 드릴게요.',
|
||||
},
|
||||
{
|
||||
matchType: 'INTERIOR',
|
||||
status: 'OPEN',
|
||||
message: '카페 → 와인바 컨셉 전환 인테리어 문의',
|
||||
},
|
||||
];
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
// Round-robin scenarios over stores
|
||||
for (let i = 0; i < Math.min(stores.length, scenarios.length * 2); i++) {
|
||||
const store = stores[i]!;
|
||||
const scenario = scenarios[i % scenarios.length]!;
|
||||
|
||||
// Idempotency: skip if we already created a demo request for this store+type+user
|
||||
const existing = await prisma.matchRequest.findFirst({
|
||||
where: {
|
||||
storeId: store.id,
|
||||
matchType: scenario.matchType,
|
||||
requesterUserId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const acceptedAt = scenario.status === 'ACCEPTED' || scenario.status === 'COMPLETED' ? now : null;
|
||||
const closedAt = scenario.status === 'COMPLETED' ? now : null;
|
||||
|
||||
await prisma.matchRequest.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
matchType: scenario.matchType,
|
||||
requesterUserId,
|
||||
sourceType: 'USER_REQUEST',
|
||||
status: scenario.status,
|
||||
message: scenario.message,
|
||||
acceptedAt,
|
||||
closedAt,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
console.log(`Demo match requests: created=${created}, skipped(existing)=${skipped}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('Starting DEMO seed...');
|
||||
const ownerId = await ensureUser(DEMO_OWNER_EMAIL, '데모 매도인', 'CLOSING_OWNER');
|
||||
const founderId = await ensureUser(DEMO_FOUNDER_EMAIL, '데모 창업자', 'FOUNDER');
|
||||
|
||||
await ensureDemoStores(ownerId);
|
||||
await ensureDemoMatchRequests(founderId);
|
||||
|
||||
console.log('DEMO seed completed.');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Demo seed failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
Reference in New Issue
Block a user