feat(cafe24): 편집기에서 상품명 수정
제목 옆 연필 버튼 → 입력칸 → 저장. 이름만 바꾸려고 카페24 관리자에 들어갈
필요가 없어진다.
- POST /cafe24/products/{no}/name (JSON) 추가. 쓰기 전에 카페24 현재값을 읽어
이전 이름을 감사로그(rename_product)에 남긴다 - 되돌릴 revision 이 없으므로
로그가 유일한 복구 단서다. 값이 같으면 호출하지 않는다.
- build_update_payload/update_product 에 product_name 추가(부분 수정이라
상세설명·진열·판매는 그대로).
- 빈 값과 250자 초과는 서버에서 400. 화면도 maxlength 로 막는다.
- 평소에는 읽기 전용 제목이고 연필을 눌러야 입력칸이 된다 - 클릭 한 번으로
실수로 고쳐지지 않게. Enter 저장, Esc 취소.
- 화면은 요청값이 아니라 카페24가 확인해 준 이름으로 다시 그리고, 왼쪽 목록의
이름·정렬키(data-name)도 함께 갱신한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -229,6 +229,67 @@ def product_pane(request: Request, product_no: int) -> HTMLResponse:
|
||||
return _no_store(render_template(request, "cafe24/_editor.html", ctx))
|
||||
|
||||
|
||||
# 카페24 상품명 최대 길이(API 문서 기준). 넘기면 카페24가 거절하므로 미리 막는다.
|
||||
NAME_MAX = 250
|
||||
|
||||
|
||||
@products_router.post("/products/{product_no}/name")
|
||||
def product_rename(
|
||||
request: Request,
|
||||
product_no: int,
|
||||
payload: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
"""상품명만 바꾼다 — 편집기 제목 옆 연필 버튼용(JSON API).
|
||||
|
||||
상세설명과 마찬가지로 **쓰기 전에 카페24의 현재값을 읽는다.** 여기서는 되돌릴
|
||||
HTML 이 없으므로 revision 은 만들지 않고, 대신 이전 이름을 감사로그에 남긴다
|
||||
(되돌리려면 로그를 보고 다시 바꾼다).
|
||||
"""
|
||||
st, user = require_store(request)
|
||||
actor = str(user.get("email") or "")
|
||||
|
||||
name = str(payload.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="상품명을 입력하세요.")
|
||||
if len(name) > NAME_MAX:
|
||||
raise HTTPException(status_code=400, detail=f"상품명은 {NAME_MAX}자를 넘을 수 없습니다.")
|
||||
|
||||
api = build_cafe24_api(st)
|
||||
try:
|
||||
current = products.get_product(api.client, product_no)
|
||||
except Cafe24Error as exc:
|
||||
st.log_audit(
|
||||
actor=actor, action="rename_product", product_no=product_no,
|
||||
result="FAIL", detail=f"현재값 조회 실패: {exc}",
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=f"카페24 현재값을 읽지 못했습니다: {exc}") from exc
|
||||
|
||||
before = str(current.get("product_name") or "")
|
||||
if before == name:
|
||||
return {"ok": True, "product_name": before, "changed": False}
|
||||
|
||||
try:
|
||||
updated = products.update_product(api.client, product_no, product_name=name)
|
||||
except Cafe24Error as exc:
|
||||
st.log_audit(
|
||||
actor=actor, action="rename_product", product_no=product_no,
|
||||
result="FAIL", detail=f"'{before}' → '{name}' 실패: {exc}",
|
||||
)
|
||||
logger.warning("카페24 상품 %s 이름 변경 실패: %s", product_no, exc)
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
info = products.normalize_product(updated) if updated else {}
|
||||
if info.get("product_no"):
|
||||
st.upsert_products([info])
|
||||
after = str(info.get("product_name") or name)
|
||||
st.log_audit(
|
||||
actor=actor, action="rename_product", product_no=product_no,
|
||||
result="SUCCESS", detail=f"'{before}' → '{after}'",
|
||||
)
|
||||
logger.info("카페24 상품 %s 이름 변경 (%s)", product_no, actor)
|
||||
return {"ok": True, "product_name": after, "changed": True}
|
||||
|
||||
|
||||
@products_router.post("/products/{product_no}/status")
|
||||
def product_status(
|
||||
request: Request,
|
||||
|
||||
@@ -12,8 +12,25 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="cf24-editor-head">
|
||||
<div>
|
||||
<h3 class="cf24-editor-title">{{ info.product_name or '상품' }}</h3>
|
||||
{# 제목 = 상품명. 연필 버튼을 누르면 입력칸으로 바뀐다(평소에는 읽기 전용 —
|
||||
클릭만으로 실수로 고쳐지지 않게). 저장은 JS 가 POST /products/{no}/name.
|
||||
카페24 조회에 실패했을 때(desc 없음)는 현재 이름을 믿을 수 없어 버튼을 뺀다. #}
|
||||
<div class="cf24-name-box" id="cf24-name" data-product-no="{{ product_no }}">
|
||||
<h3 class="cf24-editor-title" id="cf24-name-view">
|
||||
<span id="cf24-name-text">{{ info.product_name or '상품' }}</span>
|
||||
{% if desc %}
|
||||
<button type="button" class="cf24-name-edit" id="cf24-name-edit"
|
||||
title="상품명 수정 (카페24에 즉시 반영)" aria-label="상품명 수정">✎</button>
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% if desc %}
|
||||
<div class="cf24-name-form is-hidden" id="cf24-name-form">
|
||||
<input class="cf24-name-input" id="cf24-name-input" type="text" maxlength="250"
|
||||
value="{{ info.product_name }}" aria-label="상품명" />
|
||||
<button class="erp-btn erp-btn-primary" type="button" id="cf24-name-save">저장</button>
|
||||
<button class="erp-btn erp-btn-outline" type="button" id="cf24-name-cancel">취소</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="cf24-editor-sub">
|
||||
상품번호 {{ product_no }}
|
||||
{% if info.product_code %}· <code>{{ info.product_code }}</code>{% endif %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260819a" />
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260819b" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -313,6 +313,87 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── 상품명 수정 ──
|
||||
// 연필 → 입력칸, 저장 시 POST /products/{no}/name. 서버가 쓰기 전에 카페24
|
||||
// 현재값을 읽어 이전 이름을 감사로그에 남긴다.
|
||||
(function setupRename() {
|
||||
var box = pane.querySelector("#cf24-name");
|
||||
var form = box && box.querySelector("#cf24-name-form");
|
||||
if (!box || !form) return; // 조회 실패 시엔 수정 버튼 자체가 없다
|
||||
var no = box.dataset.productNo;
|
||||
var view = box.querySelector("#cf24-name-view");
|
||||
var text = box.querySelector("#cf24-name-text");
|
||||
var input = box.querySelector("#cf24-name-input");
|
||||
var saveBtn = box.querySelector("#cf24-name-save");
|
||||
var cancelBtn = box.querySelector("#cf24-name-cancel");
|
||||
|
||||
function open(on) {
|
||||
view.classList.toggle("is-hidden", on);
|
||||
form.classList.toggle("is-hidden", !on);
|
||||
if (on) { input.value = text.textContent; input.focus(); input.select(); }
|
||||
}
|
||||
|
||||
// 왼쪽 목록도 다시 받지 않으므로 같은 상품 행의 이름·정렬키를 직접 맞춘다.
|
||||
function paintRow(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;
|
||||
}
|
||||
|
||||
function busy(state) {
|
||||
saveBtn.disabled = state;
|
||||
cancelBtn.disabled = state;
|
||||
input.disabled = state;
|
||||
}
|
||||
|
||||
function save() {
|
||||
var name = input.value.trim();
|
||||
if (!name) { window.alert("상품명을 입력하세요."); input.focus(); return; }
|
||||
if (name === text.textContent) { open(false); return; }
|
||||
if (!window.confirm("상품명을 「" + name + "」(으)로 바꿉니다.\n카페24 쇼핑몰에 바로 반영됩니다. 계속할까요?")) return;
|
||||
|
||||
busy(true);
|
||||
fetch("/cafe24/products/" + no + "/name", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name })
|
||||
})
|
||||
.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) {
|
||||
// 화면은 요청값이 아니라 카페24가 확인해 준 이름으로 그린다.
|
||||
var applied = data.product_name || name;
|
||||
text.textContent = applied;
|
||||
input.value = applied;
|
||||
paintRow(applied);
|
||||
open(false);
|
||||
})
|
||||
.catch(function (err) {
|
||||
window.alert("상품명 변경에 실패했습니다: " + err.message);
|
||||
})
|
||||
.then(function () { busy(false); });
|
||||
}
|
||||
|
||||
box.querySelector("#cf24-name-edit").addEventListener("click", function () { open(true); });
|
||||
cancelBtn.addEventListener("click", function () { open(false); });
|
||||
saveBtn.addEventListener("click", save);
|
||||
input.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") { e.preventDefault(); save(); }
|
||||
else if (e.key === "Escape") { e.preventDefault(); open(false); }
|
||||
});
|
||||
})();
|
||||
|
||||
// ── 진열/판매 배지 클릭 = 상태 토글 ──
|
||||
// 쓰기는 서버가 한다(POST /products/{no}/status). 화면은 **응답에 담긴 실제
|
||||
// 상태**로 다시 그린다 — 요청값을 낙관적으로 반영하면 실패했을 때 화면과
|
||||
|
||||
Reference in New Issue
Block a user