296 lines
17 KiB
TypeScript
296 lines
17 KiB
TypeScript
|
|
"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);
|
||
|
|
const partners = data?.partners ?? [];
|
||
|
|
const [form, setForm] = useState({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
|
||
|
|
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() {
|
||
|
|
// 필수값 검증: 이름/카테고리/위도/경도
|
||
|
|
if (!form.name || !form.category || !String(form.latitude).trim() || !String(form.longitude).trim()) {
|
||
|
|
alert("이름, 카테고리, 위도, 경도를 모두 입력해 주세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const lat = Number(form.latitude);
|
||
|
|
const lon = Number(form.longitude);
|
||
|
|
if (!isFinite(lat) || !isFinite(lon)) {
|
||
|
|
alert("위도/경도는 숫자여야 합니다.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const payload = { ...form, latitude: lat, longitude: lon } as any;
|
||
|
|
const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
||
|
|
if (r.ok) {
|
||
|
|
setForm({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
|
||
|
|
mutate();
|
||
|
|
setShowCreateModal(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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>
|
||
|
|
<input style={inputStyle} value={form.category} onChange={(e) => setForm({ ...form, category: 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.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>
|
||
|
|
<input style={inputStyle} value={editDraft?.category ?? ""} onChange={(e) => setEditDraft({ ...editDraft, category: 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?.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
|
||
|
|
onClick={async () => { if (!editingId) return; await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft }) }); setEditingId(null); setEditDraft(null); setShowEditModal(false); mutate(); }}
|
||
|
|
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>
|
||
|
|
<div><strong>{p.name}</strong> <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>{p.category}</span></div>
|
||
|
|
<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
|
||
|
|
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, category: p.category, latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }}
|
||
|
|
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>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
|