diff --git a/app/modules/cafe24/routes_products.py b/app/modules/cafe24/routes_products.py index 416e8d0..35b1a5c 100644 --- a/app/modules/cafe24/routes_products.py +++ b/app/modules/cafe24/routes_products.py @@ -34,13 +34,13 @@ import logging from typing import Any 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 app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products from . import store -from .common import base_ctx, guard +from .common import base_ctx, guard, require_store 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)) +@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}") def product_redirect(request: Request, product_no: int): """옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다.""" diff --git a/app/modules/cafe24/templates/cafe24/_editor.html b/app/modules/cafe24/templates/cafe24/_editor.html index 94fd1f8..1df77f5 100644 --- a/app/modules/cafe24/templates/cafe24/_editor.html +++ b/app/modules/cafe24/templates/cafe24/_editor.html @@ -21,11 +21,23 @@ {% if info.updated_date %}· 최근 수정 {{ info.updated_date }}{% endif %}

-
+ {# 배지 클릭 = 진열/판매 토글. 카페24 조회에 실패했을 때(desc 없음)는 현재 상태를 + 믿을 수 없으므로 누를 수 없는 표시로만 둔다. 실제 전환은 products.html 의 + JS 가 POST /products/{no}/status 로 처리하고 응답값으로 다시 그린다. #} +
+ {% if desc %} + + + {% else %} {% if info.display %}진열 {% else %}미진열{% endif %} {% if info.selling %}판매 {% else %}중지{% endif %} + {% endif %}
diff --git a/app/modules/cafe24/templates/cafe24/products.html b/app/modules/cafe24/templates/cafe24/products.html index 5225a73..aed3757 100644 --- a/app/modules/cafe24/templates/cafe24/products.html +++ b/app/modules/cafe24/templates/cafe24/products.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} {% 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"); if (form) { form.addEventListener("submit", function (e) { diff --git a/app/static/cafe24.css b/app/static/cafe24.css index 4b5b605..8ed49e0 100644 --- a/app/static/cafe24.css +++ b/app/static/cafe24.css @@ -469,6 +469,25 @@ 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 { padding: var(--sp-10, 10px) var(--sp-12, 12px); diff --git a/docs/CAFE24_MODULE.md b/docs/CAFE24_MODULE.md index 00bd3dd..932666c 100644 --- a/docs/CAFE24_MODULE.md +++ b/docs/CAFE24_MODULE.md @@ -55,6 +55,7 @@ app/modules/cafe24/ ← 상품관리 모듈 | `GET /cafe24/products/{product_no}/pane` | 오른쪽 편집기 조각 (JS 가 가져감) | `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` | | `GET /cafe24/schedules` | 예약 목록 · 취소 | `cafe24` | | `POST /cafe24/schedules` | 예약 등록 (편집기에서) | `cafe24` | | `POST /cafe24/schedules/{id}/cancel` | 대기 중 예약 취소 | `cafe24` |