feat(cafe24): 상품관리를 좌우 2분할로 — 목록(좁게) | 상세페이지 편집(넓게)

왼쪽에서 상품을 클릭하면 오른쪽에 편집기가 바로 열린다. 목록을 다시 받지 않고
오른쪽 조각만 교체한다(GET /products/{no}/pane → JS 삽입). 목록까지 다시 그리면
클릭마다 카페24 호출이 2회 더 늘어나기 때문이다. JS 가 실패하거나 없으면 각 행의
링크(/cafe24/?selected=)로 그대로 동작한다.

목록은 페이지를 없애고 전체를 한 번에 받는다(list_all_products, 1회 100개·상한
1000개). 필터·정렬을 한 페이지에만 적용하면 다음 페이지에 있는 상품이 빠져
"진열중만 보기" 가 거짓이 된다. 현재 87개라 1회 호출로 끝난다.

컬럼은 요청대로 번호·상품명·진열·판매·수정 5개다. 좁은 칸에 맞춰 진열/판매는
배지 대신 점, 수정일은 월-일만 표시하고 전체 값은 title 로 둔다. 긴 상품명은
2줄로 제한해 행 높이를 고르게 유지한다(전체 이름은 title·편집기 제목에서 확인).

진열중/판매중 체크박스는 중복 선택이 되며 둘 다 켜면 AND 다. 문서에 없는 API
필터 파라미터에 기대지 않고 받아온 뒤 파이썬에서 걸러낸다. 제목행 클릭은
오름↔내림 토글이며 한글 정렬은 localeCompare(ko) 를 쓴다.

편집 영역을 넓게 쓰려고 이 화면에서만 .erp-page 의 max-width 를 풀었다. 이때
box-sizing:border-box 를 함께 줘야 한다 — width:100% + padding:24px 이라
max-width 만 풀면 문서 전체에 가로 스크롤이 생긴다(측정으로 확인 후 수정).

편집 중 다른 상품을 클릭하거나 페이지를 벗어나면 저장 안 됨 경고를 띄운다.

옛 단독 화면(product.html)은 제거하고 /products/{no} 는 2분할 화면으로
리다이렉트한다. 편집기 조각을 두 곳에서 함께 쓰도록 _editor.html 로 분리했다.

검증: 유닛테스트 33개 통과(신규 3개 — 전체 조회의 페이지 순회·상한 처리·1회
종료). 상한 처리는 테스트가 잡아서 고쳤다(요청한 만큼 받았는지로 판정). 가짜
데이터로 렌더해 브라우저에서 실측: 왼쪽 360px·오른쪽 940px, 각 칸 독립 스크롤,
분할 영역이 화면 높이에 맞고, 가로 스크롤 없음, 정렬 오름/내림 동작 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:34:52 +09:00
parent 7a2e933c16
commit 119a128ee0
8 changed files with 828 additions and 351 deletions
+33
View File
@@ -71,6 +71,39 @@ def list_products(
return products if isinstance(products, list) else [] return products if isinstance(products, list) else []
def list_all_products(
client: Cafe24Client,
*,
product_name: str = "",
max_items: int = 1000,
) -> tuple[list[dict[str, Any]], bool]:
"""전체 상품을 페이지를 넘겨가며 모두 가져온다.
2분할 화면의 왼쪽 목록은 페이지 없이 한 번에 보여주고 필터·정렬을 브라우저에서
처리한다. 그래야 "진열중만" 같은 필터가 전체 기준으로 정확해진다
(한 페이지만 받아 걸러내면 다음 페이지의 해당 상품이 빠진다).
반환: (상품 목록, 상한에 걸려 잘렸는지)
상품이 max_items 를 넘으면 거기서 멈춘다 — 무한 호출로 API 제한에 걸리는
것을 막기 위한 안전장치다(현재 쇼핑몰 87개, 1회 100개 조회).
"""
collected: list[dict[str, Any]] = []
while len(collected) < max_items:
want = min(PAGE_LIMIT, max_items - len(collected))
batch = list_products(
client,
limit=want,
offset=len(collected),
product_name=product_name,
)
collected.extend(batch)
if len(batch) < want:
return collected, False # 요청한 만큼 못 받았다 = 마지막 페이지
if len(collected) >= max_items:
return collected, True # 상한에서 멈췄다 — 뒤에 더 있을 수 있다
return collected, False
def get_product(client: Cafe24Client, product_no: int) -> dict[str, Any]: def get_product(client: Cafe24Client, product_no: int) -> dict[str, Any]:
"""상품 1건 상세. 이 응답에 상세설명 필드까지 들어 있다.""" """상품 1건 상세. 이 응답에 상세설명 필드까지 들어 있다."""
no = int(product_no) no = int(product_no)
+138 -100
View File
@@ -1,12 +1,16 @@
"""카페24 상품 화면 — 목록/검색 · 상세설명(HTML) 조회 · 편집 후 즉시 적용. """카페24 상품 화면 — 좌우 2분할(목록 | 상세페이지 편집).
카페24를 언제나 source of truth 로 본다. 목록도 상세설명도 화면을 열 때마다 화면 구성
API 로 현재값을 읽고, 목록 결과는 `cafe24_products` 캐시에 UPSERT 한다 왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬.
(예약·로그 화면에서 API 없이 상품명을 보여주기 위한 용도). 오른쪽 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게.
상세설명은 상품 리소스의 필드다(`/description` 서브리소스는 존재하지 않는다 — 목록은 페이지를 넘겨가며 **전체**를 한 번에 받는다(`list_all_products`). 필터·정렬을
app/integrations/cafe24/products.py 주석 참고). 목록 응답에는 상세설명이 없어 브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음
상품 1건씩 조회해야 하므로, 목록 화면에서는 미리보기를 뿌리지 않는다. 페이지에 있는 해당 상품이 빠진다.
상품을 클릭하면 오른쪽만 교체한다(`GET /products/{no}/pane` 이 편집기 조각을
돌려주고 JS 가 끼워 넣는다). 목록을 다시 불러오지 않으므로 카페24 호출이 1회로
끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크(`/cafe24/?selected=`)로 동작한다.
쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다. 쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다.
카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT → 카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT →
@@ -25,6 +29,7 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
from urllib.parse import urlencode
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
@@ -38,15 +43,10 @@ logger = logging.getLogger("cafe24.products")
products_router = APIRouter() products_router = APIRouter()
# 한 화면에 보여줄 상품 수. 카페24 1회 조회 한도(100)를 넘지 않는다.
PAGE_SIZE = 50
def _checked(request: Request, name: str) -> bool:
def _page_param(raw: str | None) -> int: """체크박스 → bool. 값이 무엇이든 파라미터가 있으면 체크된 것으로 본다."""
try: return request.query_params.get(name) is not None
return max(1, int(raw or 1))
except ValueError:
return 1
def _short_dt(value: Any) -> str: def _short_dt(value: Any) -> str:
@@ -58,75 +58,33 @@ def _short_dt(value: Any) -> str:
def _row_for_list(raw: dict[str, Any]) -> dict[str, Any]: def _row_for_list(raw: dict[str, Any]) -> dict[str, Any]:
"""목록에 쓸 필드만 골라낸다(응답 필드가 90개라 그대로 넘기지 않는다).""" """왼쪽 목록에 쓸 필드만 — 상품번호·상품명·진열·판매·최근수정."""
normalized = products.normalize_product(raw) normalized = products.normalize_product(raw)
return { return {
**normalized, "product_no": normalized["product_no"],
"product_name": normalized["product_name"],
"display": normalized["display"],
"selling": normalized["selling"],
"updated_date": _short_dt(raw.get("updated_date")), "updated_date": _short_dt(raw.get("updated_date")),
"price": str(raw.get("price") or ""),
} }
@products_router.get("/", response_class=HTMLResponse) def _list_query(request: Request, *, selected: int | None = None) -> str:
def product_list(request: Request) -> HTMLResponse: """현재 검색·필터를 유지한 목록 URL 쿼리스트링."""
"""상품 목록/검색. 검색어는 상품명 부분일치(카페24 API 가 처리).""" params: list[tuple[str, str]] = []
from app.main import render_template # noqa: WPS433
checked = guard(request)
if not isinstance(checked, tuple):
return checked
st, user = checked
keyword = (request.query_params.get("q") or "").strip() keyword = (request.query_params.get("q") or "").strip()
page = _page_param(request.query_params.get("page")) if keyword:
params.append(("q", keyword))
api = build_cafe24_api(st) for flag in ("display", "selling"):
rows: list[dict[str, Any]] = [] if _checked(request, flag):
total = 0 params.append((flag, "1"))
error = "" if selected:
try: params.append(("selected", str(selected)))
total = products.count_products(api.client, product_name=keyword) return urlencode(params)
raw_rows = products.list_products(
api.client,
limit=PAGE_SIZE,
offset=(page - 1) * PAGE_SIZE,
product_name=keyword,
)
rows = [_row_for_list(r) for r in raw_rows]
st.upsert_products([products.normalize_product(r) for r in raw_rows])
except Cafe24Error as exc:
# 미연결/토큰만료/호출제한 모두 여기로 온다. 화면은 살려두고 사유만 알린다.
error = str(exc)
logger.warning("카페24 상품 목록 조회 실패: %s", exc)
last_page = max(1, -(-total // PAGE_SIZE)) if total else 1
ctx = base_ctx(request, user, active_tab="products")
ctx.update(
{
"page_title": "카페24 상품관리",
"page_subtitle": "상품 상세페이지 조회·편집·예약",
"rows": rows,
"keyword": keyword,
"page": page,
"last_page": last_page,
"total": total,
"error": error,
}
)
return render_template(request, "cafe24/products.html", ctx)
@products_router.get("/products/{product_no}", response_class=HTMLResponse) def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]:
def product_detail(request: Request, product_no: int) -> HTMLResponse: """오른쪽 편집기 조각에 필요한 컨텍스트. 전체 페이지와 조각이 함께 쓴다."""
"""상품 1건 — 기본정보 + 카페24에 지금 올라가 있는 상세설명 HTML."""
from app.main import render_template # noqa: WPS433
checked = guard(request)
if not isinstance(checked, tuple):
return checked
st, user = checked
api = build_cafe24_api(st) api = build_cafe24_api(st)
product: dict[str, Any] = {} product: dict[str, Any] = {}
desc = None desc = None
@@ -140,32 +98,109 @@ def product_detail(request: Request, product_no: int) -> HTMLResponse:
logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc) logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc)
info = products.normalize_product(product) if product else {} info = products.normalize_product(product) if product else {}
return {
"product_no": product_no,
"info": {
**info,
"price": str(product.get("price") or ""),
"updated_date": _short_dt(product.get("updated_date")),
"summary_description": product.get("summary_description") or "",
},
"desc": desc,
# 편집기에는 이미지 경로의 %EC%9A%A9… 을 한글로 풀어서 보여준다.
# 저장할 때 다시 인코딩하므로 카페24에 저장되는 값은 그대로다.
"html_pc": store.decode_html_urls(desc.description) if desc else "",
"html_mobile": store.decode_html_urls(desc.mobile_description) if desc else "",
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로).
"fingerprint": store.fingerprint(desc.description) if desc else "",
"revisions": st.list_revisions(product_no, limit=20),
"editor_error": error,
}
@products_router.get("/", response_class=HTMLResponse)
def product_list(request: Request) -> HTMLResponse:
"""2분할 화면. `selected` 가 있으면 오른쪽 편집기까지 서버에서 그린다."""
from app.main import render_template # noqa: WPS433
checked = guard(request)
if not isinstance(checked, tuple):
return checked
st, user = checked
keyword = (request.query_params.get("q") or "").strip()
only_display = _checked(request, "display")
only_selling = _checked(request, "selling")
api = build_cafe24_api(st)
rows: list[dict[str, Any]] = []
total = 0
truncated = False
error = ""
try:
raw_rows, truncated = products.list_all_products(api.client, product_name=keyword)
st.upsert_products([products.normalize_product(r) for r in raw_rows])
total = len(raw_rows)
rows = [_row_for_list(r) for r in raw_rows]
# 필터는 전체를 받아온 뒤 적용한다(문서에 없는 API 파라미터에 기대지 않는다).
if only_display:
rows = [r for r in rows if r["display"]]
if only_selling:
rows = [r for r in rows if r["selling"]]
except Cafe24Error as exc:
# 미연결/토큰만료/호출제한 모두 여기로 온다. 화면은 살려두고 사유만 알린다.
error = str(exc)
logger.warning("카페24 상품 목록 조회 실패: %s", exc)
try:
selected = int(request.query_params.get("selected") or 0)
except ValueError:
selected = 0
ctx = base_ctx(request, user, active_tab="products") ctx = base_ctx(request, user, active_tab="products")
ctx.update( ctx.update(
{ {
"page_title": f"카페24 상품 {product_no}", "page_title": "카페24 상품관리",
"page_subtitle": product.get("product_name") or "상품 상세페이지", "page_subtitle": "상품 상세페이지 조회·편집·예약",
"product_no": product_no, "rows": rows,
"info": { "total": total,
**info, "shown": len(rows),
"price": str(product.get("price") or ""), "truncated": truncated,
"updated_date": _short_dt(product.get("updated_date")), "keyword": keyword,
"summary_description": product.get("summary_description") or "", "only_display": only_display,
}, "only_selling": only_selling,
"desc": desc, "selected": selected,
# 편집기에는 이미지 경로의 %EC%9A%A9… 을 한글로 풀어서 보여준다. "list_query": _list_query(request),
# 저장할 때 다시 인코딩하므로 카페24에 저장되는 값은 그대로다.
"html_pc": store.decode_html_urls(desc.description) if desc else "",
"html_mobile": store.decode_html_urls(desc.mobile_description) if desc else "",
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로).
"fingerprint": store.fingerprint(desc.description) if desc else "",
"revisions": st.list_revisions(product_no, limit=20),
"error": error, "error": error,
"flash": request.query_params.get("msg", ""), "flash": request.query_params.get("msg", ""),
"flash_error": request.query_params.get("err", ""), "flash_error": request.query_params.get("err", ""),
} }
) )
return render_template(request, "cafe24/product.html", ctx) if selected:
ctx.update(_editor_ctx(st, selected))
return render_template(request, "cafe24/products.html", ctx)
@products_router.get("/products/{product_no}/pane", response_class=HTMLResponse)
def product_pane(request: Request, product_no: int) -> HTMLResponse:
"""오른쪽 편집기 조각만 — 목록을 다시 그리지 않기 위해 JS 가 가져간다."""
from app.main import render_template # noqa: WPS433
checked = guard(request)
if not isinstance(checked, tuple):
return checked
st, user = checked
ctx = base_ctx(request, user, active_tab="products")
ctx.update(_editor_ctx(st, product_no))
ctx["list_query"] = _list_query(request)
return render_template(request, "cafe24/_editor.html", ctx)
@products_router.get("/products/{product_no}")
def product_redirect(request: Request, product_no: int):
"""옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
return RedirectResponse(url=f"/cafe24/?selected={product_no}", status_code=303)
@products_router.post("/products/{product_no}/apply") @products_router.post("/products/{product_no}/apply")
@@ -175,6 +210,7 @@ def product_apply(
html: str = Form(""), html: str = Form(""),
base_fingerprint: str = Form(""), base_fingerprint: str = Form(""),
memo: str = Form(""), memo: str = Form(""),
list_query: str = Form(""),
): ):
"""편집한 HTML 을 카페24에 즉시 적용한다. """편집한 HTML 을 카페24에 즉시 적용한다.
@@ -193,12 +229,14 @@ def product_apply(
st, user = checked st, user = checked
actor = str(user.get("email") or "") actor = str(user.get("email") or "")
back = f"/cafe24/products/{product_no}" # 적용 후에는 검색·필터를 유지한 채 같은 상품이 선택된 화면으로 돌아온다.
base = f"/cafe24/?{list_query}" if list_query else f"/cafe24/?selected={product_no}"
back = base if f"selected={product_no}" in base else f"{base}&selected={product_no}"
submitted = store.encode_html_urls(html or "") submitted = store.encode_html_urls(html or "")
if not submitted.strip(): if not submitted.strip():
return RedirectResponse( return RedirectResponse(
url=f"{back}?err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.", url=f"{back}&err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.",
status_code=303, status_code=303,
) )
@@ -210,7 +248,7 @@ def product_apply(
actor=actor, action="apply_description", product_no=product_no, actor=actor, action="apply_description", product_no=product_no,
result="FAIL", detail=f"현재값 조회 실패: {exc}", result="FAIL", detail=f"현재값 조회 실패: {exc}",
) )
return RedirectResponse(url=f"{back}?err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303) return RedirectResponse(url=f"{back}&err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303)
backup_id = st.add_revision( backup_id = st.add_revision(
product_no=product_no, product_no=product_no,
@@ -226,12 +264,12 @@ def product_apply(
revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨", revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨",
) )
return RedirectResponse( return RedirectResponse(
url=f"{back}?err=편집하는 동안 카페24 값이 변경되었습니다. 새로고침해 현재 내용을 확인한 뒤 다시 적용하세요.", url=f"{back}&err=편집하는 동안 카페24 값이 변경되었습니다. 새로고침해 현재 내용을 확인한 뒤 다시 적용하세요.",
status_code=303, status_code=303,
) )
if submitted == current.description: if submitted == current.description:
return RedirectResponse(url=f"{back}?msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303) return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
# 미분리 상품은 모바일도 함께 맞춘다(PC 만 바꾸면 모바일이 어긋난다). # 미분리 상품은 모바일도 함께 맞춘다(PC 만 바꾸면 모바일이 어긋난다).
mobile_html = None if current.separated_mobile else submitted mobile_html = None if current.separated_mobile else submitted
@@ -246,7 +284,7 @@ def product_apply(
) )
logger.warning("카페24 상품 %s 적용 실패: %s", product_no, exc) logger.warning("카페24 상품 %s 적용 실패: %s", product_no, exc)
return RedirectResponse( return RedirectResponse(
url=f"{back}?err=적용에 실패했습니다: {exc} (직전 내용은 버전 {backup_id} 로 보관됨)", url=f"{back}&err=적용에 실패했습니다: {exc} (직전 내용은 버전 {backup_id} 로 보관됨)",
status_code=303, status_code=303,
) )
@@ -265,6 +303,6 @@ def product_apply(
) )
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor) logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
return RedirectResponse( return RedirectResponse(
url=f"{back}?msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.", url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.",
status_code=303, status_code=303,
) )
@@ -0,0 +1,102 @@
{# 오른쪽 편집기 조각.
전체 페이지(products.html)가 include 하고, JS 가 /products/{no}/pane 으로
같은 조각만 다시 받아 끼워 넣는다. 그래서 여기에는 <script>
(innerHTML 로 삽입된 script 는 실행되지 않는다 — JS 는 products.html 에 있고
삽입 후 cf24BindEditor() 로 다시 연결한다). #}
{% if editor_error %}
<div class="cf24-flash cf24-flash-err">
카페24 조회에 실패했습니다: {{ editor_error }}<br />
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
</div>
{% endif %}
<div class="cf24-editor-head">
<div>
<h3 class="cf24-editor-title">{{ info.product_name or '상품' }}</h3>
<p class="cf24-editor-sub">
상품번호 {{ product_no }}
{% if info.product_code %}· <code>{{ info.product_code }}</code>{% endif %}
{% if info.price %}· {{ info.price }}{% endif %}
{% if info.updated_date %}· 최근 수정 {{ info.updated_date }}{% endif %}
</p>
</div>
<div class="cf24-editor-badges">
{% if info.display %}<span class="erp-badge cf24-badge-ok">진열</span>
{% else %}<span class="erp-badge cf24-badge-off">미진열</span>{% endif %}
{% if info.selling %}<span class="erp-badge cf24-badge-ok">판매</span>
{% else %}<span class="erp-badge cf24-badge-off">중지</span>{% endif %}
</div>
</div>
{% if desc %}
<p class="cf24-note">
{% if desc.separated_mobile %}
<strong>PC/모바일 분리 사용 상품입니다.</strong> 아래 적용은 PC 만 바꿉니다 —
모바일({{ desc.mobile_description | length }}자)은 카페24 관리자에서 따로 반영해야 합니다.
{% else %}
모바일은 PC와 동일 설정이라 적용 시 <strong>함께 반영</strong>됩니다.
{% endif %}
{% if desc.mobile_differs %}<span class="cf24-warn">현재 PC/모바일 내용이 다릅니다.</span>{% endif %}
이미지 경로의 한글 파일명은 카페24에 <code>%EC%9A%A9…</code> 로 저장되어 있습니다.
여기서는 읽기 쉽게 한글로 보여주고, 적용할 때 원래 형식으로 되돌립니다.
</p>
<form class="cf24-editor-form" method="post"
action="/cafe24/products/{{ product_no }}/apply"
data-confirm="카페24 쇼핑몰에 바로 반영됩니다. 적용할까요?&#10;&#10;직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.">
<input type="hidden" name="base_fingerprint" value="{{ fingerprint }}" />
<input type="hidden" name="list_query" value="{{ list_query }}" />
<div class="cf24-editor-bar">
<span class="cf24-muted">PC 상세설명 HTML · {{ desc.description | length }}자</span>
<span class="cf24-editor-bar-right">
<input class="cf24-memo" type="text" name="memo" maxlength="200"
placeholder="변경 메모 (버전 이력에 남습니다)" />
<button class="erp-btn erp-btn-outline" type="button" data-copy="cf24-html-pc">복사</button>
<button class="erp-btn erp-btn-primary" type="submit">카페24에 적용</button>
</span>
</div>
<textarea id="cf24-html-pc" class="cf24-html cf24-html-main" name="html"
spellcheck="false">{{ html_pc }}</textarea>
</form>
{% if desc.separated_mobile or desc.mobile_differs %}
<details class="cf24-details">
<summary>모바일 상세설명 HTML 보기 (읽기 전용 · {{ desc.mobile_description | length }}자)</summary>
<textarea id="cf24-html-mo" class="cf24-html" rows="12" readonly
spellcheck="false">{{ html_mobile }}</textarea>
<div class="cf24-actions">
<button type="button" class="erp-btn erp-btn-outline" data-copy="cf24-html-mo">복사</button>
</div>
</details>
{% endif %}
<details class="cf24-details">
<summary>버전 이력 {% if revisions %}({{ revisions | length }}건){% endif %}</summary>
{% if revisions %}
<div class="cf24-scroll">
<table class="erp-table cf24-compact">
<thead>
<tr><th>시각</th><th>유형</th><th>길이</th><th>작업자</th><th>메모</th></tr>
</thead>
<tbody>
{% for rev in revisions %}
<tr>
<td class="cf24-nowrap">{{ rev.created_at }}</td>
<td class="cf24-nowrap"><code>{{ rev.revision_type }}</code></td>
<td class="cf24-nowrap">{{ rev.html_length }}자</td>
<td class="cf24-nowrap">{{ rev.created_by or '—' }}</td>
<td>{{ rev.memo or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="cf24-muted">버전 선택 복원은 Phase 6 에서 붙습니다. 내용은 모두 보관됩니다.</p>
{% else %}
<p class="cf24-muted">아직 이 상품의 변경 이력이 없습니다.</p>
{% endif %}
</details>
{% endif %}
@@ -1,180 +0,0 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814d" />
{% endblock %}
{% block content %}
{% include "cafe24/_nav.html" %}
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
{% if error %}
<div class="cf24-flash cf24-flash-err">
카페24 조회에 실패했습니다: {{ error }}<br />
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
</div>
{% endif %}
{# ── 기본 정보 ─────────────────────────────────────────────── #}
<div class="erp-card cf24-card">
<div class="cf24-card-head">
<h3>{{ info.product_name or '상품' }}</h3>
<span class="cf24-muted">상품번호 {{ product_no }}</span>
</div>
<table class="erp-table cf24-kv">
<tbody>
<tr><th>상품코드</th><td><code>{{ info.product_code or '—' }}</code></td></tr>
<tr><th>판매가</th><td>{{ info.price or '—' }}</td></tr>
<tr>
<th>진열 / 판매</th>
<td>
{% if info.display %}<span class="erp-badge cf24-badge-ok">진열</span>
{% else %}<span class="erp-badge cf24-badge-off">미진열</span>{% endif %}
{% if info.selling %}<span class="erp-badge cf24-badge-ok">판매</span>
{% else %}<span class="erp-badge cf24-badge-off">중지</span>{% endif %}
</td>
</tr>
<tr><th>최근 수정</th><td>{{ info.updated_date or '—' }}</td></tr>
<tr><th>요약설명</th><td>{{ info.summary_description or '—' }}</td></tr>
</tbody>
</table>
<div class="cf24-actions">
<a class="erp-btn erp-btn-outline" href="/cafe24/">← 상품 목록</a>
</div>
</div>
{# ── 상세설명 편집 ─────────────────────────────────────────── #}
{% if desc %}
<div class="erp-card cf24-card">
<div class="cf24-card-head">
<h3>상세페이지 HTML 편집</h3>
<span class="cf24-muted">적용하면 쇼핑몰에 바로 반영됩니다</span>
</div>
<table class="erp-table cf24-kv">
<tbody>
<tr><th>PC 상세설명</th><td>{{ desc.description | length }}자</td></tr>
<tr>
<th>모바일 상세설명</th>
<td>
{{ desc.mobile_description | length }}자 ·
{% if desc.separated_mobile %}
<span class="cf24-warn">PC와 분리 사용</span> — 아래 적용은 PC 만 바꿉니다.
모바일은 카페24 관리자에서 따로 반영해야 합니다.
{% else %}
<span class="cf24-muted">PC와 동일 설정</span> — 적용 시 모바일도 같은 내용으로 함께 반영됩니다.
{% endif %}
{% if desc.mobile_differs %}<span class="cf24-warn">현재 내용 불일치</span>{% endif %}
</td>
</tr>
</tbody>
</table>
<p class="cf24-note">
이미지 경로의 한글 파일명은 카페24에 <code>%EC%9A%A9…</code> 형태로 저장되어 있습니다.
편집기에서는 읽기 쉽게 <strong>한글로 풀어서</strong> 보여주고, 적용할 때 원래 형식으로
되돌려 저장합니다. 경로를 직접 고칠 때도 한글로 그냥 쓰시면 됩니다.
</p>
<form method="post" action="/cafe24/products/{{ product_no }}/apply"
onsubmit="return confirm('카페24 쇼핑몰에 바로 반영됩니다. 적용할까요?\n\n직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.');">
<input type="hidden" name="base_fingerprint" value="{{ fingerprint }}" />
<label class="cf24-label" for="cf24-html-pc">PC 상세설명 HTML</label>
<textarea id="cf24-html-pc" class="cf24-html" name="html" rows="22"
spellcheck="false">{{ html_pc }}</textarea>
<div class="cf24-toolbar" style="margin-top:12px;">
<input class="cf24-search" style="flex:1 1 320px;" type="text" name="memo" maxlength="200"
placeholder="변경 메모 (버전 이력에 남습니다 — 예: 8월 프로모션 배너 교체)" />
<button class="erp-btn erp-btn-primary" type="submit">카페24에 적용</button>
<button class="erp-btn erp-btn-outline" type="button" data-copy="cf24-html-pc">HTML 복사</button>
<a class="erp-btn erp-btn-outline" href="/cafe24/products/{{ product_no }}">되돌리기(새로고침)</a>
</div>
</form>
{% if desc.separated_mobile or desc.mobile_differs %}
<label class="cf24-label" for="cf24-html-mo">모바일 상세설명 HTML (읽기 전용)</label>
<textarea id="cf24-html-mo" class="cf24-html" rows="12" readonly spellcheck="false"
>{{ html_mobile }}</textarea>
<div class="cf24-actions">
<button type="button" class="erp-btn erp-btn-outline" data-copy="cf24-html-mo">HTML 복사</button>
</div>
{% endif %}
</div>
{# ── 버전 이력 ─────────────────────────────────────────────── #}
<div class="erp-card cf24-card">
<div class="cf24-card-head">
<h3>버전 이력</h3>
<span class="cf24-muted">최근 20건 · 적용 직전 내용은 BACKUP 으로 자동 보관</span>
</div>
{% if revisions %}
<div class="cf24-scroll">
<table class="erp-table">
<thead>
<tr><th>시각</th><th>유형</th><th>길이</th><th>작업자</th><th>메모</th></tr>
</thead>
<tbody>
{% for rev in revisions %}
<tr>
<td class="cf24-nowrap">{{ rev.created_at }}</td>
<td class="cf24-nowrap"><code>{{ rev.revision_type }}</code></td>
<td class="cf24-nowrap">{{ rev.html_length }}자</td>
<td class="cf24-nowrap">{{ rev.created_by or '—' }}</td>
<td>{{ rev.memo or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="cf24-muted">되돌리기(버전 선택 복원)는 Phase 6 에서 화면에 붙습니다. 지금도 내용은 모두 보관됩니다.</p>
{% else %}
<p class="cf24-muted">아직 이 상품의 변경 이력이 없습니다.</p>
{% endif %}
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// HTML 복사 — clipboard API 가 막히면 textarea 선택으로 대체한다.
document.querySelectorAll("[data-copy]").forEach(function (btn) {
btn.addEventListener("click", function () {
var box = document.getElementById(btn.dataset.copy);
if (!box) return;
var done = function () {
var old = btn.textContent;
btn.textContent = "복사했습니다";
setTimeout(function () { btn.textContent = old; }, 1500);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(box.value).then(done, function () { box.select(); });
} else {
box.select();
try { document.execCommand("copy"); done(); } catch (e) { /* 사용자가 직접 복사 */ }
}
});
});
// 편집 중 실수로 페이지를 벗어나는 것 방지.
(function () {
var box = document.getElementById("cf24-html-pc");
if (!box || box.readOnly) return;
var initial = box.value;
var form = box.form;
var submitting = false;
if (form) form.addEventListener("submit", function () { submitting = true; });
window.addEventListener("beforeunload", function (e) {
if (!submitting && box.value !== initial) {
e.preventDefault();
e.returnValue = "";
}
});
})();
</script>
{% endblock %}
+213 -66
View File
@@ -1,11 +1,15 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814d" /> <link rel="stylesheet" href="/static/cafe24.css?v=20260814e" />
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include "cafe24/_nav.html" %} {% include "cafe24/_nav.html" %}
{% set qs = (list_query ~ '&') if list_query else '' %}
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
{% if error %} {% if error %}
<div class="cf24-flash cf24-flash-err"> <div class="cf24-flash cf24-flash-err">
@@ -14,74 +18,217 @@
</div> </div>
{% endif %} {% endif %}
<div class="erp-card cf24-card"> <div class="cf24-split">
<div class="cf24-card-head">
<h3>상품 목록</h3>
<span class="cf24-muted">
{% if keyword %}“{{ keyword }}” 검색 결과 {{ total }}건{% else %}전체 {{ total }}건{% endif %}
· {{ page }} / {{ last_page }} 페이지
</span>
</div>
<form class="cf24-toolbar" method="get" action="/cafe24/"> {# ── 왼쪽: 상품 목록 ──────────────────────────────────────── #}
<input class="cf24-search" type="search" name="q" value="{{ keyword }}" <aside class="erp-card cf24-pane cf24-pane-list">
placeholder="상품명으로 검색 (부분일치)" /> <form class="cf24-filters" method="get" action="/cafe24/" id="cf24-filter-form">
<button class="erp-btn erp-btn-primary" type="submit">검색</button> {% if selected %}<input type="hidden" name="selected" value="{{ selected }}" />{% endif %}
{% if keyword %}<a class="erp-btn erp-btn-outline" href="/cafe24/">전체보기</a>{% endif %} <input class="cf24-search" type="search" name="q" value="{{ keyword }}"
</form> placeholder="상품명 검색" />
<div class="cf24-checks">
<label><input type="checkbox" name="display" value="1"
{% if only_display %}checked{% endif %} /> 진열중</label>
<label><input type="checkbox" name="selling" value="1"
{% if only_selling %}checked{% endif %} /> 판매중</label>
</div>
<div class="cf24-list-count">
{{ shown }}건{% if shown != total %} / 전체 {{ total }}건{% endif %}
{% if truncated %}<span class="cf24-warn">(상한 도달)</span>{% endif %}
</div>
</form>
{% if rows %} <div class="cf24-list-scroll">
<div class="cf24-scroll"> <table class="erp-table cf24-list-table" id="cf24-list">
<table class="erp-table"> <thead>
<thead> <tr>
<tr> <th class="cf24-col-no" data-sort-key="no" data-sort-type="num">번호</th>
<th>상품번호</th><th>상품코드</th><th>상품명</th> <th class="cf24-col-name" data-sort-key="name" data-sort-type="text">상품명</th>
<th>진열</th><th>판매</th><th>판매가</th><th>최근 수정</th><th></th> <th class="cf24-col-flag" data-sort-key="display" data-sort-type="num">진열</th>
</tr> <th class="cf24-col-flag" data-sort-key="selling" data-sort-type="num">판매</th>
</thead> <th class="cf24-col-date" data-sort-key="updated" data-sort-type="text">수정</th>
<tbody> </tr>
{% for r in rows %} </thead>
<tr> <tbody>
<td class="cf24-nowrap">{{ r.product_no }}</td> {% for r in rows %}
<td class="cf24-nowrap"><code>{{ r.product_code }}</code></td> <tr class="cf24-row {% if selected == r.product_no %}is-active{% endif %}"
<td>{{ r.product_name }}</td> data-no="{{ r.product_no }}"
<td class="cf24-nowrap"> data-name="{{ r.product_name }}"
{% if r.display %}<span class="erp-badge cf24-badge-ok">진열</span> data-display="{{ 1 if r.display else 0 }}"
{% else %}<span class="erp-badge cf24-badge-off">미진열</span>{% endif %} data-selling="{{ 1 if r.selling else 0 }}"
</td> data-updated="{{ r.updated_date }}">
<td class="cf24-nowrap"> <td class="cf24-col-no">{{ r.product_no }}</td>
{% if r.selling %}<span class="erp-badge cf24-badge-ok">판매</span> <td class="cf24-col-name" title="{{ r.product_name }}">
{% else %}<span class="erp-badge cf24-badge-off">중지</span>{% endif %} <a href="/cafe24/?{{ qs }}selected={{ r.product_no }}">{{ r.product_name }}</a>
</td> </td>
<td class="cf24-nowrap">{{ r.price }}</td> <td class="cf24-col-flag">
<td class="cf24-nowrap">{{ r.updated_date }}</td> {% if r.display %}<span class="cf24-dot cf24-dot-on" title="진열중"></span>
<td class="cf24-nowrap"> {% else %}<span class="cf24-dot" title="미진열"></span>{% endif %}
<a class="erp-btn erp-btn-outline" href="/cafe24/products/{{ r.product_no }}">상세페이지</a> </td>
</td> <td class="cf24-col-flag">
</tr> {% if r.selling %}<span class="cf24-dot cf24-dot-on" title="판매중"></span>
{% endfor %} {% else %}<span class="cf24-dot" title="판매중지"></span>{% endif %}
</tbody> </td>
</table> {# 좁은 칸이라 월-일만. 전체 값은 title 로 확인 #}
</div> <td class="cf24-col-date" title="{{ r.updated_date }}">{{ r.updated_date[5:10] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not rows and not error %}
<p class="cf24-muted" style="padding:12px;">조건에 맞는 상품이 없습니다.</p>
{% endif %}
</div>
</aside>
{% if last_page > 1 %} {# ── 오른쪽: 상세페이지 편집 ──────────────────────────────── #}
<div class="cf24-pager"> <section class="erp-card cf24-pane cf24-pane-editor" id="cf24-editor-pane">
{% if page > 1 %} {% if selected %}
<a class="erp-btn erp-btn-outline" {% include "cafe24/_editor.html" %}
href="/cafe24/?q={{ keyword | urlencode }}&page={{ page - 1 }}">← 이전</a> {% else %}
<div class="cf24-empty-pane">
<h3>왼쪽에서 상품을 선택하세요.</h3>
<p class="cf24-muted">
선택한 상품의 상세페이지 HTML 을 여기서 바로 편집하고 카페24에 적용할 수 있습니다.<br />
적용 직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.
</p>
</div>
{% endif %} {% endif %}
<span class="cf24-muted">{{ page }} / {{ last_page }}</span> </section>
{% if page < last_page %}
<a class="erp-btn erp-btn-outline"
href="/cafe24/?q={{ keyword | urlencode }}&page={{ page + 1 }}">다음 →</a>
{% endif %}
</div>
{% endif %}
{% elif not error %}
<p class="cf24-muted">
{% if keyword %}“{{ keyword }}” 로 찾은 상품이 없습니다.{% else %}표시할 상품이 없습니다.{% endif %}
</p>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
(function () {
var pane = document.getElementById("cf24-editor-pane");
var listQuery = {{ list_query | tojson }};
var dirty = false;
// ── 편집기 조각을 새로 끼워 넣은 뒤 다시 연결 ──
window.cf24BindEditor = function () {
dirty = false;
pane.querySelectorAll("[data-copy]").forEach(function (btn) {
btn.addEventListener("click", function () {
var box = document.getElementById(btn.dataset.copy);
if (!box) return;
var done = function () {
var old = btn.textContent;
btn.textContent = "복사됨";
setTimeout(function () { btn.textContent = old; }, 1500);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(box.value).then(done, function () { box.select(); });
} else {
box.select();
try { document.execCommand("copy"); done(); } catch (e) { /* 직접 복사 */ }
}
});
});
var form = pane.querySelector(".cf24-editor-form");
if (form) {
form.addEventListener("submit", function (e) {
if (!window.confirm(form.dataset.confirm)) { e.preventDefault(); return; }
dirty = false;
});
}
var box = document.getElementById("cf24-html-pc");
if (box && !box.readOnly) {
box.addEventListener("input", function () { dirty = true; });
}
};
function confirmLeave() {
return !dirty || window.confirm("편집한 내용이 저장되지 않았습니다. 이동할까요?");
}
// ── 목록 클릭 → 오른쪽만 교체 ──
function select(no, push) {
pane.innerHTML = '<p class="cf24-muted" style="padding:16px;">불러오는 중…</p>';
var url = "/cafe24/products/" + no + "/pane" + (listQuery ? "?" + listQuery : "");
fetch(url, { credentials: "same-origin" })
.then(function (res) {
if (res.redirected) { window.location.href = res.url; return null; }
if (!res.ok) throw new Error("HTTP " + res.status);
return res.text();
})
.then(function (htmlText) {
if (htmlText === null) return;
pane.innerHTML = htmlText;
window.cf24BindEditor();
pane.scrollTop = 0;
if (push) {
var target = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no;
history.pushState({ no: no }, "", target);
}
})
.catch(function () {
// 조각 로드가 실패하면 평범한 페이지 이동으로 대체한다.
window.location.href = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no;
});
}
document.querySelectorAll("#cf24-list tbody tr.cf24-row").forEach(function (tr) {
tr.addEventListener("click", function (e) {
if (e.target.tagName === "A") e.preventDefault();
if (!confirmLeave()) return;
document.querySelectorAll("#cf24-list tr.is-active").forEach(function (el) {
el.classList.remove("is-active");
});
tr.classList.add("is-active");
select(tr.dataset.no, true);
});
});
window.addEventListener("popstate", function () {
window.location.reload();
});
// ── 필터 체크박스는 즉시 적용 ──
var filterForm = document.getElementById("cf24-filter-form");
filterForm.querySelectorAll('input[type="checkbox"]').forEach(function (cb) {
cb.addEventListener("change", function () {
if (confirmLeave()) filterForm.submit();
});
});
// ── 제목행 클릭 정렬(오름/내림 토글) ──
var table = document.getElementById("cf24-list");
var tbody = table.querySelector("tbody");
var sortKey = null, sortAsc = true;
table.querySelectorAll("th[data-sort-key]").forEach(function (th) {
th.classList.add("cf24-sortable");
th.addEventListener("click", function () {
var key = th.dataset.sortKey;
sortAsc = key === sortKey ? !sortAsc : true;
sortKey = key;
table.querySelectorAll("th[data-sort-key]").forEach(function (other) {
other.classList.remove("is-asc", "is-desc");
});
th.classList.add(sortAsc ? "is-asc" : "is-desc");
var numeric = th.dataset.sortType === "num";
var rows = Array.prototype.slice.call(tbody.querySelectorAll("tr.cf24-row"));
rows.sort(function (a, b) {
var x = a.dataset[key] || "", y = b.dataset[key] || "";
var cmp = numeric
? (parseFloat(x) || 0) - (parseFloat(y) || 0)
: x.localeCompare(y, "ko");
return sortAsc ? cmp : -cmp;
});
rows.forEach(function (tr) { tbody.appendChild(tr); });
});
});
window.addEventListener("beforeunload", function (e) {
if (dirty) { e.preventDefault(); e.returnValue = ""; }
});
if (pane.querySelector(".cf24-editor-form")) window.cf24BindEditor();
})();
</script>
{% endblock %}
+39
View File
@@ -333,6 +333,45 @@ def test_normalize_product_flags():
assert products.normalize_product({"product_no": "9"})["display"] is True assert products.normalize_product({"product_no": "9"})["display"] is True
class _PagingClient:
"""페이지를 넘겨가며 응답하는 가짜 클라이언트."""
def __init__(self, count: int):
self.count = count
self.calls: list[dict] = []
def get(self, path, *, params=None, json=None, product_no=None):
self.calls.append(dict(params or {}))
offset = int((params or {}).get("offset", 0))
limit = int((params or {}).get("limit", 100))
page = [{"product_no": n} for n in range(offset, min(offset + limit, self.count))]
return {"products": page}
def test_list_all_products_walks_pages():
client = _PagingClient(230)
rows, truncated = products.list_all_products(client)
assert len(rows) == 230 and truncated is False
# 100 + 100 + 30 → 3회 호출로 끝나야 한다.
assert len(client.calls) == 3
assert [c["offset"] for c in client.calls] == [0, 100, 200]
def test_list_all_products_stops_at_cap():
"""상한을 넘으면 잘렸다고 알린다 — 무한 호출로 API 제한에 걸리지 않게."""
client = _PagingClient(10_000)
rows, truncated = products.list_all_products(client, max_items=150)
assert len(rows) == 150 and truncated is True
def test_list_all_products_single_page():
"""현재 쇼핑몰(87개)은 1회 호출로 끝난다."""
client = _PagingClient(87)
rows, truncated = products.list_all_products(client)
assert len(rows) == 87 and truncated is False
assert len(client.calls) == 1
def test_list_products_clamps_paging(): def test_list_products_clamps_paging():
client = _FakeClient({"products": []}) client = _FakeClient({"products": []})
products.list_products(client, limit=999, offset=-5, product_name="") products.list_products(client, limit=999, offset=-5, product_name="")
+269
View File
@@ -11,10 +11,279 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* 2분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다.
box-sizing 을 함께 바꿔야 한다: .erp-page 는 width:100% + padding:24px 라서
max-width 를 풀면 padding 이 폭에 더해져 문서 전체에 가로 스크롤이 생긴다. */
.erp-page {
max-width: none;
box-sizing: border-box;
}
.cf24-card { .cf24-card {
margin-bottom: var(--sp-16, 16px); margin-bottom: var(--sp-16, 16px);
} }
/* ════════════════════════════════════════════════════════════
좌우 2분할: 왼쪽 목록(좁게) | 오른쪽 상세페이지 편집(넓게)
각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다.
════════════════════════════════════════════════════════════ */
.cf24-split {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: var(--sp-12, 12px);
align-items: stretch;
}
/* .erp-page 는 flex column 이다. 위 flex-shrink:0 규칙의 예외로, 분할 영역만은
남은 높이를 모두 차지하고 안에서 스크롤되게 한다(휴가/쿠팡 모듈과 같은 방식). */
.erp-page > .cf24-split {
flex: 1 1 auto;
min-height: 0;
}
.cf24-pane {
min-height: 0;
overflow: auto;
margin-bottom: 0;
padding: var(--sp-12, 12px);
}
.cf24-pane-list {
display: flex;
flex-direction: column;
gap: var(--sp-8, 8px);
overflow: hidden; /* 표만 스크롤 — 검색/필터는 고정 */
}
.cf24-pane-editor {
display: flex;
flex-direction: column;
gap: var(--sp-8, 8px);
padding: var(--sp-16, 16px);
}
@media (max-width: 1100px) {
.cf24-split {
grid-template-columns: minmax(0, 1fr);
}
.cf24-pane {
overflow: visible;
}
.cf24-pane-list {
max-height: 45vh;
}
}
/* ── 왼쪽: 검색·필터 ── */
.cf24-filters {
display: flex;
flex-direction: column;
gap: var(--sp-8, 8px);
flex: 0 0 auto;
}
.cf24-checks {
display: flex;
gap: var(--sp-12, 12px);
font-size: var(--text-caption, 12px);
}
.cf24-checks label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.cf24-list-count {
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
}
/* ── 왼쪽: 목록 표 (좁게) ── */
.cf24-list-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.cf24-list-table {
font-size: 12px;
table-layout: fixed;
width: 100%;
}
.cf24-list-table thead th {
position: sticky;
top: 0;
z-index: 1;
padding: 6px 4px;
white-space: nowrap;
}
.cf24-list-table tbody td {
padding: 6px 4px;
vertical-align: top;
}
/* 고정폭 합계를 최소로 잡아 상품명에 남은 폭을 준다.
진열/판매 칸은 제목(2글자) + 정렬 화살표가 들어갈 만큼만. */
.cf24-col-no { width: 36px; text-align: right; color: var(--color-midtone-gray, #737373); }
.cf24-col-flag { width: 40px; text-align: center; }
.cf24-col-date { width: 46px; white-space: nowrap; color: var(--color-midtone-gray, #737373); }
.cf24-col-name { word-break: break-word; }
/* 긴 상품명은 2줄까지만 — 행 높이를 고르게 유지해 목록을 훑기 쉽게 한다.
전체 이름은 title 툴팁과 오른쪽 편집기 제목에서 확인한다. */
.cf24-col-name a {
color: inherit;
text-decoration: none;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.cf24-row {
cursor: pointer;
}
.cf24-row:hover {
background: var(--color-ghost-gray, #f2f2f2);
}
.cf24-row.is-active {
background: var(--color-rich-black, #0a0a0a);
}
.cf24-row.is-active td,
.cf24-row.is-active .cf24-col-name a {
color: #fff;
}
/* 진열/판매 표시는 좁은 칸이라 배지 대신 점으로 */
.cf24-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-subtle-ash, #e5e5e5);
}
.cf24-dot-on {
background: var(--color-success-green, #10c22b);
}
/* 제목행 클릭 정렬 */
.cf24-sortable {
cursor: pointer;
user-select: none;
}
.cf24-sortable::after {
content: "↕";
opacity: 0.35;
margin-left: 2px;
}
.cf24-sortable.is-asc::after { content: "▲"; opacity: 1; }
.cf24-sortable.is-desc::after { content: "▼"; opacity: 1; }
/* ── 오른쪽: 편집기 ── */
.cf24-editor-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--sp-12, 12px);
flex: 0 0 auto;
}
.cf24-editor-title {
margin: 0;
font-size: var(--text-heading, 18px);
letter-spacing: -0.45px;
}
.cf24-editor-sub {
margin: 4px 0 0;
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
}
.cf24-editor-badges {
display: flex;
gap: 4px;
flex: 0 0 auto;
}
.cf24-editor-form {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
gap: var(--sp-8, 8px);
}
.cf24-editor-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--sp-8, 8px);
flex-wrap: wrap;
flex: 0 0 auto;
}
.cf24-editor-bar-right {
display: flex;
gap: var(--sp-8, 8px);
align-items: center;
}
.cf24-memo {
width: 260px;
padding: 6px var(--sp-10, 10px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-lg, 10px);
font-size: 12px;
}
.cf24-html-main {
flex: 1 1 auto;
min-height: 340px;
}
.cf24-details {
flex: 0 0 auto;
border-top: 1px solid var(--color-subtle-ash, #e5e5e5);
padding-top: var(--sp-8, 8px);
}
.cf24-details > summary {
cursor: pointer;
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
}
.cf24-compact {
font-size: 12px;
}
.cf24-compact tbody td,
.cf24-compact thead th {
padding: 6px 8px;
}
.cf24-empty-pane {
margin: auto;
text-align: center;
padding: var(--sp-24, 24px);
}
.cf24-empty-pane h3 {
margin: 0 0 var(--sp-8, 8px);
}
.cf24-card-head { .cf24-card-head {
display: flex; display: flex;
align-items: center; align-items: center;
+34 -5
View File
@@ -25,14 +25,14 @@ app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리
app/modules/cafe24/ ← 상품관리 모듈 app/modules/cafe24/ ← 상품관리 모듈
├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합 ├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합
├─ routes_products.py 상품 목록/검색 · 상세설명 조회 (읽기 전용) ├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기)
├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그 ├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그
├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리) ├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리)
├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL) ├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL)
├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증 ├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증
├─ tests/ DB/네트워크 없는 유닛테스트 ├─ tests/ DB/네트워크 없는 유닛테스트
└─ templates/cafe24/ _nav.html · products.html · product.html · └─ templates/cafe24/ _nav.html · products.html(2분할) ·
schedules.html · system.html _editor.html(오른쪽 조각) · schedules.html · system.html
``` ```
**규칙: 라우터에서 `httpx`/`requests` 를 직접 부르지 않는다.** 반드시 **규칙: 라우터에서 `httpx`/`requests` 를 직접 부르지 않는다.** 반드시
@@ -48,8 +48,9 @@ app/modules/cafe24/ ← 상품관리 모듈
| 경로 | 화면 | 권한 | | 경로 | 화면 | 권한 |
| --- | --- | --- | | --- | --- | --- |
| `GET /cafe24/` | 상품 목록·검색 (`q`, `page`) | `cafe24` | | `GET /cafe24/` | 2분할 화면 (`q`, `display`, `selling`, `selected`) | `cafe24` |
| `GET /cafe24/products/{product_no}` | 상품 1건 + 상세설명 HTML 편집기 + 버전 이력 | `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}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` |
| `GET /cafe24/schedules` | 예약관리 (Phase 5 안내) | `cafe24` | | `GET /cafe24/schedules` | 예약관리 (Phase 5 안내) | `cafe24` |
| `GET /cafe24/system` | 연결 상태 · API 로그 · 작업 로그 | `cafe24` | | `GET /cafe24/system` | 연결 상태 · API 로그 · 작업 로그 | `cafe24` |
@@ -87,6 +88,34 @@ cafe24_oauth_tokens 저장
--- ---
### 2-1. 상품관리 화면 구성 (2분할)
```
┌─ 360px ─────────┬────────────── 남은 폭 전부 ──────────────┐
│ 검색 / 진열·판매 │ 선택한 상품 이름·상태 │
│ 필터(중복 선택) │ PC 상세설명 HTML 편집(칸이 남은 높이 차지) │
│ ── 목록 ── │ [메모] [복사] [카페24에 적용] │
│ 번호 상품명 진열 │ ▸ 모바일 HTML(읽기 전용, 분리 상품만) │
│ 판매 수정 │ ▸ 버전 이력 │
└─────────────────┴───────────────────────────────────────────┘
```
- **왼쪽은 전체 목록**(페이지 없음). `list_all_products` 로 페이지를 넘겨가며 전부
받는다(1회 100개, 상한 1000개). 필터를 한 페이지에만 적용하면 다음 페이지의
해당 상품이 빠지기 때문이다.
- **필터**는 `진열중`/`판매중` 체크박스이며 **중복 선택 시 AND** 다. 문서에 없는 API
파라미터에 기대지 않고 받아온 뒤 파이썬에서 걸러낸다.
- **정렬**은 제목행 클릭(오름↔내림 토글). 브라우저에서 처리하므로 전체를 받아둔
덕분에 목록 전체가 대상이 된다.
- **상품 클릭 시 오른쪽만 교체**한다(`/pane` 조각을 fetch → 삽입). 목록을 다시 받지
않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다.
- 편집 중 다른 상품을 클릭하거나 페이지를 벗어나면 **저장 안 됨 경고**가 뜬다.
- `.erp-page``max-width` 를 이 화면에서만 풀어 편집 영역을 넓게 쓴다. 이때
`box-sizing: border-box` 를 함께 줘야 한다(안 주면 padding 이 폭에 더해져 문서에
가로 스크롤이 생긴다).
---
## 3-1. 상세설명 API 사실 (실물 확인 결과 — 추측 금지) ## 3-1. 상세설명 API 사실 (실물 확인 결과 — 추측 금지)
운영 쇼핑몰(`miraskitchen`)에서 직접 확인한 내용이다. 문서에 없는 경로를 운영 쇼핑몰(`miraskitchen`)에서 직접 확인한 내용이다. 문서에 없는 경로를