From 636eaaca23edb29eb3b5291015a99d81104843fb Mon Sep 17 00:00:00 2001 From: Johngreen Date: Sun, 8 Mar 2026 01:06:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=83=98=ED=94=8C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20Prisma=20DB=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin 페이지: contracts, stores, subsidies, vendors 실제 DB 조회 - ActionButtons 컴포넌트 분리 (클라이언트 컴포넌트) - 사용자 페이지: contracts, matching, stores, subsidies, vendors DB 연동 - vendor 신청 폼 및 server actions 추가 Co-Authored-By: Claude Opus 4.6 --- .../admin/contracts/ContractActionButtons.tsx | 29 ++++ apps/web/src/app/admin/contracts/page.tsx | 111 +++++++++---- .../app/admin/stores/StoreActionButtons.tsx | 30 ++++ apps/web/src/app/admin/stores/page.tsx | 81 +++++++--- .../admin/subsidies/SubsidyActionButtons.tsx | 33 ++++ apps/web/src/app/admin/subsidies/page.tsx | 85 +++++++--- .../app/admin/vendors/VendorActionButtons.tsx | 56 +++++++ apps/web/src/app/admin/vendors/page.tsx | 113 +++++++++---- apps/web/src/app/contracts/page.tsx | 131 ++++++++------- apps/web/src/app/matching/page.tsx | 111 +++++++------ apps/web/src/app/stores/[id]/page.tsx | 113 ++++++++++--- apps/web/src/app/stores/new/page.tsx | 118 ++++++++++++-- apps/web/src/app/stores/page.tsx | 136 +++++++++------- apps/web/src/app/subsidies/page.tsx | 107 ++++++------ apps/web/src/app/vendors/actions.ts | 50 ++++++ apps/web/src/app/vendors/page.tsx | 152 +++++++++--------- .../app/vendors/vendor-application-form.tsx | 115 +++++++++++++ 17 files changed, 1131 insertions(+), 440 deletions(-) create mode 100644 apps/web/src/app/admin/contracts/ContractActionButtons.tsx create mode 100644 apps/web/src/app/admin/stores/StoreActionButtons.tsx create mode 100644 apps/web/src/app/admin/subsidies/SubsidyActionButtons.tsx create mode 100644 apps/web/src/app/admin/vendors/VendorActionButtons.tsx create mode 100644 apps/web/src/app/vendors/actions.ts create mode 100644 apps/web/src/app/vendors/vendor-application-form.tsx diff --git a/apps/web/src/app/admin/contracts/ContractActionButtons.tsx b/apps/web/src/app/admin/contracts/ContractActionButtons.tsx new file mode 100644 index 0000000..12531f0 --- /dev/null +++ b/apps/web/src/app/admin/contracts/ContractActionButtons.tsx @@ -0,0 +1,29 @@ +'use client'; + +interface ContractActionButtonsProps { + contractPublicId: string; + escrowStatus: string; + releaseAction: (formData: FormData) => Promise; +} + +export default function ContractActionButtons({ + contractPublicId, + escrowStatus, + releaseAction, +}: ContractActionButtonsProps) { + return ( +
+ {escrowStatus === 'RELEASE_REVIEW' && ( +
+ + +
+ )} + {escrowStatus === 'DISPUTED' && ( + 분쟁 검토 + )} +
+ ); +} diff --git a/apps/web/src/app/admin/contracts/page.tsx b/apps/web/src/app/admin/contracts/page.tsx index 7cdf03e..67a5275 100644 --- a/apps/web/src/app/admin/contracts/page.tsx +++ b/apps/web/src/app/admin/contracts/page.tsx @@ -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 = { DRAFT: { label: '초안', color: 'bg-gray-100 text-gray-700' }, @@ -19,31 +19,62 @@ const ESCROW_MAP: Record = { 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 (

계약/정산 관리

-

- 계약 현황, 에스크로 정산, 분쟁을 관리합니다 -

+

계약 현황, 에스크로 정산, 분쟁을 관리합니다

{/* 요약 카드 */}

활성 계약

-

2

+

{activeCount}

정산 검토 대기

-

1

+

{releaseReviewCount}

분쟁 진행 중

-

1

+

{disputedCount}

에스크로 총 보관액

-

₩63M

+

+ ₩{totalHolding.toLocaleString('ko-KR')} +

@@ -62,35 +93,49 @@ export default function AdminContractsPage() { - {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 ( - - {c.storeTitle} - {c.type} + + + {c.store?.listingTitle ?? '-'} + + {c.contractType} - + {statusInfo.label} - + {escrowInfo.label} - {c.amount} - {c.createdAt} + + {amount > 0 ? `₩${amount.toLocaleString('ko-KR')}` : '-'} + + + {c.createdAt.toLocaleDateString('ko-KR')} + - {c.escrow === 'RELEASE_REVIEW' && ( -
- - -
- )} - {c.escrow === 'DISPUTED' && ( - - )} + ); diff --git a/apps/web/src/app/admin/stores/StoreActionButtons.tsx b/apps/web/src/app/admin/stores/StoreActionButtons.tsx new file mode 100644 index 0000000..cb52a41 --- /dev/null +++ b/apps/web/src/app/admin/stores/StoreActionButtons.tsx @@ -0,0 +1,30 @@ +'use client'; + +interface StoreActionButtonsProps { + storePublicId: string; + approveAction: (formData: FormData) => Promise; + rejectAction: (formData: FormData) => Promise; +} + +export default function StoreActionButtons({ + storePublicId, + approveAction, + rejectAction, +}: StoreActionButtonsProps) { + return ( +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/app/admin/stores/page.tsx b/apps/web/src/app/admin/stores/page.tsx index f1e3c7b..cc37744 100644 --- a/apps/web/src/app/admin/stores/page.tsx +++ b/apps/web/src/app/admin/stores/page.tsx @@ -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 = { SUBMITTED: { label: '검토 대기', color: 'bg-yellow-100 text-yellow-700' }, @@ -13,7 +12,37 @@ const STATUS_MAP: Record = { 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 (

매장 검토

@@ -46,26 +75,34 @@ export default function AdminStoresPage() { - {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 ( - - {store.title} - {store.region} - {store.industry} - {store.owner} + + {store.listingTitle} + {store.regionCluster?.nameKo ?? '-'} + {store.industryLeaf?.nameKo ?? '-'} + {store.ownerUser?.phone ?? '-'} - + {statusInfo.label} - {store.submittedAt} + + {store.createdAt.toLocaleDateString('ko-KR')} + - {store.status === 'SUBMITTED' && ( -
- - -
+ {store.reviewStatus === 'SUBMITTED' && ( + )} diff --git a/apps/web/src/app/admin/subsidies/SubsidyActionButtons.tsx b/apps/web/src/app/admin/subsidies/SubsidyActionButtons.tsx new file mode 100644 index 0000000..5ab8c6f --- /dev/null +++ b/apps/web/src/app/admin/subsidies/SubsidyActionButtons.tsx @@ -0,0 +1,33 @@ +'use client'; + +interface SubsidyActionButtonsProps { + subsidyCasePublicId: string; + status: string; + approveAction: (formData: FormData) => Promise; + rejectAction: (formData: FormData) => Promise; +} + +export default function SubsidyActionButtons({ + subsidyCasePublicId, + status, + approveAction, + rejectAction, +}: SubsidyActionButtonsProps) { + if (status !== 'SUBMITTED' && status !== 'REVIEWING') return null; + return ( +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/app/admin/subsidies/page.tsx b/apps/web/src/app/admin/subsidies/page.tsx index ee2e91a..12ac9d0 100644 --- a/apps/web/src/app/admin/subsidies/page.tsx +++ b/apps/web/src/app/admin/subsidies/page.tsx @@ -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 = { DOCUMENTS_PENDING: { label: '서류 준비 중', color: 'bg-gray-100 text-gray-700' }, @@ -13,7 +13,35 @@ const STATUS_MAP: Record = { 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 (

지원금 검토

@@ -43,26 +71,41 @@ export default function AdminSubsidiesPage() { - {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 ( - - {c.storeTitle} - {c.owner} - {c.checklist} + + + {c.store?.listingTitle ?? '-'} + + {c.applicantUser?.phone ?? '-'} + + {completedItems}/{totalItems} + - + {statusInfo.label} - {c.createdAt} + + {c.createdAt.toLocaleDateString('ko-KR')} + - {(c.status === 'SUBMITTED' || c.status === 'REVIEWING') && ( -
- - -
- )} + ); diff --git a/apps/web/src/app/admin/vendors/VendorActionButtons.tsx b/apps/web/src/app/admin/vendors/VendorActionButtons.tsx new file mode 100644 index 0000000..2e6da2c --- /dev/null +++ b/apps/web/src/app/admin/vendors/VendorActionButtons.tsx @@ -0,0 +1,56 @@ +'use client'; + +interface VendorActionButtonsProps { + vendorPublicId: string; + status: string; + approveAction: (formData: FormData) => Promise; + rejectAction: (formData: FormData) => Promise; + suspendAction: (formData: FormData) => Promise; + restoreAction: (formData: FormData) => Promise; +} + +export default function VendorActionButtons({ + vendorPublicId, + status, + approveAction, + rejectAction, + suspendAction, + restoreAction, +}: VendorActionButtonsProps) { + return ( +
+ {(status === 'APPLIED' || status === 'REVIEWING') && ( + <> +
+ + +
+
+ + +
+ + )} + {status === 'APPROVED' && ( +
+ + +
+ )} + {status === 'SUSPENDED' && ( +
+ + +
+ )} +
+ ); +} diff --git a/apps/web/src/app/admin/vendors/page.tsx b/apps/web/src/app/admin/vendors/page.tsx index 20b3b92..cdcad03 100644 --- a/apps/web/src/app/admin/vendors/page.tsx +++ b/apps/web/src/app/admin/vendors/page.tsx @@ -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 = { DEMOLITION: '철거', INTERIOR: '인테리어', ACQUISITION: '시설인수' }; +export const dynamic = 'force-dynamic'; + +const TYPE_LABELS: Record = { + DEMOLITION: '철거', + INTERIOR: '인테리어', + ACQUISITION: '시설인수', +}; const STATUS_MAP: Record = { 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 = { 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 (

업체 인증 관리

@@ -45,33 +93,42 @@ export default function AdminVendorsPage() { - {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 ( - - {vendor.name} - {TYPE_LABELS[vendor.type] ?? vendor.type} - {vendor.region} + + {vendor.businessName} + + {TYPE_LABELS[vendor.vendorType] ?? vendor.vendorType} + + {regionNames || '-'} {vendor.contactName} - + {statusInfo.label} - {vendor.appliedAt} + + {vendor.createdAt.toLocaleDateString('ko-KR')} + - {vendor.status === 'APPLIED' && ( -
- - -
- )} - {vendor.status === 'APPROVED' && ( - - )} - {vendor.status === 'SUSPENDED' && ( - - )} + ); diff --git a/apps/web/src/app/contracts/page.tsx b/apps/web/src/app/contracts/page.tsx index 82bc7df..56f62f2 100644 --- a/apps/web/src/app/contracts/page.tsx +++ b/apps/web/src/app/contracts/page.tsx @@ -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 = { ACQUISITION: '시설인수', @@ -53,7 +29,32 @@ const ESCROW_MAP: Record = { 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>>[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 (

계약 관리

@@ -61,50 +62,56 @@ export default function ContractsPage() { {/* 요약 */}
- - - - + + + +
{/* 계약 목록 */}
- {SAMPLE_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 ( -
-
-
-
-

{contract.storeTitle}

- - {CONTRACT_TYPE_LABELS[contract.contractType] ?? contract.contractType} - + {contracts.length === 0 ? ( +

데이터가 없습니다

+ ) : ( + 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 ( +
+
+
+
+

{contract.store.listingTitle}

+ + {CONTRACT_TYPE_LABELS[contract.contractType] ?? contract.contractType} + +
+

+ 생성일: {new Date(contract.createdAt).toLocaleDateString('ko-KR')} +

-

생성일: {contract.createdAt}

+ + {statusInfo.label} +
- - {statusInfo.label} - -
-
-
- 에스크로: - {escrowInfo.label} +
+
+ 에스크로: + {escrowInfo.label} +
-
- {contract.status === 'ACTIVE' && ( -
- - -
- )} -
- ); - })} + {contract.status === 'ACTIVE' && ( +
+ + +
+ )} +
+ ); + }) + )}
diff --git a/apps/web/src/app/matching/page.tsx b/apps/web/src/app/matching/page.tsx index cbf3cf8..ee656ab 100644 --- a/apps/web/src/app/matching/page.tsx +++ b/apps/web/src/app/matching/page.tsx @@ -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 = { ACQUISITION: '인수', @@ -40,45 +16,68 @@ const STATUS_LABELS: Record = { 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>>[number]; + let requests: RequestRow[] = []; + try { + requests = await prisma.matchRequest.findMany(query); + } catch (err) { + console.error('매칭 요청 조회 실패:', err); + } + return (

매칭 요청

매장과의 매칭 요청 현황을 관리합니다

- {SAMPLE_REQUESTS.map((req) => { - const statusInfo = STATUS_LABELS[req.status] ?? { label: req.status, color: 'bg-gray-100 text-gray-700' }; - return ( -
-
-
-
-

{req.storeTitle}

- - {MATCH_TYPE_LABELS[req.matchType] ?? req.matchType} - + {requests.length === 0 ? ( +

데이터가 없습니다

+ ) : ( + requests.map((req) => { + const statusInfo = STATUS_LABELS[req.status] ?? { label: req.status, color: 'bg-gray-100 text-gray-700' }; + return ( +
+
+
+
+

{req.store.listingTitle}

+ + {MATCH_TYPE_LABELS[req.matchType] ?? req.matchType} + +
+ {req.message && ( +

{req.message}

+ )} +

+ 요청일: {new Date(req.createdAt).toLocaleDateString('ko-KR')} +

-

{req.message}

-

요청일: {req.createdAt}

+ + {statusInfo.label} +
- - {statusInfo.label} - + {req.status === 'ACCEPTED' && ( +
+ + 계약 진행하기 → + +
+ )}
- {req.status === 'ACCEPTED' && ( -
- - 계약 진행하기 → - -
- )} -
- ); - })} + ); + }) + )}
diff --git a/apps/web/src/app/stores/[id]/page.tsx b/apps/web/src/app/stores/[id]/page.tsx index 02615be..8e6a2c7 100644 --- a/apps/web/src/app/stores/[id]/page.tsx +++ b/apps/web/src/app/stores/[id]/page.tsx @@ -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 (
@@ -14,11 +49,11 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id
-

매장 상세 정보

-

매장 ID: {id}

+

{store.listingTitle}

+

매장 ID: {store.publicId}

- - 거래 가능 + + {statusLabel}
@@ -26,10 +61,10 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id

기본 정보

- - - - + + + +
@@ -37,10 +72,38 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id

임대 정보

- - - - + + + +
@@ -48,15 +111,25 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id

시설 정보

- - -
-
-

시설 설명

-

- 에스프레소 머신, 그라인더, 제빙기 포함. 인테리어 2024년 리뉴얼 완료. 좌석 30석. -

+ +
+ {store.facility?.kitchenEquipmentSummary && ( +
+

시설 설명

+

{store.facility.kitchenEquipmentSummary}

+
+ )}
{/* 액션 버튼 */} diff --git a/apps/web/src/app/stores/new/page.tsx b/apps/web/src/app/stores/new/page.tsx index 78baeba..db3aa70 100644 --- a/apps/web/src/app/stores/new/page.tsx +++ b/apps/web/src/app/stores/new/page.tsx @@ -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() { 매장 정보를 등록하면 창업자, 철거업체, 인테리어업체와 매칭됩니다

-
+ {/* 기본 정보 */}

기본 정보

@@ -23,14 +99,20 @@ export default function NewStorePage() {
- @@ -38,7 +120,11 @@ export default function NewStorePage() {
- @@ -51,7 +137,9 @@ export default function NewStorePage() {
@@ -67,6 +155,7 @@ export default function NewStorePage() { @@ -75,6 +164,7 @@ export default function NewStorePage() { @@ -85,15 +175,19 @@ export default function NewStorePage() {
- +
@@ -107,18 +201,21 @@ export default function NewStorePage() {
- +
@@ -127,6 +224,7 @@ export default function NewStorePage() {