feat(cafe24): 진열/판매 배지 클릭으로 상태 토글

편집기 오른쪽 위 배지를 눌러 진열·판매를 바로 바꾼다. 상태만 바꾸려고
카페24 관리자에 들어갈 필요가 없어진다.

- POST /cafe24/products/{no}/status (JSON) 추가. 상세설명은 건드리지 않아
  BACKUP revision 을 만들지 않는다 - 되돌릴 HTML 이 없고 다시 눌러 복구된다.
- 요청값을 낙관적으로 반영하지 않고 쓰기 후 카페24가 돌려준 실제 상태로
  화면을 다시 그린다. 실패해도 화면과 카페24가 어긋나지 않는다.
- 왼쪽 목록의 점과 정렬용 data-display/data-selling 도 함께 갱신(목록을
  다시 받지 않으므로).
- 카페24 조회 실패 시에는 현재 상태를 믿을 수 없어 배지를 버튼으로 만들지
  않는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 17:38:20 +09:00
parent 67c28ae1c7
commit 5127600a3d
5 changed files with 171 additions and 4 deletions
@@ -21,11 +21,23 @@
{% if info.updated_date %}· 최근 수정 {{ info.updated_date }}{% endif %}
</p>
</div>
<div class="cf24-editor-badges">
{# 배지 클릭 = 진열/판매 토글. 카페24 조회에 실패했을 때(desc 없음)는 현재 상태를
믿을 수 없으므로 누를 수 없는 표시로만 둔다. 실제 전환은 products.html 의
JS 가 POST /products/{no}/status 로 처리하고 응답값으로 다시 그린다. #}
<div class="cf24-editor-badges" id="cf24-status" data-product-no="{{ product_no }}">
{% if desc %}
<button type="button" class="erp-badge cf24-badge-btn {{ 'cf24-badge-ok' if info.display else 'cf24-badge-off' }}"
data-status-field="display" data-status-on="{{ 1 if info.display else 0 }}"
title="클릭하면 진열 상태를 바꿉니다 (카페24에 즉시 반영)">{{ '진열' if info.display else '미진열' }}</button>
<button type="button" class="erp-badge cf24-badge-btn {{ 'cf24-badge-ok' if info.selling else 'cf24-badge-off' }}"
data-status-field="selling" data-status-on="{{ 1 if info.selling else 0 }}"
title="클릭하면 판매 상태를 바꿉니다 (카페24에 즉시 반영)">{{ '판매' if info.selling else '중지' }}</button>
{% else %}
{% if info.display %}<span class="erp-badge cf24-badge-ok">진열</span>
{% else %}<span class="erp-badge cf24-badge-off">미진열</span>{% endif %}
{% if info.selling %}<span class="erp-badge cf24-badge-ok">판매</span>
{% else %}<span class="erp-badge cf24-badge-off">중지</span>{% endif %}
{% endif %}
</div>
</div>
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814w" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260819a" />
{% endblock %}
{% block content %}
@@ -313,6 +313,83 @@
});
}
// ── 진열/판매 배지 클릭 = 상태 토글 ──
// 쓰기는 서버가 한다(POST /products/{no}/status). 화면은 **응답에 담긴 실제
// 상태**로 다시 그린다 — 요청값을 낙관적으로 반영하면 실패했을 때 화면과
// 카페24가 어긋난다.
(function setupStatusToggle() {
var box = pane.querySelector("#cf24-status");
if (!box) return;
var no = box.dataset.productNo;
var LABEL = {
display: { word: "진열", on: "진열", off: "미진열", dotOn: "진열중", dotOff: "미진열" },
selling: { word: "판매", on: "판매", off: "중지", dotOn: "판매중", dotOff: "판매중지" }
};
var buttons = box.querySelectorAll("[data-status-field]");
function paint(btn, on) {
var label = LABEL[btn.dataset.statusField];
btn.dataset.statusOn = on ? "1" : "0";
btn.textContent = on ? label.on : label.off;
btn.classList.toggle("cf24-badge-ok", on);
btn.classList.toggle("cf24-badge-off", !on);
}
// 왼쪽 목록은 다시 불러오지 않으므로 같은 상품 행의 점도 직접 맞춘다.
// data-display/data-selling 은 정렬 기준이라 함께 갱신한다.
function paintRow(state) {
var tr = document.querySelector('#cf24-list tr.cf24-row[data-no="' + no + '"]');
if (!tr) return;
["display", "selling"].forEach(function (field, i) {
var on = !!state[field];
var label = LABEL[field];
tr.dataset[field] = on ? "1" : "0";
var dot = tr.querySelectorAll(".cf24-dot")[i];
if (!dot) return;
dot.classList.toggle("cf24-dot-on", on);
dot.title = on ? label.dotOn : label.dotOff;
});
}
function busy(state) {
buttons.forEach(function (b) { b.disabled = state; });
}
buttons.forEach(function (btn) {
btn.addEventListener("click", function () {
var field = btn.dataset.statusField;
var label = LABEL[field];
var want = btn.dataset.statusOn !== "1";
var msg = label.word + " 상태를 「" + (want ? label.on : label.off) + "」(으)로 바꿉니다.\n" +
"카페24 쇼핑몰에 바로 반영됩니다. 계속할까요?";
if (!window.confirm(msg)) return;
busy(true);
fetch("/cafe24/products/" + no + "/status", {
method: "POST",
credentials: "same-origin",
cache: "no-store",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ field: field, value: want })
})
.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;
});
})
.then(function (data) {
buttons.forEach(function (b) { paint(b, !!data[b.dataset.statusField]); });
paintRow(data);
})
.catch(function (err) {
window.alert("상태 변경에 실패했습니다: " + err.message);
})
.then(function () { busy(false); });
});
});
})();
var form = pane.querySelector(".cf24-editor-form");
if (form) {
form.addEventListener("submit", function (e) {