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
@@ -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). 화면은 **응답에 담긴 실제
// 상태**로 다시 그린다 — 요청값을 낙관적으로 반영하면 실패했을 때 화면과