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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
이메일
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+7
-1
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# Startover DB 상호작용 전수조사 버그 리포트
|
||||
|
||||
**조사일**: 2026-03-08
|
||||
**조사 범위**: DB와 상호작용하는 모든 페이지 (총 15개 페이지, 5개 API 라우트, 4개 서비스)
|
||||
**조사 방법**: 코드 정적 분석 (모든 CRUD 경로 추적)
|
||||
**빌드 검증**: `pnpm turbo build` 전체 통과 (7/7 tasks, 0 type errors)
|
||||
|
||||
---
|
||||
|
||||
## 수정 현황
|
||||
|
||||
| 심각도 | 발견 | 수정 완료 | 미수정 |
|
||||
|--------|------|-----------|--------|
|
||||
| CRITICAL | 6건 | **6건** | 0건 |
|
||||
| HIGH | 9건 | **8건** | 1건 |
|
||||
| MEDIUM | 8건 | **6건** | 2건 |
|
||||
| **합계** | **23건** | **20건** | **3건** |
|
||||
|
||||
### 수정 완료 목록 (20건)
|
||||
- BUG-001~006: 하드코딩 사용자 ID → `auth()` 세션 사용 (CRITICAL x6) ✅
|
||||
- BUG-007: `/stores/new` 서비스 레이어 우회 → `createStoreDraftService` 호출 (HIGH) ✅
|
||||
- BUG-008: 매장 DRAFT→SUBMITTED 제출/삭제 + admin APPROVED→PUBLISHED 공개 버튼 (HIGH) ✅
|
||||
- BUG-010: `/stores` 필터 동작 연결 (HIGH) ✅
|
||||
- BUG-011: `/contracts` 분쟁 접수 버튼 → `openDisputeService` 연결 (HIGH) ✅
|
||||
- BUG-012: `/matching` 매칭 요청 생성 폼 + `matchRequest.create` 구현 (HIGH) ✅
|
||||
- BUG-013: `/subsidies` 지원금 케이스 생성 폼 + `createSubsidyCaseService` 연결 (HIGH) ✅
|
||||
- BUG-014: admin 3개 페이지 필터 → `searchParams` + `Link` 연동 (HIGH) ✅
|
||||
- BUG-015: 초대 후 목록 갱신 (`router.refresh()`) (HIGH) ✅
|
||||
- BUG-017: 회원가입 전화번호 필드 추가 (MEDIUM) ✅
|
||||
- BUG-018: `/stores/new` 에러 핸들링 추가 (MEDIUM) ✅
|
||||
- BUG-019~022: 관리자 4개 페이지 인증 검증 추가 (MEDIUM x4) ✅
|
||||
|
||||
### 미수정 (추가 작업 필요, 3건)
|
||||
- BUG-009: 매장 수정 폼 (DRAFT 상태 수정 기능)
|
||||
- BUG-016: 에스크로 RELEASE_REVIEW → RELEASED 전환 관리자 액션
|
||||
- BUG-023: `/stores/new` form validation 빈 문자열 검증 (도메인 서비스에서 부분 처리됨)
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL (즉시 수정 필요) - 전부 수정 완료 ✅
|
||||
|
||||
### BUG-001: `/stores/new` - 하드코딩된 사용자 ID ✅
|
||||
- **파일**: `apps/web/src/app/stores/new/page.tsx`
|
||||
- **수정**: `TEMP_OWNER_USER_ID = BigInt(1)` → `auth()` 세션에서 `session.user.dbId` 사용
|
||||
|
||||
### BUG-002: `/vendors` actions - 하드코딩된 사용자 ID ✅
|
||||
- **파일**: `apps/web/src/app/vendors/actions.ts`
|
||||
- **수정**: `ownerUserId: '1'` → `session.user.dbId`
|
||||
|
||||
### BUG-003: `/admin/stores` - 하드코딩된 actorUserId ✅
|
||||
- **파일**: `apps/web/src/app/admin/stores/page.tsx`
|
||||
- **수정**: `actorUserId = '1'` → `session.user.dbId`
|
||||
|
||||
### BUG-004: `/admin/vendors` - 하드코딩된 actorUserId ✅
|
||||
- **파일**: `apps/web/src/app/admin/vendors/page.tsx`
|
||||
- **수정**: 4개 server action 모두 `auth()` 세션 사용
|
||||
|
||||
### BUG-005: `/admin/contracts` - 하드코딩된 actorUserId ✅
|
||||
- **파일**: `apps/web/src/app/admin/contracts/page.tsx`
|
||||
- **수정**: `releaseEscrowService(prisma, contractPublicId, '1')` → `session.user.dbId`
|
||||
|
||||
### BUG-006: `/admin/subsidies` - 하드코딩된 actorUserId ✅
|
||||
- **파일**: `apps/web/src/app/admin/subsidies/page.tsx`
|
||||
- **수정**: 승인/반려 모두 `session.user.dbId` 사용
|
||||
|
||||
---
|
||||
|
||||
## HIGH (빠른 수정 필요)
|
||||
|
||||
### BUG-007: `/stores/new` - Server Action이 서비스 레이어를 우회 ✅
|
||||
- **파일**: `apps/web/src/app/stores/new/page.tsx`
|
||||
- **수정**: 직접 `prisma.store.create` → `createStoreDraftService` 호출
|
||||
- **효과**: 도메인 검증, AuditLog, OutboxEvent 정상 동작
|
||||
|
||||
### BUG-008: 매장 등록 → 공개 플로우 단절 ✅
|
||||
- **수정 내용**:
|
||||
- `/stores/[id]`: DRAFT 상태에서 "검토 제출" 버튼 (→SUBMITTED) + "삭제" 버튼 추가
|
||||
- `/admin/stores`: APPROVED 상태에서 "공개" 버튼 추가 (`publishStoreService` 연결)
|
||||
- `StoreActionButtons`: 상태별 버튼 분기 (SUBMITTED: 승인/반려, APPROVED: 공개)
|
||||
|
||||
### BUG-009: `/stores/[id]` - 수정 기능 부재 ⚠️ 미수정
|
||||
- **파일**: `apps/web/src/app/stores/[id]/page.tsx`
|
||||
- **현재 상태**: 삭제는 가능 (DRAFT 상태), 수정 폼은 미구현
|
||||
- **남은 작업**: 매장 정보 수정 폼 페이지 추가
|
||||
|
||||
### BUG-010: `/stores` - 필터가 동작하지 않음 ✅
|
||||
- **파일**: `apps/web/src/app/stores/page.tsx`
|
||||
- **수정**: `searchParams` 기반 Prisma `where` 조건 연동 (region/industry/status 필터)
|
||||
|
||||
### BUG-011: `/contracts` - "분쟁 접수" 버튼 미동작 ✅
|
||||
- **파일**: `apps/web/src/app/contracts/page.tsx`
|
||||
- **수정**: `handleOpenDispute` server action → `openDisputeService` 연결
|
||||
- **참고**: "검수 요청" 버튼은 "준비 중" 상태로 비활성화
|
||||
|
||||
### BUG-012: `/matching` - 매칭 요청 생성 기능 없음 ✅
|
||||
- **파일**: `apps/web/src/app/matching/page.tsx`
|
||||
- **수정**: `handleCreateMatchRequest` server action + 매칭 요청 폼 구현
|
||||
- `storeId` searchParams 처리, 매장 정보 조회
|
||||
- matchType(인수/철거/인테리어) 선택 + 메시지 입력
|
||||
- `matchRequest.create` + `auditLog.create` 트랜잭션
|
||||
|
||||
### BUG-013: `/subsidies` - 지원금 케이스 생성 UI 없음 ✅
|
||||
- **파일**: `apps/web/src/app/subsidies/page.tsx`
|
||||
- **수정**: `handleCreateSubsidyCase` server action + 신청 폼 구현
|
||||
- `storeId` searchParams로 매장 연결
|
||||
- `createSubsidyCaseService` 호출
|
||||
- 체크리스트 진행률 프로그레스바 표시
|
||||
|
||||
### BUG-014: 관리자 페이지 필터 버튼 미동작 ✅
|
||||
- **수정**:
|
||||
- `/admin/stores`: `FILTER_LABELS` + `searchParams` + `StoreReviewStatus` 타입 캐스트
|
||||
- `/admin/vendors`: `FILTER_BUTTONS` + `VendorCertificationStatus` 타입 캐스트
|
||||
- `/admin/subsidies`: `FILTER_BUTTONS` + `SubsidyCaseStatus` 타입 캐스트
|
||||
|
||||
### BUG-015: `/admin/settings/invite` - 초대 후 목록 갱신 안 됨 ✅
|
||||
- **파일**: `apps/web/src/app/admin/settings/invite/invite-form.tsx`
|
||||
- **수정**: 성공 시 `router.refresh()` 추가
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM (개선 필요)
|
||||
|
||||
### BUG-016: `/admin/contracts` - releaseAction이 RELEASE_REVIEW까지만 처리 ⚠️ 미수정
|
||||
- **파일**: `apps/web/src/services/contract-service.ts:150-154`
|
||||
- **증상**: `releaseEscrowService`가 `RELEASE_REVIEW`로만 변경. `RELEASED` 전환 후속 처리 없음
|
||||
- **남은 작업**: RELEASE_REVIEW → RELEASED 전환 관리자 액션 추가
|
||||
|
||||
### BUG-017: `/auth/register` - 전화번호 필드 누락 ✅
|
||||
- **파일**: `apps/web/src/app/auth/register/page.tsx`, `actions.ts`
|
||||
- **수정**: phone 입력 필드 추가, zod 스키마에 phone 정규식 검증 추가
|
||||
|
||||
### BUG-018: `/stores/new` - 에러 핸들링 부재 ✅
|
||||
- **파일**: `apps/web/src/app/stores/new/page.tsx`
|
||||
- **수정**: `useActionState` + ErrorBanner로 에러 표시
|
||||
|
||||
### BUG-019~022: 관리자 페이지 인증 검증 없음 ✅
|
||||
- `/admin/stores`, `/admin/vendors`, `/admin/contracts`, `/admin/subsidies`
|
||||
- **수정**: `auth()` + `SUPER_ADMIN`/`OPS_MANAGER` role 체크, 미인증 시 `/403` redirect
|
||||
|
||||
### BUG-023: `/stores/new` - form validation 불완전 ⚠️ 미수정
|
||||
- **현재 상태**: `createStoreDraftService` 도메인 레이어에서 부분 검증
|
||||
- **남은 작업**: 서버 측 빈 문자열 추가 검증
|
||||
|
||||
---
|
||||
|
||||
## 변경된 파일 목록 (15개 파일, +637 / -202 lines)
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|-----------|
|
||||
| `apps/web/src/app/admin/contracts/page.tsx` | auth 가드, session.user.dbId |
|
||||
| `apps/web/src/app/admin/settings/invite/invite-form.tsx` | router.refresh() |
|
||||
| `apps/web/src/app/admin/stores/StoreActionButtons.tsx` | 상태별 버튼 분기 (publish 추가) |
|
||||
| `apps/web/src/app/admin/stores/page.tsx` | auth 가드, publish, 필터, enum 캐스트 |
|
||||
| `apps/web/src/app/admin/subsidies/page.tsx` | auth 가드, 필터, enum 캐스트 |
|
||||
| `apps/web/src/app/admin/vendors/page.tsx` | auth 가드, 필터, enum 캐스트 |
|
||||
| `apps/web/src/app/auth/register/actions.ts` | phone zod 스키마 + DB 저장 |
|
||||
| `apps/web/src/app/auth/register/page.tsx` | phone 입력 필드 |
|
||||
| `apps/web/src/app/contracts/page.tsx` | 분쟁 접수 server action |
|
||||
| `apps/web/src/app/matching/page.tsx` | 매칭 요청 생성 폼 + server action |
|
||||
| `apps/web/src/app/stores/[id]/page.tsx` | 검토 제출 + 삭제 server action |
|
||||
| `apps/web/src/app/stores/new/page.tsx` | 서비스 레이어, auth, 에러 핸들링 |
|
||||
| `apps/web/src/app/stores/page.tsx` | searchParams 필터 연동 |
|
||||
| `apps/web/src/app/subsidies/page.tsx` | 지원금 신청 폼 + server action |
|
||||
| `apps/web/src/app/vendors/actions.ts` | session.user.dbId |
|
||||
|
||||
---
|
||||
|
||||
## 페이지별 DB 상호작용 현황 (수정 후)
|
||||
|
||||
### 사용자 페이지
|
||||
|
||||
| 페이지 | CREATE | READ | UPDATE | DELETE | 상태 |
|
||||
|--------|--------|------|--------|--------|------|
|
||||
| `/stores` | - | O | - | - | ✅ 필터 동작 |
|
||||
| `/stores/new` | O | - | - | - | ✅ 서비스 레이어, auth |
|
||||
| `/stores/[id]` | - | O | O | O | ✅ 제출/삭제 (수정 미구현) |
|
||||
| `/vendors` | O | O | - | - | ✅ auth 세션 사용 |
|
||||
| `/contracts` | - | O | O | - | ✅ 분쟁 접수 연결 |
|
||||
| `/subsidies` | O | O | - | - | ✅ 신청 폼 구현 |
|
||||
| `/matching` | O | O | - | - | ✅ 요청 생성 구현 |
|
||||
| `/auth/register` | O | O | - | - | ✅ phone 추가 |
|
||||
| `/auth/complete-profile` | O | O | O | - | 정상 |
|
||||
| `/auth/invite/[token]` | O | O | O | - | 정상 |
|
||||
|
||||
### 관리자 페이지
|
||||
|
||||
| 페이지 | CREATE | READ | UPDATE | DELETE | 상태 |
|
||||
|--------|--------|------|--------|--------|------|
|
||||
| `/admin/stores` | - | O | O | - | ✅ auth, 필터, publish |
|
||||
| `/admin/vendors` | - | O | O | - | ✅ auth, 필터 |
|
||||
| `/admin/contracts` | - | O | O | - | ✅ auth |
|
||||
| `/admin/subsidies` | - | O | O | - | ✅ auth, 필터 |
|
||||
| `/admin/settings/invite` | O | O | - | - | ✅ 목록 갱신 |
|
||||
|
||||
### API 라우트
|
||||
|
||||
| 엔드포인트 | 동작 | 상태 |
|
||||
|-----------|------|------|
|
||||
| `POST /api/v1/stores` | CREATE | 정상 |
|
||||
| `POST /api/v1/stores/[id]/submit` | UPDATE | 정상 |
|
||||
| `POST /api/auth/complete-profile` | UPDATE | 정상 |
|
||||
| `POST /api/auth/invite/create` | CREATE | 정상 |
|
||||
| `POST /api/auth/invite/accept` | CREATE | 정상 |
|
||||
Reference in New Issue
Block a user