feat: 샘플 데이터를 실제 Prisma DB 연동으로 전환

- admin 페이지: contracts, stores, subsidies, vendors 실제 DB 조회
- ActionButtons 컴포넌트 분리 (클라이언트 컴포넌트)
- 사용자 페이지: contracts, matching, stores, subsidies, vendors DB 연동
- vendor 신청 폼 및 server actions 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Johngreen
2026-03-08 01:06:56 +09:00
parent 713b4d7241
commit 636eaaca23
17 changed files with 1131 additions and 440 deletions
@@ -0,0 +1,29 @@
'use client';
interface ContractActionButtonsProps {
contractPublicId: string;
escrowStatus: string;
releaseAction: (formData: FormData) => Promise<void>;
}
export default function ContractActionButtons({
contractPublicId,
escrowStatus,
releaseAction,
}: ContractActionButtonsProps) {
return (
<div className="flex gap-2">
{escrowStatus === 'RELEASE_REVIEW' && (
<form action={releaseAction}>
<input type="hidden" name="contractPublicId" value={contractPublicId} />
<button type="submit" className="text-sm text-green-600 hover:underline">
</button>
</form>
)}
{escrowStatus === 'DISPUTED' && (
<span className="text-sm text-blue-600"> </span>
)}
</div>
);
}
+78 -33
View File
@@ -1,9 +1,9 @@
const SAMPLE_CONTRACTS = [
{ id: 'ac-1', storeTitle: '선릉역 한식당', type: '철거', status: 'ACTIVE', escrow: 'RELEASE_REVIEW', amount: '₩5,000,000', createdAt: '2026-03-02' },
{ id: 'ac-2', storeTitle: '강남역 카페', type: '시설인수', status: 'SIGNED', escrow: 'HOLDING', amount: '₩50,000,000', createdAt: '2026-03-05' },
{ id: 'ac-3', storeTitle: '홍대 디저트카페', type: '인테리어', status: 'ACTIVE', escrow: 'DISPUTED', amount: '₩8,000,000', createdAt: '2026-02-28' },
{ id: 'ac-4', storeTitle: '합정 베이커리', type: '철거', status: 'COMPLETED', escrow: 'RELEASED', amount: '₩3,500,000', createdAt: '2026-02-15' },
];
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@relink/database';
import { releaseEscrowService } from '@/services/contract-service';
import ContractActionButtons from './ContractActionButtons';
export const dynamic = 'force-dynamic';
const STATUS_MAP: Record<string, { label: string; color: string }> = {
DRAFT: { label: '초안', color: 'bg-gray-100 text-gray-700' },
@@ -19,31 +19,62 @@ const ESCROW_MAP: Record<string, { label: string; color: string }> = {
DISPUTED: { label: '분쟁 중', color: 'bg-red-100 text-red-700' },
};
export default function AdminContractsPage() {
async function handleRelease(formData: FormData) {
'use server';
const contractPublicId = formData.get('contractPublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
await releaseEscrowService(prisma, contractPublicId, '1');
revalidatePath('/admin/contracts');
}
export default async function AdminContractsPage() {
const prisma = createPrismaClient();
const [contracts, activeCount, releaseReviewCount, disputedCount, escrowTotal] =
await Promise.all([
prisma.contract.findMany({
include: {
store: { select: { listingTitle: true } },
escrowTransactions: { select: { amount: true, transactionType: true } },
},
orderBy: { createdAt: 'desc' },
}),
prisma.contract.count({ where: { status: 'ACTIVE' } }),
prisma.contract.count({ where: { escrowStatus: 'RELEASE_REVIEW' } }),
prisma.contract.count({ where: { escrowStatus: 'DISPUTED' } }),
prisma.escrowTransaction.aggregate({
where: { transactionType: 'DEPOSIT', contract: { escrowStatus: 'HOLDING' } },
_sum: { amount: true },
}),
]);
const totalHolding = Number(escrowTotal._sum.amount ?? 0);
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900">/ </h1>
<p className="mt-1 text-sm text-gray-500">
, ,
</p>
<p className="mt-1 text-sm text-gray-500"> , , </p>
{/* 요약 카드 */}
<div className="mt-6 grid grid-cols-4 gap-4">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-2xl font-bold text-gray-900">2</p>
<p className="mt-1 text-2xl font-bold text-gray-900">{activeCount}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-2xl font-bold text-purple-600">1</p>
<p className="mt-1 text-2xl font-bold text-purple-600">{releaseReviewCount}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-2xl font-bold text-red-600">1</p>
<p className="mt-1 text-2xl font-bold text-red-600">{disputedCount}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-2xl font-bold text-gray-900">63M</p>
<p className="mt-1 text-2xl font-bold text-gray-900">
{totalHolding.toLocaleString('ko-KR')}
</p>
</div>
</div>
@@ -62,35 +93,49 @@ export default function AdminContractsPage() {
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{SAMPLE_CONTRACTS.map((c) => {
const statusInfo = STATUS_MAP[c.status] ?? { label: c.status, color: 'bg-gray-100 text-gray-700' };
const escrowInfo = ESCROW_MAP[c.escrow] ?? { label: c.escrow, color: 'bg-gray-100 text-gray-700' };
{contracts.map((c) => {
const statusInfo = STATUS_MAP[c.status] ?? {
label: c.status,
color: 'bg-gray-100 text-gray-700',
};
const escrowInfo = ESCROW_MAP[c.escrowStatus] ?? {
label: c.escrowStatus,
color: 'bg-gray-100 text-gray-700',
};
const depositTx = c.escrowTransactions.find((t) => t.transactionType === 'DEPOSIT');
const amount = depositTx ? Number(depositTx.amount) : 0;
return (
<tr key={c.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{c.storeTitle}</td>
<td className="px-4 py-3 text-gray-600">{c.type}</td>
<tr key={c.publicId} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">
{c.store?.listingTitle ?? '-'}
</td>
<td className="px-4 py-3 text-gray-600">{c.contractType}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}
>
{statusInfo.label}
</span>
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${escrowInfo.color}`}>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${escrowInfo.color}`}
>
{escrowInfo.label}
</span>
</td>
<td className="px-4 py-3 text-gray-600">{c.amount}</td>
<td className="px-4 py-3 text-gray-400">{c.createdAt}</td>
<td className="px-4 py-3 text-gray-600">
{amount > 0 ? `${amount.toLocaleString('ko-KR')}` : '-'}
</td>
<td className="px-4 py-3 text-gray-400">
{c.createdAt.toLocaleDateString('ko-KR')}
</td>
<td className="px-4 py-3">
{c.escrow === 'RELEASE_REVIEW' && (
<div className="flex gap-2">
<button className="text-sm text-green-600 hover:underline"> </button>
<button className="text-sm text-orange-600 hover:underline"></button>
</div>
)}
{c.escrow === 'DISPUTED' && (
<button className="text-sm text-blue-600 hover:underline"> </button>
)}
<ContractActionButtons
contractPublicId={c.publicId}
escrowStatus={c.escrowStatus}
releaseAction={handleRelease}
/>
</td>
</tr>
);
@@ -0,0 +1,30 @@
'use client';
interface StoreActionButtonsProps {
storePublicId: string;
approveAction: (formData: FormData) => Promise<void>;
rejectAction: (formData: FormData) => Promise<void>;
}
export default function StoreActionButtons({
storePublicId,
approveAction,
rejectAction,
}: StoreActionButtonsProps) {
return (
<div className="flex gap-2">
<form action={approveAction}>
<input type="hidden" name="storePublicId" value={storePublicId} />
<button type="submit" className="text-sm text-green-600 hover:underline">
</button>
</form>
<form action={rejectAction}>
<input type="hidden" name="storePublicId" value={storePublicId} />
<button type="submit" className="text-sm text-red-600 hover:underline">
</button>
</form>
</div>
);
}
+59 -22
View File
@@ -1,10 +1,9 @@
const SAMPLE_STORES = [
{ id: 's-1', title: '강남역 치킨집', region: '강남구', industry: '한식', status: 'SUBMITTED', owner: '김폐업', submittedAt: '2026-03-07 09:30' },
{ id: 's-2', title: '선릉역 중식당', region: '강남구', industry: '중식', status: 'SUBMITTED', owner: '이폐업', submittedAt: '2026-03-07 06:15' },
{ id: 's-3', title: '논현동 베이커리', region: '강남구', industry: '카페', status: 'SUBMITTED', owner: '박폐업', submittedAt: '2026-03-06 18:00' },
{ id: 's-4', title: '홍대 파스타집', region: '마포구', industry: '양식', status: 'APPROVED', owner: '최폐업', submittedAt: '2026-03-05 14:00' },
{ id: 's-5', title: '합정 디저트카페', region: '마포구', industry: '카페', status: 'REJECTED', owner: '정폐업', submittedAt: '2026-03-04 10:00' },
];
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@relink/database';
import { reviewStoreService } from '@/services/store-service';
import StoreActionButtons from './StoreActionButtons';
export const dynamic = 'force-dynamic';
const STATUS_MAP: Record<string, { label: string; color: string }> = {
SUBMITTED: { label: '검토 대기', color: 'bg-yellow-100 text-yellow-700' },
@@ -13,7 +12,37 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
PUBLISHED: { label: '공개', color: 'bg-blue-100 text-blue-700' },
};
export default function AdminStoresPage() {
async function handleApprove(formData: FormData) {
'use server';
const storePublicId = formData.get('storePublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
const actorUserId = '1';
await reviewStoreService(prisma, storePublicId, 'APPROVED', actorUserId);
revalidatePath('/admin/stores');
}
async function handleReject(formData: FormData) {
'use server';
const storePublicId = formData.get('storePublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
const actorUserId = '1';
await reviewStoreService(prisma, storePublicId, 'REJECTED', actorUserId);
revalidatePath('/admin/stores');
}
export default async function AdminStoresPage() {
const prisma = createPrismaClient();
const stores = await prisma.store.findMany({
include: {
ownerUser: { select: { phone: true } },
regionCluster: { select: { nameKo: true } },
industryLeaf: { select: { nameKo: true } },
},
orderBy: { createdAt: 'desc' },
});
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
@@ -46,26 +75,34 @@ export default function AdminStoresPage() {
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{SAMPLE_STORES.map((store) => {
const statusInfo = STATUS_MAP[store.status] ?? { label: store.status, color: 'bg-gray-100 text-gray-700' };
{stores.map((store) => {
const statusInfo = STATUS_MAP[store.reviewStatus] ?? {
label: store.reviewStatus,
color: 'bg-gray-100 text-gray-700',
};
return (
<tr key={store.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{store.title}</td>
<td className="px-4 py-3 text-gray-600">{store.region}</td>
<td className="px-4 py-3 text-gray-600">{store.industry}</td>
<td className="px-4 py-3 text-gray-600">{store.owner}</td>
<tr key={store.publicId} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{store.listingTitle}</td>
<td className="px-4 py-3 text-gray-600">{store.regionCluster?.nameKo ?? '-'}</td>
<td className="px-4 py-3 text-gray-600">{store.industryLeaf?.nameKo ?? '-'}</td>
<td className="px-4 py-3 text-gray-600">{store.ownerUser?.phone ?? '-'}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}
>
{statusInfo.label}
</span>
</td>
<td className="px-4 py-3 text-gray-400">{store.submittedAt}</td>
<td className="px-4 py-3 text-gray-400">
{store.createdAt.toLocaleDateString('ko-KR')}
</td>
<td className="px-4 py-3">
{store.status === 'SUBMITTED' && (
<div className="flex gap-2">
<button className="text-sm text-green-600 hover:underline"></button>
<button className="text-sm text-red-600 hover:underline"></button>
</div>
{store.reviewStatus === 'SUBMITTED' && (
<StoreActionButtons
storePublicId={store.publicId}
approveAction={handleApprove}
rejectAction={handleReject}
/>
)}
</td>
</tr>
@@ -0,0 +1,33 @@
'use client';
interface SubsidyActionButtonsProps {
subsidyCasePublicId: string;
status: string;
approveAction: (formData: FormData) => Promise<void>;
rejectAction: (formData: FormData) => Promise<void>;
}
export default function SubsidyActionButtons({
subsidyCasePublicId,
status,
approveAction,
rejectAction,
}: SubsidyActionButtonsProps) {
if (status !== 'SUBMITTED' && status !== 'REVIEWING') return null;
return (
<div className="flex gap-2">
<form action={approveAction}>
<input type="hidden" name="subsidyCasePublicId" value={subsidyCasePublicId} />
<button type="submit" className="text-sm text-green-600 hover:underline">
</button>
</form>
<form action={rejectAction}>
<input type="hidden" name="subsidyCasePublicId" value={subsidyCasePublicId} />
<button type="submit" className="text-sm text-red-600 hover:underline">
</button>
</form>
</div>
);
}
+64 -21
View File
@@ -1,9 +1,9 @@
const SAMPLE_CASES = [
{ id: 'asc-1', storeTitle: '강남역 카페', owner: '김폐업', status: 'SUBMITTED', checklist: '5/5', createdAt: '2026-03-06' },
{ id: 'asc-2', storeTitle: '선릉역 한식당', owner: '이폐업', status: 'REVIEWING', checklist: '5/5', createdAt: '2026-03-04' },
{ id: 'asc-3', storeTitle: '합정 베이커리', owner: '박폐업', status: 'APPROVED', checklist: '4/4', createdAt: '2026-03-01' },
{ id: 'asc-4', storeTitle: '논현동 분식점', owner: '최폐업', status: 'REJECTED', checklist: '3/5', createdAt: '2026-02-28' },
];
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@relink/database';
import { reviewSubsidyCaseService } from '@/services/subsidy-case-service';
import SubsidyActionButtons from './SubsidyActionButtons';
export const dynamic = 'force-dynamic';
const STATUS_MAP: Record<string, { label: string; color: string }> = {
DOCUMENTS_PENDING: { label: '서류 준비 중', color: 'bg-gray-100 text-gray-700' },
@@ -13,7 +13,35 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
REJECTED: { label: '반려', color: 'bg-red-100 text-red-700' },
};
export default function AdminSubsidiesPage() {
async function handleApprove(formData: FormData) {
'use server';
const subsidyCasePublicId = formData.get('subsidyCasePublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
await reviewSubsidyCaseService(prisma, subsidyCasePublicId, 'APPROVED', '1');
revalidatePath('/admin/subsidies');
}
async function handleReject(formData: FormData) {
'use server';
const subsidyCasePublicId = formData.get('subsidyCasePublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
await reviewSubsidyCaseService(prisma, subsidyCasePublicId, 'REJECTED', '1');
revalidatePath('/admin/subsidies');
}
export default async function AdminSubsidiesPage() {
const prisma = createPrismaClient();
const cases = await prisma.subsidyCase.findMany({
include: {
store: { select: { listingTitle: true } },
applicantUser: { select: { phone: true } },
checklistItems: true,
},
orderBy: { createdAt: 'desc' },
});
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
@@ -43,26 +71,41 @@ export default function AdminSubsidiesPage() {
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{SAMPLE_CASES.map((c) => {
const statusInfo = STATUS_MAP[c.status] ?? { label: c.status, color: 'bg-gray-100 text-gray-700' };
{cases.map((c) => {
const statusInfo = STATUS_MAP[c.status] ?? {
label: c.status,
color: 'bg-gray-100 text-gray-700',
};
const totalItems = c.checklistItems.length;
const completedItems = c.checklistItems.filter(
(item) => item.status === 'CHECKED',
).length;
return (
<tr key={c.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{c.storeTitle}</td>
<td className="px-4 py-3 text-gray-600">{c.owner}</td>
<td className="px-4 py-3 text-gray-600">{c.checklist}</td>
<tr key={c.publicId} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">
{c.store?.listingTitle ?? '-'}
</td>
<td className="px-4 py-3 text-gray-600">{c.applicantUser?.phone ?? '-'}</td>
<td className="px-4 py-3 text-gray-600">
{completedItems}/{totalItems}
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}
>
{statusInfo.label}
</span>
</td>
<td className="px-4 py-3 text-gray-400">{c.createdAt}</td>
<td className="px-4 py-3 text-gray-400">
{c.createdAt.toLocaleDateString('ko-KR')}
</td>
<td className="px-4 py-3">
{(c.status === 'SUBMITTED' || c.status === 'REVIEWING') && (
<div className="flex gap-2">
<button className="text-sm text-green-600 hover:underline"></button>
<button className="text-sm text-red-600 hover:underline"></button>
</div>
)}
<SubsidyActionButtons
subsidyCasePublicId={c.publicId}
status={c.status}
approveAction={handleApprove}
rejectAction={handleReject}
/>
</td>
</tr>
);
+56
View File
@@ -0,0 +1,56 @@
'use client';
interface VendorActionButtonsProps {
vendorPublicId: string;
status: string;
approveAction: (formData: FormData) => Promise<void>;
rejectAction: (formData: FormData) => Promise<void>;
suspendAction: (formData: FormData) => Promise<void>;
restoreAction: (formData: FormData) => Promise<void>;
}
export default function VendorActionButtons({
vendorPublicId,
status,
approveAction,
rejectAction,
suspendAction,
restoreAction,
}: VendorActionButtonsProps) {
return (
<div className="flex gap-2">
{(status === 'APPLIED' || status === 'REVIEWING') && (
<>
<form action={approveAction}>
<input type="hidden" name="vendorPublicId" value={vendorPublicId} />
<button type="submit" className="text-sm text-green-600 hover:underline">
</button>
</form>
<form action={rejectAction}>
<input type="hidden" name="vendorPublicId" value={vendorPublicId} />
<button type="submit" className="text-sm text-red-600 hover:underline">
</button>
</form>
</>
)}
{status === 'APPROVED' && (
<form action={suspendAction}>
<input type="hidden" name="vendorPublicId" value={vendorPublicId} />
<button type="submit" className="text-sm text-orange-600 hover:underline">
</button>
</form>
)}
{status === 'SUSPENDED' && (
<form action={restoreAction}>
<input type="hidden" name="vendorPublicId" value={vendorPublicId} />
<button type="submit" className="text-sm text-green-600 hover:underline">
</button>
</form>
)}
</div>
);
}
+85 -28
View File
@@ -1,11 +1,15 @@
const SAMPLE_VENDORS = [
{ id: 'v-1', name: '(주)클린철거', type: 'DEMOLITION', region: '강남권', status: 'APPLIED', contactName: '김담당', appliedAt: '2026-03-07 08:00' },
{ id: 'v-2', name: '모던인테리어', type: 'INTERIOR', region: '마포권', status: 'APPLIED', contactName: '이담당', appliedAt: '2026-03-06 14:30' },
{ id: 'v-3', name: '서울철거공사', type: 'DEMOLITION', region: '강남권, 마포권', status: 'APPROVED', contactName: '박담당', appliedAt: '2026-03-01 10:00' },
{ id: 'v-4', name: '그린인테리어', type: 'INTERIOR', region: '강남권', status: 'SUSPENDED', contactName: '최담당', appliedAt: '2026-02-25 09:00' },
];
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@relink/database';
import { reviewVendorCertificationService } from '@/services/vendor-certification-service';
import VendorActionButtons from './VendorActionButtons';
const TYPE_LABELS: Record<string, string> = { DEMOLITION: '철거', INTERIOR: '인테리어', ACQUISITION: '시설인수' };
export const dynamic = 'force-dynamic';
const TYPE_LABELS: Record<string, string> = {
DEMOLITION: '철거',
INTERIOR: '인테리어',
ACQUISITION: '시설인수',
};
const STATUS_MAP: Record<string, { label: string; color: string }> = {
APPLIED: { label: '심사 대기', color: 'bg-yellow-100 text-yellow-700' },
REVIEWING: { label: '심사 중', color: 'bg-blue-100 text-blue-700' },
@@ -14,7 +18,51 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
REJECTED: { label: '반려', color: 'bg-gray-100 text-gray-700' },
};
export default function AdminVendorsPage() {
async function handleApprove(formData: FormData) {
'use server';
const vendorPublicId = formData.get('vendorPublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
await reviewVendorCertificationService(prisma, vendorPublicId, 'APPROVED', '1');
revalidatePath('/admin/vendors');
}
async function handleReject(formData: FormData) {
'use server';
const vendorPublicId = formData.get('vendorPublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
await reviewVendorCertificationService(prisma, vendorPublicId, 'REJECTED', '1');
revalidatePath('/admin/vendors');
}
async function handleSuspend(formData: FormData) {
'use server';
const vendorPublicId = formData.get('vendorPublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
await reviewVendorCertificationService(prisma, vendorPublicId, 'SUSPENDED', '1');
revalidatePath('/admin/vendors');
}
async function handleRestore(formData: FormData) {
'use server';
const vendorPublicId = formData.get('vendorPublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
await reviewVendorCertificationService(prisma, vendorPublicId, 'APPROVED', '1');
revalidatePath('/admin/vendors');
}
export default async function AdminVendorsPage() {
const prisma = createPrismaClient();
const vendors = await prisma.vendor.findMany({
include: {
coverageRegions: { include: { region: { select: { nameKo: true } } } },
},
orderBy: { createdAt: 'desc' },
});
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
@@ -45,33 +93,42 @@ export default function AdminVendorsPage() {
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{SAMPLE_VENDORS.map((vendor) => {
const statusInfo = STATUS_MAP[vendor.status] ?? { label: vendor.status, color: 'bg-gray-100 text-gray-700' };
{vendors.map((vendor) => {
const statusInfo = STATUS_MAP[vendor.certificationStatus] ?? {
label: vendor.certificationStatus,
color: 'bg-gray-100 text-gray-700',
};
const regionNames = vendor.coverageRegions
.map((cr) => cr.region.nameKo)
.filter(Boolean)
.join(', ');
return (
<tr key={vendor.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{vendor.name}</td>
<td className="px-4 py-3 text-gray-600">{TYPE_LABELS[vendor.type] ?? vendor.type}</td>
<td className="px-4 py-3 text-gray-600">{vendor.region}</td>
<tr key={vendor.publicId} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{vendor.businessName}</td>
<td className="px-4 py-3 text-gray-600">
{TYPE_LABELS[vendor.vendorType] ?? vendor.vendorType}
</td>
<td className="px-4 py-3 text-gray-600">{regionNames || '-'}</td>
<td className="px-4 py-3 text-gray-600">{vendor.contactName}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}
>
{statusInfo.label}
</span>
</td>
<td className="px-4 py-3 text-gray-400">{vendor.appliedAt}</td>
<td className="px-4 py-3 text-gray-400">
{vendor.createdAt.toLocaleDateString('ko-KR')}
</td>
<td className="px-4 py-3">
{vendor.status === 'APPLIED' && (
<div className="flex gap-2">
<button className="text-sm text-green-600 hover:underline"></button>
<button className="text-sm text-red-600 hover:underline"></button>
</div>
)}
{vendor.status === 'APPROVED' && (
<button className="text-sm text-orange-600 hover:underline"></button>
)}
{vendor.status === 'SUSPENDED' && (
<button className="text-sm text-green-600 hover:underline"></button>
)}
<VendorActionButtons
vendorPublicId={vendor.publicId}
status={vendor.certificationStatus}
approveAction={handleApprove}
rejectAction={handleReject}
suspendAction={handleSuspend}
restoreAction={handleRestore}
/>
</td>
</tr>
);
+43 -36
View File
@@ -1,31 +1,7 @@
import Link from 'next/link';
import { createPrismaClient } from '@relink/database';
const SAMPLE_CONTRACTS = [
{
id: 'ct-1',
storeTitle: '강남역 카페 양도',
contractType: 'ACQUISITION',
status: 'DRAFT',
escrowStatus: 'NOT_STARTED',
createdAt: '2026-03-06',
},
{
id: 'ct-2',
storeTitle: '선릉역 한식당',
contractType: 'DEMOLITION',
status: 'ACTIVE',
escrowStatus: 'HOLDING',
createdAt: '2026-03-02',
},
{
id: 'ct-3',
storeTitle: '합정 베이커리',
contractType: 'INTERIOR',
status: 'COMPLETED',
escrowStatus: 'RELEASED',
createdAt: '2026-02-20',
},
];
export const dynamic = 'force-dynamic';
const CONTRACT_TYPE_LABELS: Record<string, string> = {
ACQUISITION: '시설인수',
@@ -53,7 +29,32 @@ const ESCROW_MAP: Record<string, { label: string; color: string }> = {
DISPUTED: { label: '분쟁 중', color: 'text-red-600' },
};
export default function ContractsPage() {
export default async function ContractsPage() {
const prisma = createPrismaClient();
const query = {
include: {
store: { select: { listingTitle: true } },
},
orderBy: { createdAt: 'desc' as const },
};
type ContractRow = Awaited<ReturnType<typeof prisma.contract.findMany<typeof query>>>[number];
let contracts: ContractRow[] = [];
let totalCount = 0;
let activeCount = 0;
let escrowCount = 0;
let completedCount = 0;
try {
[contracts, totalCount, activeCount, escrowCount, completedCount] = await Promise.all([
prisma.contract.findMany(query),
prisma.contract.count(),
prisma.contract.count({ where: { status: 'ACTIVE' } }),
prisma.contract.count({ where: { escrowStatus: 'HOLDING' } }),
prisma.contract.count({ where: { status: 'COMPLETED' } }),
]);
} catch (err) {
console.error('계약 조회 실패:', err);
}
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
@@ -61,28 +62,33 @@ export default function ContractsPage() {
{/* 요약 */}
<div className="mt-6 grid grid-cols-4 gap-4">
<SummaryCard label="전체 계약" value="3" />
<SummaryCard label="진행 중" value="1" />
<SummaryCard label="에스크로 보관" value="1" />
<SummaryCard label="완료" value="1" />
<SummaryCard label="전체 계약" value={String(totalCount)} />
<SummaryCard label="진행 중" value={String(activeCount)} />
<SummaryCard label="에스크로 보관" value={String(escrowCount)} />
<SummaryCard label="완료" value={String(completedCount)} />
</div>
{/* 계약 목록 */}
<div className="mt-6 space-y-4">
{SAMPLE_CONTRACTS.map((contract) => {
{contracts.length === 0 ? (
<p className="text-center text-sm text-gray-500"> </p>
) : (
contracts.map((contract) => {
const statusInfo = STATUS_MAP[contract.status] ?? { label: contract.status, color: 'bg-gray-100 text-gray-700' };
const escrowInfo = ESCROW_MAP[contract.escrowStatus] ?? { label: contract.escrowStatus, color: 'text-gray-500' };
return (
<div key={contract.id} className="rounded-lg border border-gray-200 bg-white p-5">
<div key={contract.publicId} className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{contract.storeTitle}</h3>
<h3 className="font-semibold text-gray-900">{contract.store.listingTitle}</h3>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{CONTRACT_TYPE_LABELS[contract.contractType] ?? contract.contractType}
</span>
</div>
<p className="mt-1 text-xs text-gray-400">: {contract.createdAt}</p>
<p className="mt-1 text-xs text-gray-400">
: {new Date(contract.createdAt).toLocaleDateString('ko-KR')}
</p>
</div>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
@@ -104,7 +110,8 @@ export default function ContractsPage() {
)}
</div>
);
})}
})
)}
</div>
<div className="mt-8 rounded-lg border border-dashed border-gray-300 p-6 text-center">
+32 -33
View File
@@ -1,31 +1,7 @@
import Link from 'next/link';
import { createPrismaClient } from '@relink/database';
const SAMPLE_REQUESTS = [
{
id: 'mr-1',
storeTitle: '강남역 카페 양도',
matchType: 'ACQUISITION',
status: 'OPEN',
createdAt: '2026-03-05',
message: '매장 인수 희망합니다.',
},
{
id: 'mr-2',
storeTitle: '선릉역 한식당 양도',
matchType: 'DEMOLITION',
status: 'ACCEPTED',
createdAt: '2026-03-03',
message: '철거 견적 요청드립니다.',
},
{
id: 'mr-3',
storeTitle: '홍대입구 디저트카페',
matchType: 'INTERIOR',
status: 'OPEN',
createdAt: '2026-03-06',
message: '인테리어 리모델링 상담 원합니다.',
},
];
export const dynamic = 'force-dynamic';
const MATCH_TYPE_LABELS: Record<string, string> = {
ACQUISITION: '인수',
@@ -40,27 +16,49 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
REJECTED: { label: '거절됨', color: 'bg-red-100 text-red-700' },
};
export default function MatchingPage() {
export default async function MatchingPage() {
const prisma = createPrismaClient();
const query = {
include: {
store: { select: { publicId: true, listingTitle: true } },
},
orderBy: { createdAt: 'desc' as const },
};
type RequestRow = Awaited<ReturnType<typeof prisma.matchRequest.findMany<typeof query>>>[number];
let requests: RequestRow[] = [];
try {
requests = await prisma.matchRequest.findMany(query);
} catch (err) {
console.error('매칭 요청 조회 실패:', err);
}
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> </p>
<div className="mt-6 space-y-4">
{SAMPLE_REQUESTS.map((req) => {
{requests.length === 0 ? (
<p className="text-center text-sm text-gray-500"> </p>
) : (
requests.map((req) => {
const statusInfo = STATUS_LABELS[req.status] ?? { label: req.status, color: 'bg-gray-100 text-gray-700' };
return (
<div key={req.id} className="rounded-lg border border-gray-200 bg-white p-5">
<div key={req.publicId} className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{req.storeTitle}</h3>
<h3 className="font-semibold text-gray-900">{req.store.listingTitle}</h3>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{MATCH_TYPE_LABELS[req.matchType] ?? req.matchType}
</span>
</div>
{req.message && (
<p className="mt-1 text-sm text-gray-600">{req.message}</p>
<p className="mt-2 text-xs text-gray-400">: {req.createdAt}</p>
)}
<p className="mt-2 text-xs text-gray-400">
: {new Date(req.createdAt).toLocaleDateString('ko-KR')}
</p>
</div>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
@@ -69,7 +67,7 @@ export default function MatchingPage() {
{req.status === 'ACCEPTED' && (
<div className="mt-4 border-t border-gray-100 pt-3">
<Link
href={`/contracts?matchId=${req.id}`}
href={`/contracts?matchId=${req.publicId}`}
className="text-sm text-blue-600 hover:underline"
>
@@ -78,7 +76,8 @@ export default function MatchingPage() {
)}
</div>
);
})}
})
)}
</div>
<div className="mt-8 rounded-lg border border-dashed border-gray-300 p-6 text-center">
+90 -17
View File
@@ -1,7 +1,42 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { createPrismaClient } from '@relink/database';
export const dynamic = 'force-dynamic';
export default async function StoreDetailPage({ params }: { params: Promise<{ id: string }> }) {
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 } },
lease: true,
facility: true,
},
});
if (!store) {
notFound();
}
const statusLabel =
store.dealStatus === 'OPEN'
? '거래 가능'
: store.dealStatus === 'MATCHING'
? '매칭 중'
: store.dealStatus === 'CONTRACTED'
? '계약 진행 중'
: store.dealStatus;
const statusClass =
store.dealStatus === 'OPEN'
? 'bg-green-100 text-green-700'
: store.dealStatus === 'MATCHING'
? 'bg-yellow-100 text-yellow-700'
: 'bg-gray-100 text-gray-700';
return (
<main className="mx-auto max-w-4xl px-4 py-8">
@@ -14,11 +49,11 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id
<div className="rounded-lg border border-gray-200 bg-white p-8">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> ID: {id}</p>
<h1 className="text-2xl font-bold text-gray-900">{store.listingTitle}</h1>
<p className="mt-1 text-sm text-gray-500"> ID: {store.publicId}</p>
</div>
<span className="rounded-full bg-green-100 px-3 py-1 text-sm font-medium text-green-700">
<span className={`rounded-full px-3 py-1 text-sm font-medium ${statusClass}`}>
{statusLabel}
</span>
</div>
@@ -26,10 +61,10 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id
<section className="mt-8">
<h2 className="mb-3 text-lg font-semibold text-gray-900"> </h2>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="지역" value="강남구 역삼동" />
<InfoItem label="업종" value="카페" />
<InfoItem label="도로명 주소" value="서울시 강남구 테헤란로 123" />
<InfoItem label="등록일" value="2026-03-07" />
<InfoItem label="지역" value={store.regionCluster?.nameKo ?? '-'} />
<InfoItem label="업종" value={store.industryLeaf?.nameKo ?? '-'} />
<InfoItem label="도로명 주소" value={store.roadAddress} />
<InfoItem label="등록일" value={store.createdAt.toLocaleDateString('ko-KR')} />
</div>
</section>
@@ -37,10 +72,38 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id
<section className="mt-8">
<h2 className="mb-3 text-lg font-semibold text-gray-900"> </h2>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="보증금" value="5,000만원" />
<InfoItem label="월세" value="300만원" />
<InfoItem label="권리금" value="3,000만원" />
<InfoItem label="임대 잔여 기간" value="18개월" />
<InfoItem
label="보증금"
value={
store.lease?.depositAmount != null
? `${Number(store.lease.depositAmount).toLocaleString('ko-KR')}`
: '-'
}
/>
<InfoItem
label="월세"
value={
store.lease?.monthlyRentAmount != null
? `${Number(store.lease.monthlyRentAmount).toLocaleString('ko-KR')}`
: '-'
}
/>
<InfoItem
label="권리금"
value={
store.lease?.premiumAmount != null
? `${Number(store.lease.premiumAmount).toLocaleString('ko-KR')}`
: '-'
}
/>
<InfoItem
label="임대 잔여 기간"
value={
store.lease?.remainingLeaseMonths != null
? `${store.lease.remainingLeaseMonths}개월`
: '-'
}
/>
</div>
</section>
@@ -48,15 +111,25 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id
<section className="mt-8">
<h2 className="mb-3 text-lg font-semibold text-gray-900"> </h2>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="면적" value="25평" />
<InfoItem label="층수" value="1층" />
<InfoItem
label="면적"
value={
store.facility?.exclusiveAreaSqm != null
? `${Number(store.facility.exclusiveAreaSqm).toLocaleString('ko-KR')}`
: '-'
}
/>
<InfoItem
label="층수"
value={store.facility?.floorLevel != null ? `${store.facility.floorLevel}` : '-'}
/>
</div>
{store.facility?.kitchenEquipmentSummary && (
<div className="mt-3">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-sm text-gray-900">
, , . 2024 . 30.
</p>
<p className="mt-1 text-sm text-gray-900">{store.facility.kitchenEquipmentSummary}</p>
</div>
)}
</section>
{/* 액션 버튼 */}
+108 -10
View File
@@ -1,4 +1,80 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { createPrismaClient } from '@relink/database';
export const dynamic = 'force-dynamic';
async function createStoreDraftAction(formData: FormData) {
'use server';
const prisma = createPrismaClient();
const listingTitle = (formData.get('listingTitle') as string | null)?.trim() ?? '';
const regionClusterCode = (formData.get('regionClusterCode') as string | null) ?? '';
const industryLeafCode = (formData.get('industryLeafCode') as string | null) ?? '';
const roadAddress = (formData.get('roadAddress') as string | null)?.trim() ?? '';
const depositAmount = formData.get('depositAmount') as string | null;
const monthlyRentAmount = formData.get('monthlyRentAmount') as string | null;
const premiumAmount = formData.get('premiumAmount') as string | null;
const remainingLeaseMonths = formData.get('remainingLeaseMonths') as string | null;
const exclusiveAreaSqm = formData.get('exclusiveAreaSqm') as string | null;
const floorLevel = formData.get('floorLevel') as string | null;
const kitchenEquipmentSummary =
(formData.get('kitchenEquipmentSummary') as string | null)?.trim() ?? '';
// TODO: Replace with authenticated user ID when auth is implemented
const TEMP_OWNER_USER_ID = BigInt(1);
// Resolve regionClusterId from code
const regionCluster = regionClusterCode
? await prisma.regionHierarchy.findUnique({ where: { code: regionClusterCode } })
: null;
// Resolve industryLeafId from code
const industryLeaf = industryLeafCode
? await prisma.industryTaxonomy.findUnique({ where: { code: industryLeafCode } })
: null;
const store = await prisma.store.create({
data: {
ownerUserId: TEMP_OWNER_USER_ID,
listingTitle,
roadAddress,
regionClusterId: regionCluster?.id ?? null,
industryLeafId: industryLeaf?.id ?? null,
reviewStatus: 'DRAFT',
publicationStatus: 'PRIVATE',
dealStatus: 'OPEN',
...(depositAmount || monthlyRentAmount || premiumAmount || remainingLeaseMonths
? {
lease: {
create: {
depositAmount: depositAmount ? Number(depositAmount) : null,
monthlyRentAmount: monthlyRentAmount ? Number(monthlyRentAmount) : null,
premiumAmount: premiumAmount ? Number(premiumAmount) : null,
remainingLeaseMonths: remainingLeaseMonths
? parseInt(remainingLeaseMonths, 10)
: null,
},
},
}
: {}),
...(exclusiveAreaSqm || floorLevel || kitchenEquipmentSummary
? {
facility: {
create: {
exclusiveAreaSqm: exclusiveAreaSqm ? Number(exclusiveAreaSqm) : null,
floorLevel: floorLevel ? parseInt(floorLevel, 10) : null,
kitchenEquipmentSummary: kitchenEquipmentSummary || null,
},
},
}
: {}),
},
});
redirect(`/stores/${store.publicId}`);
}
export default function NewStorePage() {
return (
@@ -14,7 +90,7 @@ export default function NewStorePage() {
, ,
</p>
<form className="mt-8 space-y-8">
<form action={createStoreDraftAction} className="mt-8 space-y-8">
{/* 기본 정보 */}
<section>
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
@@ -23,14 +99,20 @@ export default function NewStorePage() {
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
name="listingTitle"
placeholder="예: 강남역 카페 양도"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<select className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
<select
name="regionClusterCode"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
>
<option value=""> </option>
<option value="KR.BETA.GANGNAM_CORE"> (//)</option>
<option value="KR.BETA.MAPO_CORE"> (//)</option>
@@ -38,7 +120,11 @@ export default function NewStorePage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<select className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
<select
name="industryLeafCode"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
>
<option value=""> </option>
<option value="FNB.CAFE"></option>
<option value="FNB.KOREAN"></option>
@@ -51,7 +137,9 @@ export default function NewStorePage() {
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
name="roadAddress"
placeholder="예: 서울시 강남구 테헤란로 123"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
@@ -67,6 +155,7 @@ export default function NewStorePage() {
<label className="block text-sm font-medium text-gray-700"> ()</label>
<input
type="number"
name="depositAmount"
placeholder="50000000"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
@@ -75,6 +164,7 @@ export default function NewStorePage() {
<label className="block text-sm font-medium text-gray-700"> ()</label>
<input
type="number"
name="monthlyRentAmount"
placeholder="3000000"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
@@ -85,15 +175,19 @@ export default function NewStorePage() {
<label className="block text-sm font-medium text-gray-700"> ()</label>
<input
type="number"
name="premiumAmount"
placeholder="30000000"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> </label>
<label className="block text-sm font-medium text-gray-700">
()
</label>
<input
type="text"
placeholder="예: 18개월"
type="number"
name="remainingLeaseMonths"
placeholder="18"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
@@ -107,18 +201,21 @@ export default function NewStorePage() {
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> ()</label>
<label className="block text-sm font-medium text-gray-700"> ()</label>
<input
type="number"
placeholder="25"
name="exclusiveAreaSqm"
placeholder="82.6"
step="0.01"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"></label>
<input
type="text"
placeholder="예: 1층"
type="number"
name="floorLevel"
placeholder="1"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
@@ -127,6 +224,7 @@ export default function NewStorePage() {
<label className="block text-sm font-medium text-gray-700"> </label>
<textarea
rows={3}
name="kitchenEquipmentSummary"
placeholder="주방 시설, 인테리어 상태, 포함 장비 등을 자유롭게 작성해주세요"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
+58 -42
View File
@@ -1,4 +1,7 @@
import Link from 'next/link';
import { createPrismaClient } from '@relink/database';
export const dynamic = 'force-dynamic';
const REGION_OPTIONS = [
{ value: '', label: '전체 지역' },
@@ -21,13 +24,34 @@ const STATUS_OPTIONS = [
{ value: 'CONTRACTED', label: '계약 진행 중' },
];
export default function StoresPage() {
export default async function StoresPage() {
const prisma = createPrismaClient();
const query = {
where: { publicationStatus: 'PUBLISHED' as const },
include: {
regionCluster: { select: { nameKo: true } },
industryLeaf: { select: { nameKo: true } },
lease: { select: { depositAmount: true, monthlyRentAmount: true } },
},
orderBy: { createdAt: 'desc' as const },
};
type StoreRow = Awaited<ReturnType<typeof prisma.store.findMany<typeof query>>>[number];
let stores: StoreRow[] = [];
try {
stores = await prisma.store.findMany(query);
} catch (err) {
console.error('매장 목록 조회 실패:', err);
}
return (
<main className="mx-auto max-w-7xl px-4 py-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> </p>
<p className="mt-1 text-sm text-gray-500">
</p>
</div>
<Link
href="/stores/new"
@@ -65,65 +89,57 @@ export default function StoresPage() {
</button>
</div>
{/* 매장 목록 (샘플) */}
{/* 매장 목록 */}
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{[
{
id: 'sample-1',
title: '강남역 카페 양도',
region: '강남구 역삼동',
industry: '카페',
status: 'OPEN',
deposit: '5,000만원',
monthlyRent: '300만원',
},
{
id: 'sample-2',
title: '선릉역 한식당 양도',
region: '강남구 선릉동',
industry: '한식',
status: 'MATCHING',
deposit: '8,000만원',
monthlyRent: '450만원',
},
{
id: 'sample-3',
title: '홍대입구 디저트카페',
region: '마포구 서교동',
industry: '카페',
status: 'OPEN',
deposit: '3,000만원',
monthlyRent: '250만원',
},
].map((store) => (
{stores.length === 0 ? (
<div className="col-span-full py-16 text-center text-sm text-gray-400">
</div>
) : (
stores.map((store) => (
<Link
key={store.id}
href={`/stores/${store.id}`}
key={store.publicId}
href={`/stores/${store.publicId}`}
className="block rounded-lg border border-gray-200 bg-white p-5 transition hover:border-blue-300 hover:shadow-md"
>
<div className="flex items-start justify-between">
<h3 className="font-semibold text-gray-900">{store.title}</h3>
<h3 className="font-semibold text-gray-900">{store.listingTitle}</h3>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
store.status === 'OPEN'
store.dealStatus === 'OPEN'
? 'bg-green-100 text-green-700'
: store.status === 'MATCHING'
: store.dealStatus === 'MATCHING'
? 'bg-yellow-100 text-yellow-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{store.status === 'OPEN' ? '거래 가능' : store.status === 'MATCHING' ? '매칭 중' : store.status}
{store.dealStatus === 'OPEN'
? '거래 가능'
: store.dealStatus === 'MATCHING'
? '매칭 중'
: store.dealStatus}
</span>
</div>
<p className="mt-1 text-sm text-gray-500">
{store.region} · {store.industry}
{store.regionCluster?.nameKo ?? '-'} · {store.industryLeaf?.nameKo ?? '-'}
</p>
<div className="mt-3 flex gap-4 text-sm text-gray-600">
<span> {store.deposit}</span>
<span> {store.monthlyRent}</span>
<span>
{' '}
{store.lease?.depositAmount != null
? `${Number(store.lease.depositAmount).toLocaleString('ko-KR')}`
: '-'}
</span>
<span>
{' '}
{store.lease?.monthlyRentAmount != null
? `${Number(store.lease.monthlyRentAmount).toLocaleString('ko-KR')}`
: '-'}
</span>
</div>
</Link>
))}
))
)}
</div>
<div className="mt-8 text-center text-sm text-gray-400">
+34 -31
View File
@@ -1,28 +1,7 @@
import Link from 'next/link';
import { createPrismaClient } from '@relink/database';
const SAMPLE_CASES = [
{
id: 'sc-1',
storeTitle: '강남역 카페',
status: 'DOCUMENTS_PENDING',
checklist: { total: 5, checked: 2 },
createdAt: '2026-03-04',
},
{
id: 'sc-2',
storeTitle: '선릉역 한식당',
status: 'SUBMITTED',
checklist: { total: 5, checked: 5 },
createdAt: '2026-03-01',
},
{
id: 'sc-3',
storeTitle: '합정 베이커리',
status: 'APPROVED',
checklist: { total: 4, checked: 4 },
createdAt: '2026-02-28',
},
];
export const dynamic = 'force-dynamic';
const STATUS_MAP: Record<string, { label: string; color: string }> = {
DOCUMENTS_PENDING: { label: '서류 준비 중', color: 'bg-yellow-100 text-yellow-700' },
@@ -33,7 +12,23 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
REJECTED: { label: '반려', color: 'bg-red-100 text-red-700' },
};
export default function SubsidiesPage() {
export default async function SubsidiesPage() {
const prisma = createPrismaClient();
const query = {
include: {
store: { select: { listingTitle: true } },
checklistItems: true,
},
orderBy: { createdAt: 'desc' as const },
};
type CaseRow = Awaited<ReturnType<typeof prisma.subsidyCase.findMany<typeof query>>>[number];
let cases: CaseRow[] = [];
try {
cases = await prisma.subsidyCase.findMany(query);
} catch (err) {
console.error('지원금 케이스 조회 실패:', err);
}
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
@@ -52,14 +47,21 @@ export default function SubsidiesPage() {
{/* 케이스 목록 */}
<div className="mt-6 space-y-4">
{SAMPLE_CASES.map((c) => {
{cases.length === 0 ? (
<p className="text-center text-sm text-gray-500"> </p>
) : (
cases.map((c) => {
const statusInfo = STATUS_MAP[c.status] ?? { label: c.status, color: 'bg-gray-100 text-gray-700' };
const total = c.checklistItems.length;
const checked = c.checklistItems.filter((item) => item.status === 'CHECKED').length;
return (
<div key={c.id} className="rounded-lg border border-gray-200 bg-white p-5">
<div key={c.publicId} className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-gray-900">{c.storeTitle}</h3>
<p className="mt-1 text-xs text-gray-400">: {c.createdAt}</p>
<h3 className="font-semibold text-gray-900">{c.store.listingTitle}</h3>
<p className="mt-1 text-xs text-gray-400">
: {new Date(c.createdAt).toLocaleDateString('ko-KR')}
</p>
</div>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
@@ -71,19 +73,20 @@ export default function SubsidiesPage() {
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600"> </span>
<span className="text-gray-900">
{c.checklist.checked}/{c.checklist.total}
{checked}/{total}
</span>
</div>
<div className="mt-1 h-2 rounded-full bg-gray-200">
<div
className="h-2 rounded-full bg-blue-500"
style={{ width: `${(c.checklist.checked / c.checklist.total) * 100}%` }}
style={{ width: total > 0 ? `${(checked / total) * 100}%` : '0%' }}
/>
</div>
</div>
</div>
);
})}
})
)}
</div>
<div className="mt-8 rounded-lg border border-dashed border-gray-300 p-6 text-center">
+50
View File
@@ -0,0 +1,50 @@
'use server';
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@relink/database';
import { applyVendorCertificationService } from '@/services/vendor-certification-service';
import type { VendorType } from '@relink/domain';
const prisma = createPrismaClient();
const VALID_VENDOR_TYPES = new Set<string>(['DEMOLITION', 'INTERIOR', 'ACQUISITION']);
export async function applyVendorCertificationAction(
_prev: { success: boolean; message: string } | null,
formData: FormData,
): Promise<{ success: boolean; message: string }> {
const vendorType = formData.get('vendorType') as string;
const businessName = formData.get('businessName') as string;
const contactName = formData.get('contactName') as string;
const businessRegistrationNumber = formData.get('businessRegistrationNumber') as string | null;
const coverageRegionCodes = formData.getAll('coverageRegions') as string[];
if (!vendorType || !VALID_VENDOR_TYPES.has(vendorType)) {
return { success: false, message: '업체 유형을 선택해주세요.' };
}
if (!businessName?.trim()) {
return { success: false, message: '업체명을 입력해주세요.' };
}
if (!contactName?.trim()) {
return { success: false, message: '담당자명을 입력해주세요.' };
}
if (coverageRegionCodes.length === 0) {
return { success: false, message: '서비스 가능 지역을 하나 이상 선택해주세요.' };
}
const result = await applyVendorCertificationService(prisma, {
ownerUserId: '1', // TODO: 인증 연동 후 실제 유저 ID
vendorType: vendorType as VendorType,
businessName: businessName.trim(),
contactName: contactName.trim(),
businessRegistrationNumber: businessRegistrationNumber?.trim() || undefined,
coverageRegionCodes,
});
if (!result.ok) {
return { success: false, message: result.error.message ?? '인증 신청에 실패했습니다.' };
}
revalidatePath('/vendors');
return { success: true, message: '인증 신청이 완료되었습니다. 심사 후 결과를 안내드립니다.' };
}
+71 -71
View File
@@ -1,4 +1,40 @@
export default function VendorsPage() {
import { createPrismaClient } from '@relink/database';
import VendorApplicationForm from './vendor-application-form';
export const dynamic = 'force-dynamic';
const prisma = createPrismaClient();
const STATUS_BADGE: Record<string, { label: string; className: string }> = {
APPLIED: { label: '심사 대기', className: 'bg-yellow-100 text-yellow-700' },
APPROVED: { label: '인증됨', className: 'bg-green-100 text-green-700' },
SUSPENDED: { label: '중지', className: 'bg-red-100 text-red-700' },
REJECTED: { label: '반려', className: 'bg-gray-100 text-gray-700' },
};
const VENDOR_TYPE_LABEL: Record<string, string> = {
DEMOLITION: '철거',
INTERIOR: '인테리어',
ACQUISITION: '시설인수',
};
export default async function VendorsPage() {
const query = {
include: {
coverageRegions: {
include: { region: { select: { nameKo: true } } },
},
},
orderBy: { createdAt: 'desc' as const },
};
type VendorRow = Awaited<ReturnType<typeof prisma.vendor.findMany<typeof query>>>[number];
let vendors: VendorRow[] = [];
try {
vendors = await prisma.vendor.findMany(query);
} catch (err) {
console.error('업체 목록 조회 실패:', err);
}
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
@@ -26,86 +62,50 @@ export default function VendorsPage() {
</div>
{/* 인증 신청 폼 */}
<div className="mt-8">
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
<form className="space-y-4 rounded-lg border border-gray-200 bg-white p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
placeholder="예: (주)클린철거"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<select className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
<option value=""></option>
<option value="DEMOLITION"></option>
<option value="INTERIOR"></option>
<option value="ACQUISITION"></option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
placeholder="담당자 이름"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
placeholder="123-45-67890"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<div className="mt-2 flex flex-wrap gap-2">
{['강남권 (역삼/선릉/논현)', '마포권 (홍대/합정/연남)'].map((region) => (
<label key={region} className="flex items-center gap-1.5 text-sm text-gray-700">
<input type="checkbox" className="rounded border-gray-300" />
{region}
</label>
))}
</div>
</div>
<div className="pt-2">
<button
type="submit"
className="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
>
</button>
</div>
</form>
</div>
<VendorApplicationForm />
{/* 인증 현황 */}
<div className="mt-8">
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
<div className="rounded-lg border border-gray-200 bg-white p-5">
{vendors.length === 0 ? (
<p className="text-sm text-gray-500"> .</p>
) : (
<div className="space-y-3">
{vendors.map((vendor) => {
const badge = STATUS_BADGE[vendor.certificationStatus] ?? {
label: '심사 대기',
className: 'bg-yellow-100 text-yellow-700',
};
const regionNames = vendor.coverageRegions.map((cr) => cr.region.nameKo).join(', ');
const typeLabel = VENDOR_TYPE_LABEL[vendor.vendorType] ?? vendor.vendorType;
return (
<div
key={vendor.publicId}
className="rounded-lg border border-gray-200 bg-white p-5"
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">()</h3>
<p className="mt-0.5 text-sm text-gray-500"> · </p>
<h3 className="font-semibold text-gray-900">{vendor.businessName}</h3>
<p className="mt-0.5 text-sm text-gray-500">
{typeLabel}
{regionNames ? ` · ${regionNames}` : ''}
</p>
</div>
<span className="rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-700">
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${badge.className}`}
>
{badge.label}
</span>
</div>
<p className="mt-2 text-xs text-gray-400">신청일: 2026-03-05</p>
<p className="mt-2 text-xs text-gray-400">
: {vendor.createdAt.toISOString().slice(0, 10)}
</p>
</div>
);
})}
</div>
)}
</div>
</main>
);
+115
View File
@@ -0,0 +1,115 @@
'use client';
import { useActionState } from 'react';
import { applyVendorCertificationAction } from './actions';
const REGION_MAP: Record<string, string> = {
'강남권 (역삼/선릉/논현)': 'KR.BETA.GANGNAM_CORE',
'마포권 (홍대/합정/연남)': 'KR.BETA.MAPO_CORE',
};
const REGIONS = Object.keys(REGION_MAP);
type ActionState = { success: boolean; message: string } | null;
export default function VendorApplicationForm() {
const [state, formAction, isPending] = useActionState<ActionState, FormData>(
applyVendorCertificationAction,
null,
);
return (
<div className="mt-8">
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
{state && (
<div
className={`mb-4 rounded-md px-4 py-3 text-sm ${
state.success
? 'bg-green-50 text-green-700'
: 'bg-red-50 text-red-700'
}`}
>
{state.message}
</div>
)}
<form action={formAction} className="space-y-4 rounded-lg border border-gray-200 bg-white p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
name="businessName"
type="text"
placeholder="예: (주)클린철거"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<select
name="vendorType"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
>
<option value=""></option>
<option value="DEMOLITION"></option>
<option value="INTERIOR"></option>
<option value="ACQUISITION"></option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
name="contactName"
type="text"
placeholder="담당자 이름"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
name="businessRegistrationNumber"
type="text"
placeholder="123-45-67890"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<div className="mt-2 flex flex-wrap gap-2">
{REGIONS.map((region) => (
<label key={region} className="flex items-center gap-1.5 text-sm text-gray-700">
<input
type="checkbox"
name="coverageRegions"
value={REGION_MAP[region]}
className="rounded border-gray-300"
/>
{region}
</label>
))}
</div>
</div>
<div className="pt-2">
<button
type="submit"
disabled={isPending}
className="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? '신청 중...' : '인증 신청'}
</button>
</div>
</form>
</div>
);
}