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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user