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
+60 -2
View File
@@ -34,13 +34,13 @@ import logging
from typing import Any
from urllib.parse import urlencode
from fastapi import APIRouter, Form, Request
from fastapi import APIRouter, Body, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
from . import store
from .common import base_ctx, guard
from .common import base_ctx, guard, require_store
logger = logging.getLogger("cafe24.products")
@@ -229,6 +229,64 @@ def product_pane(request: Request, product_no: int) -> HTMLResponse:
return _no_store(render_template(request, "cafe24/_editor.html", ctx))
@products_router.post("/products/{product_no}/status")
def product_status(
request: Request,
product_no: int,
payload: dict[str, Any] = Body(default_factory=dict),
) -> dict[str, Any]:
"""진열/판매 상태만 바꾼다 — 편집기 오른쪽 위 배지 클릭용(JSON API).
상세설명은 건드리지 않는다(`build_update_payload` 는 준 필드만 보낸다). 그래서
BACKUP revision 도 만들지 않는다 — 되돌릴 HTML 이 없고, 상태는 다시 눌러
되돌릴 수 있다.
`value` 는 클라이언트가 **원하는 결과값**이다(현재값을 뒤집지 않는다). 화면의
배지가 카페24와 어긋나 있어도 사용자가 누른 대로 되는 편이 예측 가능하다.
응답에는 쓰기 후 카페24가 돌려준 실제 상태를 담아 화면을 그것에 맞춘다.
"""
st, user = require_store(request)
actor = str(user.get("email") or "")
field = str(payload.get("field") or "").strip()
if field not in ("display", "selling"):
raise HTTPException(status_code=400, detail="field 는 display 또는 selling 이어야 합니다.")
want = bool(payload.get("value"))
api = build_cafe24_api(st)
try:
updated = products.update_product(api.client, product_no, **{field: want})
except Cafe24Error as exc:
st.log_audit(
actor=actor, action=f"set_{field}", product_no=product_no,
result="FAIL", detail=f"{want} 설정 실패: {exc}",
)
logger.warning("카페24 상품 %s %s 변경 실패: %s", product_no, field, exc)
raise HTTPException(status_code=502, detail=str(exc)) from exc
# 응답이 상품 dict 면 그것이 곧 현재 상태다. 모양이 다르면(방어) 다시 조회한다.
if "display" not in updated or "selling" not in updated:
try:
updated = products.get_product(api.client, product_no)
except Cafe24Error as exc: # 쓰기는 됐다 — 화면만 요청값으로 맞춘다.
logger.warning("카페24 상품 %s 상태 재조회 실패: %s", product_no, exc)
updated = {}
info = products.normalize_product(updated) if updated else {}
if info.get("product_no"):
st.upsert_products([info])
state = {
"display": bool(info.get("display", want if field == "display" else True)),
"selling": bool(info.get("selling", want if field == "selling" else True)),
}
st.log_audit(
actor=actor, action=f"set_{field}", product_no=product_no,
result="SUCCESS", detail=f"{field}={'T' if want else 'F'}",
)
logger.info("카페24 상품 %s %s=%s (%s)", product_no, field, want, actor)
return {"ok": True, **state}
@products_router.get("/products/{product_no}")
def product_redirect(request: Request, product_no: int):
"""옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
@@ -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) {
+19
View File
@@ -469,6 +469,25 @@
color: var(--color-rich-black, #0a0a0a);
}
/* 클릭으로 진열/판매를 토글하는 배지. 눌리는 것임을 커서·테두리로 알린다. */
.cf24-badge-btn {
border: 1px solid transparent;
font: inherit;
font-size: var(--text-caption, 12px);
cursor: pointer;
transition: filter 0.12s ease, box-shadow 0.12s ease;
}
.cf24-badge-btn:hover:not(:disabled) {
filter: brightness(0.94);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
}
.cf24-badge-btn:disabled {
opacity: 0.55;
cursor: progress;
}
/* 안내/오류 배너 */
.cf24-flash {
padding: var(--sp-10, 10px) var(--sp-12, 12px);