feat: 철거/폐업 메뉴 + 매장 수정 페이지 추가
Deploy Startover / deploy (push) Failing after 0s

요구사항 반영:
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로 이미 적용됨).
This commit is contained in:
chpark
2026-04-30 22:57:51 +09:00
parent 857d3f08a7
commit 4524c3bbf4
7 changed files with 982 additions and 15 deletions
+184
View File
@@ -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>
);
}
+220
View File
@@ -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>
);
}
+2
View File
@@ -68,6 +68,8 @@ const OPERATOR_ROLES = [
const NAV_LINKS = [ const NAV_LINKS = [
{ href: '/stores', label: '매장 검색' }, { href: '/stores', label: '매장 검색' },
{ href: '/stores/new', label: '매장 등록' }, { href: '/stores/new', label: '매장 등록' },
{ href: '/demolition', label: '철거' },
{ href: '/closure', label: '폐업' },
{ href: '/matching', label: '매칭' }, { href: '/matching', label: '매칭' },
{ href: '/subsidies', label: '지원금' }, { href: '/subsidies', label: '지원금' },
{ href: '/vendors', 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} />;
}
+30 -15
View File
@@ -82,26 +82,32 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id
const { id } = await params; const { id } = await params;
const prisma = createPrismaClient(); const prisma = createPrismaClient();
const store = await prisma.store.findUnique({ const [store, session] = await Promise.all([
where: { publicId: id }, prisma.store.findUnique({
include: { where: { publicId: id },
regionCluster: { select: { nameKo: true } }, include: {
industryLeaf: { regionCluster: { select: { nameKo: true } },
select: { nameKo: true, parent: { 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, auth(),
facility: true, ]);
photos: {
orderBy: { sortOrder: 'asc' },
},
},
});
if (!store) { if (!store) {
notFound(); 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 status = STATUS_META[store.dealStatus] ?? { label: store.dealStatus, bg: '#E4E8E6', fg: '#0F1D17' };
const industryLabel = [ const industryLabel = [
@@ -311,7 +317,16 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id
{/* 액션 버튼 */} {/* 액션 버튼 */}
<div className="mt-6 space-y-3"> <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}> <form action={handleSubmitForReview}>
<input type="hidden" name="storePublicId" value={store.publicId} /> <input type="hidden" name="storePublicId" value={store.publicId} />