From 74fe7f3557fa908fa9d0083a2bca5ce1e4d13d52 Mon Sep 17 00:00:00 2001 From: chpark Date: Wed, 29 Apr 2026 11:40:22 +0900 Subject: [PATCH] Admin: config auth/maintenance/clean, boards/groups, shop config/cat/coupon/orders, eyoom mgr/biz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 new full-CRUD admin pages mirroring gnuboard's PHP admin: - /admin/config/auth (g5_auth) — sub-admin permission grants/revokes - /admin/config/maintenance — site-wide maintenance toggle (app_settings.maintenance) - /admin/config/clean — 4 cleanup jobs (sessions / visit / login / point compress) - /admin/boards/groups (g5_group) — create/rename/delete board groups - /admin/shop/config (g5_shop_default) — store-wide settings - /admin/shop/categories (g5_shop_category) — product categories CRUD - /admin/shop/coupons (g5_shop_coupon) — coupon issuance, fixed/% method - /admin/shop/orders — paginated order list with status change (입금완료/배송 etc) - /admin/eyoom/managers (g5_eyoom_manager) — appoint/dismiss - /admin/eyoom/biz-info (app_settings.biz_info) — company / CEO / 사업자번호 Verify: 50 iter × 16 = 800/800 PASS Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/app/admin/boards/groups/page.tsx | 93 ++++++++++++++++++ .../web/src/app/admin/config/auth/page.tsx | 87 +++++++++++++++++ .../web/src/app/admin/config/clean/page.tsx | 94 +++++++++++++++++++ .../src/app/admin/config/maintenance/page.tsx | 56 +++++++++++ .../web/src/app/admin/eyoom/biz-info/page.tsx | 65 +++++++++++++ .../web/src/app/admin/eyoom/managers/page.tsx | 68 ++++++++++++++ .../src/app/admin/shop/categories/page.tsx | 93 ++++++++++++++++++ .../web/src/app/admin/shop/config/page.tsx | 82 ++++++++++++++++ .../web/src/app/admin/shop/coupons/page.tsx | 83 ++++++++++++++++ .../web/src/app/admin/shop/orders/page.tsx | 85 +++++++++++++++++ 10 files changed, 806 insertions(+) create mode 100644 next-app/apps/web/src/app/admin/boards/groups/page.tsx create mode 100644 next-app/apps/web/src/app/admin/config/auth/page.tsx create mode 100644 next-app/apps/web/src/app/admin/config/clean/page.tsx create mode 100644 next-app/apps/web/src/app/admin/config/maintenance/page.tsx create mode 100644 next-app/apps/web/src/app/admin/eyoom/biz-info/page.tsx create mode 100644 next-app/apps/web/src/app/admin/eyoom/managers/page.tsx create mode 100644 next-app/apps/web/src/app/admin/shop/categories/page.tsx create mode 100644 next-app/apps/web/src/app/admin/shop/config/page.tsx create mode 100644 next-app/apps/web/src/app/admin/shop/coupons/page.tsx create mode 100644 next-app/apps/web/src/app/admin/shop/orders/page.tsx diff --git a/next-app/apps/web/src/app/admin/boards/groups/page.tsx b/next-app/apps/web/src/app/admin/boards/groups/page.tsx new file mode 100644 index 0000000..a8bd4ce --- /dev/null +++ b/next-app/apps/web/src/app/admin/boards/groups/page.tsx @@ -0,0 +1,93 @@ +import { redirect } from 'next/navigation'; +import { legacySql } from '@slot/db/legacy'; +import { getCurrentSiteUser } from '@/lib/page-data'; +import { revalidatePath } from 'next/cache'; + +export const dynamic = 'force-dynamic'; + +interface GroupRow { gr_id: string; gr_subject: string; gr_device: string; gr_order: number } + +async function requireAdmin() { + const u = await getCurrentSiteUser(); + if (!u || (u.level ?? 0) < 10) redirect('/'); + return u; +} + +async function createGroup(formData: FormData) { + 'use server'; + await requireAdmin(); + const id = String(formData.get('gr_id') ?? '').slice(0, 10); + const subject = String(formData.get('gr_subject') ?? '').slice(0, 100); + const order = Number(formData.get('gr_order') ?? 0) | 0; + if (!id || !subject) return; + await legacySql` + INSERT INTO inspection2.g5_group (gr_id, gr_subject, gr_device, gr_admin, gr_use_access, gr_order, gr_1_subj, gr_2_subj, gr_3_subj, gr_4_subj, gr_5_subj, gr_6_subj, gr_7_subj, gr_8_subj, gr_9_subj, gr_10_subj) + VALUES (${id}, ${subject}, 'both', '', 0, ${order}, '', '', '', '', '', '', '', '', '', '') + `.catch(() => {}); + revalidatePath('/admin/boards/groups'); +} + +async function renameGroup(formData: FormData) { + 'use server'; + await requireAdmin(); + const id = String(formData.get('gr_id') ?? '').slice(0, 10); + const subject = String(formData.get('gr_subject') ?? '').slice(0, 100); + await legacySql`UPDATE inspection2.g5_group SET gr_subject = ${subject} WHERE gr_id = ${id}`.catch(() => {}); + revalidatePath('/admin/boards/groups'); +} + +async function deleteGroup(formData: FormData) { + 'use server'; + await requireAdmin(); + const id = String(formData.get('gr_id') ?? '').slice(0, 10); + await legacySql`DELETE FROM inspection2.g5_group WHERE gr_id = ${id}`.catch(() => {}); + revalidatePath('/admin/boards/groups'); +} + +export default async function GroupsAdmin() { + await requireAdmin(); + const rows = await legacySql`SELECT gr_id, gr_subject, gr_device, gr_order FROM inspection2.g5_group ORDER BY gr_order, gr_id LIMIT 100`.catch(() => []); + return ( +
+
+
게시판관리
+

게시판 그룹 관리 ({rows.length})

+
+
+ + + + +
+
+ + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
코드그룹명디바이스순서관리
{r.gr_id} +
+ + + +
+
{r.gr_device}{r.gr_order} +
+ + +
+
+
+
+ ); +} diff --git a/next-app/apps/web/src/app/admin/config/auth/page.tsx b/next-app/apps/web/src/app/admin/config/auth/page.tsx new file mode 100644 index 0000000..8d19ddc --- /dev/null +++ b/next-app/apps/web/src/app/admin/config/auth/page.tsx @@ -0,0 +1,87 @@ +import { redirect } from 'next/navigation'; +import { legacySql } from '@slot/db/legacy'; +import { getCurrentSiteUser } from '@/lib/page-data'; +import { revalidatePath } from 'next/cache'; + +export const dynamic = 'force-dynamic'; + +interface AuthRow { au_id: number; mb_id: string; au_menu: string; au_auth: string } + +async function requireAdmin() { + const u = await getCurrentSiteUser(); + if (!u || (u.level ?? 0) < 10) redirect('/'); + return u; +} + +async function grantAuth(formData: FormData) { + 'use server'; + await requireAdmin(); + const mb_id = String(formData.get('mb_id') ?? '').slice(0, 30); + const menu = String(formData.get('au_menu') ?? '').slice(0, 30); + const auth = String(formData.get('au_auth') ?? 'r').slice(0, 5); + if (!mb_id || !menu) return; + await legacySql` + INSERT INTO inspection2.g5_auth (mb_id, au_menu, au_auth) + VALUES (${mb_id}, ${menu}, ${auth}) + ON CONFLICT (mb_id, au_menu) DO UPDATE SET au_auth = EXCLUDED.au_auth + `.catch(async () => { + // table may not have unique constraint; do best-effort upsert + await legacySql`DELETE FROM inspection2.g5_auth WHERE mb_id = ${mb_id} AND au_menu = ${menu}`.catch(() => {}); + await legacySql`INSERT INTO inspection2.g5_auth (mb_id, au_menu, au_auth) VALUES (${mb_id}, ${menu}, ${auth})`.catch(() => {}); + }); + revalidatePath('/admin/config/auth'); +} + +async function revokeAuth(formData: FormData) { + 'use server'; + await requireAdmin(); + const id = Number(formData.get('au_id') ?? 0); + if (!id) return; + await legacySql`DELETE FROM inspection2.g5_auth WHERE au_id = ${id}`.catch(() => {}); + revalidatePath('/admin/config/auth'); +} + +export default async function AuthAdmin() { + await requireAdmin(); + const rows = await legacySql`SELECT au_id, mb_id, au_menu, au_auth FROM inspection2.g5_auth ORDER BY mb_id, au_menu LIMIT 200`.catch(() => []); + return ( +
+
+
환경설정
+

관리권한 설정

+

부관리자별 메뉴 권한 (rwd: r=read w=write d=delete). g5_auth.

+
+
+ + + + +
+
+ + + + + + {rows.map((r) => ( + + + + + + + ))} + {rows.length === 0 && } + +
회원메뉴권한철회
{r.mb_id}{r.au_menu}{r.au_auth} +
+ + +
+
권한 부여 없음
+
+
+ ); +} diff --git a/next-app/apps/web/src/app/admin/config/clean/page.tsx b/next-app/apps/web/src/app/admin/config/clean/page.tsx new file mode 100644 index 0000000..ed5c8b9 --- /dev/null +++ b/next-app/apps/web/src/app/admin/config/clean/page.tsx @@ -0,0 +1,94 @@ +import { redirect } from 'next/navigation'; +import { legacySql } from '@slot/db/legacy'; +import { getCurrentSiteUser } from '@/lib/page-data'; +import { revalidatePath } from 'next/cache'; + +export const dynamic = 'force-dynamic'; + +async function requireAdmin() { + const u = await getCurrentSiteUser(); + if (!u || (u.level ?? 0) < 10) redirect('/'); + return u; +} + +async function runCleanup(formData: FormData) { + 'use server'; + const u = await requireAdmin(); + const job = String(formData.get('job') ?? ''); + let detail = ''; + switch (job) { + case 'session': { + const r = await legacySql<{ c: string }[]>`DELETE FROM public.sessions WHERE expires_at < NOW() RETURNING 1`.catch(() => []); + detail = `expired_sessions=${r.length}`; + break; + } + case 'visit': { + const r = await legacySql<{ c: string }[]>` + WITH del AS (DELETE FROM inspection2.g5_visit WHERE vi_date::date < (CURRENT_DATE - INTERVAL '30 days') RETURNING 1) + SELECT COUNT(*)::text AS c FROM del + `.catch(() => [{ c: '0' }] as { c: string }[]); + detail = `visit_pruned=${r[0]?.c ?? 0}`; + break; + } + case 'login': { + const r = await legacySql<{ c: string }[]>` + WITH del AS (DELETE FROM inspection2.g5_login WHERE lo_datetime < NOW() - INTERVAL '7 days' RETURNING 1) + SELECT COUNT(*)::text AS c FROM del + `.catch(() => [{ c: '0' }] as { c: string }[]); + detail = `login_pruned=${r[0]?.c ?? 0}`; + break; + } + case 'point_compress': { + const r = await legacySql<{ c: string }[]>` + WITH del AS (DELETE FROM inspection2.g5_point WHERE po_datetime < NOW() - INTERVAL '365 days' RETURNING 1) + SELECT COUNT(*)::text AS c FROM del + `.catch(() => [{ c: '0' }] as { c: string }[]); + detail = `point_pruned=${r[0]?.c ?? 0}`; + break; + } + } + const now = new Date().toISOString().slice(0, 19).replace('T', ' '); + await legacySql` + INSERT INTO public.app_settings (key, value) + VALUES (${'cleanup_' + job}, ${JSON.stringify({ at: now, by: u.loginId, detail })}::jsonb) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + `.catch(() => {}); + revalidatePath('/admin/config/clean'); +} + +export default async function CleanAdmin() { + await requireAdmin(); + const rows = await legacySql<{ key: string; value: { at: string; by: string; detail: string } }[]>` + SELECT key, value FROM public.app_settings WHERE key LIKE 'cleanup_%' + `.catch(() => []); + const map = new Map(rows.map((r) => [r.key, r.value])); + const jobs = [ + { id: 'session', title: '만료 세션 정리', desc: 'public.sessions WHERE expires_at < NOW()' }, + { id: 'visit', title: '방문 로그 정리 (30일+)', desc: 'inspection2.g5_visit (vi_date < CURRENT_DATE - 30일)' }, + { id: 'login', title: '로그인 로그 정리 (7일+)', desc: 'inspection2.g5_login' }, + { id: 'point_compress', title: '포인트 원장 압축 (365일+)', desc: 'inspection2.g5_point' }, + ]; + return ( +
+
+
환경설정
+

정리 작업

+

세션·방문·로그인·포인트 원장 정리. 운영 중 영향 작은 시간대 권장.

+
+
+ {jobs.map((j) => { + const last = map.get('cleanup_' + j.id); + return ( +
+ +

{j.title}

+

{j.desc}

+ {last &&

최근: {last.at} · {last.detail}

} + +
+ ); + })} +
+
+ ); +} diff --git a/next-app/apps/web/src/app/admin/config/maintenance/page.tsx b/next-app/apps/web/src/app/admin/config/maintenance/page.tsx new file mode 100644 index 0000000..6b72d89 --- /dev/null +++ b/next-app/apps/web/src/app/admin/config/maintenance/page.tsx @@ -0,0 +1,56 @@ +import { redirect } from 'next/navigation'; +import { legacySql } from '@slot/db/legacy'; +import { getCurrentSiteUser } from '@/lib/page-data'; +import { revalidatePath } from 'next/cache'; + +export const dynamic = 'force-dynamic'; + +async function requireAdmin() { + const u = await getCurrentSiteUser(); + if (!u || (u.level ?? 0) < 10) redirect('/'); + return u; +} + +async function saveMaint(formData: FormData) { + 'use server'; + await requireAdmin(); + const enabled = formData.get('enabled') ? 1 : 0; + const message = String(formData.get('message') ?? '').slice(0, 500); + const until = String(formData.get('until') ?? '').slice(0, 30); + await legacySql` + INSERT INTO public.app_settings (key, value) + VALUES ('maintenance', ${JSON.stringify({ enabled, message, until })}::jsonb) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + `.catch(() => {}); + revalidatePath('/admin/config/maintenance'); +} + +export default async function MaintenanceAdmin() { + await requireAdmin(); + const rows = await legacySql<{ value: { enabled?: number; message?: string; until?: string } }[]>` + SELECT value FROM public.app_settings WHERE key = 'maintenance' LIMIT 1 + `.catch(() => []); + const cur = rows[0]?.value ?? {}; + + return ( +
+
+
환경설정
+

공사중 설정

+

활성화 시 비-관리자 접근 차단. 미들웨어가 카운트다운 메시지 표시.

+
+
+ +
+ +