2025-11-02 02:46:20 +09:00
|
|
|
"use client";
|
|
|
|
|
import useSWR from "swr";
|
|
|
|
|
import { useState, useRef } from "react";
|
|
|
|
|
import { Modal } from "@/app/components/ui/Modal";
|
|
|
|
|
|
|
|
|
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
|
|
|
|
|
|
|
|
export default function AdminPartnersPage() {
|
|
|
|
|
const { data, mutate } = useSWR<{ partners: any[] }>("/api/admin/partners", fetcher);
|
2025-11-07 21:36:34 +09:00
|
|
|
const { data: catData, mutate: mutateCategories } = useSWR<{ categories: any[] }>("/api/admin/partner-categories", fetcher);
|
2025-11-02 02:46:20 +09:00
|
|
|
const partners = data?.partners ?? [];
|
2025-11-07 21:36:34 +09:00
|
|
|
const categories = catData?.categories ?? [];
|
|
|
|
|
const [form, setForm] = useState({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
|
2025-11-02 02:46:20 +09:00
|
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
const editFileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
const [editUploading, setEditUploading] = useState(false);
|
|
|
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
|
|
|
const [editDraft, setEditDraft] = useState<any>(null);
|
|
|
|
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
|
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
|
|
|
|
|
|
|
|
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
|
|
|
|
|
const inputEl = e.currentTarget;
|
|
|
|
|
const file = inputEl.files?.[0];
|
|
|
|
|
if (!file) return;
|
|
|
|
|
try {
|
|
|
|
|
setUploading(true);
|
|
|
|
|
const fd = new FormData();
|
|
|
|
|
fd.append("file", file);
|
|
|
|
|
const r = await fetch("/api/uploads", { method: "POST", body: fd });
|
|
|
|
|
const json = await r.json();
|
|
|
|
|
if (!r.ok) throw new Error(json?.error || "upload_failed");
|
|
|
|
|
setForm((f) => ({ ...f, imageUrl: json.url }));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
alert("이미지 업로드 중 오류가 발생했습니다.");
|
|
|
|
|
} finally {
|
|
|
|
|
setUploading(false);
|
|
|
|
|
if (inputEl) inputEl.value = "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onSelectEditFile(e: React.ChangeEvent<HTMLInputElement>) {
|
|
|
|
|
const inputEl = e.currentTarget;
|
|
|
|
|
const file = inputEl.files?.[0];
|
|
|
|
|
if (!file) return;
|
|
|
|
|
try {
|
|
|
|
|
setEditUploading(true);
|
|
|
|
|
const fd = new FormData();
|
|
|
|
|
fd.append("file", file);
|
|
|
|
|
const r = await fetch("/api/uploads", { method: "POST", body: fd });
|
|
|
|
|
const json = await r.json();
|
|
|
|
|
if (!r.ok) throw new Error(json?.error || "upload_failed");
|
|
|
|
|
setEditDraft((d: any) => ({ ...(d || {}), imageUrl: json.url }));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
alert("이미지 업로드 중 오류가 발생했습니다.");
|
|
|
|
|
} finally {
|
|
|
|
|
setEditUploading(false);
|
|
|
|
|
if (inputEl) inputEl.value = "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function create() {
|
|
|
|
|
// 필수값 검증: 이름/카테고리/위도/경도
|
2025-11-07 21:36:34 +09:00
|
|
|
if (!form.name || !form.categoryId || !String(form.latitude).trim() || !String(form.longitude).trim()) {
|
|
|
|
|
alert("이름, 카테고리, 위도, 경도를 모두 선택/입력해 주세요.");
|
2025-11-02 02:46:20 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const lat = Number(form.latitude);
|
|
|
|
|
const lon = Number(form.longitude);
|
|
|
|
|
if (!isFinite(lat) || !isFinite(lon)) {
|
|
|
|
|
alert("위도/경도는 숫자여야 합니다.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-11-07 21:36:34 +09:00
|
|
|
const payload = { ...form, latitude: lat, longitude: lon, categoryId: form.categoryId } as any;
|
2025-11-02 02:46:20 +09:00
|
|
|
const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
|
|
|
|
if (r.ok) {
|
2025-11-07 21:36:34 +09:00
|
|
|
setForm({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
|
2025-11-02 02:46:20 +09:00
|
|
|
mutate();
|
|
|
|
|
setShowCreateModal(false);
|
2025-11-07 21:36:34 +09:00
|
|
|
} else {
|
|
|
|
|
let msg = "저장에 실패했습니다.";
|
|
|
|
|
try {
|
|
|
|
|
const j = await r.json();
|
|
|
|
|
msg = j?.message || j?.error || msg;
|
|
|
|
|
if (r.status === 409 && j?.error === "duplicate_name") msg = "이미 존재하는 업체명입니다.";
|
|
|
|
|
if (r.status === 400) msg = msg || "입력값을 확인해 주세요.";
|
|
|
|
|
} catch {}
|
|
|
|
|
alert(msg);
|
2025-11-02 02:46:20 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
|
|
|
|
|
<h1>제휴업체 관리</h1>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setShowCreateModal(true)}
|
|
|
|
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
|
|
|
|
>
|
|
|
|
|
추가
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<Modal open={showCreateModal} onClose={() => setShowCreateModal(false)}>
|
|
|
|
|
<div className="w-[720px] max-w-[90vw]">
|
|
|
|
|
<div className="p-6">
|
|
|
|
|
<h2 className="text-lg font-bold mb-4">제휴업체 추가</h2>
|
|
|
|
|
{(() => {
|
|
|
|
|
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
|
|
|
|
|
<div style={{ gridColumn: "span 3" }}>
|
|
|
|
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>이름</label>
|
|
|
|
|
<input style={inputStyle} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ gridColumn: "span 3" }}>
|
|
|
|
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
2025-11-07 21:36:34 +09:00
|
|
|
<select style={inputStyle} value={form.categoryId} onChange={(e) => setForm({ ...form, categoryId: e.target.value })}>
|
|
|
|
|
<option value="">(없음)</option>
|
|
|
|
|
{categories.map((c: any) => (
|
|
|
|
|
<option key={c.id} value={c.id}>{c.name}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
2025-11-02 02:46:20 +09:00
|
|
|
</div>
|
|
|
|
|
<div style={{ gridColumn: "span 3" }}>
|
|
|
|
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
|
|
|
|
<input style={inputStyle} value={form.latitude} onChange={(e) => setForm({ ...form, latitude: e.target.value })} />
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ gridColumn: "span 3" }}>
|
|
|
|
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>경도</label>
|
|
|
|
|
<input style={inputStyle} value={form.longitude} onChange={(e) => setForm({ ...form, longitude: e.target.value })} />
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ gridColumn: "span 6" }}>
|
|
|
|
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>이미지 URL</label>
|
|
|
|
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
|
|
|
<input style={inputStyle} value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
disabled={uploading}
|
|
|
|
|
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
|
|
|
|
|
>
|
|
|
|
|
업로드
|
|
|
|
|
</button>
|
|
|
|
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={onSelectFile} style={{ display: "none" }} />
|
|
|
|
|
{uploading && <span style={{ fontSize: 12, opacity: .7 }}>업로드 중...</span>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ gridColumn: "span 6" }}>
|
|
|
|
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>주소(선택)</label>
|
|
|
|
|
<input style={inputStyle} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
|
|
|
|
</div>
|
|
|
|
|
{form.imageUrl && (
|
|
|
|
|
<div style={{ gridColumn: "span 6" }}>
|
|
|
|
|
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}>미리보기</div>
|
|
|
|
|
<img src={form.imageUrl} alt="미리보기" style={{ width: 240, height: 120, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={create}
|
|
|
|
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
|
|
|
|
>
|
|
|
|
|
저장
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setShowCreateModal(false)}
|
|
|
|
|
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Modal>
|
|
|
|
|
<Modal open={showEditModal} onClose={() => { setShowEditModal(false); setEditingId(null); setEditDraft(null); }}>
|
|
|
|
|
<div className="w-[720px] max-w-[90vw]">
|
|
|
|
|
<div className="p-6">
|
|
|
|
|
<h2 className="text-lg font-bold mb-4">제휴업체 수정</h2>
|
|
|
|
|
{(() => {
|
|
|
|
|
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
|
|
|
|
|
<div style={{ gridColumn: "span 3" }}>
|
|
|
|
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>이름</label>
|
|
|
|
|
<input style={inputStyle} value={editDraft?.name ?? ""} onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })} />
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ gridColumn: "span 3" }}>
|
|
|
|
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
2025-11-07 21:36:34 +09:00
|
|
|
<select style={inputStyle} value={editDraft?.categoryId ?? ""} onChange={(e) => setEditDraft({ ...editDraft, categoryId: e.target.value })}>
|
|
|
|
|
<option value="">(없음)</option>
|
|
|
|
|
{categories.map((c: any) => (
|
|
|
|
|
<option key={c.id} value={c.id}>{c.name}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
2025-11-02 02:46:20 +09:00
|
|
|
</div>
|
|
|
|
|
<div style={{ gridColumn: "span 3" }}>
|
|
|
|
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
|
|
|
|
<input style={inputStyle} value={editDraft?.latitude ?? ""} onChange={(e) => setEditDraft({ ...editDraft, latitude: e.target.value })} />
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ gridColumn: "span 3" }}>
|
|
|
|
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>경도</label>
|
|
|
|
|
<input style={inputStyle} value={editDraft?.longitude ?? ""} onChange={(e) => setEditDraft({ ...editDraft, longitude: e.target.value })} />
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ gridColumn: "span 6" }}>
|
|
|
|
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>이미지 URL</label>
|
|
|
|
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
|
|
|
<input style={inputStyle} value={editDraft?.imageUrl ?? ""} onChange={(e) => setEditDraft({ ...editDraft, imageUrl: e.target.value })} />
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => editFileInputRef.current?.click()}
|
|
|
|
|
disabled={editUploading}
|
|
|
|
|
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
|
|
|
|
|
>
|
|
|
|
|
업로드
|
|
|
|
|
</button>
|
|
|
|
|
<input ref={editFileInputRef} type="file" accept="image/*" onChange={onSelectEditFile} style={{ display: "none" }} />
|
|
|
|
|
{editUploading && <span style={{ fontSize: 12, opacity: .7 }}>업로드 중...</span>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ gridColumn: "span 6" }}>
|
|
|
|
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>주소</label>
|
|
|
|
|
<input style={inputStyle} value={editDraft?.address ?? ""} onChange={(e) => setEditDraft({ ...editDraft, address: e.target.value })} />
|
|
|
|
|
</div>
|
|
|
|
|
{editDraft?.imageUrl && (
|
|
|
|
|
<div style={{ gridColumn: "span 6" }}>
|
|
|
|
|
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}>미리보기</div>
|
|
|
|
|
<img src={editDraft.imageUrl} alt="미리보기" style={{ width: 240, height: 120, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
|
|
|
|
<button
|
2025-11-07 21:36:34 +09:00
|
|
|
onClick={async () => {
|
|
|
|
|
if (!editingId) return;
|
|
|
|
|
const resp = await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft, categoryId: (editDraft?.categoryId || null) }) });
|
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
let msg = "저장에 실패했습니다.";
|
|
|
|
|
try {
|
|
|
|
|
const j = await resp.json();
|
|
|
|
|
msg = j?.message || j?.error || msg;
|
|
|
|
|
} catch {}
|
|
|
|
|
alert(msg);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setEditingId(null);
|
|
|
|
|
setEditDraft(null);
|
|
|
|
|
setShowEditModal(false);
|
|
|
|
|
mutate();
|
|
|
|
|
}}
|
2025-11-02 02:46:20 +09:00
|
|
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
|
|
|
|
>
|
|
|
|
|
저장
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => { setShowEditModal(false); setEditingId(null); setEditDraft(null); }}
|
|
|
|
|
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Modal>
|
|
|
|
|
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
|
|
|
{partners.map((p) => (
|
|
|
|
|
<li key={p.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "grid", gridTemplateColumns: "auto 1fr 1fr 1fr auto", gap: 8, alignItems: "center" }}>
|
|
|
|
|
<div style={{ width: 80 }}>
|
|
|
|
|
{p.imageUrl ? (
|
|
|
|
|
<img src={p.imageUrl} alt={p.name} style={{ width: 80, height: 48, objectFit: "cover", borderRadius: 6 }} />
|
|
|
|
|
) : (
|
|
|
|
|
<div style={{ width: 80, height: 48, border: "1px solid #eee", borderRadius: 6, background: "#fafafa" }} />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{false ? (
|
|
|
|
|
<></>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div>
|
2025-11-07 21:36:34 +09:00
|
|
|
<div><strong>{p.name}</strong> {p.categoryRef ? <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>[{p.categoryRef.name}]</span> : null}</div>
|
2025-11-02 02:46:20 +09:00
|
|
|
<div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ fontSize: 12, opacity: .7 }}>위도 {p.latitude}</div>
|
|
|
|
|
<div style={{ fontSize: 12, opacity: .7 }}>경도 {p.longitude}</div>
|
|
|
|
|
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
|
|
|
|
<button
|
2025-11-07 21:36:34 +09:00
|
|
|
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, categoryId: p.categoryId ?? "", latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }}
|
2025-11-02 02:46:20 +09:00
|
|
|
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
|
|
|
|
>
|
|
|
|
|
수정
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "DELETE" }); mutate(); }}
|
|
|
|
|
className="h-8 px-3 rounded-md border border-red-200 text-red-600 text-sm cursor-pointer hover:bg-red-100 hover:border-red-300 hover:text-red-700 active:translate-y-px transition"
|
|
|
|
|
>
|
|
|
|
|
삭제
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
title="위로"
|
|
|
|
|
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: (p.sortOrder ?? 0) - 1 }) }); mutate(); }}
|
|
|
|
|
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
|
|
|
|
>
|
|
|
|
|
↑
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
title="아래로"
|
|
|
|
|
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: (p.sortOrder ?? 0) + 1 }) }); mutate(); }}
|
|
|
|
|
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
|
|
|
|
>
|
|
|
|
|
↓
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
2025-11-07 21:36:34 +09:00
|
|
|
<div>
|
|
|
|
|
<hr style={{ margin: "24px 0" }} />
|
|
|
|
|
<h2 className="text-lg font-bold mb-2">카테고리 관리</h2>
|
|
|
|
|
<CategoryManager categories={categories} onChanged={mutateCategories} />
|
|
|
|
|
</div>
|
2025-11-02 02:46:20 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2025-11-07 21:36:34 +09:00
|
|
|
function CategoryManager({ categories, onChanged }: { categories: any[]; onChanged: () => void }) {
|
|
|
|
|
const [name, setName] = useState("");
|
|
|
|
|
return (
|
|
|
|
|
<div className="border border-neutral-200 rounded-lg p-3">
|
|
|
|
|
<div className="flex items-center gap-2 mb-3">
|
|
|
|
|
<input
|
|
|
|
|
placeholder="카테고리 이름"
|
|
|
|
|
value={name}
|
|
|
|
|
onChange={(e) => setName(e.target.value)}
|
|
|
|
|
className="h-9 px-3 rounded-md border border-neutral-300 flex-1"
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
onClick={async () => { if (!name.trim()) return; const r = await fetch("/api/admin/partner-categories", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: name.trim() }) }); if (r.ok) { setName(""); onChanged(); } else { try { const j = await r.json(); alert(j?.message || j?.error || "생성 실패"); } catch { alert("생성 실패"); } } }}
|
|
|
|
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
|
|
|
|
>
|
|
|
|
|
추가
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<ul className="flex flex-wrap gap-2">
|
|
|
|
|
{categories.map((c: any) => (
|
|
|
|
|
<li key={c.id} className="flex items-center gap-2 px-2 py-1 rounded-full border border-neutral-300 text-sm">
|
|
|
|
|
<span>{c.name}</span>
|
|
|
|
|
<button
|
|
|
|
|
title="삭제"
|
|
|
|
|
onClick={async () => { const r = await fetch(`/api/admin/partner-categories/${c.id}`, { method: "DELETE" }); if (r.ok) onChanged(); else { try { const j = await r.json(); alert(j?.message || j?.error || "삭제 실패"); } catch { alert("삭제 실패"); } } }}
|
|
|
|
|
className="px-2 h-6 rounded-md border border-red-200 text-red-600 hover:bg-red-100 hover:border-red-300 hover:text-red-700"
|
|
|
|
|
>
|
|
|
|
|
삭제
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|