Admin: config auth/maintenance/clean, boards/groups, shop config/cat/coupon/orders, eyoom mgr/biz

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) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-29 11:40:22 +09:00
parent 505398ec9f
commit 74fe7f3557
10 changed files with 806 additions and 0 deletions
@@ -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<GroupRow[]>`SELECT gr_id, gr_subject, gr_device, gr_order FROM inspection2.g5_group ORDER BY gr_order, gr_id LIMIT 100`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> ({rows.length})</h1>
</header>
<form action={createGroup} className="mb-4 grid gap-2 rounded-xl bg-white p-4 ring-1 ring-neutral-100 sm:grid-cols-[140px_1fr_120px_auto]">
<input name="gr_id" required placeholder="그룹 코드" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<input name="gr_subject" required placeholder="그룹명" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<input name="gr_order" type="number" defaultValue={0} className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<button type="submit" className="rounded-lg bg-brand-600 px-4 py-2 text-[13px] font-bold text-white">+ </button>
</form>
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
<table className="w-full border-collapse text-[12.5px]">
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
<tr><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-center"></th><th className="px-3 py-2 text-center"></th><th className="px-3 py-2 text-center"></th></tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.gr_id} className="border-t border-neutral-100">
<td className="px-3 py-2 font-mono">{r.gr_id}</td>
<td className="px-3 py-2">
<form action={renameGroup} className="flex items-center gap-1">
<input type="hidden" name="gr_id" value={r.gr_id} />
<input name="gr_subject" defaultValue={r.gr_subject} className="flex-1 rounded border border-neutral-200 px-2 py-1 text-[12px]" />
<button type="submit" className="rounded bg-brand-600 px-2 py-1 text-[10px] font-bold text-white"></button>
</form>
</td>
<td className="px-3 py-2 text-center">{r.gr_device}</td>
<td className="px-3 py-2 text-center">{r.gr_order}</td>
<td className="px-3 py-2 text-center">
<form action={deleteGroup} className="inline">
<input type="hidden" name="gr_id" value={r.gr_id} />
<button type="submit" className="rounded bg-rose-50 px-2 py-1 text-[10px] font-bold text-rose-600"></button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
);
}
@@ -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<AuthRow[]>`SELECT au_id, mb_id, au_menu, au_auth FROM inspection2.g5_auth ORDER BY mb_id, au_menu LIMIT 200`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
<p className="mt-1.5 text-[13px] text-neutral-text-soft"> (rwd: r=read w=write d=delete). g5_auth.</p>
</header>
<form action={grantAuth} className="mb-4 grid gap-2 rounded-xl bg-white p-4 ring-1 ring-neutral-100 sm:grid-cols-[160px_140px_120px_auto]">
<input name="mb_id" required placeholder="회원 ID" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<input name="au_menu" required placeholder="메뉴 코드 (예: 100)" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<select name="au_auth" defaultValue="rwd" className="rounded border border-neutral-200 px-2 py-2 text-[13px]">
<option value="r"> (r)</option><option value="rw">/ (rw)</option><option value="rwd"> (rwd)</option>
</select>
<button type="submit" className="rounded-lg bg-brand-600 px-4 py-2 text-[13px] font-bold text-white">+ </button>
</form>
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
<table className="w-full border-collapse text-[12.5px]">
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
<tr><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-center"></th><th className="px-3 py-2 text-center"></th></tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.au_id} className="border-t border-neutral-100">
<td className="px-3 py-2 font-mono">{r.mb_id}</td>
<td className="px-3 py-2 font-mono">{r.au_menu}</td>
<td className="px-3 py-2 text-center"><span className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-bold text-brand-700">{r.au_auth}</span></td>
<td className="px-3 py-2 text-center">
<form action={revokeAuth} className="inline">
<input type="hidden" name="au_id" value={r.au_id} />
<button type="submit" className="rounded bg-rose-50 px-2 py-1 text-[10px] font-bold text-rose-600"></button>
</form>
</td>
</tr>
))}
{rows.length === 0 && <tr><td colSpan={4} className="py-6 text-center text-[12px] text-neutral-text-soft"> </td></tr>}
</tbody>
</table>
</div>
</article>
);
}
@@ -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 (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
<p className="mt-1.5 text-[13px] text-neutral-text-soft">··· . .</p>
</header>
<div className="grid gap-3 sm:grid-cols-2">
{jobs.map((j) => {
const last = map.get('cleanup_' + j.id);
return (
<form key={j.id} action={runCleanup} className="rounded-2xl bg-white p-4 ring-1 ring-neutral-100">
<input type="hidden" name="job" value={j.id} />
<h3 className="m-0 text-[15px] font-bold">{j.title}</h3>
<p className="m-0 mt-1 text-[11.5px] text-neutral-text-soft">{j.desc}</p>
{last && <p className="m-0 mt-2 text-[10.5px] text-emerald-700">: {last.at} · {last.detail}</p>}
<button type="submit" className="mt-3 w-full rounded-lg bg-rose-600 py-1.5 text-[12px] font-bold text-white">🧹 </button>
</form>
);
})}
</div>
</article>
);
}
@@ -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 (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
<p className="mt-1.5 text-[13px] text-neutral-text-soft"> - . .</p>
</header>
<form action={saveMaint} className="grid max-w-xl gap-3 rounded-2xl bg-white p-5 ring-1 ring-neutral-100">
<label className="flex items-center gap-2 text-[13px]"><input type="checkbox" name="enabled" defaultChecked={Boolean(cur.enabled)} /> </label>
<div>
<label className="block text-[12px] font-bold text-neutral-700"></label>
<textarea name="message" defaultValue={cur.message ?? ''} rows={3} className="mt-1 w-full rounded-lg border border-neutral-200 px-3 py-2 text-[13px]" placeholder="현재 시스템 점검 중입니다." />
</div>
<div>
<label className="block text-[12px] font-bold text-neutral-700"> (YYYY-MM-DD HH:MM)</label>
<input name="until" defaultValue={cur.until ?? ''} className="mt-1 w-full rounded-lg border border-neutral-200 px-3 py-2 text-[13px] font-mono" />
</div>
<button type="submit" className="rounded-full bg-brand-600 py-2 text-[13px] font-bold text-white"></button>
</form>
</article>
);
}
@@ -0,0 +1,65 @@
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 saveBizInfo(formData: FormData) {
'use server';
await requireAdmin();
const data = {
company: String(formData.get('company') ?? '').slice(0, 200),
ceo: String(formData.get('ceo') ?? '').slice(0, 100),
address: String(formData.get('address') ?? '').slice(0, 500),
bizno: String(formData.get('bizno') ?? '').slice(0, 30),
phone: String(formData.get('phone') ?? '').slice(0, 30),
email: String(formData.get('email') ?? '').slice(0, 100),
};
await legacySql`
INSERT INTO public.app_settings (key, value) VALUES ('biz_info', ${JSON.stringify(data)}::jsonb)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`.catch(() => {});
revalidatePath('/admin/eyoom/biz-info');
}
export default async function BizInfoAdmin() {
await requireAdmin();
const rows = await legacySql<{ value: Record<string, string> }[]>`
SELECT value FROM public.app_settings WHERE key = 'biz_info' LIMIT 1
`.catch(() => []);
const cur = rows[0]?.value ?? {};
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
<p className="mt-1.5 text-[13px] text-neutral-text-soft"> / / (public.app_settings.biz_info).</p>
</header>
<form action={saveBizInfo} className="grid max-w-xl gap-3 rounded-2xl bg-white p-5 ring-1 ring-neutral-100">
<Field name="company" label="상호" defaultValue={cur.company ?? ''} />
<Field name="ceo" label="대표자" defaultValue={cur.ceo ?? ''} />
<Field name="address" label="주소" defaultValue={cur.address ?? ''} />
<Field name="bizno" label="사업자등록번호" defaultValue={cur.bizno ?? ''} />
<Field name="phone" label="대표전화" defaultValue={cur.phone ?? ''} />
<Field name="email" label="이메일" defaultValue={cur.email ?? ''} type="email" />
<button type="submit" className="rounded-full bg-brand-600 py-2 text-[13px] font-bold text-white"></button>
</form>
</article>
);
}
function Field({ name, label, defaultValue, type = 'text' }: { name: string; label: string; defaultValue: string; type?: string }) {
return (
<div>
<label className="block text-[12px] font-bold text-neutral-700">{label}</label>
<input name={name} type={type} defaultValue={defaultValue} className="mt-1 w-full rounded-lg border border-neutral-200 px-3 py-2 text-[13px]" />
</div>
);
}
@@ -0,0 +1,68 @@
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 MgrRow { id: number; mb_id: string; role: string; reg_date: Date | null }
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
return u;
}
async function appoint(formData: FormData) {
'use server';
await requireAdmin();
const mb_id = String(formData.get('mb_id') ?? '').slice(0, 30);
const role = String(formData.get('role') ?? '').slice(0, 50);
if (!mb_id || !role) return;
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
await legacySql`INSERT INTO inspection2.g5_eyoom_manager (mb_id, role, reg_date) VALUES (${mb_id}, ${role}, ${now})`.catch(() => {});
revalidatePath('/admin/eyoom/managers');
}
async function dismiss(formData: FormData) {
'use server';
await requireAdmin();
const id = Number(formData.get('id') ?? 0);
if (!id) return;
await legacySql`DELETE FROM inspection2.g5_eyoom_manager WHERE id = ${id}`.catch(() => {});
revalidatePath('/admin/eyoom/managers');
}
export default async function ManagersAdmin() {
await requireAdmin();
const rows = await legacySql<MgrRow[]>`SELECT id, mb_id, role, reg_date FROM inspection2.g5_eyoom_manager ORDER BY id DESC LIMIT 100`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
</header>
<form action={appoint} className="mb-4 grid gap-2 rounded-xl bg-white p-4 ring-1 ring-neutral-100 sm:grid-cols-[180px_1fr_auto]">
<input name="mb_id" required placeholder="회원 ID" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<input name="role" required placeholder="직책 (예: 부관리자)" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<button type="submit" className="rounded-lg bg-brand-600 px-4 py-2 text-[13px] font-bold text-white">+ </button>
</form>
<ul className="m-0 grid gap-2 p-0 list-none sm:grid-cols-2">
{rows.map((r) => (
<li key={r.id} className="flex items-center justify-between rounded-xl bg-white p-3 ring-1 ring-neutral-100">
<div>
<strong className="text-[14px] text-neutral-800">{r.mb_id}</strong>
<span className="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold text-amber-700">{r.role}</span>
<div className="text-[10.5px] text-neutral-text-soft">{r.reg_date && new Date(r.reg_date).toLocaleDateString('ko-KR')}</div>
</div>
<form action={dismiss}>
<input type="hidden" name="id" value={r.id} />
<button type="submit" className="rounded bg-rose-50 px-2 py-1 text-[10px] font-bold text-rose-600"></button>
</form>
</li>
))}
{rows.length === 0 && <li className="rounded-xl border border-dashed border-neutral-200 bg-white py-8 text-center text-[12px] text-neutral-text-soft sm:col-span-2"> </li>}
</ul>
</article>
);
}
@@ -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 CatRow { ca_id: string; ca_name: string; ca_use: number; ca_order: number; ca_skin: string | null }
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
return u;
}
async function createCat(formData: FormData) {
'use server';
await requireAdmin();
const id = String(formData.get('ca_id') ?? '').slice(0, 10);
const name = String(formData.get('ca_name') ?? '').slice(0, 100);
if (!id || !name) return;
await legacySql`
INSERT INTO inspection2.g5_shop_category (ca_id, ca_name, ca_skin, ca_mobile_skin, ca_use, ca_order)
VALUES (${id}, ${name}, 'basic', 'basic', 1, 0)
`.catch(() => {});
revalidatePath('/admin/shop/categories');
}
async function saveCat(formData: FormData) {
'use server';
await requireAdmin();
const id = String(formData.get('ca_id') ?? '').slice(0, 10);
const name = String(formData.get('ca_name') ?? '').slice(0, 100);
const order = Number(formData.get('ca_order') ?? 0) | 0;
const use = formData.get('ca_use') ? 1 : 0;
await legacySql`
UPDATE inspection2.g5_shop_category SET ca_name = ${name}, ca_order = ${order}, ca_use = ${use} WHERE ca_id = ${id}
`.catch(() => {});
revalidatePath('/admin/shop/categories');
}
async function deleteCat(formData: FormData) {
'use server';
await requireAdmin();
const id = String(formData.get('ca_id') ?? '').slice(0, 10);
await legacySql`DELETE FROM inspection2.g5_shop_category WHERE ca_id = ${id}`.catch(() => {});
revalidatePath('/admin/shop/categories');
}
export default async function CategoriesAdmin() {
await requireAdmin();
const rows = await legacySql<CatRow[]>`SELECT ca_id, ca_name, ca_use, ca_order, ca_skin FROM inspection2.g5_shop_category ORDER BY ca_order, ca_id LIMIT 200`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> ({rows.length})</h1>
</header>
<form action={createCat} className="mb-4 grid gap-2 rounded-xl bg-white p-4 ring-1 ring-neutral-100 sm:grid-cols-[140px_1fr_auto]">
<input name="ca_id" required placeholder="카테고리 코드" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<input name="ca_name" required placeholder="이름" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<button type="submit" className="rounded-lg bg-brand-600 px-4 py-2 text-[13px] font-bold text-white">+ </button>
</form>
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
<table className="w-full border-collapse text-[12.5px]">
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
<tr><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-center"></th><th className="px-3 py-2 text-center"></th><th className="px-3 py-2 text-center"></th></tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.ca_id} className="border-t border-neutral-100">
<td colSpan={5} className="px-2 py-1.5">
<form action={saveCat} className="grid items-center gap-1 sm:grid-cols-[100px_1fr_70px_60px_60px_60px]">
<input type="hidden" name="ca_id" value={r.ca_id} />
<code className="font-mono text-[11px]">{r.ca_id}</code>
<input name="ca_name" defaultValue={r.ca_name} className="rounded border border-neutral-200 px-2 py-1 text-[11.5px]" />
<input name="ca_order" type="number" defaultValue={r.ca_order} className="rounded border border-neutral-200 px-1 py-1 text-center text-[11px]" />
<label className="flex items-center justify-center gap-1 text-[10px]"><input type="checkbox" name="ca_use" defaultChecked={r.ca_use > 0} /> </label>
<button type="submit" className="rounded bg-brand-600 px-2 py-1 text-[10px] font-bold text-white"></button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
<form action={deleteCat} className="mt-3 flex items-center gap-2 text-[12px] text-neutral-text-soft">
<input name="ca_id" placeholder="삭제할 카테고리 코드" className="rounded border border-neutral-200 px-2 py-1 text-[11px]" />
<button type="submit" className="rounded bg-rose-50 px-2 py-1 text-[10px] font-bold text-rose-600"></button>
</form>
</article>
);
}
@@ -0,0 +1,82 @@
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 ShopRow {
de_id: number; de_admin_company_name: string; de_admin_company_owner: string;
de_admin_telephone: string; de_send_cost_limit: number;
de_admin_company_saupja_no: string | null;
}
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
return u;
}
async function saveShop(formData: FormData) {
'use server';
await requireAdmin();
const id = Number(formData.get('de_id') ?? 0);
const company = String(formData.get('company') ?? '').slice(0, 200);
const owner = String(formData.get('owner') ?? '').slice(0, 100);
const phone = String(formData.get('phone') ?? '').slice(0, 30);
const limit = Math.max(0, Math.trunc(Number(formData.get('send_limit') ?? 0)) || 0);
if (!id) return;
await legacySql`
UPDATE inspection2.g5_shop_default
SET de_admin_company_name = ${company},
de_admin_company_owner = ${owner},
de_admin_telephone = ${phone},
de_send_cost_limit = ${limit}
WHERE de_id = ${id}
`.catch(() => {});
revalidatePath('/admin/shop/config');
}
export default async function ShopConfigAdmin() {
await requireAdmin();
const rows = await legacySql<ShopRow[]>`
SELECT de_id, de_admin_company_name, de_admin_company_owner, de_admin_telephone, de_send_cost_limit, de_admin_company_saupja_no
FROM inspection2.g5_shop_default LIMIT 1
`.catch(() => []);
const c = rows[0];
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
<p className="mt-1.5 text-[13px] text-neutral-text-soft">g5_shop_default , .</p>
</header>
{!c ? (
<p className="rounded-xl border border-dashed border-neutral-200 bg-white py-10 text-center text-[13px] text-neutral-text-soft"> .</p>
) : (
<form action={saveShop} className="grid max-w-xl gap-3 rounded-2xl bg-white p-5 ring-1 ring-neutral-100">
<input type="hidden" name="de_id" value={c.de_id} />
<Field name="company" label="회사명" defaultValue={c.de_admin_company_name ?? ''} />
<Field name="owner" label="대표자" defaultValue={c.de_admin_company_owner ?? ''} />
<Field name="phone" label="전화번호" defaultValue={c.de_admin_telephone ?? ''} />
<div>
<label className="block text-[12px] font-bold text-neutral-700"> ()</label>
<input name="send_limit" type="number" defaultValue={Number(c.de_send_cost_limit ?? 0)} className="mt-1 w-full rounded-lg border border-neutral-200 px-3 py-2 text-right text-[13px]" />
</div>
{c.de_admin_company_saupja_no && <p className="text-[11.5px] text-neutral-500">: {c.de_admin_company_saupja_no}</p>}
<button type="submit" className="rounded-full bg-brand-600 py-2 text-[13px] font-bold text-white"></button>
</form>
)}
</article>
);
}
function Field({ name, label, defaultValue }: { name: string; label: string; defaultValue: string }) {
return (
<div>
<label className="block text-[12px] font-bold text-neutral-700">{label}</label>
<input name={name} defaultValue={defaultValue} className="mt-1 w-full rounded-lg border border-neutral-200 px-3 py-2 text-[13px]" />
</div>
);
}
@@ -0,0 +1,83 @@
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 CouponRow { cp_id: string; cp_subject: string; cp_method: string; cp_price: number; cp_minimum: number | null; cp_maximum: number | null; cp_start: Date | null; cp_end: Date | null }
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
return u;
}
async function createCoupon(formData: FormData) {
'use server';
await requireAdmin();
const subject = String(formData.get('subject') ?? '').slice(0, 100);
const method = String(formData.get('method') ?? '0').slice(0, 1);
const price = Math.max(0, Math.trunc(Number(formData.get('price') ?? 0)) || 0);
if (!subject) return;
const id = 'CP' + Date.now();
const start = new Date().toISOString().slice(0, 10) + ' 00:00:00';
const end = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10) + ' 23:59:59';
await legacySql`
INSERT INTO inspection2.g5_shop_coupon (cp_id, cp_subject, cp_method, cp_price, cp_minimum, cp_maximum, cp_start, cp_end, cp_target, cp_trunc, cp_send_count, cp_unique, cp_uniqueid, cp_count)
VALUES (${id}, ${subject}, ${method}, ${price}, 0, 0, ${start}, ${end}, '', 1000, 0, 'N', '', 0)
`.catch(() => {});
revalidatePath('/admin/shop/coupons');
}
async function deleteCoupon(formData: FormData) {
'use server';
await requireAdmin();
const id = String(formData.get('cp_id') ?? '').slice(0, 30);
await legacySql`DELETE FROM inspection2.g5_shop_coupon WHERE cp_id = ${id}`.catch(() => {});
revalidatePath('/admin/shop/coupons');
}
export default async function CouponsAdmin() {
await requireAdmin();
const rows = await legacySql<CouponRow[]>`SELECT cp_id, cp_subject, cp_method, cp_price, cp_minimum, cp_maximum, cp_start, cp_end FROM inspection2.g5_shop_coupon ORDER BY cp_id DESC LIMIT 100`.catch(() => []);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> </h1>
</header>
<form action={createCoupon} className="mb-4 grid gap-2 rounded-xl bg-white p-4 ring-1 ring-neutral-100 sm:grid-cols-[1fr_120px_120px_auto]">
<input name="subject" required placeholder="쿠폰 제목" className="rounded border border-neutral-200 px-3 py-2 text-[13px]" />
<select name="method" defaultValue="0" className="rounded border border-neutral-200 px-2 py-2 text-[13px]"><option value="0"> </option><option value="1"> (%)</option></select>
<input name="price" type="number" required placeholder="할인 금액" className="rounded border border-neutral-200 px-3 py-2 text-right text-[13px]" />
<button type="submit" className="rounded-lg bg-brand-600 px-4 py-2 text-[13px] font-bold text-white">+ </button>
</form>
<div className="overflow-hidden rounded-xl border border-neutral-100 bg-white">
<table className="w-full border-collapse text-[12.5px]">
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
<tr><th className="px-3 py-2 text-left">ID</th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-center"></th><th className="px-3 py-2 text-right"></th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-center"></th></tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.cp_id} className="border-t border-neutral-100">
<td className="px-3 py-2 font-mono text-[11px]">{r.cp_id}</td>
<td className="px-3 py-2">{r.cp_subject}</td>
<td className="px-3 py-2 text-center">{r.cp_method === '0' ? '정액' : '정률'}</td>
<td className="px-3 py-2 text-right tabular">{Number(r.cp_price ?? 0).toLocaleString()}{r.cp_method === '1' ? '%' : '원'}</td>
<td className="px-3 py-2 text-[10.5px]">{r.cp_start && new Date(r.cp_start).toISOString().slice(0,10)}<br />{r.cp_end && new Date(r.cp_end).toISOString().slice(0,10)}</td>
<td className="px-3 py-2 text-center">
<form action={deleteCoupon} className="inline">
<input type="hidden" name="cp_id" value={r.cp_id} />
<button type="submit" className="rounded bg-rose-50 px-2 py-1 text-[10px] font-bold text-rose-600"></button>
</form>
</td>
</tr>
))}
{rows.length === 0 && <tr><td colSpan={6} className="py-6 text-center text-[12px] text-neutral-text-soft"> </td></tr>}
</tbody>
</table>
</div>
</article>
);
}
@@ -0,0 +1,85 @@
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 OrderRow { od_id: number; mb_id: string; od_name: string; od_cart_price: number; od_settle_case: string; od_status: string; od_time: Date }
const STATUSES = ['입금전', '입금완료', '준비', '배송', '배송완료', '주문취소'];
async function requireAdmin() {
const u = await getCurrentSiteUser();
if (!u || (u.level ?? 0) < 10) redirect('/');
return u;
}
async function changeStatus(formData: FormData) {
'use server';
await requireAdmin();
const id = Number(formData.get('od_id') ?? 0);
const status = String(formData.get('status') ?? '').slice(0, 20);
if (!id || !status) return;
await legacySql`UPDATE inspection2.g5_shop_order SET od_status = ${status} WHERE od_id = ${id}`.catch(() => {});
revalidatePath('/admin/shop/orders');
}
export default async function OrdersAdmin({ searchParams }: { searchParams: Promise<{ page?: string; status?: string }> }) {
await requireAdmin();
const sp = await searchParams;
const page = Math.max(1, Number(sp.page ?? 1) | 0);
const offset = (page - 1) * 30;
const filterStatus = sp.status ?? '';
const where = filterStatus ? legacySql`WHERE od_status = ${filterStatus}` : legacySql``;
const rows = await legacySql<OrderRow[]>`
SELECT od_id, mb_id, od_name, od_cart_price, od_settle_case, od_status, od_time
FROM inspection2.g5_shop_order ${where}
ORDER BY od_id DESC LIMIT 30 OFFSET ${offset}
`.catch(() => []);
const totalRow = await legacySql<{ c: string }[]>`SELECT COUNT(*)::text AS c FROM inspection2.g5_shop_order ${where}`.catch(() => [{ c: '0' }]);
const total = Number(totalRow[0]?.c ?? 0);
return (
<article>
<header className="mb-5 border-b border-neutral-100 pb-3">
<div className="text-[11px] font-semibold uppercase tracking-widest text-brand-600"></div>
<h1 className="mt-1 text-[22px] font-bold text-neutral-900"> ( {total.toLocaleString()})</h1>
</header>
<nav className="mb-4 flex flex-wrap gap-1.5">
<a href="/admin/shop/orders" className={`rounded-full px-3 py-1 text-[11.5px] font-bold ${!filterStatus ? 'bg-brand-600 text-white' : 'bg-white text-neutral-700 ring-1 ring-neutral-200'}`}></a>
{STATUSES.map((s) => (
<a key={s} href={`?status=${encodeURIComponent(s)}`} className={`rounded-full px-3 py-1 text-[11.5px] font-bold ${filterStatus === s ? 'bg-brand-600 text-white' : 'bg-white text-neutral-700 ring-1 ring-neutral-200'}`}>{s}</a>
))}
</nav>
<div className="overflow-x-auto rounded-xl border border-neutral-100 bg-white">
<table className="w-full border-collapse text-[12.5px]">
<thead className="bg-neutral-50 text-[11px] uppercase tracking-wide text-neutral-600">
<tr><th className="px-3 py-2 text-left">#</th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-left"></th><th className="px-3 py-2 text-right"></th><th className="px-3 py-2 text-center"></th><th className="px-3 py-2 text-center"> ()</th><th className="px-3 py-2 text-left"></th></tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.od_id} className="border-t border-neutral-100">
<td className="px-3 py-2 font-mono">{r.od_id}</td>
<td className="px-3 py-2">{r.mb_id}</td>
<td className="px-3 py-2">{r.od_name}</td>
<td className="px-3 py-2 text-right tabular">{Number(r.od_cart_price ?? 0).toLocaleString()}p</td>
<td className="px-3 py-2 text-center text-[10.5px]">{r.od_settle_case}</td>
<td className="px-3 py-2 text-center">
<form action={changeStatus} className="inline-flex items-center gap-1">
<input type="hidden" name="od_id" value={r.od_id} />
<select name="status" defaultValue={r.od_status} className="rounded border border-neutral-200 px-1 py-0.5 text-[11px]">
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<button type="submit" className="rounded bg-brand-600 px-1.5 py-0.5 text-[10px] font-bold text-white"></button>
</form>
</td>
<td className="px-3 py-2 text-[11px]">{r.od_time && new Date(r.od_time).toISOString().slice(0,16).replace('T',' ')}</td>
</tr>
))}
{rows.length === 0 && <tr><td colSpan={7} className="py-6 text-center text-[12px] text-neutral-text-soft"> </td></tr>}
</tbody>
</table>
</div>
</article>
);
}