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:
2026-08-19 18:35:37 +09:00
parent 5127600a3d
commit 3537e058b0
6 changed files with 219 additions and 4 deletions
+6 -1
View File
@@ -158,6 +158,7 @@ def build_update_payload(
*,
description: str | None = None,
mobile_description: str | None = None,
product_name: str | None = None,
display: bool | None = None,
selling: bool | None = None,
shop_no: int | None = None,
@@ -171,6 +172,8 @@ def build_update_payload(
request: dict[str, Any] = {}
if description is not None:
request["description"] = description
if product_name is not None:
request["product_name"] = product_name
if mobile_description is not None:
request["mobile_description"] = mobile_description
if display is not None:
@@ -189,11 +192,12 @@ def update_product(
*,
description: str | None = None,
mobile_description: str | None = None,
product_name: str | None = None,
display: bool | None = None,
selling: bool | None = None,
shop_no: int | None = None,
) -> dict[str, Any]:
"""상품 부분 수정. 상세설명·진열·판매를 한 번의 호출로 바꿀 수 있다.
"""상품 부분 수정. 상세설명·상품명·진열·판매를 한 번의 호출로 바꿀 수 있다.
바꿀 것이 하나도 없으면 호출하지 않고 빈 dict 를 돌려준다.
@@ -203,6 +207,7 @@ def update_product(
payload = build_update_payload(
description=description,
mobile_description=mobile_description,
product_name=product_name,
display=display,
selling=selling,
shop_no=shop_no,
+61
View File
@@ -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). 화면은 **응답에 담긴 실제
// 상태**로 다시 그린다 — 요청값을 낙관적으로 반영하면 실패했을 때 화면과
+50
View File
@@ -205,6 +205,56 @@
letter-spacing: -0.45px;
}
/* 상품명 수정 — 평소엔 제목, 연필을 누르면 입력칸으로 바뀐다 */
/* 입력칸이 남는 폭을 다 쓰게 한다(min-width:0 이 없으면 긴 이름이 배지를 밀어낸다) */
.cf24-name-box {
flex: 1 1 auto;
min-width: 0;
}
.cf24-name-box .is-hidden {
display: none;
}
.cf24-name-edit {
margin-left: var(--sp-6, 6px);
padding: 0 4px;
border: 0;
border-radius: var(--r-sm, 4px);
background: transparent;
color: var(--color-midtone-gray, #737373);
font-size: var(--text-body, 14px);
line-height: 1.4;
cursor: pointer;
vertical-align: middle;
}
.cf24-name-edit:hover {
background: var(--color-ghost-gray, #f2f2f2);
color: var(--color-rich-black, #0a0a0a);
}
.cf24-name-form {
display: flex;
align-items: center;
gap: var(--sp-6, 6px);
}
.cf24-name-input {
flex: 1 1 auto;
min-width: 200px;
padding: var(--sp-6, 6px) var(--sp-8, 8px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-md, 6px);
font-size: var(--text-heading, 18px);
letter-spacing: -0.45px;
}
.cf24-name-input:focus {
outline: none;
border-color: var(--color-rich-black, #0a0a0a);
}
.cf24-editor-sub {
margin: 4px 0 0;
font-size: var(--text-caption, 12px);
+1
View File
@@ -56,6 +56,7 @@ app/modules/cafe24/ ← 상품관리 모듈
| `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` |
| `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` |
| `POST /cafe24/products/{product_no}/status` | 진열/판매 토글 (JSON: `{field, value}` → 적용 후 상태) | `cafe24` |
| `POST /cafe24/products/{product_no}/name` | 상품명 변경 (JSON: `{name}` → 적용된 이름). 이전 이름은 감사로그 `rename_product` | `cafe24` |
| `GET /cafe24/schedules` | 예약 목록 · 취소 | `cafe24` |
| `POST /cafe24/schedules` | 예약 등록 (편집기에서) | `cafe24` |
| `POST /cafe24/schedules/{id}/cancel` | 대기 중 예약 취소 | `cafe24` |