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:
@@ -158,6 +158,7 @@ def build_update_payload(
|
|||||||
*,
|
*,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
mobile_description: str | None = None,
|
mobile_description: str | None = None,
|
||||||
|
product_name: str | None = None,
|
||||||
display: bool | None = None,
|
display: bool | None = None,
|
||||||
selling: bool | None = None,
|
selling: bool | None = None,
|
||||||
shop_no: int | None = None,
|
shop_no: int | None = None,
|
||||||
@@ -171,6 +172,8 @@ def build_update_payload(
|
|||||||
request: dict[str, Any] = {}
|
request: dict[str, Any] = {}
|
||||||
if description is not None:
|
if description is not None:
|
||||||
request["description"] = description
|
request["description"] = description
|
||||||
|
if product_name is not None:
|
||||||
|
request["product_name"] = product_name
|
||||||
if mobile_description is not None:
|
if mobile_description is not None:
|
||||||
request["mobile_description"] = mobile_description
|
request["mobile_description"] = mobile_description
|
||||||
if display is not None:
|
if display is not None:
|
||||||
@@ -189,11 +192,12 @@ def update_product(
|
|||||||
*,
|
*,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
mobile_description: str | None = None,
|
mobile_description: str | None = None,
|
||||||
|
product_name: str | None = None,
|
||||||
display: bool | None = None,
|
display: bool | None = None,
|
||||||
selling: bool | None = None,
|
selling: bool | None = None,
|
||||||
shop_no: int | None = None,
|
shop_no: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""상품 부분 수정. 상세설명·진열·판매를 한 번의 호출로 바꿀 수 있다.
|
"""상품 부분 수정. 상세설명·상품명·진열·판매를 한 번의 호출로 바꿀 수 있다.
|
||||||
|
|
||||||
바꿀 것이 하나도 없으면 호출하지 않고 빈 dict 를 돌려준다.
|
바꿀 것이 하나도 없으면 호출하지 않고 빈 dict 를 돌려준다.
|
||||||
|
|
||||||
@@ -203,6 +207,7 @@ def update_product(
|
|||||||
payload = build_update_payload(
|
payload = build_update_payload(
|
||||||
description=description,
|
description=description,
|
||||||
mobile_description=mobile_description,
|
mobile_description=mobile_description,
|
||||||
|
product_name=product_name,
|
||||||
display=display,
|
display=display,
|
||||||
selling=selling,
|
selling=selling,
|
||||||
shop_no=shop_no,
|
shop_no=shop_no,
|
||||||
|
|||||||
@@ -229,6 +229,67 @@ 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))
|
||||||
|
|
||||||
|
|
||||||
|
# 카페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")
|
@products_router.post("/products/{product_no}/status")
|
||||||
def product_status(
|
def product_status(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -12,8 +12,25 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="cf24-editor-head">
|
<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">
|
<p class="cf24-editor-sub">
|
||||||
상품번호 {{ product_no }}
|
상품번호 {{ product_no }}
|
||||||
{% if info.product_code %}· <code>{{ info.product_code }}</code>{% endif %}
|
{% if info.product_code %}· <code>{{ info.product_code }}</code>{% endif %}
|
||||||
|
|||||||
@@ -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=20260819a" />
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260819b" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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). 화면은 **응답에 담긴 실제
|
// 쓰기는 서버가 한다(POST /products/{no}/status). 화면은 **응답에 담긴 실제
|
||||||
// 상태**로 다시 그린다 — 요청값을 낙관적으로 반영하면 실패했을 때 화면과
|
// 상태**로 다시 그린다 — 요청값을 낙관적으로 반영하면 실패했을 때 화면과
|
||||||
|
|||||||
@@ -205,6 +205,56 @@
|
|||||||
letter-spacing: -0.45px;
|
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 {
|
.cf24-editor-sub {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: var(--text-caption, 12px);
|
font-size: var(--text-caption, 12px);
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ app/modules/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` |
|
| `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` |
|
| `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