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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Vendored
+50
@@ -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: '인증 신청이 완료되었습니다. 심사 후 결과를 안내드립니다.' };
|
||||
}
|
||||
Vendored
+71
-71
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user