|
|
|
@@ -1,7 +1,7 @@
|
|
|
|
|
{% extends "erp_base.html" %}
|
|
|
|
|
|
|
|
|
|
{% block head_extra %}
|
|
|
|
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260819c" />
|
|
|
|
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260918a" />
|
|
|
|
|
{% endblock %}
|
|
|
|
|
|
|
|
|
|
{% block content %}
|
|
|
|
@@ -18,7 +18,7 @@
|
|
|
|
|
</div>
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
|
|
|
|
<div class="cf24-split">
|
|
|
|
|
<div class="cf24-split cf24-split-3">
|
|
|
|
|
|
|
|
|
|
{# ── 왼쪽: 상품 목록 ──────────────────────────────────────── #}
|
|
|
|
|
<aside class="erp-card cf24-pane cf24-pane-list">
|
|
|
|
@@ -83,7 +83,7 @@
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
{# ── 오른쪽: 상세페이지 편집 ──────────────────────────────── #}
|
|
|
|
|
{# ── 가운데: 상세페이지 편집 ──────────────────────────────── #}
|
|
|
|
|
<section class="erp-card cf24-pane cf24-pane-editor" id="cf24-editor-pane">
|
|
|
|
|
{% if selected %}
|
|
|
|
|
{% include "cafe24/_editor.html" %}
|
|
|
|
@@ -97,6 +97,17 @@
|
|
|
|
|
</div>
|
|
|
|
|
{% endif %}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{# ── 오른쪽: 상품 정보 패널(상품명·가격 / 대표 이미지 / 옵션·품목) ── #}
|
|
|
|
|
<section class="erp-card cf24-pane cf24-pane-side" id="cf24-side-pane">
|
|
|
|
|
{% if selected %}
|
|
|
|
|
{% include "cafe24/_side.html" %}
|
|
|
|
|
{% else %}
|
|
|
|
|
<div class="cf24-empty-pane">
|
|
|
|
|
<p class="cf24-muted">상품을 선택하면 상품명·가격, 대표 이미지, 옵션/품목을 여기서 수정할 수 있습니다.</p>
|
|
|
|
|
</div>
|
|
|
|
|
{% endif %}
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
{% endblock %}
|
|
|
|
|
|
|
|
|
@@ -374,15 +385,15 @@
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 「다시 읽기」 — 카페24 관리자에서 방금 고친 경우 현재 소스를 강제로 다시 받는다.
|
|
|
|
|
var reloadBtn = pane.querySelector("[data-reload]");
|
|
|
|
|
if (reloadBtn) {
|
|
|
|
|
// 「다시 읽기」/「다시 확인」 — 카페24 관리자에서 방금 고친 경우 현재 소스를 강제로
|
|
|
|
|
// 다시 받는다(도구 막대 버튼 + 반영 대기 배너 버튼, 둘 다 같은 동작).
|
|
|
|
|
pane.querySelectorAll("[data-reload]").forEach(function (reloadBtn) {
|
|
|
|
|
reloadBtn.addEventListener("click", function () {
|
|
|
|
|
confirmLeave().then(function (ok) {
|
|
|
|
|
if (ok) select(reloadBtn.dataset.reload, false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 예약 시각 — 네이티브 date/time input 은 투명하게 두고(opacity:0), 보이는
|
|
|
|
|
// 글자는 우리가 원하는 형식으로 직접 그린다("2026년 08월 20일" / "오후 07시 30분").
|
|
|
|
@@ -647,6 +658,509 @@
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ════════════════════════════════════════════════════════════
|
|
|
|
|
오른쪽 상품 정보 패널 — 상품명·가격 / 대표 이미지 / 옵션·품목
|
|
|
|
|
쓰기는 전부 routes_product_info.py 의 JSON API. 화면은 **응답값**으로 그린다
|
|
|
|
|
(요청값을 낙관 반영하면 실패했을 때 카페24와 어긋난다). 카페24 GET 은 쓰기
|
|
|
|
|
직후 한동안 예전 값을 돌려주므로, 저장 뒤에 다시 GET 하지 않는다.
|
|
|
|
|
════════════════════════════════════════════════════════════ */
|
|
|
|
|
function escHtml(text) {
|
|
|
|
|
return String(text == null ? "" : text).replace(/[&<>"']/g, function (c) {
|
|
|
|
|
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function apiJson(method, url, body) {
|
|
|
|
|
var opts = { method: method, credentials: "same-origin", cache: "no-store", headers: {} };
|
|
|
|
|
if (body instanceof FormData) {
|
|
|
|
|
opts.body = body;
|
|
|
|
|
} else if (body !== undefined) {
|
|
|
|
|
opts.headers["Content-Type"] = "application/json";
|
|
|
|
|
opts.body = JSON.stringify(body);
|
|
|
|
|
}
|
|
|
|
|
return fetch(url, opts).then(function (res) {
|
|
|
|
|
return res.json().catch(function () { return {}; }).then(function (data) {
|
|
|
|
|
if (!res.ok) throw new Error(data.detail || "HTTP " + res.status);
|
|
|
|
|
return data;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function wonText(value) {
|
|
|
|
|
var n = parseInt(String(value || "").replace(/,/g, ""), 10);
|
|
|
|
|
return isNaN(n) ? "" : n.toLocaleString("ko-KR") + "원";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bust(url) {
|
|
|
|
|
// 같은 경로에 새 이미지가 올라가면 브라우저가 옛 그림을 재사용한다 — 시각을 붙인다.
|
|
|
|
|
if (!url) return "";
|
|
|
|
|
return url + (url.indexOf("?") === -1 ? "?" : "&") + "t=" + Date.now();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setMsg(el, text, kind) {
|
|
|
|
|
if (!el) return;
|
|
|
|
|
el.textContent = text || "";
|
|
|
|
|
el.classList.remove("is-ok", "is-err");
|
|
|
|
|
if (kind) el.classList.add(kind);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 입력칸의 값이 처음 값(defaultValue)과 다르면 표시한다.
|
|
|
|
|
function trackDirty(container) {
|
|
|
|
|
container.querySelectorAll("input[type='text'], select, textarea").forEach(function (el) {
|
|
|
|
|
var mark = function () {
|
|
|
|
|
var base = el.tagName === "SELECT" ? el.dataset.initial : el.defaultValue;
|
|
|
|
|
el.classList.toggle("is-dirty", el.value !== (base == null ? "" : base));
|
|
|
|
|
};
|
|
|
|
|
if (el.tagName === "SELECT" && el.dataset.initial == null) el.dataset.initial = el.value;
|
|
|
|
|
el.addEventListener("input", mark);
|
|
|
|
|
el.addEventListener("change", mark);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function commitValues(container) {
|
|
|
|
|
container.querySelectorAll("input[type='text'], select, textarea").forEach(function (el) {
|
|
|
|
|
if (el.tagName === "SELECT") el.dataset.initial = el.value;
|
|
|
|
|
else el.defaultValue = el.value;
|
|
|
|
|
el.classList.remove("is-dirty");
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.cf24BindSide = function () {
|
|
|
|
|
var sideRoot = document.getElementById("cf24-side-pane");
|
|
|
|
|
var root = sideRoot && sideRoot.querySelector("#cf24-side");
|
|
|
|
|
if (!root) return;
|
|
|
|
|
var no = root.dataset.productNo;
|
|
|
|
|
|
|
|
|
|
// ── 기본 정보: 상품명 · 판매가 · 공급가 · 소비자가 ──
|
|
|
|
|
(function setupBasic() {
|
|
|
|
|
var form = root.querySelector("#cf24-basic-form");
|
|
|
|
|
if (!form) return;
|
|
|
|
|
var msg = form.querySelector("#cf24-basic-msg");
|
|
|
|
|
var btn = form.querySelector("#cf24-basic-save");
|
|
|
|
|
trackDirty(form);
|
|
|
|
|
|
|
|
|
|
function paintTitle(name) {
|
|
|
|
|
var text = document.getElementById("cf24-name-text");
|
|
|
|
|
if (text) text.textContent = name;
|
|
|
|
|
var input = document.getElementById("cf24-name-input");
|
|
|
|
|
if (input) input.value = name;
|
|
|
|
|
var tr = document.querySelector('#cf24-list tr.cf24-row[data-no="' + no + '"]');
|
|
|
|
|
if (!tr) return;
|
|
|
|
|
tr.dataset.name = name;
|
|
|
|
|
var cell = tr.querySelector(".cf24-col-name");
|
|
|
|
|
if (!cell) return;
|
|
|
|
|
cell.title = name;
|
|
|
|
|
var link = cell.querySelector("a");
|
|
|
|
|
(link || cell).textContent = name;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
form.addEventListener("submit", function (e) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
var payload = {};
|
|
|
|
|
var labels = { product_name: "상품명", price: "판매가", supply_price: "공급가", retail_price: "소비자가" };
|
|
|
|
|
var lines = [];
|
|
|
|
|
form.querySelectorAll("input[type='text']").forEach(function (el) {
|
|
|
|
|
if (el.value !== el.defaultValue) {
|
|
|
|
|
payload[el.name] = el.value.trim();
|
|
|
|
|
lines.push(labels[el.name] + ": " + (el.defaultValue || "(없음)") + " → " + (el.value.trim() || "(없음)"));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (!lines.length) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
|
|
|
|
|
if (payload.product_name === "") {
|
|
|
|
|
window.erpAlert("상품명은 비울 수 없습니다.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
window.erpConfirm("다음 항목을 카페24에 바로 반영합니다. 계속할까요?\n\n" + lines.join("\n"))
|
|
|
|
|
.then(function (ok) {
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
setMsg(msg, "저장 중…", "");
|
|
|
|
|
apiJson("POST", "/cafe24/products/" + no + "/basic", payload)
|
|
|
|
|
.then(function (data) {
|
|
|
|
|
var p = data.product || {};
|
|
|
|
|
form.querySelector('[name="product_name"]').value = p.product_name || "";
|
|
|
|
|
form.querySelector('[name="price"]').value = p.price ? String(parseInt(p.price, 10)) : "";
|
|
|
|
|
form.querySelector('[name="supply_price"]').value = p.supply_price ? String(parseInt(p.supply_price, 10)) : "";
|
|
|
|
|
form.querySelector('[name="retail_price"]').value = p.retail_price ? String(parseInt(p.retail_price, 10)) : "";
|
|
|
|
|
commitValues(form);
|
|
|
|
|
if (p.product_name) paintTitle(p.product_name);
|
|
|
|
|
setMsg(msg, (data.changed && data.changed.length) ? "카페24에 반영했습니다." : "변경된 값이 없어 보내지 않았습니다.", "is-ok");
|
|
|
|
|
})
|
|
|
|
|
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); })
|
|
|
|
|
.then(function () { btn.disabled = false; });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
// ── 대표 이미지 ──
|
|
|
|
|
(function setupImage() {
|
|
|
|
|
var form = root.querySelector("#cf24-image-form");
|
|
|
|
|
if (!form) return;
|
|
|
|
|
var fileEl = form.querySelector("#cf24-image-file");
|
|
|
|
|
var nameEl = form.querySelector("#cf24-image-filename");
|
|
|
|
|
var msg = form.querySelector("#cf24-image-msg");
|
|
|
|
|
var btn = form.querySelector("#cf24-image-save");
|
|
|
|
|
var img = root.querySelector("#cf24-image-main");
|
|
|
|
|
var link = root.querySelector("#cf24-image-link");
|
|
|
|
|
var empty = root.querySelector("#cf24-image-empty");
|
|
|
|
|
|
|
|
|
|
function paint(p) {
|
|
|
|
|
if (p.detail_image && img) {
|
|
|
|
|
img.src = bust(p.detail_image);
|
|
|
|
|
if (link) { link.href = p.detail_image; link.classList.remove("is-hidden"); }
|
|
|
|
|
if (empty) empty.classList.add("is-hidden");
|
|
|
|
|
}
|
|
|
|
|
root.querySelectorAll("[data-image-key]").forEach(function (el) {
|
|
|
|
|
var v = p[el.dataset.imageKey];
|
|
|
|
|
if (!v) return;
|
|
|
|
|
el.src = bust(v);
|
|
|
|
|
var fig = el.closest("[data-image-figure]");
|
|
|
|
|
if (fig) fig.classList.remove("is-hidden");
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fileEl.addEventListener("change", function () {
|
|
|
|
|
var f = fileEl.files && fileEl.files[0];
|
|
|
|
|
nameEl.textContent = f ? f.name + " (" + Math.round(f.size / 1024) + "KB)" : "선택된 파일 없음";
|
|
|
|
|
btn.disabled = !f;
|
|
|
|
|
if (f && f.size > 10 * 1024 * 1024) {
|
|
|
|
|
setMsg(msg, "10MB 를 넘는 파일은 올릴 수 없습니다.", "is-err");
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (f && img) {
|
|
|
|
|
// 업로드 전 미리보기 — 실제 교체는 응답을 받은 뒤 서버 경로로 다시 그린다.
|
|
|
|
|
img.src = URL.createObjectURL(f);
|
|
|
|
|
if (link) link.classList.remove("is-hidden");
|
|
|
|
|
if (empty) empty.classList.add("is-hidden");
|
|
|
|
|
setMsg(msg, "미리보기입니다. 「업로드하여 교체」를 누르면 카페24에 반영됩니다.", "");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
form.addEventListener("submit", function (e) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
var f = fileEl.files && fileEl.files[0];
|
|
|
|
|
if (!f) return;
|
|
|
|
|
window.erpConfirm("대표 이미지를 「" + f.name + "」(으)로 교체합니다.\n카페24 쇼핑몰에 바로 반영되며 목록/축소 이미지도 함께 바뀝니다. 계속할까요?")
|
|
|
|
|
.then(function (ok) {
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
var fd = new FormData();
|
|
|
|
|
fd.append("file", f);
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
setMsg(msg, "업로드 중… (파일 크기에 따라 수십 초 걸릴 수 있습니다)", "");
|
|
|
|
|
apiJson("POST", "/cafe24/products/" + no + "/image", fd)
|
|
|
|
|
.then(function (data) {
|
|
|
|
|
paint(data.product || {});
|
|
|
|
|
fileEl.value = "";
|
|
|
|
|
nameEl.textContent = "선택된 파일 없음";
|
|
|
|
|
setMsg(msg, "카페24에 반영했습니다.", "is-ok");
|
|
|
|
|
})
|
|
|
|
|
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); btn.disabled = false; });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
// ── 옵션 / 품목 — 펼칠 때 불러온다 ──
|
|
|
|
|
(function setupOptions() {
|
|
|
|
|
var details = root.querySelector("#cf24-options-details");
|
|
|
|
|
var body = root.querySelector("#cf24-options-body");
|
|
|
|
|
var count = root.querySelector("#cf24-options-count");
|
|
|
|
|
if (!details || !body) return;
|
|
|
|
|
var loaded = false;
|
|
|
|
|
var state = { option: null, variants: [], displayTypes: {} };
|
|
|
|
|
|
|
|
|
|
details.addEventListener("toggle", function () {
|
|
|
|
|
if (details.open && !loaded) { loaded = true; load(); }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function load() {
|
|
|
|
|
body.innerHTML = '<p class="cf24-muted">불러오는 중…</p>';
|
|
|
|
|
apiJson("GET", "/cafe24/products/" + no + "/options")
|
|
|
|
|
.then(function (data) {
|
|
|
|
|
state.option = data.option;
|
|
|
|
|
state.variants = data.variants || [];
|
|
|
|
|
state.displayTypes = data.display_types || {};
|
|
|
|
|
render();
|
|
|
|
|
})
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
loaded = false;
|
|
|
|
|
body.innerHTML = '<p class="cf24-err">옵션을 불러오지 못했습니다: ' + escHtml(err.message) +
|
|
|
|
|
'</p><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-retry">다시 시도</button>';
|
|
|
|
|
body.querySelector("#cf24-opt-retry").addEventListener("click", function () { loaded = true; load(); });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function typeSelect(current, name) {
|
|
|
|
|
var out = '<select name="' + name + '">';
|
|
|
|
|
Object.keys(state.displayTypes).forEach(function (k) {
|
|
|
|
|
out += '<option value="' + k + '"' + (k === current ? " selected" : "") + ">" + escHtml(state.displayTypes[k]) + "</option>";
|
|
|
|
|
});
|
|
|
|
|
return out + "</select>";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function render() {
|
|
|
|
|
var opt = state.option || { has_option: false, options: [] };
|
|
|
|
|
var variants = state.variants || [];
|
|
|
|
|
if (count) count.textContent = opt.has_option ? "(" + variants.length + "품목)" : "(옵션 없음)";
|
|
|
|
|
|
|
|
|
|
if (!opt.has_option) {
|
|
|
|
|
body.innerHTML =
|
|
|
|
|
'<p class="cf24-side-note">이 상품에는 옵션이 없습니다. 옵션명과 옵션값을 넣어 조합형 옵션을 만들면 품목이 자동으로 생성됩니다.</p>' +
|
|
|
|
|
'<form class="cf24-side-form cf24-opt-create" id="cf24-opt-create">' +
|
|
|
|
|
' <label class="cf24-side-field"><span>옵션명</span><input type="text" name="option_name" placeholder="예: 색상" /></label>' +
|
|
|
|
|
' <label class="cf24-side-field"><span>옵션값 (쉼표 또는 줄바꿈으로 구분)</span><textarea name="values" placeholder="예: 빨강, 파랑, 노랑"></textarea></label>' +
|
|
|
|
|
' <label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect("S", "display_type") + "</label>" +
|
|
|
|
|
' <div class="cf24-side-actions"><span class="cf24-muted" id="cf24-opt-msg"></span>' +
|
|
|
|
|
' <button class="erp-btn erp-btn-primary" type="submit">옵션 만들기</button></div>' +
|
|
|
|
|
"</form>";
|
|
|
|
|
var form = body.querySelector("#cf24-opt-create");
|
|
|
|
|
form.addEventListener("submit", function (e) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
var payload = {
|
|
|
|
|
option_name: form.querySelector('[name="option_name"]').value.trim(),
|
|
|
|
|
values: form.querySelector('[name="values"]').value,
|
|
|
|
|
display_type: form.querySelector('[name="display_type"]').value
|
|
|
|
|
};
|
|
|
|
|
if (!payload.option_name || !payload.values.trim()) {
|
|
|
|
|
window.erpAlert("옵션명과 옵션값을 입력하세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
window.erpConfirm("옵션 「" + payload.option_name + "」을(를) 만들고 품목을 생성합니다.\n카페24에 바로 반영됩니다. 계속할까요?")
|
|
|
|
|
.then(function (ok) {
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
var msg = form.querySelector("#cf24-opt-msg");
|
|
|
|
|
setMsg(msg, "생성 중…", "");
|
|
|
|
|
apiJson("POST", "/cafe24/products/" + no + "/options", payload)
|
|
|
|
|
.then(function (data) { state.option = data.option; state.variants = data.variants || []; render(); })
|
|
|
|
|
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 옵션 정의 편집
|
|
|
|
|
var html = '<form class="cf24-side-form" id="cf24-opt-form">';
|
|
|
|
|
opt.options.forEach(function (o, gi) {
|
|
|
|
|
html += '<div class="cf24-opt-group" data-group="' + gi + '">' +
|
|
|
|
|
'<div class="cf24-opt-head">' +
|
|
|
|
|
' <label class="cf24-side-field"><span>옵션명</span><input type="text" name="option_name" value="' + escHtml(o.option_name) + '" /></label>' +
|
|
|
|
|
' <label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect(o.option_display_type || "S", "option_display_type") + "</label>" +
|
|
|
|
|
"</div>";
|
|
|
|
|
o.option_value.forEach(function (v, vi) {
|
|
|
|
|
var thumb = v.option_image_file
|
|
|
|
|
? '<img class="cf24-opt-thumb" src="' + escHtml(v.option_image_file) + '" alt="" />'
|
|
|
|
|
: '<div class="cf24-opt-thumb-empty">없음</div>';
|
|
|
|
|
html += '<div class="cf24-opt-value" data-value="' + vi + '">' +
|
|
|
|
|
'<span class="cf24-opt-thumb-slot">' + thumb + "</span>" +
|
|
|
|
|
'<input type="text" name="option_text" value="' + escHtml(v.option_text) + '" placeholder="옵션값 이름" />' +
|
|
|
|
|
'<input type="hidden" name="option_image_file" value="' + escHtml(v.option_image_file) + '" />' +
|
|
|
|
|
'<input type="hidden" name="option_link_image" value="' + escHtml(v.option_link_image) + '" />' +
|
|
|
|
|
'<label class="cf24-file-pick"><input type="file" accept="image/jpeg,image/png,image/gif,image/webp" />' +
|
|
|
|
|
'<span class="erp-btn erp-btn-outline">썸네일</span></label>' +
|
|
|
|
|
"</div>";
|
|
|
|
|
});
|
|
|
|
|
html += "</div>";
|
|
|
|
|
});
|
|
|
|
|
html += '<p class="cf24-side-note">옵션값 추가·삭제는 카페24 API 가 지원하지 않습니다(옵션 삭제 후 다시 만들어야 합니다). 썸네일은 표시방식이 「미리보기(이미지)」일 때 쇼핑몰에 보입니다.</p>' +
|
|
|
|
|
'<div class="cf24-side-actions"><span class="cf24-muted" id="cf24-opt-msg"></span>' +
|
|
|
|
|
' <button class="erp-btn erp-btn-primary" type="submit">옵션 저장</button></div>' +
|
|
|
|
|
"</form>";
|
|
|
|
|
|
|
|
|
|
// 품목 표
|
|
|
|
|
html += '<div class="cf24-opt-group"><h5 class="cf24-side-title">품목 (' + variants.length + ')</h5>';
|
|
|
|
|
if (!variants.length) {
|
|
|
|
|
html += '<p class="cf24-muted">품목이 아직 조회되지 않았습니다. 잠시 뒤 「옵션 / 품목」을 접었다 펴서 다시 불러오세요.</p>';
|
|
|
|
|
} else {
|
|
|
|
|
html += '<div class="cf24-variants-wrap"><table class="cf24-variants"><thead><tr>' +
|
|
|
|
|
"<th>옵션</th><th class=\"cf24-v-code\">자체코드</th><th class=\"cf24-v-amount\">추가금액</th>" +
|
|
|
|
|
'<th class="cf24-v-flag">진열</th><th class="cf24-v-flag">판매</th></tr></thead><tbody>';
|
|
|
|
|
variants.forEach(function (v) {
|
|
|
|
|
var label = v.options.map(function (o) { return o.value; }).join(" / ") || v.variant_code;
|
|
|
|
|
var amount = String(parseInt(v.additional_amount || "0", 10) || 0);
|
|
|
|
|
html += '<tr data-code="' + escHtml(v.variant_code) + '">' +
|
|
|
|
|
'<td class="cf24-v-name" title="' + escHtml(v.variant_code) + '">' +
|
|
|
|
|
(v.image ? '<img class="cf24-opt-thumb" style="width:28px;height:28px;vertical-align:middle;margin-right:4px" src="' + escHtml(v.image) + '" alt="" />' : "") +
|
|
|
|
|
escHtml(label) + "</td>" +
|
|
|
|
|
'<td class="cf24-v-code"><input type="text" name="custom_variant_code" maxlength="40" value="' + escHtml(v.custom_variant_code) + '" /></td>' +
|
|
|
|
|
'<td class="cf24-v-amount"><input type="text" name="additional_amount" inputmode="numeric" value="' + escHtml(amount) + '" /></td>' +
|
|
|
|
|
'<td class="cf24-v-flag"><button type="button" class="cf24-v-toggle' + (v.display ? " is-on" : "") + '" data-flag="display" data-on="' + (v.display ? 1 : 0) + '" data-initial="' + (v.display ? 1 : 0) + '">' + (v.display ? "진열" : "미진열") + "</button></td>" +
|
|
|
|
|
'<td class="cf24-v-flag"><button type="button" class="cf24-v-toggle' + (v.selling ? " is-on" : "") + '" data-flag="selling" data-on="' + (v.selling ? 1 : 0) + '" data-initial="' + (v.selling ? 1 : 0) + '">' + (v.selling ? "판매" : "중지") + "</button></td>" +
|
|
|
|
|
"</tr>";
|
|
|
|
|
});
|
|
|
|
|
html += "</tbody></table></div>" +
|
|
|
|
|
'<div class="cf24-side-actions" style="margin-top:8px;"><span class="cf24-muted" id="cf24-var-msg"></span>' +
|
|
|
|
|
' <button type="button" class="erp-btn erp-btn-primary" id="cf24-var-save">품목 저장</button></div>';
|
|
|
|
|
}
|
|
|
|
|
html += '<div class="cf24-opt-danger"><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-delete">옵션 전체 삭제</button></div></div>';
|
|
|
|
|
|
|
|
|
|
body.innerHTML = html;
|
|
|
|
|
var optForm = body.querySelector("#cf24-opt-form");
|
|
|
|
|
trackDirty(optForm);
|
|
|
|
|
bindOptionImages(optForm);
|
|
|
|
|
optForm.addEventListener("submit", function (e) { e.preventDefault(); saveOptions(optForm); });
|
|
|
|
|
bindVariants();
|
|
|
|
|
body.querySelector("#cf24-opt-delete").addEventListener("click", deleteOptions);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bindOptionImages(optForm) {
|
|
|
|
|
optForm.querySelectorAll(".cf24-opt-value").forEach(function (row) {
|
|
|
|
|
var fileEl = row.querySelector('input[type="file"]');
|
|
|
|
|
fileEl.addEventListener("change", function () {
|
|
|
|
|
var f = fileEl.files && fileEl.files[0];
|
|
|
|
|
if (!f) return;
|
|
|
|
|
var slot = row.querySelector(".cf24-opt-thumb-slot");
|
|
|
|
|
var msg = optForm.querySelector("#cf24-opt-msg");
|
|
|
|
|
setMsg(msg, "썸네일 업로드 중…", "");
|
|
|
|
|
var fd = new FormData();
|
|
|
|
|
fd.append("file", f);
|
|
|
|
|
apiJson("POST", "/cafe24/products/" + no + "/options/image", fd)
|
|
|
|
|
.then(function (data) {
|
|
|
|
|
row.querySelector('[name="option_image_file"]').value = data.path;
|
|
|
|
|
row.querySelector('[name="option_link_image"]').value = data.path;
|
|
|
|
|
slot.innerHTML = '<img class="cf24-opt-thumb is-dirty" src="' + escHtml(data.path) + '" alt="" />';
|
|
|
|
|
setMsg(msg, "썸네일을 올렸습니다. 「옵션 저장」을 눌러야 카페24에 반영됩니다.", "is-ok");
|
|
|
|
|
})
|
|
|
|
|
.catch(function (err) { setMsg(msg, "썸네일 업로드 실패: " + err.message, "is-err"); })
|
|
|
|
|
.then(function () { fileEl.value = ""; });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveOptions(optForm) {
|
|
|
|
|
var msg = optForm.querySelector("#cf24-opt-msg");
|
|
|
|
|
var edited = [];
|
|
|
|
|
var changed = false;
|
|
|
|
|
optForm.querySelectorAll(".cf24-opt-group[data-group]").forEach(function (g, gi) {
|
|
|
|
|
var orig = state.option.options[gi];
|
|
|
|
|
var entry = {
|
|
|
|
|
option_name: g.querySelector('[name="option_name"]').value.trim(),
|
|
|
|
|
option_display_type: g.querySelector('[name="option_display_type"]').value,
|
|
|
|
|
option_value: []
|
|
|
|
|
};
|
|
|
|
|
if (entry.option_name !== orig.option_name || entry.option_display_type !== (orig.option_display_type || "S")) changed = true;
|
|
|
|
|
g.querySelectorAll(".cf24-opt-value").forEach(function (row, vi) {
|
|
|
|
|
var ov = orig.option_value[vi] || {};
|
|
|
|
|
var item = {
|
|
|
|
|
option_text: row.querySelector('[name="option_text"]').value.trim(),
|
|
|
|
|
option_image_file: row.querySelector('[name="option_image_file"]').value,
|
|
|
|
|
option_link_image: row.querySelector('[name="option_link_image"]').value,
|
|
|
|
|
option_color: ov.option_color || ""
|
|
|
|
|
};
|
|
|
|
|
if (item.option_text !== ov.option_text || item.option_image_file !== (ov.option_image_file || "") ||
|
|
|
|
|
item.option_link_image !== (ov.option_link_image || "")) changed = true;
|
|
|
|
|
entry.option_value.push(item);
|
|
|
|
|
});
|
|
|
|
|
edited.push(entry);
|
|
|
|
|
});
|
|
|
|
|
if (!changed) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
|
|
|
|
|
window.erpConfirm("옵션명·옵션값을 카페24에 바로 반영합니다. 계속할까요?").then(function (ok) {
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
setMsg(msg, "저장 중…", "");
|
|
|
|
|
apiJson("PUT", "/cafe24/products/" + no + "/options", { options: edited, option_list_type: state.option.option_list_type })
|
|
|
|
|
.then(function (data) {
|
|
|
|
|
state.option = data.option;
|
|
|
|
|
state.variants = data.variants || state.variants;
|
|
|
|
|
render();
|
|
|
|
|
setMsg(body.querySelector("#cf24-opt-msg"), "카페24에 반영했습니다.", "is-ok");
|
|
|
|
|
})
|
|
|
|
|
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bindVariants() {
|
|
|
|
|
var saveBtn = body.querySelector("#cf24-var-save");
|
|
|
|
|
if (!saveBtn) return;
|
|
|
|
|
var wrap = body.querySelector(".cf24-variants-wrap");
|
|
|
|
|
trackDirty(wrap);
|
|
|
|
|
wrap.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
|
|
|
|
|
btn.addEventListener("click", function () {
|
|
|
|
|
var on = btn.dataset.on !== "1";
|
|
|
|
|
btn.dataset.on = on ? "1" : "0";
|
|
|
|
|
btn.classList.toggle("is-on", on);
|
|
|
|
|
btn.textContent = btn.dataset.flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
|
|
|
|
|
btn.classList.toggle("is-dirty", btn.dataset.on !== btn.dataset.initial);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
saveBtn.addEventListener("click", function () {
|
|
|
|
|
var msg = body.querySelector("#cf24-var-msg");
|
|
|
|
|
var rows = [];
|
|
|
|
|
var lines = [];
|
|
|
|
|
wrap.querySelectorAll("tr[data-code]").forEach(function (tr) {
|
|
|
|
|
var row = { variant_code: tr.dataset.code };
|
|
|
|
|
var any = false;
|
|
|
|
|
var code = tr.querySelector('[name="custom_variant_code"]');
|
|
|
|
|
var amount = tr.querySelector('[name="additional_amount"]');
|
|
|
|
|
if (code.value !== code.defaultValue) { row.custom_variant_code = code.value.trim(); any = true; }
|
|
|
|
|
if (amount.value !== amount.defaultValue) { row.additional_amount = amount.value.trim() || "0"; any = true; }
|
|
|
|
|
tr.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
|
|
|
|
|
if (btn.dataset.on !== btn.dataset.initial) { row[btn.dataset.flag] = btn.dataset.on === "1" ? "on" : "off"; any = true; }
|
|
|
|
|
});
|
|
|
|
|
if (any) {
|
|
|
|
|
rows.push(row);
|
|
|
|
|
lines.push(tr.querySelector(".cf24-v-name").textContent.trim());
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (!rows.length) { setMsg(msg, "바뀐 품목이 없습니다.", ""); return; }
|
|
|
|
|
window.erpConfirm("품목 " + rows.length + "건을 카페24에 바로 반영합니다. 계속할까요?\n\n" + lines.slice(0, 8).join("\n") + (lines.length > 8 ? "\n…" : ""))
|
|
|
|
|
.then(function (ok) {
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
saveBtn.disabled = true;
|
|
|
|
|
setMsg(msg, "저장 중…", "");
|
|
|
|
|
apiJson("PUT", "/cafe24/products/" + no + "/variants", { rows: rows })
|
|
|
|
|
.then(function (data) {
|
|
|
|
|
var updated = data.updated || {};
|
|
|
|
|
Object.keys(updated).forEach(function (code) {
|
|
|
|
|
var u = updated[code];
|
|
|
|
|
var tr = wrap.querySelector('tr[data-code="' + code + '"]');
|
|
|
|
|
if (!tr) return;
|
|
|
|
|
var codeEl = tr.querySelector('[name="custom_variant_code"]');
|
|
|
|
|
var amountEl = tr.querySelector('[name="additional_amount"]');
|
|
|
|
|
if (u.custom_variant_code !== undefined) codeEl.value = u.custom_variant_code;
|
|
|
|
|
if (u.additional_amount !== undefined) amountEl.value = String(parseInt(u.additional_amount, 10) || 0);
|
|
|
|
|
tr.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
|
|
|
|
|
var flag = btn.dataset.flag;
|
|
|
|
|
if (u[flag] === undefined) return;
|
|
|
|
|
var on = !!u[flag];
|
|
|
|
|
btn.dataset.on = on ? "1" : "0";
|
|
|
|
|
btn.dataset.initial = btn.dataset.on;
|
|
|
|
|
btn.classList.toggle("is-on", on);
|
|
|
|
|
btn.classList.remove("is-dirty");
|
|
|
|
|
btn.textContent = flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
|
|
|
|
|
});
|
|
|
|
|
// 상태 보관 — 접었다 펴도 응답값이 유지되게
|
|
|
|
|
state.variants.forEach(function (v) {
|
|
|
|
|
if (v.variant_code !== code) return;
|
|
|
|
|
if (u.custom_variant_code !== undefined) v.custom_variant_code = u.custom_variant_code;
|
|
|
|
|
if (u.additional_amount !== undefined) v.additional_amount = u.additional_amount;
|
|
|
|
|
if (u.display !== undefined) v.display = !!u.display;
|
|
|
|
|
if (u.selling !== undefined) v.selling = !!u.selling;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
commitValues(wrap);
|
|
|
|
|
setMsg(msg, "카페24에 반영했습니다.", "is-ok");
|
|
|
|
|
})
|
|
|
|
|
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); })
|
|
|
|
|
.then(function () { saveBtn.disabled = false; });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deleteOptions() {
|
|
|
|
|
window.erpConfirm("옵션을 전부 삭제합니다. 카페24가 이 상품의 **품목도 모두 삭제**하며 되돌릴 수 없습니다.\n정말 삭제할까요?")
|
|
|
|
|
.then(function (ok) {
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
body.innerHTML = '<p class="cf24-muted">삭제 중…</p>';
|
|
|
|
|
apiJson("DELETE", "/cafe24/products/" + no + "/options")
|
|
|
|
|
.then(function (data) { state.option = data.option; state.variants = []; render(); })
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
body.innerHTML = '<p class="cf24-err">삭제 실패: ' + escHtml(err.message) + "</p>";
|
|
|
|
|
loaded = false;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 공용 확인창(erp-dialog.js)은 비동기다 — 기본 confirm() 과 달리 Promise 를
|
|
|
|
|
// 돌려주므로 호출부는 모두 then() 안에서 이어간다.
|
|
|
|
|
function confirmLeave() {
|
|
|
|
@@ -654,9 +1168,13 @@
|
|
|
|
|
return window.erpConfirm("편집한 내용이 저장되지 않았습니다. 이동할까요?");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 목록 클릭 → 오른쪽만 교체 ──
|
|
|
|
|
// ── 목록 클릭 → 가운데(편집기)·오른쪽(정보 패널)만 교체 ──
|
|
|
|
|
// /pane 응답 하나에 두 조각이 담겨 온다([data-pane=editor] / [data-pane=side]).
|
|
|
|
|
var sidePane = document.getElementById("cf24-side-pane");
|
|
|
|
|
|
|
|
|
|
function select(no, push) {
|
|
|
|
|
pane.innerHTML = '<p class="cf24-muted" style="padding:16px;">불러오는 중…</p>';
|
|
|
|
|
if (sidePane) sidePane.innerHTML = "";
|
|
|
|
|
var url = "/cafe24/products/" + no + "/pane" + (listQuery ? "?" + listQuery : "");
|
|
|
|
|
// no-store: 캐시된 조각이 뜨면 카페24의 현재 소스가 아닌 예전 것을 편집하게 된다.
|
|
|
|
|
fetch(url, { credentials: "same-origin", cache: "no-store" })
|
|
|
|
@@ -667,9 +1185,16 @@
|
|
|
|
|
})
|
|
|
|
|
.then(function (htmlText) {
|
|
|
|
|
if (htmlText === null) return;
|
|
|
|
|
pane.innerHTML = htmlText;
|
|
|
|
|
var holder = document.createElement("div");
|
|
|
|
|
holder.innerHTML = htmlText;
|
|
|
|
|
var editorPart = holder.querySelector('[data-pane="editor"]');
|
|
|
|
|
var sidePart = holder.querySelector('[data-pane="side"]');
|
|
|
|
|
pane.innerHTML = editorPart ? editorPart.innerHTML : htmlText;
|
|
|
|
|
if (sidePane) sidePane.innerHTML = sidePart ? sidePart.innerHTML : "";
|
|
|
|
|
window.cf24BindEditor();
|
|
|
|
|
window.cf24BindSide();
|
|
|
|
|
pane.scrollTop = 0;
|
|
|
|
|
if (sidePane) sidePane.scrollTop = 0;
|
|
|
|
|
if (push) {
|
|
|
|
|
var target = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no;
|
|
|
|
|
history.pushState({ no: no }, "", target);
|
|
|
|
@@ -746,6 +1271,7 @@
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (pane.querySelector(".cf24-editor-form")) window.cf24BindEditor();
|
|
|
|
|
window.cf24BindSide();
|
|
|
|
|
})();
|
|
|
|
|
</script>
|
|
|
|
|
{% endblock %}
|
|
|
|
|