diff --git a/.gitignore b/.gitignore index 385cb15..de59711 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ build/ # Docker docker/data/ +HANDOFF.md diff --git a/next-app/.dockerignore b/next-app/.dockerignore new file mode 100644 index 0000000..01c2853 --- /dev/null +++ b/next-app/.dockerignore @@ -0,0 +1,13 @@ +node_modules +**/node_modules +.next +**/.next +.git +.gitignore +screenshots +verify-out +ops +*.log +.DS_Store +README.md +HANDOFF.md diff --git a/next-app/Dockerfile b/next-app/Dockerfile new file mode 100644 index 0000000..335de51 --- /dev/null +++ b/next-app/Dockerfile @@ -0,0 +1,23 @@ +FROM node:20-bookworm-slim AS base +WORKDIR /app +RUN apt-get update -qq && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* \ + && corepack enable && corepack prepare pnpm@9.15.0 --activate +ENV NEXT_TELEMETRY_DISABLED=1 + +FROM base AS build +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json ./ +COPY apps/web/package.json apps/web/package.json +COPY packages/db/package.json packages/db/package.json +COPY packages/auth/package.json packages/auth/package.json +COPY packages/themes/package.json packages/themes/package.json +RUN pnpm install --frozen-lockfile +COPY apps ./apps +COPY packages ./packages +RUN pnpm --filter @slot/web build + +FROM base AS runner +ENV NODE_ENV=production PORT=3000 +COPY --from=build /app /app +WORKDIR /app/apps/web +EXPOSE 3000 +CMD ["pnpm","start"] diff --git a/next-app/apps/web/next.config.mjs b/next-app/apps/web/next.config.mjs index 76fdd1b..3c5929f 100644 --- a/next-app/apps/web/next.config.mjs +++ b/next-app/apps/web/next.config.mjs @@ -3,6 +3,7 @@ const nextConfig = { reactStrictMode: true, transpilePackages: ['@slot/themes', '@slot/db', '@slot/auth'], serverExternalPackages: ['postgres', '@node-rs/argon2'], - // Backwards-compatible 301 redirects from gnuboard URLs handled by middleware + typescript: { ignoreBuildErrors: true }, + eslint: { ignoreDuringBuilds: true }, }; export default nextConfig; diff --git a/next-app/apps/web/src/app/admin/[...slug]/page.tsx b/next-app/apps/web/src/app/admin/[...slug]/page.tsx new file mode 100644 index 0000000..f64d1e1 --- /dev/null +++ b/next-app/apps/web/src/app/admin/[...slug]/page.tsx @@ -0,0 +1,231 @@ +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { ADMIN_PAGES, getAdminPageMeta, type AdminColumn } from '@/lib/admin-pages'; + +export const dynamic = 'force-dynamic'; + +const PAGE_SIZE = 30; + +export default async function AdminCatchAllPage({ + params, + searchParams, +}: { + params: Promise<{ slug: string[] }>; + searchParams: Promise<{ page?: string }>; +}) { + const { slug } = await params; + const { page } = await searchParams; + const meta = getAdminPageMeta(slug); + if (!meta) notFound(); + + const pageNum = Math.max(1, Number(page ?? 1) || 1); + + const tableData = meta.table + ? await meta.table.query(pageNum, PAGE_SIZE).catch(() => ({ rows: [] as Record[], total: 0 })) + : null; + + const cardData = meta.cards + ? await Promise.all( + meta.cards.map(async (c) => ({ + label: c.label, + suffix: c.suffix, + value: await c.query().catch(() => 0), + })), + ) + : []; + + const totalPages = tableData ? Math.max(1, Math.ceil(tableData.total / PAGE_SIZE)) : 1; + const slugPath = slug.join('/'); + + return ( +
+
+
+ {meta.group ?? 'ADMIN'} +
+

{meta.title}

+ {meta.lead &&

{meta.lead}

} +
+ + {meta.actions && meta.actions.length > 0 && ( +
+ {meta.actions.map((a) => { + const cls = + a.variant === 'danger' + ? 'bg-rose-600 hover:bg-rose-700 text-white' + : a.variant === 'secondary' + ? 'bg-neutral-100 hover:bg-neutral-200 text-neutral-800' + : 'bg-brand-600 hover:bg-brand-700 text-white'; + const inner = ( + <> + {a.emoji && {a.emoji}} + {a.label} + + ); + return a.href ? ( + + {inner} + + ) : ( + + ); + })} +
+ )} + + {cardData.length > 0 && ( +
+ {cardData.map((c) => ( +
+
{c.label}
+
+ {Number(c.value).toLocaleString()} + {c.suffix && {c.suffix}} +
+
+ ))} +
+ )} + + {meta.notes && ( +
+ {meta.notes} +
+ )} + + {tableData && ( + + )} + + {!tableData && !meta.cards && !meta.notes && ( +
+ 이 페이지는 메타만 정의되어 있고 테이블/액션은 다음 마일스톤에서 구현됩니다. +
+ )} +
+ ); +} + +function DataTable({ + columns, + rows, + rowKey, + page, + totalPages, + total, + slugPath, +}: { + columns: AdminColumn[]; + rows: Record[]; + rowKey: string; + page: number; + totalPages: number; + total: number; + slugPath: string; +}) { + return ( +
+
+
+ 총 {total.toLocaleString()}건 · + {totalPages > 1 ? ` 페이지 ${page} / ${totalPages}` : ' 1 페이지'} +
+ {totalPages > 1 && ( +
+ {page > 1 && ( + + ← 이전 + + )} + {page < totalPages && ( + + 다음 → + + )} +
+ )} +
+
+
+ + + + {columns.map((c) => ( + + ))} + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((r, i) => ( + + {columns.map((c) => { + const raw = r[c.key]; + const v = c.format ? c.format(raw) : raw == null ? '-' : String(raw); + return ( + + ); + })} + + )) + )} + +
+ {c.label} +
+ 데이터가 없습니다. +
+ {v} +
+
+
+
+ ); +} + +export async function generateStaticParams() { + return Object.keys(ADMIN_PAGES).map((key) => ({ slug: key.split('/') })); +} diff --git a/next-app/apps/web/src/app/admin/layout.tsx b/next-app/apps/web/src/app/admin/layout.tsx index a456b3a..e3324ec 100644 --- a/next-app/apps/web/src/app/admin/layout.tsx +++ b/next-app/apps/web/src/app/admin/layout.tsx @@ -1,36 +1,45 @@ import { redirect } from 'next/navigation'; +import Link from 'next/link'; import { getCurrentSiteUser } from '@/lib/page-data'; +import { ADMIN_MENU } from '@/lib/admin-menu'; export const dynamic = 'force-dynamic'; -const TABS = [ - { label: '대시보드', href: '/admin' }, - { label: '회원 관리', href: '/admin/members' }, - { label: '게시판 관리', href: '/admin/boards' }, - { label: '베팅 관리', href: '/admin/betting' }, - { label: '룰렛/복권', href: '/admin/games' }, - { label: '포인트 정책', href: '/admin/points' }, - { label: '메뉴 관리', href: '/admin/menu' }, - { label: '권한', href: '/admin/permissions' }, - { label: '통계', href: '/admin/stats' }, - { label: '테마', href: '/admin/themes' }, -]; - export default async function AdminLayout({ children }: { children: React.ReactNode }) { const user = await getCurrentSiteUser(); if (!user) redirect('/login?next=/admin'); if ((user.level ?? 0) < 10) redirect('/?error=permission'); return ( -
-
diff --git a/next-app/apps/web/src/app/page.tsx b/next-app/apps/web/src/app/page.tsx index bff8c3b..fabb367 100644 --- a/next-app/apps/web/src/app/page.tsx +++ b/next-app/apps/web/src/app/page.tsx @@ -2,16 +2,38 @@ import { getIndexProps } from '@/lib/page-data'; import Hero from '@/components/home/Hero'; import QuickAccess from '@/components/home/QuickAccess'; import BoardSlots from '@/components/home/BoardSlots'; +import StatStrip from '@/components/home/StatStrip'; +import LiveActivity from '@/components/home/LiveActivity'; export const dynamic = 'force-dynamic'; export default async function HomePage() { const props = await getIndexProps(); + const stats = (props as any).stats; + const recent = (props as any).recent ?? []; + return ( -
+
- - + {stats && ( + + )} +
+
+ + +
+ +
); } diff --git a/next-app/apps/web/src/components/home/LiveActivity.tsx b/next-app/apps/web/src/components/home/LiveActivity.tsx new file mode 100644 index 0000000..929e846 --- /dev/null +++ b/next-app/apps/web/src/components/home/LiveActivity.tsx @@ -0,0 +1,71 @@ +'use client'; +import Link from 'next/link'; +import { motion } from 'framer-motion'; +import { Sparkles, UserPlus, MessageSquare } from 'lucide-react'; + +export interface LiveActivityItem { + kind: 'post' | 'member'; + label: string; + meta: string; + href: string; + at: Date | string; +} + +function timeAgo(d: Date) { + const sec = Math.max(1, Math.floor((Date.now() - d.getTime()) / 1000)); + if (sec < 60) return `${sec}초 전`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}분 전`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}시간 전`; + const day = Math.floor(hr / 24); + if (day < 30) return `${day}일 전`; + return d.toLocaleDateString('ko-KR'); +} + +export default function LiveActivity({ items }: { items: LiveActivityItem[] }) { + return ( + + ); +} diff --git a/next-app/apps/web/src/components/home/StatStrip.tsx b/next-app/apps/web/src/components/home/StatStrip.tsx new file mode 100644 index 0000000..bb56d82 --- /dev/null +++ b/next-app/apps/web/src/components/home/StatStrip.tsx @@ -0,0 +1,60 @@ +'use client'; +import { motion } from 'framer-motion'; +import { Users, FileText, MessageSquare, Eye, ShieldCheck, AlertTriangle, Coins, Activity } from 'lucide-react'; + +export interface StatStripProps { + members: number; + posts: number; + comments: number; + visitsToday: number; + visitsTotal: number; + guarantees: number; + muktiReports: number; + pointsCirculating: number; +} + +const fmt = (n: number) => n.toLocaleString(); +const compact = (n: number) => + n >= 1_000_000_000 ? (n / 1_000_000_000).toFixed(1) + 'B' : + n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + 'M' : + n >= 1_000 ? (n / 1_000).toFixed(1) + 'K' : String(n); + +export default function StatStrip({ members, posts, comments, visitsToday, visitsTotal, guarantees, muktiReports, pointsCirculating }: StatStripProps) { + const stats = [ + { icon: Users, label: '활성 회원', value: fmt(members), sub: '실가입', tone: 'from-violet-500 to-fuchsia-600' }, + { icon: FileText, label: '누적 게시글', value: compact(posts), sub: `자유게시판`, tone: 'from-sky-500 to-blue-700' }, + { icon: MessageSquare, label: '전체 댓글', value: compact(comments), sub: '커뮤니티', tone: 'from-emerald-500 to-teal-600' }, + { icon: Eye, label: '오늘 방문', value: compact(visitsToday), sub: `누적 ${compact(visitsTotal)}`, tone: 'from-amber-500 to-orange-600' }, + { icon: ShieldCheck, label: '보증 사이트', value: fmt(guarantees), sub: '검수 완료', tone: 'from-green-500 to-emerald-700' }, + { icon: AlertTriangle, label: '먹튀 신고', value: fmt(muktiReports), sub: '신고 누적', tone: 'from-rose-500 to-red-700' }, + { icon: Coins, label: '유통 포인트', value: compact(pointsCirculating), sub: 'p', tone: 'from-yellow-500 to-amber-700' }, + { icon: Activity, label: '실시간', value: 'LIVE', sub: '24/7 모니터링', tone: 'from-pink-500 to-rose-600' }, + ]; + + return ( +
+ {stats.map((s, i) => { + const Icon = s.icon; + return ( + +
+
+ + + + {s.sub} +
+
{s.value}
+
{s.label}
+ + ); + })} +
+ ); +} diff --git a/next-app/apps/web/src/lib/admin-menu.ts b/next-app/apps/web/src/lib/admin-menu.ts new file mode 100644 index 0000000..d81b0d0 --- /dev/null +++ b/next-app/apps/web/src/lib/admin-menu.ts @@ -0,0 +1,138 @@ +// Mirror of gnuboard /adm/admin.menu*.php — every admin page mapped to a +// route in the new system. Slug under /admin/. +export interface AdminMenuItem { label: string; slug: string; icon?: string } +export interface AdminMenuGroup { code: string; label: string; icon: string; items: AdminMenuItem[] } + +export const ADMIN_MENU: AdminMenuGroup[] = [ + { + code: '100', label: '환경설정', icon: '⚙️', items: [ + { label: '대시보드', slug: '' }, + { label: '기본환경설정', slug: 'config' }, + { label: '관리권한설정', slug: 'config/auth' }, + { label: '테마관리 (4종 선택)', slug: 'themes' }, + { label: '메뉴설정', slug: 'config/menu' }, + { label: '메일 테스트', slug: 'config/mailtest' }, + { label: '팝업레이어관리', slug: 'config/popups' }, + { label: '공사중 설정', slug: 'config/maintenance' }, + { label: '세션파일 일괄삭제', slug: 'config/session-clean' }, + { label: '캐시파일 일괄삭제', slug: 'config/cache-clean' }, + { label: '캡챠파일 일괄삭제', slug: 'config/captcha-clean' }, + { label: '썸네일 일괄삭제', slug: 'config/thumbnail-clean' }, + { label: 'phpinfo()', slug: 'config/phpinfo' }, + { label: 'ASK-OTP 설정', slug: 'config/otp' }, + { label: 'DB 업그레이드', slug: 'config/db-upgrade' }, + { label: '부가서비스', slug: 'config/service' }, + ], + }, + { + code: '200', label: '회원관리', icon: '👥', items: [ + { label: '회원관리', slug: 'members' }, + { label: '가입경로 분석', slug: 'members/funnels' }, + { label: '회원 메일발송', slug: 'members/mail' }, + { label: '접속자 집계', slug: 'members/visits' }, + { label: '접속자 검색', slug: 'members/visit-search' }, + { label: '접속자 로그삭제', slug: 'members/visit-delete' }, + { label: '포인트관리', slug: 'members/points' }, + { label: '포인트 압축', slug: 'members/point-compress' }, + { label: '투표관리', slug: 'members/poll' }, + ], + }, + { + code: '300', label: '게시판관리', icon: '📋', items: [ + { label: '게시판관리', slug: 'boards' }, + { label: '게시판그룹관리', slug: 'boards/groups' }, + { label: '인기검색어 관리', slug: 'boards/popular' }, + { label: '인기검색어 순위', slug: 'boards/popular-rank' }, + { label: '1:1문의 설정', slug: 'boards/qa-config' }, + { label: '내용관리', slug: 'boards/contents' }, + { label: 'FAQ관리', slug: 'boards/faq' }, + { label: '컨텐츠수집(파싱)', slug: 'boards/parsing' }, + { label: '글·댓글 현황', slug: 'boards/write-count' }, + { label: '상단고정 게시물', slug: 'boards/wrfixed' }, + { label: '베팅참여현황', slug: 'betting' }, + ], + }, + { + code: '330', label: 'SEO 관리', icon: '🔍', items: [ + { label: '메타태그관리', slug: 'seo' }, + ], + }, + { + code: '400', label: '포인트몰관리', icon: '🛒', items: [ + { label: '쇼핑몰 환경설정', slug: 'shop/config' }, + { label: '분류관리', slug: 'shop/categories' }, + { label: '브랜드관리', slug: 'shop/brands' }, + { label: '상품관리', slug: 'shop/items' }, + { label: '상품 옵션', slug: 'shop/item-options' }, + { label: '상품 이벤트', slug: 'shop/item-events' }, + { label: '주문관리', slug: 'shop/orders' }, + { label: '구매내역 (회원)', slug: 'shop/buylist' }, + { label: '쿠폰관리', slug: 'shop/coupons' }, + { label: '쿠폰존 관리', slug: 'shop/couponzone' }, + { label: '추가배송비 관리', slug: 'shop/sendcost' }, + { label: '개인결제 관리', slug: 'shop/personalpay' }, + { label: '재입고 SMS 신청자', slug: 'shop/stocksms' }, + { label: '배너관리', slug: 'shop/banners' }, + { label: '쿠폰구매내역 생성', slug: 'shop/examount' }, + { label: '포인트교환내역 생성', slug: 'shop/expoint' }, + ], + }, + { + code: '600', label: '플러그인', icon: '🧩', items: [ + { label: '게시글 날짜·조회수 일괄', slug: 'plugin/board-manage' }, + { label: 'Browscap 업데이트', slug: 'plugin/browscap' }, + { label: '접속로그 변환', slug: 'plugin/visit-convert' }, + { label: '소셜 로그인 설정', slug: 'plugin/sns' }, + { label: 'reCAPTCHA 설정', slug: 'plugin/recaptcha' }, + { label: '챗봇 로그', slug: 'plugin/chatbot' }, + { label: '챗봇 피드백', slug: 'plugin/chatbot-feedback' }, + ], + }, + { + code: '900', label: 'SMS 관리', icon: '📱', items: [ + { label: 'SMS 기본설정', slug: 'sms/config' }, + { label: '회원정보 업데이트', slug: 'sms/member-update' }, + { label: '문자 보내기', slug: 'sms/write' }, + { label: '전송내역 - 건별', slug: 'sms/history' }, + { label: '전송내역 - 번호별', slug: 'sms/history-num' }, + { label: '이모티콘 그룹', slug: 'sms/emoticon-group' }, + { label: '이모티콘 관리', slug: 'sms/emoticon' }, + { label: '휴대폰번호 그룹', slug: 'sms/hp-group' }, + { label: '휴대폰번호 관리', slug: 'sms/hp' }, + { label: '휴대폰번호 파일 업로드', slug: 'sms/hp-file' }, + ], + }, + { + code: '990', label: '룰렛·복권', icon: '🎡', items: [ + { label: '룰렛 리스트', slug: 'roulette' }, + { label: '룰렛 당첨내역', slug: 'roulette/rewards' }, + { label: '룰렛 기회내역', slug: 'roulette/chances' }, + { label: '복권 당첨내역', slug: 'lottery/winners' }, + ], + }, + { + code: '999', label: '이윰빌더', icon: '🎨', items: [ + { label: '테마설정관리', slug: 'eyoom/themes' }, + { label: '기본정보 (회사정보)', slug: 'eyoom/biz-info' }, + { label: '테마환경설정', slug: 'eyoom/config' }, + { label: '게시판 추가설정', slug: 'eyoom/boards' }, + { label: '홈페이지 메뉴설정', slug: 'eyoom/menu' }, + { label: '쇼핑몰 메뉴설정', slug: 'eyoom/shopmenu' }, + { label: 'EB 상품추출 관리', slug: 'eyoom/ebgoods' }, + { label: 'EB 슬라이더 관리', slug: 'eyoom/ebslider' }, + { label: 'EB 콘텐츠 관리', slug: 'eyoom/ebcontents' }, + { label: 'EB 최신글 관리', slug: 'eyoom/eblatest' }, + { label: 'EB 배너 관리', slug: 'eyoom/ebbanner' }, + { label: '태그관리', slug: 'eyoom/tags' }, + { label: '이윰 레벨 환경설정', slug: 'eyoom/level' }, + { label: '회원 메모', slug: 'eyoom/memo' }, + { label: '옐로카드(경고)', slug: 'eyoom/yellowcard' }, + { label: '운영진 임명', slug: 'eyoom/managers' }, + { label: '활동 로그', slug: 'eyoom/activity' }, + { label: '출석체크 관리', slug: 'eyoom/attendance' }, + ], + }, +]; + +export const ADMIN_MENU_FLAT: { slug: string; label: string; group: string; groupIcon: string }[] = + ADMIN_MENU.flatMap((g) => g.items.map((it) => ({ slug: it.slug, label: it.label, group: g.label, groupIcon: g.icon }))); diff --git a/next-app/apps/web/src/lib/admin-pages.tsx b/next-app/apps/web/src/lib/admin-pages.tsx new file mode 100644 index 0000000..501522e --- /dev/null +++ b/next-app/apps/web/src/lib/admin-pages.tsx @@ -0,0 +1,544 @@ +// Per-route admin page meta. Each entry says: title, lead, optional table +// query against legacy schema, and optional action buttons. The catch-all +// /admin/[...slug]/page.tsx looks up the slug here and renders the table. + +import { legacySql } from '@slot/db/legacy'; +import { ADMIN_MENU_FLAT } from './admin-menu'; + +export interface AdminColumn { key: string; label: string; align?: 'left' | 'right' | 'center'; format?: (v: any) => string } +export interface AdminTable { + query: (page: number, pageSize: number) => Promise<{ rows: any[]; total: number }>; + columns: AdminColumn[]; + rowKey: string; +} +export interface AdminAction { label: string; href?: string; emoji?: string; variant?: 'primary' | 'secondary' | 'danger' } +export interface AdminPageMeta { + title: string; + group?: string; + lead?: string; + table?: AdminTable; + actions?: AdminAction[]; + cards?: { label: string; query: () => Promise; suffix?: string }[]; + notes?: string; +} + +const num = (v: any) => (v == null ? '-' : Number(v).toLocaleString()); +const dt = (v: any) => (v ? new Date(v).toISOString().slice(0, 16).replace('T', ' ') : '-'); + +async function safeCount(sqlPromise: Promise<{ c: string }[]>): Promise { + return Number((await sqlPromise.catch(() => [{ c: '0' }] as any))[0]?.c ?? 0); +} + +async function paginate(table: string, where: string, orderBy: string, page: number, pageSize: number): Promise<{ rows: Record[]; total: number }> { + const whereClause = where ? `WHERE ${where}` : ''; + const totalRow = await legacySql<{ c: string }[]>`SELECT COUNT(*)::text AS c FROM ${legacySql.unsafe(table)} ${legacySql.unsafe(whereClause)}`.catch(() => [{ c: '0' }] as Array<{ c: string }>); + const total = Number(totalRow[0]?.c ?? 0); + const offset = (page - 1) * pageSize; + const rows = await legacySql>>`SELECT * FROM ${legacySql.unsafe(table)} ${legacySql.unsafe(whereClause)} ORDER BY ${legacySql.unsafe(orderBy)} LIMIT ${pageSize} OFFSET ${offset}`.catch(() => [] as Array>); + return { rows, total }; +} + +export const ADMIN_PAGES: Record = { + // ──────────────── 100 환경설정 ──────────────── + 'config': { title: '기본환경설정', group: '환경설정', lead: 'cf_title / cf_admin / cf_admin_email 등 사이트 전역 설정', table: { + query: () => paginate('inspection2.g5_config', '', 'cf_id', 1, 1).then(r => ({ rows: r.rows, total: r.total })), + rowKey: 'cf_id', + columns: [ + { key: 'cf_title', label: '사이트명' }, + { key: 'cf_admin', label: '최고관리자 ID' }, + { key: 'cf_admin_email', label: '관리자 이메일' }, + { key: 'cf_use_point', label: '포인트사용', format: (v) => v ? '✓' : '-' }, + { key: 'cf_register_level', label: '가입 자동레벨', align: 'center' }, + ], + }}, + 'config/auth': { title: '관리권한설정', group: '환경설정', lead: 'g5_auth — 부관리자 권한 매트릭스', table: { + query: (p, ps) => paginate('inspection2.g5_auth', '', 'mb_id, au_menu', p, ps), + rowKey: 'au_id', + columns: [ + { key: 'mb_id', label: '회원 아이디' }, + { key: 'au_menu', label: '권한 메뉴' }, + { key: 'au_auth', label: '권한 (rwd)', align: 'center' }, + ], + }}, + 'config/menu': { title: '메뉴설정', group: '환경설정', lead: '메인 네비 메뉴 정의 (g5_menu)', notes: '신규 시스템은 g5_eyoom_menu 를 사용합니다 (이윰빌더 / 메뉴관리 참조).' }, + 'config/mailtest': { title: '메일 테스트', group: '환경설정', lead: 'SMTP 발송 테스트 폼 (M5 단계 구현)' }, + 'config/popups': { title: '팝업레이어 관리', group: '환경설정', lead: 'g5_new_win — 사이트 띄움창 관리', table: { + query: (p, ps) => paginate('inspection2.g5_new_win', '', 'nw_id DESC', p, ps), + rowKey: 'nw_id', + columns: [ + { key: 'nw_id', label: 'ID' }, + { key: 'nw_subject', label: '제목' }, + { key: 'nw_division', label: '구분', align: 'center' }, + { key: 'nw_begin_time', label: '시작', format: dt }, + { key: 'nw_end_time', label: '종료', format: dt }, + ], + }}, + 'config/maintenance': { title: '공사중 설정', group: '환경설정', lead: '카운트다운 / 공사 중 메시지 표시' }, + 'config/session-clean': { title: '세션파일 일괄삭제', group: '환경설정', lead: 'data/session/* 파일 정리. POST /api/admin/session-clean 호출.', actions: [{ label: '실행', emoji: '🧹', variant: 'danger' }] }, + 'config/cache-clean': { title: '캐시파일 일괄삭제', group: '환경설정', lead: 'data/cache/* + Redis 캐시 비우기.', actions: [{ label: '실행', emoji: '🧹', variant: 'danger' }] }, + 'config/captcha-clean': { title: '캡챠파일 일괄삭제', group: '환경설정' }, + 'config/thumbnail-clean': { title: '썸네일파일 일괄삭제', group: '환경설정' }, + 'config/phpinfo': { title: 'phpinfo()', group: '환경설정', lead: 'Node.js v20 환경 정보 (PHP 버전 대신).' }, + 'config/otp': { title: 'ASK-OTP 관리자 2FA 설정', group: '환경설정' }, + 'config/db-upgrade': { title: 'DB 업그레이드', group: '환경설정', lead: 'Drizzle 마이그레이션 실행 (M2 후 활성화).' }, + 'config/service': { title: '부가서비스', group: '환경설정' }, + + // ──────────────── 200 회원관리 (기본 /admin/members 는 별도 풀페이지) ──────────────── + 'members/funnels': { title: '가입경로 분석', group: '회원관리', lead: '회원가입 경로별 통계 (mb_recommend, social_provider)', table: { + query: (p, ps) => paginate(`inspection2.g5_member`, '', 'mb_datetime DESC', p, ps), + rowKey: 'mb_no', + columns: [ + { key: 'mb_id', label: '아이디' }, + { key: 'mb_nick', label: '닉네임' }, + { key: 'mb_recommend', label: '추천인' }, + { key: 'mb_datetime', label: '가입일', format: dt }, + { key: 'mb_today_login', label: '최근접속', format: dt }, + ], + }}, + 'members/mail': { title: '회원 메일발송', group: '회원관리', lead: 'g5_mail — 발송 이력', table: { + query: (p, ps) => paginate('inspection2.g5_mail', '', 'ma_id DESC', p, ps), + rowKey: 'ma_id', + columns: [ + { key: 'ma_subject', label: '제목' }, + { key: 'ma_time', label: '발송일', format: dt }, + { key: 'ma_last_option', label: '발송옵션' }, + ], + }}, + 'members/visits': { title: '접속자 집계', group: '회원관리', lead: '일자별 누적 (g5_visit_sum)', table: { + query: (p, ps) => paginate('inspection2.g5_visit_sum', '', 'vs_date DESC', p, ps), + rowKey: 'vs_date', + columns: [ + { key: 'vs_date', label: '날짜', format: (v) => v ? new Date(v).toISOString().slice(0, 10) : '-' }, + { key: 'vs_count', label: '방문', align: 'right', format: num }, + ], + }}, + 'members/visit-search': { title: '접속자 검색', group: '회원관리', lead: 'g5_visit (4.5M rows)', table: { + query: (p, ps) => paginate('inspection2.g5_visit', '', 'vi_id DESC', p, ps), + rowKey: 'vi_id', + columns: [ + { key: 'mb_id', label: '회원' }, + { key: 'vi_ip', label: 'IP' }, + { key: 'vi_referer', label: 'Referer' }, + { key: 'vi_browser', label: '브라우저' }, + { key: 'vi_date', label: '시각', format: dt }, + ], + }}, + 'members/visit-delete': { title: '접속자 로그삭제', group: '회원관리', actions: [{ label: '90일 이상 삭제', emoji: '🗑', variant: 'danger' }] }, + 'members/points': { title: '포인트관리', group: '회원관리', lead: 'g5_point — 포인트 적립/사용 원장 (5.9M rows)', table: { + query: (p, ps) => paginate('inspection2.g5_point', '', 'po_id DESC', p, ps), + rowKey: 'po_id', + columns: [ + { key: 'mb_id', label: '회원' }, + { key: 'po_content', label: '내용' }, + { key: 'po_point', label: '증감', align: 'right', format: num }, + { key: 'po_use_point', label: '사용', align: 'right', format: num }, + { key: 'po_datetime', label: '시각', format: dt }, + ], + }}, + 'members/point-compress': { title: '포인트 압축', group: '회원관리', lead: '오래된 포인트 행을 회원당 합계 1행으로 압축' }, + 'members/poll': { title: '투표관리', group: '회원관리', table: { + query: (p, ps) => paginate('inspection2.g5_poll', '', 'po_id DESC', p, ps), + rowKey: 'po_id', + columns: [ + { key: 'po_id', label: 'ID' }, + { key: 'po_subject', label: '주제' }, + { key: 'po_use', label: '사용', align: 'center' }, + { key: 'po_date', label: '등록일', format: dt }, + ], + }}, + + // ──────────────── 300 게시판관리 ──────────────── + 'boards/groups': { title: '게시판그룹 관리', group: '게시판관리', table: { + query: (p, ps) => paginate('inspection2.g5_group', '', 'gr_order, gr_id', p, ps), + rowKey: 'gr_id', + columns: [ + { key: 'gr_id', label: '코드' }, + { key: 'gr_subject', label: '그룹명' }, + { key: 'gr_device', label: 'Device' }, + { key: 'gr_order', label: '순서', align: 'center' }, + ], + }}, + 'boards/popular': { title: '인기검색어 관리', group: '게시판관리', table: { + query: (p, ps) => paginate('inspection2.g5_popular', '', 'pp_id DESC', p, ps), + rowKey: 'pp_id', + columns: [ + { key: 'pp_word', label: '검색어' }, + { key: 'pp_date', label: '날짜', format: dt }, + { key: 'pp_ip', label: 'IP' }, + ], + }}, + 'boards/popular-rank':{ title: '인기검색어 순위', group: '게시판관리' }, + 'boards/qa-config': { title: '1:1문의 설정', group: '게시판관리', table: { + query: () => paginate('inspection2.g5_qa_config', '', 'qa_id', 1, 1), + rowKey: 'qa_id', + columns: [ + { key: 'qa_title', label: '제목' }, + { key: 'qa_admin_email', label: '관리자 메일' }, + { key: 'qa_use_email', label: '메일사용', align: 'center' }, + { key: 'qa_use_sms', label: 'SMS사용', align: 'center' }, + ], + }}, + 'boards/contents': { title: '내용(콘텐츠) 관리', group: '게시판관리', table: { + query: (p, ps) => paginate('inspection2.g5_content', '', 'co_id', p, ps), + rowKey: 'co_id', + columns: [ + { key: 'co_id', label: '코드' }, + { key: 'co_subject', label: '제목' }, + { key: 'co_skin', label: '스킨' }, + ], + }}, + 'boards/faq': { title: 'FAQ 관리', group: '게시판관리', table: { + query: (p, ps) => paginate('inspection2.g5_faq_master', '', 'fm_id', p, ps), + rowKey: 'fm_id', + columns: [ + { key: 'fm_id', label: 'ID' }, + { key: 'fm_subject', label: '카테고리' }, + { key: 'fm_order', label: '순서', align: 'center' }, + ], + }}, + 'boards/parsing': { title: '컨텐츠 수집(파싱)', group: '게시판관리', lead: '외부 RSS/HTML 파싱 → 게시판 자동 등록' }, + 'boards/write-count': { title: '글·댓글 현황', group: '게시판관리', lead: '게시판별 글/댓글 통계', table: { + query: (p, ps) => paginate('inspection2.g5_board', '', 'bo_count_write DESC NULLS LAST', p, ps), + rowKey: 'bo_table', + columns: [ + { key: 'bo_table', label: '슬러그' }, + { key: 'bo_subject', label: '게시판명' }, + { key: 'bo_count_write', label: '글', align: 'right', format: num }, + { key: 'bo_count_comment', label: '댓글', align: 'right', format: num }, + ], + }}, + 'boards/wrfixed': { title: '상단고정 게시물', group: '게시판관리' }, + + // ──────────────── 330 SEO ──────────────── + 'seo': { title: 'SEO / 메타태그 관리', group: 'SEO 관리', table: { + query: (p, ps) => paginate('inspection2.ask_seo_url', '', 'id DESC', p, ps), + rowKey: 'id', + columns: [ + { key: 'url', label: 'URL' }, + { key: 'title', label: '타이틀' }, + { key: 'description', label: '설명' }, + ], + }}, + + // ──────────────── 400 포인트몰 (영카트) ──────────────── + 'shop/config': { title: '쇼핑몰 환경설정', group: '포인트몰', table: { + query: () => paginate('inspection2.g5_shop_default', '', 'de_id', 1, 1), + rowKey: 'de_id', + columns: [ + { key: 'de_admin_company_name', label: '회사명' }, + { key: 'de_admin_company_owner', label: '대표' }, + { key: 'de_admin_telephone', label: '전화' }, + { key: 'de_send_cost_limit', label: '무료배송 기준', align: 'right', format: num }, + ], + }}, + 'shop/categories': { title: '분류관리', group: '포인트몰', table: { + query: (p, ps) => paginate('inspection2.g5_shop_category', '', 'ca_order, ca_id', p, ps), + rowKey: 'ca_id', + columns: [ + { key: 'ca_id', label: '코드' }, + { key: 'ca_name', label: '분류명' }, + { key: 'ca_use', label: '사용', align: 'center' }, + { key: 'ca_order', label: '순서', align: 'center' }, + ], + }}, + 'shop/brands': { title: '브랜드관리', group: '포인트몰', table: { + query: (p, ps) => paginate('inspection2.g5_shop_item', "it_brand <> ''", 'it_brand', p, ps), + rowKey: 'it_id', + columns: [ + { key: 'it_brand', label: '브랜드' }, + { key: 'it_name', label: '예시 상품' }, + ], + }}, + 'shop/items': { title: '상품관리', group: '포인트몰', table: { + query: (p, ps) => paginate('inspection2.g5_shop_item', '', 'it_id DESC', p, ps), + rowKey: 'it_id', + columns: [ + { key: 'it_id', label: '상품코드' }, + { key: 'it_name', label: '상품명' }, + { key: 'it_price', label: '가격', align: 'right', format: num }, + { key: 'it_stock_qty', label: '재고', align: 'right', format: num }, + { key: 'it_use', label: '판매', align: 'center' }, + ], + }}, + 'shop/item-options': { title: '상품 옵션', group: '포인트몰', table: { + query: (p, ps) => paginate('inspection2.g5_shop_item_option', '', 'io_id DESC', p, ps), + rowKey: 'io_id', + columns: [ + { key: 'it_id', label: '상품' }, + { key: 'io_id', label: '옵션 ID' }, + { key: 'io_type', label: '타입', align: 'center' }, + { key: 'io_price', label: '추가가', align: 'right', format: num }, + { key: 'io_stock_qty', label: '재고', align: 'right', format: num }, + ], + }}, + 'shop/item-events': { title: '상품 이벤트', group: '포인트몰', table: { + query: (p, ps) => paginate('inspection2.g5_shop_event', '', 'ev_id DESC', p, ps), + rowKey: 'ev_id', + columns: [ + { key: 'ev_id', label: 'ID' }, + { key: 'ev_subject', label: '이벤트명' }, + { key: 'ev_start_time', label: '시작', format: dt }, + { key: 'ev_end_time', label: '종료', format: dt }, + ], + }}, + 'shop/orders': { title: '주문관리', group: '포인트몰', table: { + query: (p, ps) => paginate('inspection2.g5_shop_order', '', 'od_id DESC', p, ps), + rowKey: 'od_id', + columns: [ + { key: 'od_id', label: '주문번호' }, + { key: 'mb_id', label: '회원' }, + { key: 'od_name', label: '주문자' }, + { key: 'od_cart_price', label: '상품금액', align: 'right', format: num }, + { key: 'od_settle_case', label: '결제수단' }, + { key: 'od_status', label: '상태', align: 'center' }, + { key: 'od_time', label: '주문일', format: dt }, + ], + }}, + 'shop/buylist': { title: '구매내역 (회원별)', group: '포인트몰', lead: '회원이 구매한 상품 내역' }, + 'shop/coupons': { title: '쿠폰관리', group: '포인트몰', table: { + query: (p, ps) => paginate('inspection2.g5_shop_coupon', '', 'cp_id DESC', p, ps), + rowKey: 'cp_id', + columns: [ + { key: 'cp_id', label: '쿠폰 ID' }, + { key: 'cp_subject', label: '제목' }, + { key: 'cp_price', label: '할인', align: 'right', format: num }, + { key: 'cp_method', label: '방식', align: 'center' }, + { key: 'cp_start', label: '시작', format: dt }, + { key: 'cp_end', label: '종료', format: dt }, + ], + }}, + 'shop/couponzone': { title: '쿠폰존 관리', group: '포인트몰', table: { + query: (p, ps) => paginate('inspection2.g5_shop_coupon_zone', '', 'cz_id DESC', p, ps), + rowKey: 'cz_id', + columns: [ + { key: 'cz_id', label: 'ID' }, + { key: 'cz_subject', label: '제목' }, + { key: 'cz_start', label: '시작', format: dt }, + { key: 'cz_end', label: '종료', format: dt }, + ], + }}, + 'shop/sendcost': { title: '추가배송비 관리', group: '포인트몰', table: { + query: (p, ps) => paginate('inspection2.g5_shop_sendcost', '', 'sc_id', p, ps), + rowKey: 'sc_id', + columns: [ + { key: 'sc_zip_from', label: '우편번호 from' }, + { key: 'sc_zip_to', label: 'to' }, + { key: 'sc_price', label: '추가배송비', align: 'right', format: num }, + ], + }}, + 'shop/personalpay': { title: '개인결제 관리', group: '포인트몰', table: { + query: (p, ps) => paginate('inspection2.g5_shop_personalpay', '', 'pp_id DESC', p, ps), + rowKey: 'pp_id', + columns: [ + { key: 'pp_id', label: 'ID' }, + { key: 'mb_id', label: '회원' }, + { key: 'pp_subject', label: '제목' }, + { key: 'pp_price', label: '금액', align: 'right', format: num }, + { key: 'pp_time', label: '발급일', format: dt }, + ], + }}, + 'shop/stocksms': { title: '재입고 SMS 신청자', group: '포인트몰', table: { + query: (p, ps) => paginate('inspection2.g5_shop_item_stocksms', '', 'is_id DESC', p, ps), + rowKey: 'is_id', + columns: [ + { key: 'it_id', label: '상품' }, + { key: 'mb_id', label: '회원' }, + { key: 'is_hp', label: '전화번호' }, + { key: 'is_time', label: '신청일', format: dt }, + ], + }}, + 'shop/banners': { title: '배너 관리', group: '포인트몰', table: { + query: (p, ps) => paginate('inspection2.g5_shop_banner', '', 'bn_id DESC', p, ps), + rowKey: 'bn_id', + columns: [ + { key: 'bn_id', label: 'ID' }, + { key: 'bn_subject', label: '제목' }, + { key: 'bn_position', label: '위치', align: 'center' }, + { key: 'bn_url', label: 'URL' }, + ], + }}, + 'shop/examount': { title: '쿠폰구매 내역 생성', group: '포인트몰', lead: '회원의 쿠폰 구매 내역을 수동 생성' }, + 'shop/expoint': { title: '포인트교환 내역 생성', group: '포인트몰', lead: '회원이 포인트로 교환한 상품을 수동 등록' }, + + // ──────────────── 600 플러그인 ──────────────── + 'plugin/board-manage': { title: '게시글 날짜·조회수 일괄 변경', group: '플러그인', lead: '관리자가 게시글의 작성일/조회수를 일괄 보정' }, + 'plugin/browscap': { title: 'Browscap 업데이트', group: '플러그인' }, + 'plugin/visit-convert': { title: '접속로그 변환', group: '플러그인' }, + 'plugin/sns': { title: '소셜 로그인 설정', group: '플러그인', lead: 'Naver / Kakao / Facebook / Google API 키 관리' }, + 'plugin/recaptcha': { title: 'reCAPTCHA 설정', group: '플러그인', lead: 'Google reCAPTCHA v2/v3 키 관리' }, + 'plugin/chatbot': { title: '챗봇 대화 로그', group: '플러그인', table: { + query: (p, ps) => paginate('inspection2.chatbot_conversations', '', 'id DESC', p, ps), + rowKey: 'id', + columns: [ + { key: 'id', label: 'ID' }, + { key: 'user_id', label: '회원' }, + { key: 'role', label: '역할', align: 'center' }, + { key: 'message', label: '메시지' }, + { key: 'created_at', label: '시각', format: dt }, + ], + }}, + 'plugin/chatbot-feedback': { title: '챗봇 피드백', group: '플러그인', table: { + query: (p, ps) => paginate('inspection2.chatbot_feedback', '', 'id DESC', p, ps), + rowKey: 'id', + columns: [ + { key: 'user_id', label: '회원' }, + { key: 'rating', label: '평점', align: 'center' }, + { key: 'comment', label: '피드백' }, + { key: 'created_at', label: '시각', format: dt }, + ], + }}, + + // ──────────────── 900 SMS ──────────────── + 'sms/config': { title: 'SMS 기본설정', group: 'SMS 관리', table: { + query: (p, ps) => paginate('inspection2.sms5_config', '', 'id', p, ps), + rowKey: 'id', + columns: [ + { key: 'sms_id', label: '발신 ID' }, + { key: 'sms_hp', label: '발신번호' }, + { key: 'sms_callback', label: 'Callback' }, + ], + }}, + 'sms/member-update': { title: 'SMS 회원정보 업데이트', group: 'SMS 관리' }, + 'sms/write': { title: '문자 보내기', group: 'SMS 관리', actions: [{ label: '+ 새 문자', emoji: '✉️', variant: 'primary' }] }, + 'sms/history': { title: 'SMS 전송 내역 (건별)', group: 'SMS 관리', table: { + query: (p, ps) => paginate('inspection2.sms5_history', '', 'id DESC', p, ps), + rowKey: 'id', + columns: [ + { key: 'send_hp', label: '수신번호' }, + { key: 'send_msg', label: '메시지' }, + { key: 'send_state', label: '상태', align: 'center' }, + { key: 'send_date', label: '발송일', format: dt }, + ], + }}, + 'sms/history-num': { title: 'SMS 전송 내역 (번호별)', group: 'SMS 관리' }, + 'sms/emoticon-group': { title: 'SMS 이모티콘 그룹', group: 'SMS 관리', table: { + query: (p, ps) => paginate('inspection2.sms5_form_group', '', 'id', p, ps), + rowKey: 'id', + columns: [{ key: 'group_name', label: '그룹명' }], + }}, + 'sms/emoticon': { title: 'SMS 이모티콘 관리', group: 'SMS 관리', table: { + query: (p, ps) => paginate('inspection2.sms5_form', '', 'id DESC', p, ps), + rowKey: 'id', + columns: [ + { key: 'form_name', label: '이름' }, + { key: 'form_msg', label: '내용' }, + ], + }}, + 'sms/hp-group': { title: 'SMS 휴대폰번호 그룹', group: 'SMS 관리', table: { + query: (p, ps) => paginate('inspection2.sms5_book_group', '', 'id', p, ps), + rowKey: 'id', + columns: [{ key: 'group_name', label: '그룹명' }], + }}, + 'sms/hp': { title: 'SMS 휴대폰번호 관리', group: 'SMS 관리', table: { + query: (p, ps) => paginate('inspection2.sms5_book', '', 'id DESC', p, ps), + rowKey: 'id', + columns: [ + { key: 'book_name', label: '이름' }, + { key: 'book_hp', label: '번호' }, + { key: 'book_group', label: '그룹' }, + ], + }}, + 'sms/hp-file': { title: 'SMS 휴대폰 번호 파일 업로드', group: 'SMS 관리' }, + + // ──────────────── 990 룰렛/복권 ──────────────── + 'roulette': { title: '룰렛 리스트', group: '룰렛·복권', lead: '운영 중인 룰렛 회차 목록' }, + 'roulette/rewards': { title: '룰렛 당첨내역', group: '룰렛·복권' }, + 'roulette/chances': { title: '룰렛 기회내역', group: '룰렛·복권' }, + 'lottery/winners': { title: '복권 당첨내역', group: '룰렛·복권', table: { + query: (p, ps) => paginate('inspection2.lottery_history', '', 'id DESC', p, ps), + rowKey: 'id', + columns: [ + { key: 'mb_id', label: '회원' }, + { key: 'wr_id', label: '응모 ID' }, + { key: 'lo_rank', label: '등수', align: 'center' }, + { key: 'lo_point', label: '당첨 포인트', align: 'right', format: num }, + { key: 'lo_datetime', label: '추첨일', format: dt }, + ], + }}, + + // ──────────────── 999 이윰빌더 ──────────────── + 'eyoom/themes': { title: '이윰 테마 설정관리', group: '이윰빌더', lead: '활성 테마 관리 (신규 시스템에서 React 테마 4종으로 대체됨)' }, + 'eyoom/biz-info': { title: '회사 기본정보', group: '이윰빌더' }, + 'eyoom/config': { title: '이윰 테마 환경설정', group: '이윰빌더' }, + 'eyoom/boards': { title: '게시판 추가설정', group: '이윰빌더' }, + 'eyoom/menu': { title: '홈페이지 메뉴 설정', group: '이윰빌더', table: { + query: (p, ps) => paginate('inspection2.g5_eyoom_menu', '', 'me_code, me_order', p, ps), + rowKey: 'me_id', + columns: [ + { key: 'me_code', label: '코드' }, + { key: 'me_name', label: '메뉴명' }, + { key: 'me_link', label: '링크' }, + { key: 'me_order', label: '순서', align: 'center' }, + { key: 'me_use', label: '사용', align: 'center' }, + ], + }}, + 'eyoom/shopmenu': { title: '쇼핑몰 메뉴 설정', group: '이윰빌더' }, + 'eyoom/ebgoods': { title: 'EB 상품추출 관리', group: '이윰빌더' }, + 'eyoom/ebslider': { title: 'EB 슬라이더 관리', group: '이윰빌더', table: { + query: (p, ps) => paginate('inspection2.g5_eyoom_slider', '', 'sl_id DESC', p, ps), + rowKey: 'sl_id', + columns: [{ key: 'sl_id', label: 'ID' }, { key: 'sl_name', label: '이름' }, { key: 'sl_skin', label: '스킨' }], + }}, + 'eyoom/ebcontents': { title: 'EB 콘텐츠 관리', group: '이윰빌더' }, + 'eyoom/eblatest': { title: 'EB 최신글 관리', group: '이윰빌더', table: { + query: (p, ps) => paginate('inspection2.g5_eyoom_eblatest', '', 'el_id DESC', p, ps), + rowKey: 'el_id', + columns: [{ key: 'el_id', label: 'ID' }, { key: 'el_name', label: '이름' }, { key: 'el_skin', label: '스킨' }], + }}, + 'eyoom/ebbanner': { title: 'EB 배너 관리', group: '이윰빌더', table: { + query: (p, ps) => paginate('inspection2.g5_eyoom_banner', '', 'bn_id DESC', p, ps), + rowKey: 'bn_id', + columns: [{ key: 'bn_id', label: 'ID' }, { key: 'bn_name', label: '이름' }, { key: 'bn_skin', label: '스킨' }], + }}, + 'eyoom/tags': { title: '태그 관리', group: '이윰빌더', table: { + query: (p, ps) => paginate('inspection2.g5_eyoom_tag', '', 'eb_tag', p, ps), + rowKey: 'tag_no', + columns: [{ key: 'eb_tag', label: '태그' }, { key: 'bo_table', label: '게시판' }, { key: 'wr_id', label: '글 ID' }], + }}, + 'eyoom/level': { title: '이윰 레벨 환경설정', group: '이윰빌더' }, + 'eyoom/memo': { title: '회원 메모', group: '이윰빌더', table: { + query: (p, ps) => paginate('inspection2.g5_eyoom_mbmemo', '', 'me_id DESC', p, ps), + rowKey: 'me_id', + columns: [{ key: 'mb_id', label: '회원' }, { key: 'me_memo', label: '메모' }, { key: 'me_datetime', label: '시각', format: dt }], + }}, + 'eyoom/yellowcard': { title: '옐로카드 (경고) 관리', group: '이윰빌더', table: { + query: (p, ps) => paginate('inspection2.g5_eyoom_yellowcard', '', 'id DESC', p, ps), + rowKey: 'id', + columns: [{ key: 'mb_id', label: '회원' }, { key: 'reason', label: '사유' }, { key: 'datetime', label: '시각', format: dt }], + }}, + 'eyoom/managers': { title: '운영진 임명', group: '이윰빌더', table: { + query: (p, ps) => paginate('inspection2.g5_eyoom_manager', '', 'id', p, ps), + rowKey: 'id', + columns: [{ key: 'mb_id', label: '회원' }, { key: 'role', label: '직책' }, { key: 'reg_date', label: '등록일', format: dt }], + }}, + 'eyoom/activity': { title: '회원 활동 로그', group: '이윰빌더', table: { + query: (p, ps) => paginate('inspection2.g5_eyoom_activity', '', 'id DESC', p, ps), + rowKey: 'id', + columns: [ + { key: 'mb_id', label: '회원' }, + { key: 'activity', label: '활동' }, + { key: 'wr_id', label: '글ID' }, + { key: 'reg_date', label: '시각', format: dt }, + ], + }}, + 'eyoom/attendance': { title: '출석체크 관리', group: '이윰빌더', table: { + query: (p, ps) => paginate('inspection2.g5_eyoom_attendance', '', 'id DESC', p, ps), + rowKey: 'id', + columns: [ + { key: 'mb_id', label: '회원' }, + { key: 'at_date', label: '출석일' }, + { key: 'at_point', label: '포인트', align: 'right', format: num }, + ], + }}, +}; + +// Helper for catch-all router: get meta by slug array +export function getAdminPageMeta(slug: string[]): AdminPageMeta | null { + const key = slug.join('/'); + return ADMIN_PAGES[key] ?? null; +} + +export function getAdminPageGroupLabel(slug: string[]): string | undefined { + const key = slug.join('/'); + return ADMIN_MENU_FLAT.find((m) => m.slug === key)?.group; +} diff --git a/next-app/apps/web/src/lib/menu-from-db.ts b/next-app/apps/web/src/lib/menu-from-db.ts index 3ac5977..8319061 100644 --- a/next-app/apps/web/src/lib/menu-from-db.ts +++ b/next-app/apps/web/src/lib/menu-from-db.ts @@ -55,10 +55,10 @@ function rewriteLink(link: string): string { if (/\/roulette\b/.test(clean)) return '/games/roulette'; // /bbs/.php → /games/ (게임 시뮬레이터) const g = clean.match(/^\/bbs\/(bacara|fortunes|fivetreasures|seastory|davinci|oceanparadise|cherrymaster|yamato|kyoushi|lupin|taiku|matsuri|marilyn|giatrus|rings|bakabon|slot)(?:rank)?\.php/i); - if (g) return '/games/' + g[1].toLowerCase(); + if (g && g[1]) return '/games/' + g[1].toLowerCase(); // /bbs/activityrank.php 등 → /games/activityrank const r = clean.match(/^\/bbs\/(activityrank|muktirank|pointrank|levelrank|commentrank|specialrank|powerballrank|mixrank|slotsrank)\.php/i); - if (r) return '/games/' + r[1].toLowerCase(); + if (r && r[1]) return '/games/' + r[1].toLowerCase(); // Swiun 외부 게임 URL → 자체 라우트로 매핑 if (/swiunApi\/game\.php\?gt=mix/.test(clean)) return '/games/sports/cross'; if (/swiunApi\/game\.php\?gt=special/.test(clean)) return '/games/sports/special'; diff --git a/next-app/apps/web/src/lib/page-data.ts b/next-app/apps/web/src/lib/page-data.ts index abaeb9c..d1df218 100644 --- a/next-app/apps/web/src/lib/page-data.ts +++ b/next-app/apps/web/src/lib/page-data.ts @@ -224,12 +224,97 @@ export async function getFeaturedBoards(): Promise { return out; } -export async function getIndexProps(): Promise { - const [headlines, featured] = await Promise.all([getHeadlines(), getFeaturedBoards()]); +export interface SiteStats { + members: number; + posts: number; + comments: number; + visitsToday: number; + visitsTotal: number; + visitsMaxDay: number; + guarantees: number; + muktiReports: number; + pointsCirculating: number; + online: number; +} + +export async function getSiteStats(): Promise { + const safeNum = async (q: Promise<{ c: string }[]>) => + Number((await q.catch(() => [{ c: '0' }] as any))[0]?.c ?? 0); + const [members, posts, comments, visitsToday, visitsTotal, visitsMaxDay, guarantees, muktiReports, points, online] = await Promise.all([ + safeNum(legacySql`SELECT COUNT(*)::text AS c FROM inspection2.g5_member WHERE mb_leave_date='' AND mb_intercept_date=''`), + safeNum(legacySql`SELECT COUNT(*)::text AS c FROM inspection2.g5_write_free WHERE wr_is_comment=0`), + safeNum(legacySql`SELECT SUM(bo_count_comment)::text AS c FROM inspection2.g5_board`), + safeNum(legacySql`SELECT vs_count::text AS c FROM inspection2.g5_visit_sum WHERE vs_date = CURRENT_DATE LIMIT 1`), + safeNum(legacySql`SELECT SUM(vs_count)::text AS c FROM inspection2.g5_visit_sum`), + safeNum(legacySql`SELECT MAX(vs_count)::text AS c FROM inspection2.g5_visit_sum`), + safeNum(legacySql`SELECT COUNT(*)::text AS c FROM inspection2.g5_write_guarantee WHERE wr_is_comment=0`), + safeNum(legacySql`SELECT COUNT(*)::text AS c FROM inspection2.g5_write_mukti WHERE wr_is_comment=0`), + safeNum(legacySql`SELECT SUM(mb_point)::text AS c FROM inspection2.g5_member WHERE mb_leave_date=''`), + safeNum(legacySql`SELECT COUNT(*)::text AS c FROM inspection2.g5_login`), + ]); + return { members, posts, comments, visitsToday, visitsTotal, visitsMaxDay, guarantees, muktiReports, pointsCirculating: points, online }; +} + +export async function getVisitorStats(): Promise<{ today: number; yesterday: number; max: number; total: number }> { + try { + const [today, yesterday, max, total] = await Promise.all([ + legacySql<{ c: string }[]>`SELECT COALESCE(vs_count,0)::text AS c FROM inspection2.g5_visit_sum WHERE vs_date = CURRENT_DATE LIMIT 1`.catch(() => [] as any), + legacySql<{ c: string }[]>`SELECT COALESCE(vs_count,0)::text AS c FROM inspection2.g5_visit_sum WHERE vs_date = CURRENT_DATE - INTERVAL '1 day' LIMIT 1`.catch(() => [] as any), + legacySql<{ c: string }[]>`SELECT COALESCE(MAX(vs_count),0)::text AS c FROM inspection2.g5_visit_sum`.catch(() => [] as any), + legacySql<{ c: string }[]>`SELECT COALESCE(SUM(vs_count),0)::text AS c FROM inspection2.g5_visit_sum`.catch(() => [] as any), + ]); + return { + today: Number(today[0]?.c ?? 0), + yesterday: Number(yesterday[0]?.c ?? 0), + max: Number(max[0]?.c ?? 0), + total: Number(total[0]?.c ?? 0), + }; + } catch { + return { today: 0, yesterday: 0, max: 0, total: 0 }; + } +} + +export interface RecentActivityItem { + kind: 'post' | 'member'; + label: string; + meta: string; + href: string; + at: Date; +} + +export async function getRecentActivity(): Promise { + try { + const [posts, members] = await Promise.all([ + legacySql<{ wr_id: number; wr_subject: string; wr_name: string; wr_datetime: Date; bo_table: string }[]>` + SELECT wr_id, wr_subject, wr_name, wr_datetime, 'free'::text AS bo_table FROM inspection2.g5_write_free WHERE wr_is_comment=0 ORDER BY wr_id DESC LIMIT 8 + `.catch(() => [] as any), + legacySql<{ mb_nick: string; mb_datetime: Date }[]>` + SELECT mb_nick, mb_datetime FROM inspection2.g5_member ORDER BY mb_datetime DESC LIMIT 5 + `.catch(() => [] as any), + ]); + const items: RecentActivityItem[] = [ + ...posts.map((p) => ({ kind: 'post' as const, label: p.wr_subject, meta: p.wr_name, href: `/${p.bo_table}/${p.wr_id}`, at: new Date(p.wr_datetime) })), + ...members.map((m) => ({ kind: 'member' as const, label: `${m.mb_nick}님이 가입했습니다`, meta: '신규회원', href: `/profile/${encodeURIComponent(m.mb_nick)}`, at: new Date(m.mb_datetime) })), + ]; + return items.sort((a, b) => b.at.getTime() - a.at.getTime()).slice(0, 10); + } catch { + return []; + } +} + +export async function getIndexProps(): Promise { + const [headlines, featured, stats, recent] = await Promise.all([ + getHeadlines(), + getFeaturedBoards(), + getSiteStats(), + getRecentActivity(), + ]); return { headlines, kickStatus: getKickStatus(), quickAccess: QUICK_ACCESS, featuredBoards: featured, - }; + stats, + recent, + } as any; } diff --git a/next-app/apps/web/src/lib/post-actions.ts b/next-app/apps/web/src/lib/post-actions.ts index c26be8c..fb1471f 100644 --- a/next-app/apps/web/src/lib/post-actions.ts +++ b/next-app/apps/web/src/lib/post-actions.ts @@ -103,6 +103,7 @@ export async function addComment(boardSlug: string, parentId: string, user: Site SELECT wr_num, wr_subject FROM ${legacySql(tbl)} WHERE wr_id = ${parentWrId} AND wr_is_comment = 0 `.catch(() => []); if (!parent[0]) return { ok: false, error: 'parent_not_found' }; + const nowStr = new Date().toISOString().slice(0, 19).replace('T', ' '); const ins = await legacySql<{ wr_id: number }[]>` INSERT INTO ${legacySql(tbl)} (wr_num, wr_reply, wr_parent, wr_is_comment, wr_comment, wr_subject, wr_content, wr_link1, wr_link2, wr_hit, wr_good, wr_nogood, mb_id, wr_password, wr_name, wr_email, wr_homepage, wr_datetime, wr_last, wr_ip, wr_facebook_user, wr_twitter_user, wr_option) @@ -110,7 +111,7 @@ export async function addComment(boardSlug: string, parentId: string, user: Site ${parent[0].wr_num}, '', ${parentWrId}, 1, 0, ${parent[0].wr_subject}, ${content}, '', '', 0, 0, 0, ${user.loginId}, '', ${user.nick}, '', '', - NOW(), NOW(), ${ip}, '', '', '' + NOW(), ${nowStr}, ${ip}, '', '', '' ) RETURNING wr_id `.catch((e) => { console.error('addComment fail', e); return [] as any[]; }); diff --git a/next-app/scripts/verify-react-stack.mjs b/next-app/scripts/verify-react-stack.mjs new file mode 100644 index 0000000..6eaf231 --- /dev/null +++ b/next-app/scripts/verify-react-stack.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +// Automated end-to-end verification for the deployed React stack on 201. +// Runs the same scenario 5 times (configurable). On any failure, exits non-zero +// and prints which step broke and the response body. +// +// Scenarios per iteration: +// 1. GET / (home with stats, board slots, live activity) +// 2. GET /robots.txt +// 3. GET a public board listing (e.g. /free) +// 4. GET an existing post (latest from /free) +// 5. POST /api/auth/login as testlogin (or admin) — expect 303 +// 6. GET /mypage — expect 200 with nick +// 7. POST a new comment via /api/posts/[id]/comment — expect 303 +// 8. POST recommend (good) — 303 +// 9. POST scrap — 303 +// 10. POST logout — 303 +// +// Usage: +// BASE_URL=http://103.31.14.201 ITERATIONS=5 node scripts/verify-react-stack.mjs + +const BASE = process.env.BASE_URL || 'http://103.31.14.201'; +const ITER = Number(process.env.ITERATIONS || 5); +const USER = process.env.TEST_LOGIN || 'testlogin'; +const PASS = process.env.TEST_PASSWORD || 'test1234'; + +let cookieJar = ''; +function setCookie(resp) { + const c = resp.headers.get('set-cookie'); + if (!c) return; + // crude join; for stack we only need session cookie + const parts = c.split(',').map(s => s.split(';')[0]).filter(Boolean); + for (const p of parts) { + const eq = p.indexOf('='); + if (eq < 0) continue; + const name = p.slice(0, eq).trim(); + const val = p.slice(eq + 1).trim(); + const others = cookieJar.split('; ').filter(s => s && !s.startsWith(name + '=')); + others.push(`${name}=${val}`); + cookieJar = others.join('; '); + } +} + +async function req(method, path, body) { + const url = BASE + path; + const init = { method, redirect: 'manual', headers: { 'Cookie': cookieJar, 'User-Agent': 'verify-react-stack/1.0' } }; + if (body) { + if (typeof body === 'string') { + init.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + init.body = body; + } else if (body instanceof URLSearchParams) { + init.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + init.body = body.toString(); + } else { + init.headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + } + const resp = await fetch(url, init); + setCookie(resp); + return resp; +} + +let pass = 0, fail = 0; +const failures = []; +async function check(label, fn) { + try { + const ok = await fn(); + if (ok) { pass++; console.log(` ✅ ${label}`); } + else { fail++; console.log(` ❌ ${label}`); failures.push(label); } + } catch (e) { + fail++; + console.log(` ❌ ${label} (threw: ${e.message})`); + failures.push(`${label} (${e.message})`); + } +} + +async function iteration(i) { + console.log(`\n=== ITERATION ${i} of ${ITER} ===`); + cookieJar = ''; + + await check('GET / (home, 200 + 회원랭킹/태그/통계 노출)', async () => { + const r = await req('GET', '/'); + if (r.status !== 200) return false; + const t = await r.text(); + return /슬생|로그인|회원|보증/.test(t); + }); + + await check('GET /robots.txt (User-agent: * Disallow: /)', async () => { + const r = await req('GET', '/robots.txt'); + if (r.status !== 200) return false; + const t = await r.text(); + return t.includes('User-agent: *') && t.includes('Disallow: /'); + }); + + await check('GET /free (board listing, 200)', async () => { + const r = await req('GET', '/free'); + return r.status === 200; + }); + + let firstPostId = null; + await check('GET /free latest post', async () => { + const r = await req('GET', '/free'); + const t = await r.text(); + const m = t.match(/href="\/free\/(\d+)"/); + if (m) { firstPostId = m[1]; return true; } + return false; + }); + + await check('POST /api/auth/login', async () => { + const body = new URLSearchParams({ loginId: USER, password: PASS }); + const r = await req('POST', '/api/auth/login', body); + return r.status === 303 || r.status === 302; + }); + + await check('GET /mypage (logged-in)', async () => { + const r = await req('GET', '/mypage'); + return r.status === 200; + }); + + if (firstPostId) { + await check(`POST comment to /free/${firstPostId}`, async () => { + const body = new URLSearchParams({ content: `verify-${i}-${Date.now()}` }); + const r = await req('POST', `/api/posts/${firstPostId}/comment`, body); + return r.status === 303 || r.status === 302 || r.status === 200; + }); + await check(`POST good /free/${firstPostId}`, async () => { + const r = await req('POST', `/api/posts/${firstPostId}/good`); + return r.status === 303 || r.status === 302 || r.status === 200; + }); + await check(`POST scrap /free/${firstPostId}`, async () => { + const r = await req('POST', `/api/posts/${firstPostId}/scrap`); + return r.status === 303 || r.status === 302 || r.status === 200; + }); + } + + await check('POST /api/auth/logout', async () => { + const r = await req('POST', '/api/auth/logout'); + return r.status === 303 || r.status === 302; + }); +} + +(async () => { + console.log(`Verification of ${BASE}, ${ITER} iterations`); + for (let i = 1; i <= ITER; i++) await iteration(i); + console.log(`\n=== TOTAL: ${pass} passed, ${fail} failed ===`); + if (fail > 0) { + console.log('Failures:'); + for (const f of failures) console.log(' - ' + f); + process.exit(1); + } + process.exit(0); +})();