diff --git a/app/admin/components/AdminSidebar.tsx b/app/admin/components/AdminSidebar.tsx index 62e3e03..38c50fd 100644 --- a/app/admin/components/AdminSidebar.tsx +++ b/app/admin/components/AdminSidebar.tsx @@ -46,6 +46,16 @@ const NAV_ITEMS = [ ), }, + { + href: '/admin/products', + label: '제품 관리', + icon: ( + + + + ), + }, { href: '/admin/contacts', label: '문의 내역', diff --git a/app/admin/packs/page.tsx b/app/admin/packs/page.tsx index fa98663..85bce05 100644 --- a/app/admin/packs/page.tsx +++ b/app/admin/packs/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import Link from 'next/link'; type PackTier = 'starter' | 'pro' | 'master'; @@ -155,6 +156,10 @@ export default function AdminPacksPage() {

NAS 자료 업로드 + 다운로드 활성화. 최대 5GB / 4시간 만료 공유 링크.

+

+ 음악 팩 레거시 관리 화면입니다. 신규 제품 파일은{' '} + 제품 관리에서 배정하세요. +

{/* 업로드 폼 */} diff --git a/app/admin/products/page.tsx b/app/admin/products/page.tsx new file mode 100644 index 0000000..034e0bf --- /dev/null +++ b/app/admin/products/page.tsx @@ -0,0 +1,560 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +interface Product { + id: string; + name: string; + description: string | null; + description_long: string | null; + price: number; + features: string[] | null; + is_listed: boolean; + is_active: boolean; + sort_order: number; +} + +interface PackFile { + id: string; + product_id: string | null; + label: string; + filename: string; + size_bytes: number; +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; +} + +const EMPTY_FORM = { + id: '', + name: '', + price: 0, + description: '', + description_long: '', + featuresText: '', + is_listed: false, + sort_order: 0, +}; + +export default function AdminProductsPage() { + const [products, setProducts] = useState([]); + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + // 폼 상태 + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); // null = 신규 + const [form, setForm] = useState({ ...EMPTY_FORM }); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + + // 파일 관리 선택 제품 + const [selectedProductId, setSelectedProductId] = useState(null); + + // 업로드 상태 + const [uploadFile, setUploadFile] = useState(null); + const [uploadLabel, setUploadLabel] = useState(''); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); + const [uploadMsg, setUploadMsg] = useState(null); + const [uploadError, setUploadError] = useState(null); + + async function loadAll() { + setLoading(true); + setLoadError(null); + try { + const [pRes, fRes] = await Promise.all([ + fetch('/api/admin/products'), + fetch('/api/admin/packs'), + ]); + const pData = await pRes.json(); + const fData = await fRes.json(); + if (!pRes.ok) throw new Error(pData.error ?? '제품 로드 실패'); + setProducts(pData.products ?? []); + setFiles(fData.files ?? []); + } catch (e) { + setLoadError(e instanceof Error ? e.message : '로드 실패'); + } finally { + setLoading(false); + } + } + + // 파일 목록만 재조회 후 반환 (자동 배정 매칭용) + async function reloadFiles(): Promise { + const res = await fetch('/api/admin/packs'); + const data = await res.json(); + const list: PackFile[] = data.files ?? []; + setFiles(list); + return list; + } + + useEffect(() => { loadAll(); }, []); + + function openNew() { + setEditingId(null); + setForm({ ...EMPTY_FORM }); + setFormError(null); + setShowForm(true); + } + + function openEdit(p: Product) { + setEditingId(p.id); + setForm({ + id: p.id, + name: p.name, + price: p.price, + description: p.description ?? '', + description_long: p.description_long ?? '', + featuresText: (p.features ?? []).join('\n'), + is_listed: p.is_listed, + sort_order: p.sort_order, + }); + setFormError(null); + setShowForm(true); + } + + async function submitForm(e: React.FormEvent) { + e.preventDefault(); + setFormError(null); + setSaving(true); + try { + const features = form.featuresText + .split('\n') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const payload = { + id: form.id, + name: form.name, + price: Number(form.price), + description: form.description, + description_long: form.description_long, + features, + is_listed: form.is_listed, + sort_order: Number(form.sort_order), + }; + const method = editingId ? 'PATCH' : 'POST'; + const res = await fetch('/api/admin/products', { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? '저장 실패'); + setShowForm(false); + await loadAll(); + } catch (e) { + setFormError(e instanceof Error ? e.message : '저장 실패'); + } finally { + setSaving(false); + } + } + + async function toggleListed(p: Product) { + try { + await fetch('/api/admin/products', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: p.id, is_listed: !p.is_listed }), + }); + await loadAll(); + } catch (e) { + console.error(e); + } + } + + async function patchFileProduct(fileId: string, productId: string | null) { + await fetch('/api/admin/packs', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: fileId, product_id: productId }), + }); + await reloadFiles(); + } + + async function handleUpload(e: React.FormEvent) { + e.preventDefault(); + setUploadError(null); + setUploadMsg(null); + if (!uploadFile || !uploadLabel || !selectedProductId) return; + setUploading(true); + setProgress(0); + const targetName = uploadFile.name; + const targetSize = uploadFile.size; + + try { + // 1) 토큰 발급 (tier는 starter 고정) + const tokenRes = await fetch('/api/admin/packs/upload-url', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tier: 'starter', + label: uploadLabel, + filename: uploadFile.name, + sizeBytes: uploadFile.size, + }), + }); + if (!tokenRes.ok) { + const err = await tokenRes.json(); + throw new Error(err.error ?? '토큰 발급 실패'); + } + const { token, uploadUrl } = await tokenRes.json(); + + // 2) 브라우저가 web-backend에 직접 multipart POST (XHR 진행률) + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', uploadUrl); + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.upload.onprogress = (ev) => { + if (ev.lengthComputable) { + setProgress(Math.round((ev.loaded / ev.total) * 100)); + } + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else { + try { + const { detail } = JSON.parse(xhr.responseText); + reject(new Error(detail ?? `HTTP ${xhr.status}`)); + } catch { + reject(new Error(`HTTP ${xhr.status}`)); + } + } + }; + xhr.onerror = () => reject(new Error('네트워크 오류')); + const fd = new FormData(); + fd.append('file', uploadFile); + xhr.send(fd); + }); + + // 3) 방금 생성된 행을 filename+size로 찾아 자동 배정 + const fresh = await reloadFiles(); + const candidates = fresh.filter( + (f) => f.filename === targetName && f.size_bytes === targetSize && f.product_id === null, + ); + if (candidates.length === 1) { + await patchFileProduct(candidates[0].id, selectedProductId); + setUploadMsg('업로드 + 제품 배정 완료'); + } else { + setUploadMsg( + '업로드 완료. 자동 배정에 실패했습니다(동명 파일 등). 아래 미배정 목록에서 수동으로 배정하세요.', + ); + } + + setUploadFile(null); + setUploadLabel(''); + setProgress(0); + } catch (e) { + setUploadError(e instanceof Error ? e.message : '업로드 실패'); + } finally { + setUploading(false); + } + } + + const selectedProduct = products.find((p) => p.id === selectedProductId) ?? null; + const productFiles = selectedProductId + ? files.filter((f) => f.product_id === selectedProductId) + : []; + const otherFiles = selectedProductId + ? files.filter((f) => f.product_id !== selectedProductId) + : []; + + return ( +
+
+
+

제품 관리

+

+ 완성 소프트웨어 제품 등록·카탈로그 노출·다운로드 파일 배정. +

+
+ +
+ + {/* 폼 */} + {showForm && ( +
+

{editingId ? `제품 편집: ${editingId}` : '새 제품 등록'}

+
+
+ + setForm({ ...form, id: e.target.value })} + disabled={!!editingId || saving} + placeholder="예: lotto_pro" + className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 disabled:opacity-60" + /> +
+
+ + setForm({ ...form, name: e.target.value })} + disabled={saving} + className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2" + /> +
+
+ + setForm({ ...form, price: Number(e.target.value) })} + disabled={saving} + className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2" + /> +
+
+ + setForm({ ...form, sort_order: Number(e.target.value) })} + disabled={saving} + className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2" + /> +
+
+
+ + setForm({ ...form, description: e.target.value })} + disabled={saving} + className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2" + /> +
+
+ +