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:
@@ -34,13 +34,13 @@ import logging
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
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 fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
|
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
|
||||||
|
|
||||||
from . import store
|
from . import store
|
||||||
from .common import base_ctx, guard
|
from .common import base_ctx, guard, require_store
|
||||||
|
|
||||||
logger = logging.getLogger("cafe24.products")
|
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))
|
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}")
|
@products_router.get("/products/{product_no}")
|
||||||
def product_redirect(request: Request, product_no: int):
|
def product_redirect(request: Request, product_no: int):
|
||||||
"""옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
|
"""옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
|
||||||
|
|||||||
@@ -21,11 +21,23 @@
|
|||||||
{% if info.updated_date %}· 최근 수정 {{ info.updated_date }}{% endif %}
|
{% if info.updated_date %}· 최근 수정 {{ info.updated_date }}{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
{% if info.display %}<span class="erp-badge cf24-badge-ok">진열</span>
|
||||||
{% else %}<span class="erp-badge cf24-badge-off">미진열</span>{% endif %}
|
{% else %}<span class="erp-badge cf24-badge-off">미진열</span>{% endif %}
|
||||||
{% if info.selling %}<span class="erp-badge cf24-badge-ok">판매</span>
|
{% if info.selling %}<span class="erp-badge cf24-badge-ok">판매</span>
|
||||||
{% else %}<span class="erp-badge cf24-badge-off">중지</span>{% endif %}
|
{% else %}<span class="erp-badge cf24-badge-off">중지</span>{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814w" />
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260819a" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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");
|
var form = pane.querySelector(".cf24-editor-form");
|
||||||
if (form) {
|
if (form) {
|
||||||
form.addEventListener("submit", function (e) {
|
form.addEventListener("submit", function (e) {
|
||||||
|
|||||||
@@ -469,6 +469,25 @@
|
|||||||
color: var(--color-rich-black, #0a0a0a);
|
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 {
|
.cf24-flash {
|
||||||
padding: var(--sp-10, 10px) var(--sp-12, 12px);
|
padding: var(--sp-10, 10px) var(--sp-12, 12px);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ app/modules/cafe24/ ← 상품관리 모듈
|
|||||||
| `GET /cafe24/products/{product_no}/pane` | 오른쪽 편집기 조각 (JS 가 가져감) | `cafe24` |
|
| `GET /cafe24/products/{product_no}/pane` | 오른쪽 편집기 조각 (JS 가 가져감) | `cafe24` |
|
||||||
| `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` |
|
| `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` |
|
||||||
| `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` |
|
| `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` |
|
||||||
|
| `POST /cafe24/products/{product_no}/status` | 진열/판매 토글 (JSON: `{field, value}` → 적용 후 상태) | `cafe24` |
|
||||||
| `GET /cafe24/schedules` | 예약 목록 · 취소 | `cafe24` |
|
| `GET /cafe24/schedules` | 예약 목록 · 취소 | `cafe24` |
|
||||||
| `POST /cafe24/schedules` | 예약 등록 (편집기에서) | `cafe24` |
|
| `POST /cafe24/schedules` | 예약 등록 (편집기에서) | `cafe24` |
|
||||||
| `POST /cafe24/schedules/{id}/cancel` | 대기 중 예약 취소 | `cafe24` |
|
| `POST /cafe24/schedules/{id}/cancel` | 대기 중 예약 취소 | `cafe24` |
|
||||||
|
|||||||
Reference in New Issue
Block a user