'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" />