Protect admin pages, add store/subsidy actions

Enforce authentication/authorization for admin pages and add several management actions and UI improvements.

Key changes:
- Added auth checks and redirects on admin pages (contracts, stores, subsidies, vendors) to restrict access to SUPER_ADMIN/OPS_MANAGER.
- Hooked server actions to authenticated user IDs (release escrow, review/publish stores, review subsidies/vendors, open disputes, create subsidy cases, create match requests, submit/delete store drafts).
- Implemented store publish flow including policy version resolution and StoreActionButtons update to show approve/reject/publish based on reviewStatus.
- Added filtering UIs and query handling: admin lists (stores/subsidies/vendors) now support status filters; public stores list uses a new client StoreFilters component to build search params.
- New client-side improvements: invite form triggers router.refresh() after success; register page accepts/validates optional phone and persists it; store creation now uses createStoreDraftService and shows error banner on failure.
- Matching and subsidies pages now support contextual forms to create match requests and subsidy cases when a storeId is provided.
- Various UX tweaks: disabled inspection button, dispute form, list link styling, and revalidation calls after server actions.
- Added docs/BUG-REPORT-2026-03-08.md.

These changes centralize auth, connect actions to real user IDs, and improve admin and store workflows and filtering.
This commit is contained in:
Johngreen
2026-03-08 23:15:21 +09:00
parent 98406af090
commit de531bfe11
17 changed files with 931 additions and 199 deletions
+9 -2
View File
@@ -1,5 +1,7 @@
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { createPrismaClient } from '@startover/database';
import { auth } from '@/lib/auth';
import { releaseEscrowService } from '@/services/contract-service';
import ContractActionButtons from './ContractActionButtons';
@@ -21,14 +23,19 @@ const ESCROW_MAP: Record<string, { label: string; color: string }> = {
async function handleRelease(formData: FormData) {
'use server';
const session = await auth();
if (!session?.user?.dbId) throw new Error('Unauthorized');
const contractPublicId = formData.get('contractPublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
await releaseEscrowService(prisma, contractPublicId, '1');
await releaseEscrowService(prisma, contractPublicId, session.user.dbId);
revalidatePath('/admin/contracts');
}
export default async function AdminContractsPage() {
const session = await auth();
if (!session?.user || !['SUPER_ADMIN', 'OPS_MANAGER'].includes(session.user.primaryRole)) {
redirect('/403');
}
const prisma = createPrismaClient();
const [contracts, activeCount, releaseReviewCount, disputedCount, escrowTotal] =
@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
const OPERATOR_ROLES = [
{ value: 'OPS_MANAGER', label: '운영 매니저' },
@@ -10,6 +11,7 @@ const OPERATOR_ROLES = [
] as const;
export function InviteForm() {
const router = useRouter();
const [isPending, setIsPending] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@@ -34,6 +36,7 @@ export function InviteForm() {
if (res.ok) {
setMessage({ type: 'success', text: `${formData.get('email')}로 초대가 발송되었습니다` });
(e.target as HTMLFormElement).reset();
router.refresh();
} else {
setMessage({ type: 'error', text: data.error || '초대 발송에 실패했습니다' });
}
@@ -2,29 +2,48 @@
interface StoreActionButtonsProps {
storePublicId: string;
reviewStatus: string;
approveAction: (formData: FormData) => Promise<void>;
rejectAction: (formData: FormData) => Promise<void>;
publishAction: (formData: FormData) => Promise<void>;
}
export default function StoreActionButtons({
storePublicId,
reviewStatus,
approveAction,
rejectAction,
publishAction,
}: StoreActionButtonsProps) {
return (
<div className="flex gap-2">
<form action={approveAction}>
if (reviewStatus === 'SUBMITTED') {
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>
);
}
if (reviewStatus === 'APPROVED') {
return (
<form action={publishAction}>
<input type="hidden" name="storePublicId" value={storePublicId} />
<button type="submit" className="text-sm text-green-600 hover:underline">
<button type="submit" className="text-sm text-blue-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>
);
);
}
return null;
}
+79 -17
View File
@@ -1,6 +1,10 @@
import Link from 'next/link';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { type StoreReviewStatus } from '@prisma/client';
import { createPrismaClient } from '@startover/database';
import { reviewStoreService } from '@/services/store-service';
import { auth } from '@/lib/auth';
import { reviewStoreService, publishStoreService } from '@/services/store-service';
import StoreActionButtons from './StoreActionButtons';
export const dynamic = 'force-dynamic';
@@ -12,29 +16,76 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
PUBLISHED: { label: '공개', color: 'bg-blue-100 text-blue-700' },
};
const FILTER_LABELS: { label: string; status?: string }[] = [
{ label: '전체' },
{ label: '검토 대기', status: 'SUBMITTED' },
{ label: '승인', status: 'APPROVED' },
{ label: '반려', status: 'REJECTED' },
];
async function handleApprove(formData: FormData) {
'use server';
const session = await auth();
if (!session?.user?.dbId) throw new Error('Unauthorized');
const storePublicId = formData.get('storePublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
const actorUserId = '1';
await reviewStoreService(prisma, storePublicId, 'APPROVED', actorUserId);
await reviewStoreService(prisma, storePublicId, 'APPROVED', session.user.dbId);
revalidatePath('/admin/stores');
}
async function handleReject(formData: FormData) {
'use server';
const session = await auth();
if (!session?.user?.dbId) throw new Error('Unauthorized');
const storePublicId = formData.get('storePublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
const actorUserId = '1';
await reviewStoreService(prisma, storePublicId, 'REJECTED', actorUserId);
await reviewStoreService(prisma, storePublicId, 'REJECTED', session.user.dbId);
revalidatePath('/admin/stores');
}
export default async function AdminStoresPage() {
async function handlePublish(formData: FormData) {
'use server';
const session = await auth();
if (!session?.user?.dbId) throw new Error('Unauthorized');
const storePublicId = formData.get('storePublicId') as string;
const prisma = createPrismaClient();
let policyVersion = await prisma.policyVersion.findFirst({
where: { isActive: true },
orderBy: { createdAt: 'desc' },
});
if (!policyVersion) {
policyVersion = await prisma.policyVersion.create({
data: {
policyType: 'TERMS_OF_SERVICE',
versionCode: 'v1.0.0',
contentHash: 'initial',
effectiveFrom: new Date(),
isActive: true,
},
});
}
await publishStoreService(prisma, storePublicId, policyVersion.id.toString(), session.user.dbId);
revalidatePath('/admin/stores');
}
export default async function AdminStoresPage({
searchParams,
}: {
searchParams: Promise<{ status?: string }>;
}) {
const session = await auth();
if (!session?.user || !['SUPER_ADMIN', 'OPS_MANAGER'].includes(session.user.primaryRole)) {
redirect('/403');
}
const params = await searchParams;
const activeStatus = params.status;
const prisma = createPrismaClient();
const stores = await prisma.store.findMany({
where: activeStatus ? { reviewStatus: activeStatus as StoreReviewStatus } : undefined,
include: {
ownerUser: { select: { phone: true } },
regionCluster: { select: { nameKo: true } },
@@ -50,14 +101,23 @@ export default async function AdminStoresPage() {
{/* 필터 */}
<div className="mt-6 flex gap-2">
{['전체', '검토 대기', '승인', '반려'].map((f) => (
<button
key={f}
className="rounded-full border border-gray-300 px-3 py-1 text-sm text-gray-700 hover:bg-gray-100"
>
{f}
</button>
))}
{FILTER_LABELS.map((f) => {
const href = f.status ? `/admin/stores?status=${f.status}` : '/admin/stores';
const isActive = f.status ? activeStatus === f.status : !activeStatus;
return (
<Link
key={f.label}
href={href}
className={`rounded-full border px-3 py-1 text-sm ${
isActive
? 'border-blue-500 bg-blue-50 text-blue-700 font-medium'
: 'border-gray-300 text-gray-700 hover:bg-gray-100'
}`}
>
{f.label}
</Link>
);
})}
</div>
{/* 매장 목록 */}
@@ -97,11 +157,13 @@ export default async function AdminStoresPage() {
{store.createdAt.toLocaleDateString('ko-KR')}
</td>
<td className="px-4 py-3">
{store.reviewStatus === 'SUBMITTED' && (
{(store.reviewStatus === 'SUBMITTED' || store.reviewStatus === 'APPROVED') && (
<StoreActionButtons
storePublicId={store.publicId}
reviewStatus={store.reviewStatus}
approveAction={handleApprove}
rejectAction={handleReject}
publishAction={handlePublish}
/>
)}
</td>
+36 -11
View File
@@ -1,7 +1,11 @@
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { createPrismaClient } from '@startover/database';
import { auth } from '@/lib/auth';
import { reviewSubsidyCaseService } from '@/services/subsidy-case-service';
import SubsidyActionButtons from './SubsidyActionButtons';
import type { SubsidyCaseStatus } from '@prisma/client';
export const dynamic = 'force-dynamic';
@@ -15,25 +19,45 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
async function handleApprove(formData: FormData) {
'use server';
const session = await auth();
if (!session?.user?.dbId) throw new Error('Unauthorized');
const subsidyCasePublicId = formData.get('subsidyCasePublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
await reviewSubsidyCaseService(prisma, subsidyCasePublicId, 'APPROVED', '1');
await reviewSubsidyCaseService(prisma, subsidyCasePublicId, 'APPROVED', session.user.dbId);
revalidatePath('/admin/subsidies');
}
async function handleReject(formData: FormData) {
'use server';
const session = await auth();
if (!session?.user?.dbId) throw new Error('Unauthorized');
const subsidyCasePublicId = formData.get('subsidyCasePublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
await reviewSubsidyCaseService(prisma, subsidyCasePublicId, 'REJECTED', '1');
await reviewSubsidyCaseService(prisma, subsidyCasePublicId, 'REJECTED', session.user.dbId);
revalidatePath('/admin/subsidies');
}
export default async function AdminSubsidiesPage() {
const FILTER_BUTTONS = [
{ label: '전체', href: '/admin/subsidies', value: undefined },
{ label: '검토 대기', href: '/admin/subsidies?status=SUBMITTED', value: 'SUBMITTED' },
{ label: '검토 중', href: '/admin/subsidies?status=REVIEWING', value: 'REVIEWING' },
{ label: '승인', href: '/admin/subsidies?status=APPROVED', value: 'APPROVED' },
{ label: '반려', href: '/admin/subsidies?status=REJECTED', value: 'REJECTED' },
] as const;
export default async function AdminSubsidiesPage({
searchParams,
}: {
searchParams: Promise<{ status?: string }>;
}) {
const params = await searchParams;
const session = await auth();
if (!session?.user || !['SUPER_ADMIN', 'OPS_MANAGER'].includes(session.user.primaryRole)) {
redirect('/403');
}
const prisma = createPrismaClient();
const cases = await prisma.subsidyCase.findMany({
where: params.status ? { status: params.status as SubsidyCaseStatus } : undefined,
include: {
store: { select: { listingTitle: true } },
applicantUser: { select: { phone: true } },
@@ -48,13 +72,14 @@ export default async function AdminSubsidiesPage() {
<p className="mt-1 text-sm text-gray-500"> </p>
<div className="mt-6 flex gap-2">
{['전체', '검토 대기', '검토 중', '승인', '반려'].map((f) => (
<button
key={f}
className="rounded-full border border-gray-300 px-3 py-1 text-sm text-gray-700 hover:bg-gray-100"
{FILTER_BUTTONS.map((f) => (
<Link
key={f.label}
href={f.href}
className={`rounded-full border border-gray-300 px-3 py-1 text-sm text-gray-700 hover:bg-gray-100 ${params.status === f.value ? 'bg-gray-200' : ''}`}
>
{f}
</button>
{f.label}
</Link>
))}
</div>
+43 -15
View File
@@ -1,7 +1,11 @@
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { createPrismaClient } from '@startover/database';
import { auth } from '@/lib/auth';
import { reviewVendorCertificationService } from '@/services/vendor-certification-service';
import VendorActionButtons from './VendorActionButtons';
import type { VendorCertificationStatus } from '@prisma/client';
export const dynamic = 'force-dynamic';
@@ -20,43 +24,66 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
async function handleApprove(formData: FormData) {
'use server';
const session = await auth();
if (!session?.user?.dbId) throw new Error('Unauthorized');
const vendorPublicId = formData.get('vendorPublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
await reviewVendorCertificationService(prisma, vendorPublicId, 'APPROVED', '1');
await reviewVendorCertificationService(prisma, vendorPublicId, 'APPROVED', session.user.dbId);
revalidatePath('/admin/vendors');
}
async function handleReject(formData: FormData) {
'use server';
const session = await auth();
if (!session?.user?.dbId) throw new Error('Unauthorized');
const vendorPublicId = formData.get('vendorPublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
await reviewVendorCertificationService(prisma, vendorPublicId, 'REJECTED', '1');
await reviewVendorCertificationService(prisma, vendorPublicId, 'REJECTED', session.user.dbId);
revalidatePath('/admin/vendors');
}
async function handleSuspend(formData: FormData) {
'use server';
const session = await auth();
if (!session?.user?.dbId) throw new Error('Unauthorized');
const vendorPublicId = formData.get('vendorPublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
await reviewVendorCertificationService(prisma, vendorPublicId, 'SUSPENDED', '1');
await reviewVendorCertificationService(prisma, vendorPublicId, 'SUSPENDED', session.user.dbId);
revalidatePath('/admin/vendors');
}
async function handleRestore(formData: FormData) {
'use server';
const session = await auth();
if (!session?.user?.dbId) throw new Error('Unauthorized');
const vendorPublicId = formData.get('vendorPublicId') as string;
const prisma = createPrismaClient();
// TODO: 인증 연동 후 실제 actorUserId 사용
await reviewVendorCertificationService(prisma, vendorPublicId, 'APPROVED', '1');
await reviewVendorCertificationService(prisma, vendorPublicId, 'APPROVED', session.user.dbId);
revalidatePath('/admin/vendors');
}
export default async function AdminVendorsPage() {
const FILTER_BUTTONS = [
{ label: '전체', href: '/admin/vendors', value: undefined },
{ label: '심사 대기', href: '/admin/vendors?status=APPLIED', value: 'APPLIED' },
{ label: '인증됨', href: '/admin/vendors?status=APPROVED', value: 'APPROVED' },
{ label: '중지', href: '/admin/vendors?status=SUSPENDED', value: 'SUSPENDED' },
] as const;
export default async function AdminVendorsPage({
searchParams,
}: {
searchParams: Promise<{ status?: string }>;
}) {
const params = await searchParams;
const session = await auth();
if (!session?.user || !['SUPER_ADMIN', 'OPS_MANAGER'].includes(session.user.primaryRole)) {
redirect('/403');
}
const prisma = createPrismaClient();
const vendors = await prisma.vendor.findMany({
where: params.status
? { certificationStatus: params.status as VendorCertificationStatus }
: undefined,
include: {
coverageRegions: { include: { region: { select: { nameKo: true } } } },
},
@@ -69,13 +96,14 @@ export default async function AdminVendorsPage() {
<p className="mt-1 text-sm text-gray-500"> ·· </p>
<div className="mt-6 flex gap-2">
{['전체', '심사 대기', '인증됨', '중지'].map((f) => (
<button
key={f}
className="rounded-full border border-gray-300 px-3 py-1 text-sm text-gray-700 hover:bg-gray-100"
{FILTER_BUTTONS.map((f) => (
<Link
key={f.label}
href={f.href}
className={`rounded-full border border-gray-300 px-3 py-1 text-sm text-gray-700 hover:bg-gray-100 ${params.status === f.value ? 'bg-gray-200' : ''}`}
>
{f}
</button>
{f.label}
</Link>
))}
</div>
+8 -1
View File
@@ -17,6 +17,11 @@ const registerSchema = z.object({
.regex(/[a-zA-Z]/, '영문을 포함해야 합니다')
.regex(/[0-9]/, '숫자를 포함해야 합니다'),
name: z.string().min(1, '이름을 입력하세요'),
phone: z
.string()
.regex(/^01[0-9]-?\d{3,4}-?\d{4}$/, '올바른 휴대폰 번호를 입력하세요')
.optional()
.or(z.literal('')),
role: z.enum(['CLOSING_OWNER', 'FOUNDER', 'VENDOR_MANAGER']),
termsOfService: z.literal(true, {
errorMap: () => ({ message: '이용약관에 동의해야 합니다' }),
@@ -48,6 +53,7 @@ export async function registerAction(
email: formData.get('email'),
password: formData.get('password'),
name: formData.get('name'),
phone: formData.get('phone') || '',
role: formData.get('role'),
termsOfService: formData.get('termsOfService') === 'on',
privacyPolicy: formData.get('privacyPolicy') === 'on',
@@ -63,7 +69,7 @@ export async function registerAction(
};
}
const { email, password, name, role, marketingConsent, kakaoNotification } = parsed.data;
const { email, password, name, phone, role, marketingConsent, kakaoNotification } = parsed.data;
const emailNormalized = email.toLowerCase().trim();
const existing = await prisma.user.findFirst({ where: { emailNormalized } });
@@ -99,6 +105,7 @@ export async function registerAction(
email,
emailNormalized,
name,
phone: phone || null,
passwordHash,
primaryRole: role as UserRole,
status: 'PENDING_VERIFICATION',
+16
View File
@@ -80,6 +80,22 @@ export default function RegisterPage() {
)}
</div>
<div>
<label htmlFor="phone" className="mb-1 block text-sm font-medium text-gray-700">
</label>
<input
id="phone"
name="phone"
type="tel"
placeholder="010-1234-5678"
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{state.fieldErrors?.phone && (
<p className="mt-1 text-sm text-red-500">{state.fieldErrors.phone[0]}</p>
)}
</div>
<div>
<label htmlFor="email" className="mb-1 block text-sm font-medium text-gray-700">
+27 -2
View File
@@ -1,8 +1,22 @@
import Link from 'next/link';
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@startover/database';
import { openDisputeService } from '@/services/contract-service';
import { auth } from '@/lib/auth';
export const dynamic = 'force-dynamic';
async function handleOpenDispute(formData: FormData) {
'use server';
const contractPublicId = formData.get('contractPublicId') as string;
const session = await auth();
if (!session?.user?.dbId) return;
const prisma = createPrismaClient();
await openDisputeService(prisma, contractPublicId, 'QUALITY_ISSUE', session.user.dbId);
revalidatePath('/contracts');
}
const CONTRACT_TYPE_LABELS: Record<string, string> = {
ACQUISITION: '시설인수',
DEMOLITION: '철거',
@@ -115,8 +129,19 @@ export default async function ContractsPage() {
{contract.status === 'ACTIVE' && (
<div className="mt-4 flex gap-3 border-t border-gray-100 pt-3">
<button className="text-sm text-blue-600 hover:underline"> </button>
<button className="text-sm text-red-600 hover:underline"> </button>
<button
className="text-sm text-gray-400 cursor-not-allowed"
disabled
title="준비 중"
>
( )
</button>
<form action={handleOpenDispute}>
<input type="hidden" name="contractPublicId" value={contract.publicId} />
<button type="submit" className="text-sm text-red-600 hover:underline">
</button>
</form>
</div>
)}
</div>
+123 -12
View File
@@ -1,5 +1,7 @@
import Link from 'next/link';
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@startover/database';
import { auth } from '@/lib/auth';
export const dynamic = 'force-dynamic';
@@ -16,8 +18,65 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
REJECTED: { label: '거절됨', color: 'bg-red-100 text-red-700' },
};
export default async function MatchingPage() {
async function handleCreateMatchRequest(formData: FormData) {
'use server';
const storePublicId = formData.get('storePublicId') as string;
const matchType = formData.get('matchType') as string;
const message = (formData.get('message') as string)?.trim() || null;
const session = await auth();
if (!session?.user?.dbId) return;
const prisma = createPrismaClient();
const store = await prisma.store.findUnique({ where: { publicId: storePublicId } });
if (!store) return;
await prisma.$transaction(async (tx) => {
const matchRequest = await tx.matchRequest.create({
data: {
storeId: store.id,
requesterUserId: BigInt(session.user.dbId!),
matchType: matchType as 'ACQUISITION' | 'DEMOLITION' | 'INTERIOR',
sourceType: 'USER_REQUEST',
status: 'OPEN',
message,
},
});
await tx.auditLog.create({
data: {
actorUserId: BigInt(session.user.dbId!),
resourceType: 'MatchRequest',
resourceId: matchRequest.publicId,
actionType: 'MATCH_REQUEST_CREATED',
afterJson: { storePublicId, matchType },
},
});
});
revalidatePath('/matching');
}
export default async function MatchingPage({
searchParams,
}: {
searchParams: Promise<{ storeId?: string }>;
}) {
const params = await searchParams;
const prisma = createPrismaClient();
// Load store info if storeId is provided
let targetStore: { publicId: string; listingTitle: string } | null = null;
if (params.storeId) {
try {
targetStore = await prisma.store.findUnique({
where: { publicId: params.storeId },
select: { publicId: true, listingTitle: true },
});
} catch (err) {
console.error('매장 조회 실패:', err);
}
}
const query = {
include: {
store: { select: { publicId: true, listingTitle: true } },
@@ -37,7 +96,69 @@ export default async function MatchingPage() {
<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">
{targetStore ? (
<div className="mt-6 rounded-lg border border-blue-200 bg-blue-50 p-6">
<h2 className="text-lg font-semibold text-gray-900"> </h2>
<p className="mt-1 text-sm text-gray-600">
<span className="font-medium">{targetStore.listingTitle}</span>
.
</p>
<form action={handleCreateMatchRequest} className="mt-4 space-y-4">
<input type="hidden" name="storePublicId" value={targetStore.publicId} />
<div>
<label
htmlFor="matchType"
className="block text-sm font-medium text-gray-700"
>
</label>
<select
id="matchType"
name="matchType"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="ACQUISITION"></option>
<option value="DEMOLITION"></option>
<option value="INTERIOR"></option>
</select>
</div>
<div>
<label
htmlFor="message"
className="block text-sm font-medium text-gray-700"
>
()
</label>
<textarea
id="message"
name="message"
rows={3}
placeholder="매칭 요청 메시지를 입력하세요"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<button
type="submit"
className="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
</button>
</form>
</div>
) : (
<div className="mt-6 rounded-lg border border-dashed border-gray-300 p-6 text-center">
<p className="text-sm text-gray-500">
{' '}
<Link href="/stores" className="text-blue-600 hover:underline">
</Link>
</p>
</div>
)}
<div className="mt-8 space-y-4">
{requests.length === 0 ? (
<p className="text-center text-sm text-gray-500"> </p>
) : (
@@ -82,16 +203,6 @@ export default async function MatchingPage() {
})
)}
</div>
<div className="mt-8 rounded-lg border border-dashed border-gray-300 p-6 text-center">
<p className="text-sm text-gray-500">
{' '}
<Link href="/stores" className="text-blue-600 hover:underline">
</Link>
</p>
</div>
</main>
);
}
+85 -14
View File
@@ -1,9 +1,48 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { notFound, redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@startover/database';
import { submitStoreService } from '@/services/store-service';
import { auth } from '@/lib/auth';
export const dynamic = 'force-dynamic';
async function handleSubmitForReview(formData: FormData) {
'use server';
const storePublicId = formData.get('storePublicId') as string;
const session = await auth();
if (!session?.user?.dbId) return;
const prisma = createPrismaClient();
const result = await submitStoreService(prisma, storePublicId, session.user.dbId);
if (result.ok) {
revalidatePath(`/stores/${storePublicId}`);
}
}
async function handleDeleteDraft(formData: FormData) {
'use server';
const storePublicId = formData.get('storePublicId') as string;
const session = await auth();
if (!session?.user?.dbId) return;
const prisma = createPrismaClient();
const store = await prisma.store.findUnique({
where: { publicId: storePublicId },
});
if (!store || store.reviewStatus !== 'DRAFT' || store.ownerUserId !== BigInt(session.user.dbId)) {
return;
}
await prisma.$transaction(async (tx) => {
await tx.storeLease.deleteMany({ where: { storeId: store.id } });
await tx.storeFacility.deleteMany({ where: { storeId: store.id } });
await tx.store.delete({ where: { id: store.id } });
});
redirect('/stores');
}
export default async function StoreDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const prisma = createPrismaClient();
@@ -133,19 +172,51 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id
</section>
{/* 액션 버튼 */}
<div className="mt-10 flex gap-3 border-t border-gray-200 pt-6">
<Link
href={`/matching?storeId=${id}`}
className="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
>
</Link>
<Link
href={`/subsidies?storeId=${id}`}
className="rounded-lg border border-gray-300 px-6 py-2.5 text-sm text-gray-700 hover:bg-gray-50"
>
</Link>
<div className="mt-10 flex flex-wrap gap-3 border-t border-gray-200 pt-6">
{store.reviewStatus === 'DRAFT' && (
<>
<form action={handleSubmitForReview}>
<input type="hidden" name="storePublicId" value={store.publicId} />
<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>
</form>
<form action={handleDeleteDraft}>
<input type="hidden" name="storePublicId" value={store.publicId} />
<button
type="submit"
className="rounded-lg border border-red-300 px-6 py-2.5 text-sm text-red-600 hover:bg-red-50"
>
</button>
</form>
</>
)}
{store.publicationStatus === 'PUBLISHED' && (
<>
<Link
href={`/matching?storeId=${id}`}
className="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
>
</Link>
<Link
href={`/subsidies?storeId=${id}`}
className="rounded-lg border border-gray-300 px-6 py-2.5 text-sm text-gray-700 hover:bg-gray-50"
>
</Link>
</>
)}
{store.reviewStatus === 'SUBMITTED' && (
<p className="text-sm text-yellow-600"> </p>
)}
{store.reviewStatus === 'REJECTED' && (
<p className="text-sm text-red-600">. .</p>
)}
</div>
</div>
</main>
+59 -51
View File
@@ -1,13 +1,19 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { createPrismaClient } from '@startover/database';
import { auth } from '@/lib/auth';
import { createStoreDraftService } from '@/services/store-service';
import type { CreateStoreDraftInput } from '@startover/domain';
export const dynamic = 'force-dynamic';
async function createStoreDraftAction(formData: FormData) {
'use server';
const prisma = createPrismaClient();
const session = await auth();
if (!session?.user?.dbId) {
redirect('/auth/signin');
}
const listingTitle = (formData.get('listingTitle') as string | null)?.trim() ?? '';
const regionClusterCode = (formData.get('regionClusterCode') as string | null) ?? '';
@@ -22,61 +28,51 @@ async function createStoreDraftAction(formData: FormData) {
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);
const input: CreateStoreDraftInput = {
ownerUserId: session.user.dbId,
listingTitle,
industryLeafCode,
regionClusterCode,
roadAddress,
...(depositAmount || monthlyRentAmount || premiumAmount || remainingLeaseMonths
? {
lease: {
depositAmount: depositAmount ? Number(depositAmount) : 0,
monthlyRentAmount: monthlyRentAmount ? Number(monthlyRentAmount) : 0,
premiumAmount: premiumAmount ? Number(premiumAmount) : 0,
remainingLeaseMonths: remainingLeaseMonths
? parseInt(remainingLeaseMonths, 10)
: undefined,
},
}
: {}),
...(exclusiveAreaSqm || floorLevel || kitchenEquipmentSummary
? {
facility: {
exclusiveAreaSqm: exclusiveAreaSqm ? Number(exclusiveAreaSqm) : 0,
seatCount: 0,
floorLevel: floorLevel ? parseInt(floorLevel, 10) : undefined,
kitchenEquipmentSummary: kitchenEquipmentSummary || undefined,
},
}
: {}),
};
// Resolve regionClusterId from code
const regionCluster = regionClusterCode
? await prisma.regionHierarchy.findUnique({ where: { code: regionClusterCode } })
: null;
const prisma = createPrismaClient();
const result = await createStoreDraftService(prisma, input);
// Resolve industryLeafId from code
const industryLeaf = industryLeafCode
? await prisma.industryTaxonomy.findUnique({ where: { code: industryLeafCode } })
: null;
if (!result.ok) {
redirect(`/stores/new?error=${encodeURIComponent(result.error.message)}`);
}
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}`);
redirect(`/stores/${result.value.publicId}`);
}
export default function NewStorePage() {
export default function NewStorePage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
return (
<main className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-6">
@@ -90,6 +86,8 @@ export default function NewStorePage() {
, ,
</p>
<ErrorBanner searchParams={searchParams} />
<form action={createStoreDraftAction} className="mt-8 space-y-8">
{/* 기본 정보 */}
<section>
@@ -251,3 +249,13 @@ export default function NewStorePage() {
</main>
);
}
async function ErrorBanner({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const params = await searchParams;
if (!params.error) return null;
return (
<div className="mt-4 rounded-md bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{params.error}
</div>
);
}
+14 -49
View File
@@ -1,34 +1,24 @@
import Link from 'next/link';
import { createPrismaClient } from '@startover/database';
import { StoreFilters } from './store-filters';
export const dynamic = 'force-dynamic';
const REGION_OPTIONS = [
{ value: '', label: '전체 지역' },
{ value: 'KR.BETA.GANGNAM_CORE', label: '강남권 (역삼/선릉/논현)' },
{ value: 'KR.BETA.MAPO_CORE', label: '마포권 (홍대/합정/연남)' },
];
const INDUSTRY_OPTIONS = [
{ value: '', label: '전체 업종' },
{ value: 'FNB.CAFE', label: '카페' },
{ value: 'FNB.KOREAN', label: '한식' },
{ value: 'FNB.WESTERN', label: '양식' },
{ value: 'FNB.JAPANESE', label: '일식' },
];
const STATUS_OPTIONS = [
{ value: '', label: '전체 상태' },
{ value: 'OPEN', label: '거래 가능' },
{ value: 'MATCHING', label: '매칭 중' },
{ value: 'CONTRACTED', label: '계약 진행 중' },
];
export default async function StoresPage() {
export default async function StoresPage({
searchParams,
}: {
searchParams: Promise<{ region?: string; industry?: string; status?: string }>;
}) {
const params = await searchParams;
const prisma = createPrismaClient();
const where: Record<string, unknown> = { publicationStatus: 'PUBLISHED' as const };
if (params.region) where['regionCluster'] = { code: params.region };
if (params.industry) where['industryLeaf'] = { code: params.industry };
if (params.status) where['dealStatus'] = params.status;
const query = {
where: { publicationStatus: 'PUBLISHED' as const },
where,
include: {
regionCluster: { select: { nameKo: true } },
industryLeaf: { select: { nameKo: true } },
@@ -62,32 +52,7 @@ export default async function StoresPage() {
</div>
{/* 필터 */}
<div className="mt-6 flex flex-wrap gap-3 rounded-lg border border-gray-200 bg-white p-4">
<select className="rounded-md border border-gray-300 px-3 py-2 text-sm">
{REGION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<select className="rounded-md border border-gray-300 px-3 py-2 text-sm">
{INDUSTRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<select className="rounded-md border border-gray-300 px-3 py-2 text-sm">
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button className="rounded-md bg-gray-100 px-4 py-2 text-sm text-gray-700 hover:bg-gray-200">
</button>
</div>
<StoreFilters />
{/* 매장 목록 */}
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
+94
View File
@@ -0,0 +1,94 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback } from 'react';
const REGION_OPTIONS = [
{ value: '', label: '전체 지역' },
{ value: 'KR.BETA.GANGNAM_CORE', label: '강남권 (역삼/선릉/논현)' },
{ value: 'KR.BETA.MAPO_CORE', label: '마포권 (홍대/합정/연남)' },
];
const INDUSTRY_OPTIONS = [
{ value: '', label: '전체 업종' },
{ value: 'FNB.CAFE', label: '카페' },
{ value: 'FNB.KOREAN', label: '한식' },
{ value: 'FNB.WESTERN', label: '양식' },
{ value: 'FNB.JAPANESE', label: '일식' },
];
const STATUS_OPTIONS = [
{ value: '', label: '전체 상태' },
{ value: 'OPEN', label: '거래 가능' },
{ value: 'MATCHING', label: '매칭 중' },
{ value: 'CONTRACTED', label: '계약 진행 중' },
];
export function StoreFilters() {
const router = useRouter();
const searchParams = useSearchParams();
const handleSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
const params = new URLSearchParams();
const region = data.get('region') as string;
const industry = data.get('industry') as string;
const status = data.get('status') as string;
if (region) params.set('region', region);
if (industry) params.set('industry', industry);
if (status) params.set('status', status);
const qs = params.toString();
router.push(qs ? `/stores?${qs}` : '/stores');
},
[router],
);
return (
<form
onSubmit={handleSubmit}
className="mt-6 flex flex-wrap gap-3 rounded-lg border border-gray-200 bg-white p-4"
>
<select
name="region"
defaultValue={searchParams.get('region') ?? ''}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
>
{REGION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<select
name="industry"
defaultValue={searchParams.get('industry') ?? ''}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
>
{INDUSTRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<select
name="status"
defaultValue={searchParams.get('status') ?? ''}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
type="submit"
className="rounded-md bg-gray-100 px-4 py-2 text-sm text-gray-700 hover:bg-gray-200"
>
</button>
</form>
);
}
+93 -11
View File
@@ -1,5 +1,8 @@
import Link from 'next/link';
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@startover/database';
import { auth } from '@/lib/auth';
import { createSubsidyCaseService } from '@/services/subsidy-case-service';
export const dynamic = 'force-dynamic';
@@ -12,8 +15,34 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
REJECTED: { label: '반려', color: 'bg-red-100 text-red-700' },
};
export default async function SubsidiesPage() {
async function handleCreateSubsidyCase(formData: FormData) {
'use server';
const storePublicId = formData.get('storePublicId') as string;
const programCode = formData.get('programCode') as string;
const session = await auth();
if (!session?.user?.dbId) {
return;
}
const prisma = createPrismaClient();
await createSubsidyCaseService(prisma, {
storePublicId,
applicantUserId: session.user.dbId,
programCode,
});
revalidatePath('/subsidies');
}
export default async function SubsidiesPage({
searchParams,
}: {
searchParams: Promise<{ storeId?: string }>;
}) {
const params = await searchParams;
const prisma = createPrismaClient();
const query = {
include: {
store: { select: { listingTitle: true } },
@@ -29,6 +58,23 @@ export default async function SubsidiesPage() {
console.error('지원금 케이스 조회 실패:', err);
}
let store: { publicId: string; listingTitle: string } | null = null;
let serviceError: string | null = null;
if (params.storeId) {
try {
store = await prisma.store.findUnique({
where: { publicId: params.storeId },
select: { publicId: true, listingTitle: true },
});
if (!store) {
serviceError = '매장을 찾을 수 없습니다.';
}
} catch (err) {
console.error('매장 조회 실패:', err);
serviceError = '매장 정보를 불러오는 중 오류가 발생했습니다.';
}
}
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
@@ -45,6 +91,52 @@ export default async function SubsidiesPage() {
</p>
</div>
{/* 지원금 신청 폼 또는 안내 */}
{params.storeId ? (
<div className="mt-6 rounded-lg border border-gray-200 bg-white p-6">
<h2 className="text-lg font-semibold text-gray-900"> </h2>
{serviceError ? (
<p className="mt-2 text-sm text-red-600">{serviceError}</p>
) : store ? (
<form action={handleCreateSubsidyCase} className="mt-4 space-y-4">
<div>
<p className="text-sm text-gray-500"></p>
<p className="mt-0.5 font-medium text-gray-900">{store.listingTitle}</p>
<input type="hidden" name="storePublicId" value={store.publicId} />
</div>
<div>
<label htmlFor="programCode" className="block text-sm font-medium text-gray-700">
</label>
<select
id="programCode"
name="programCode"
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="SMALL_BIZ_CLOSURE_2024"> 2024</option>
</select>
</div>
<button
type="submit"
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
</button>
</form>
) : null}
</div>
) : (
<div className="mt-8 rounded-lg border border-dashed border-gray-300 p-6 text-center">
<p className="text-sm text-gray-500">
{' '}
<Link href="/stores" className="text-blue-600 hover:underline">
</Link>
</p>
</div>
)}
{/* 케이스 목록 */}
<div className="mt-6 space-y-4">
{cases.length === 0 ? (
@@ -93,16 +185,6 @@ export default async function SubsidiesPage() {
})
)}
</div>
<div className="mt-8 rounded-lg border border-dashed border-gray-300 p-6 text-center">
<p className="text-sm text-gray-500">
{' '}
<Link href="/stores" className="text-blue-600 hover:underline">
</Link>
</p>
</div>
</main>
);
}
+7 -1
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@startover/database';
import { auth } from '@/lib/auth';
import { applyVendorCertificationService } from '@/services/vendor-certification-service';
import type { VendorType } from '@startover/domain';
@@ -13,6 +14,11 @@ export async function applyVendorCertificationAction(
_prev: { success: boolean; message: string } | null,
formData: FormData,
): Promise<{ success: boolean; message: string }> {
const session = await auth();
if (!session?.user?.dbId) {
return { success: false, message: '로그인이 필요합니다.' };
}
const vendorType = formData.get('vendorType') as string;
const businessName = formData.get('businessName') as string;
const contactName = formData.get('contactName') as string;
@@ -33,7 +39,7 @@ export async function applyVendorCertificationAction(
}
const result = await applyVendorCertificationService(prisma, {
ownerUserId: '1', // TODO: 인증 연동 후 실제 유저 ID
ownerUserId: session.user.dbId,
vendorType: vendorType as VendorType,
businessName: businessName.trim(),
contactName: contactName.trim(),