feat(cafe24): 옵션값+품목 통합 목록, 드래그 순서, 옵션값 불러오기
- 옵션 1개 상품은 한 행에 썸네일·옵션값 이름·품목코드(복사)·자체코드· 추가금액·진열·판매를 두고 「저장」 한 번으로 옵션 PUT → 품목 PUT. - 옵션 없는 상품: 행 추가/삭제 후 저장 → 옵션 생성 → 카페24가 부여한 품목코드를 재시도 조회(products.wait_for_variants)로 받아 자체코드· 추가금액·썸네일까지 이어서 반영. - 순서 드래그는 품목 display_order 로 저장(옵션값 재배열 PUT 은 위치 짝맞춤 때문에 품목코드↔이름이 뒤바뀔 수 있어 사용하지 않음). - 「옵션값 불러오기」: 탭/공백 구분 텍스트(이름·판매가·추가금액·자체코드) 붙여넣기 → 행 자동 채움. 기존 옵션은 이름으로 짝지어 코드/금액만. - 옵션 2개 이상(조합) 상품은 기존 2열 화면 유지. - 모달 JS 를 app/static/cafe24-options.js 로 분리. 유닛테스트 90 통과, 통합 하네스 통과, 헤드리스 크롬 렌더 확인. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -402,6 +402,33 @@ def list_variants(client: Cafe24Client, product_no: int) -> list[dict[str, Any]]
|
||||
return variants if isinstance(variants, list) else []
|
||||
|
||||
|
||||
def wait_for_variants(
|
||||
client: Cafe24Client,
|
||||
product_no: int,
|
||||
expected: int,
|
||||
*,
|
||||
attempts: int = 4,
|
||||
delay: float = 0.8,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""옵션 생성 직후 카페24가 자동 생성한 품목이 조회될 때까지 짧게 재시도한다.
|
||||
|
||||
상세설명과 같은 읽기 지연이 품목 조회에도 있다 — POST options 직후 GET variants 가
|
||||
비어 있거나 일부만 올 수 있다. 기대 개수(옵션값 수)만큼 오면 바로 돌려준다.
|
||||
끝까지 못 채워도 마지막 결과를 돌려준다(호출부가 안내).
|
||||
"""
|
||||
latest: list[dict[str, Any]] = []
|
||||
for attempt in range(max(1, attempts)):
|
||||
if attempt:
|
||||
time.sleep(delay)
|
||||
try:
|
||||
latest = list_variants(client, product_no)
|
||||
except Exception: # noqa: BLE001 — 조회 실패는 다음 시도로
|
||||
latest = []
|
||||
if len(latest) >= max(1, expected):
|
||||
return latest
|
||||
return latest
|
||||
|
||||
|
||||
def update_variants(
|
||||
client: Cafe24Client, product_no: int, requests: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -327,11 +327,11 @@ def options_create(
|
||||
st.log_audit(actor=actor, action="create_options", product_no=product_no, result="SUCCESS",
|
||||
detail=f"옵션 '{body['options'][0]['option_name']}' 생성: {', '.join(values)}")
|
||||
logger.info("카페24 상품 %s 옵션 생성 (%s)", product_no, actor)
|
||||
try:
|
||||
variants = products.list_variants(api.client, product_no)
|
||||
except Cafe24Error as exc: # 옵션은 만들어졌다 — 품목 목록만 비워서 돌려준다.
|
||||
logger.warning("카페24 상품 %s 옵션 생성 후 품목 조회 실패: %s", product_no, exc)
|
||||
variants = []
|
||||
# 카페24가 자동 생성한 품목(코드 부여)을 바로 돌려준다 — 읽기 지연이 있어 짧게 재시도.
|
||||
# 화면은 이 코드로 자체코드·추가금액을 이어서 PUT 한다.
|
||||
variants = products.wait_for_variants(api.client, product_no, len(values))
|
||||
if len(variants) < len(values):
|
||||
logger.warning("카페24 상품 %s 옵션 생성 후 품목 %s/%s 건만 조회됨", product_no, len(variants), len(values))
|
||||
return {"ok": True, "option": _options_view(created), "variants": [_variant_view(v) for v in variants]}
|
||||
|
||||
|
||||
@@ -484,7 +484,7 @@ def variants_update(
|
||||
for r in results:
|
||||
code = str(r.get("variant_code") or "")
|
||||
if code in by_code:
|
||||
for key in ("custom_variant_code", "additional_amount", "display", "selling"):
|
||||
for key in ("custom_variant_code", "additional_amount", "display", "selling", "display_order"):
|
||||
if key in r and r[key] is not None:
|
||||
by_code[code][key] = r[key]
|
||||
st.save_write_snapshot(product_no, "variants", by_code)
|
||||
|
||||
@@ -649,10 +649,16 @@ ADDITIONAL_AMOUNT_MAX = 2_147_483_647
|
||||
|
||||
|
||||
def parse_option_values(raw: object) -> list[str]:
|
||||
"""'빨강, 파랑\\n노랑' → ['빨강','파랑','노랑'] (중복·빈 값 제거, 순서 유지)."""
|
||||
text = str(raw or "")
|
||||
"""'빨강, 파랑\\n노랑' 또는 ['빨강','파랑'] → ['빨강','파랑','노랑'] (중복·빈 값 제거, 순서 유지).
|
||||
|
||||
목록으로 오면 항목 안의 쉼표는 이름의 일부로 본다(화면이 행 단위로 보낼 때).
|
||||
"""
|
||||
if isinstance(raw, (list, tuple)):
|
||||
parts = [str(p) for p in raw]
|
||||
else:
|
||||
parts = re.split(r"[,\n]", str(raw or ""))
|
||||
seen: list[str] = []
|
||||
for part in re.split(r"[,\n]", text):
|
||||
for part in parts:
|
||||
value = part.strip()
|
||||
if value and value not in seen:
|
||||
seen.append(value)
|
||||
@@ -785,6 +791,15 @@ def build_variant_updates(rows: list[dict]) -> list[dict]:
|
||||
for flag in ("display", "selling"):
|
||||
if row.get(flag) is not None:
|
||||
item[flag] = "T" if parse_tristate(row[flag]) else "F"
|
||||
# 진열 순서(1~300) — 카페24 문서: 조합형 옵션 품목에만. 화면의 드래그 정렬이 보낸다.
|
||||
if row.get("display_order") is not None:
|
||||
try:
|
||||
order = int(row["display_order"])
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("진열 순서는 숫자여야 합니다.") from None
|
||||
if not 1 <= order <= 300:
|
||||
raise ValueError("진열 순서는 1~300 사이여야 합니다.")
|
||||
item["display_order"] = order
|
||||
if len(item) > 1:
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260918d" />
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260918e" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -125,6 +125,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/cafe24-options.js?v=20260918e"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var pane = document.getElementById("cf24-editor-pane");
|
||||
@@ -913,355 +914,15 @@
|
||||
});
|
||||
})();
|
||||
|
||||
// ── 옵션 / 품목 — 「팝업에서 관리」 버튼으로 모달을 열고 그때 불러온다 ──
|
||||
(function setupOptions() {
|
||||
var openBtn = root.querySelector("#cf24-options-open");
|
||||
var body = optModal.body;
|
||||
var count = root.querySelector("#cf24-options-count");
|
||||
if (!openBtn || !body) return;
|
||||
var state = { option: null, variants: [], displayTypes: {} };
|
||||
|
||||
openBtn.addEventListener("click", function () {
|
||||
var nameEl = document.getElementById("cf24-name-text");
|
||||
optModal.open((nameEl ? nameEl.textContent : "") + " (상품번호 " + no + ")");
|
||||
load();
|
||||
// ── 옵션 / 품목 — 모달 로직은 /static/cafe24-options.js 에 있다(크기 때문에 분리).
|
||||
// 통합 목록(옵션값+품목 한 행) · 드래그 순서 · 옵션값 불러오기 · 저장 한 번에 처리.
|
||||
if (window.cf24SetupOptions) {
|
||||
window.cf24SetupOptions({
|
||||
root: root, productNo: no, modal: optModal,
|
||||
apiJson: apiJson, escHtml: escHtml, setMsg: setMsg,
|
||||
trackDirty: trackDirty, commitValues: commitValues
|
||||
});
|
||||
|
||||
function load() {
|
||||
body.innerHTML = '<p class="cf24-muted">카페24에서 불러오는 중…</p>';
|
||||
apiJson("GET", "/cafe24/products/" + no + "/options")
|
||||
.then(function (data) {
|
||||
state.option = data.option;
|
||||
state.variants = data.variants || [];
|
||||
state.displayTypes = data.display_types || {};
|
||||
render();
|
||||
})
|
||||
.catch(function (err) {
|
||||
body.innerHTML = '<p class="cf24-err">옵션을 불러오지 못했습니다: ' + escHtml(err.message) +
|
||||
'</p><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-retry">다시 시도</button>';
|
||||
body.querySelector("#cf24-opt-retry").addEventListener("click", load);
|
||||
});
|
||||
}
|
||||
|
||||
function typeSelect(current, name) {
|
||||
var out = '<select name="' + name + '">';
|
||||
Object.keys(state.displayTypes).forEach(function (k) {
|
||||
out += '<option value="' + k + '"' + (k === current ? " selected" : "") + ">" + escHtml(state.displayTypes[k]) + "</option>";
|
||||
});
|
||||
return out + "</select>";
|
||||
}
|
||||
|
||||
function render() {
|
||||
var opt = state.option || { has_option: false, options: [] };
|
||||
var variants = state.variants || [];
|
||||
if (count) count.textContent = opt.has_option ? "(" + variants.length + "품목)" : "(옵션 없음)";
|
||||
|
||||
if (!opt.has_option) {
|
||||
body.innerHTML =
|
||||
'<p class="cf24-side-note">이 상품에는 옵션이 없습니다. 옵션명과 옵션값을 넣어 조합형 옵션을 만들면 품목이 자동으로 생성됩니다.</p>' +
|
||||
'<form class="cf24-side-form cf24-opt-create" id="cf24-opt-create">' +
|
||||
' <label class="cf24-side-field"><span>옵션명</span><input type="text" name="option_name" placeholder="예: 색상" /></label>' +
|
||||
' <label class="cf24-side-field"><span>옵션값 (쉼표 또는 줄바꿈으로 구분)</span><textarea name="values" placeholder="예: 빨강, 파랑, 노랑"></textarea></label>' +
|
||||
' <label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect("S", "display_type") + "</label>" +
|
||||
' <div class="cf24-side-actions"><span class="cf24-muted" id="cf24-opt-msg"></span>' +
|
||||
' <button class="erp-btn erp-btn-primary" type="submit">옵션 만들기</button></div>' +
|
||||
"</form>";
|
||||
var form = body.querySelector("#cf24-opt-create");
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
var payload = {
|
||||
option_name: form.querySelector('[name="option_name"]').value.trim(),
|
||||
values: form.querySelector('[name="values"]').value,
|
||||
display_type: form.querySelector('[name="display_type"]').value
|
||||
};
|
||||
if (!payload.option_name || !payload.values.trim()) {
|
||||
window.erpAlert("옵션명과 옵션값을 입력하세요.");
|
||||
return;
|
||||
}
|
||||
window.erpConfirm("옵션 「" + payload.option_name + "」을(를) 만들고 품목을 생성합니다.\n카페24에 바로 반영됩니다. 계속할까요?")
|
||||
.then(function (ok) {
|
||||
if (!ok) return;
|
||||
var msg = form.querySelector("#cf24-opt-msg");
|
||||
setMsg(msg, "생성 중…", "");
|
||||
apiJson("POST", "/cafe24/products/" + no + "/options", payload)
|
||||
.then(function (data) { state.option = data.option; state.variants = data.variants || []; render(); })
|
||||
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); });
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 옵션값 썸네일은 세 곳 중 있는 것을 보여준다 — 옵션 버튼 이미지(option_image_file)
|
||||
// → 옵션 연결 이미지(option_link_image) → 그 옵션값을 가진 품목의 image.
|
||||
// 카페24 관리자에서 "옵션 연결 이미지"로만 등록한 상품은 option_image_file 이
|
||||
// 비어 있고 품목 image 에만 나타난다(실물 — 버튼 이미지만 보면 "없음"으로 오인).
|
||||
function variantImageFor(name, value) {
|
||||
for (var i = 0; i < variants.length; i++) {
|
||||
var v = variants[i];
|
||||
if (!v.image) continue;
|
||||
for (var j = 0; j < v.options.length; j++) {
|
||||
if (v.options[j].name === name && v.options[j].value === value) return v.image;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// 옵션 정의 편집 (모달 왼쪽 열)
|
||||
var html = '<div class="cf24-modal-grid"><section class="cf24-modal-col cf24-modal-col-options">' +
|
||||
'<h4 class="cf24-side-title">옵션 정의</h4>' +
|
||||
'<form class="cf24-side-form" id="cf24-opt-form">';
|
||||
opt.options.forEach(function (o, gi) {
|
||||
html += '<div class="cf24-opt-group" data-group="' + gi + '">' +
|
||||
'<div class="cf24-opt-head">' +
|
||||
' <label class="cf24-side-field"><span>옵션명</span><input type="text" name="option_name" value="' + escHtml(o.option_name) + '" /></label>' +
|
||||
' <label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect(o.option_display_type || "S", "option_display_type") + "</label>" +
|
||||
"</div>";
|
||||
o.option_value.forEach(function (v, vi) {
|
||||
var thumbSrc = v.option_image_file || v.option_link_image || variantImageFor(o.option_name, v.option_text);
|
||||
var thumb = thumbSrc
|
||||
? '<img class="cf24-opt-thumb" src="' + escHtml(thumbSrc) + '" alt="" title="' +
|
||||
(v.option_image_file ? "옵션 버튼 이미지" : v.option_link_image ? "옵션 연결 이미지" : "품목 이미지") + '" />'
|
||||
: '<div class="cf24-opt-thumb-empty">없음</div>';
|
||||
html += '<div class="cf24-opt-value" data-value="' + vi + '">' +
|
||||
'<span class="cf24-opt-thumb-slot">' + thumb + "</span>" +
|
||||
'<input type="text" name="option_text" value="' + escHtml(v.option_text) + '" placeholder="옵션값 이름" />' +
|
||||
'<input type="hidden" name="option_image_file" value="' + escHtml(v.option_image_file) + '" />' +
|
||||
'<input type="hidden" name="option_link_image" value="' + escHtml(v.option_link_image) + '" />' +
|
||||
'<label class="cf24-file-pick"><input type="file" accept="image/jpeg,image/png,image/gif,image/webp" />' +
|
||||
'<span class="erp-btn erp-btn-outline">썸네일</span></label>' +
|
||||
"</div>";
|
||||
});
|
||||
html += "</div>";
|
||||
});
|
||||
html += '<p class="cf24-side-note">옵션값 추가·삭제는 카페24 API 가 지원하지 않습니다(옵션 삭제 후 다시 만들어야 합니다). 썸네일은 표시방식이 「미리보기(이미지)」일 때 쇼핑몰에 보입니다.</p>' +
|
||||
'<div class="cf24-side-actions"><span class="cf24-muted" id="cf24-opt-msg"></span>' +
|
||||
' <button class="erp-btn erp-btn-primary" type="submit">옵션 저장</button></div>' +
|
||||
"</form>" +
|
||||
'<div class="cf24-opt-danger"><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-delete">옵션 전체 삭제</button></div>' +
|
||||
"</section>";
|
||||
|
||||
// 품목 표 (모달 오른쪽 열) — 넓은 창이라 한 줄에 다 들어간다.
|
||||
html += '<section class="cf24-modal-col cf24-modal-col-variants"><h4 class="cf24-side-title">품목 (' + variants.length + ')</h4>';
|
||||
if (!variants.length) {
|
||||
html += '<p class="cf24-muted">품목이 아직 조회되지 않았습니다. 잠시 뒤 창을 닫았다 다시 여세요.</p>';
|
||||
} else {
|
||||
html += '<div class="cf24-variants-wrap"><table class="cf24-variants"><thead><tr>' +
|
||||
'<th class="cf24-v-imgcol"></th><th>옵션</th><th class="cf24-v-syscode">품목코드</th>' +
|
||||
'<th class="cf24-v-code">자체 품목코드</th><th class="cf24-v-amount">추가금액</th>' +
|
||||
'<th class="cf24-v-flag">진열</th><th class="cf24-v-flag">판매</th></tr></thead><tbody>';
|
||||
variants.forEach(function (v) {
|
||||
var label = v.options.map(function (o) { return o.value; }).join(" / ") || v.variant_code;
|
||||
var amount = String(parseInt(v.additional_amount || "0", 10) || 0);
|
||||
html += '<tr class="cf24-v-item" data-code="' + escHtml(v.variant_code) + '">' +
|
||||
'<td class="cf24-v-imgcol">' + (v.image ? '<img class="cf24-v-img" src="' + escHtml(v.image) + '" alt="" />' : '<span class="cf24-v-img cf24-v-img-empty"></span>') + "</td>" +
|
||||
'<td class="cf24-v-name" title="' + escHtml(label) + '">' + escHtml(label) + "</td>" +
|
||||
'<td class="cf24-v-syscode"><button type="button" class="cf24-v-copy" data-copy-text="' + escHtml(v.variant_code) + '" title="클릭하면 품목코드를 복사합니다">' + escHtml(v.variant_code) + "</button></td>" +
|
||||
'<td class="cf24-v-code"><input type="text" name="custom_variant_code" maxlength="40" value="' + escHtml(v.custom_variant_code) + '" /></td>' +
|
||||
'<td class="cf24-v-amount"><input type="text" name="additional_amount" inputmode="numeric" value="' + escHtml(amount) + '" /></td>' +
|
||||
'<td class="cf24-v-flag"><button type="button" class="cf24-v-toggle' + (v.display ? " is-on" : "") + '" data-flag="display" data-on="' + (v.display ? 1 : 0) + '" data-initial="' + (v.display ? 1 : 0) + '">' + (v.display ? "진열" : "미진열") + "</button></td>" +
|
||||
'<td class="cf24-v-flag"><button type="button" class="cf24-v-toggle' + (v.selling ? " is-on" : "") + '" data-flag="selling" data-on="' + (v.selling ? 1 : 0) + '" data-initial="' + (v.selling ? 1 : 0) + '">' + (v.selling ? "판매" : "중지") + "</button></td>" +
|
||||
"</tr>";
|
||||
});
|
||||
html += "</tbody></table></div>" +
|
||||
'<div class="cf24-side-actions" style="margin-top:8px;"><span class="cf24-muted" id="cf24-var-msg"></span>' +
|
||||
' <button type="button" class="erp-btn erp-btn-primary" id="cf24-var-save">품목 저장</button></div>';
|
||||
}
|
||||
html += "</section></div>";
|
||||
|
||||
body.innerHTML = html;
|
||||
var optForm = body.querySelector("#cf24-opt-form");
|
||||
trackDirty(optForm);
|
||||
bindOptionImages(optForm);
|
||||
optForm.addEventListener("submit", function (e) { e.preventDefault(); saveOptions(optForm); });
|
||||
bindVariants();
|
||||
body.querySelector("#cf24-opt-delete").addEventListener("click", deleteOptions);
|
||||
}
|
||||
|
||||
function bindOptionImages(optForm) {
|
||||
optForm.querySelectorAll(".cf24-opt-value").forEach(function (row) {
|
||||
var fileEl = row.querySelector('input[type="file"]');
|
||||
fileEl.addEventListener("change", function () {
|
||||
var f = fileEl.files && fileEl.files[0];
|
||||
if (!f) return;
|
||||
var slot = row.querySelector(".cf24-opt-thumb-slot");
|
||||
var msg = optForm.querySelector("#cf24-opt-msg");
|
||||
setMsg(msg, "썸네일 업로드 중…", "");
|
||||
var fd = new FormData();
|
||||
fd.append("file", f);
|
||||
apiJson("POST", "/cafe24/products/" + no + "/options/image", fd)
|
||||
.then(function (data) {
|
||||
row.querySelector('[name="option_image_file"]').value = data.path;
|
||||
row.querySelector('[name="option_link_image"]').value = data.path;
|
||||
slot.innerHTML = '<img class="cf24-opt-thumb is-dirty" src="' + escHtml(data.path) + '" alt="" />';
|
||||
setMsg(msg, "썸네일을 올렸습니다. 「옵션 저장」을 눌러야 카페24에 반영됩니다.", "is-ok");
|
||||
})
|
||||
.catch(function (err) { setMsg(msg, "썸네일 업로드 실패: " + err.message, "is-err"); })
|
||||
.then(function () { fileEl.value = ""; });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function saveOptions(optForm) {
|
||||
var msg = optForm.querySelector("#cf24-opt-msg");
|
||||
var edited = [];
|
||||
var changed = false;
|
||||
optForm.querySelectorAll(".cf24-opt-group[data-group]").forEach(function (g, gi) {
|
||||
var orig = state.option.options[gi];
|
||||
var entry = {
|
||||
option_name: g.querySelector('[name="option_name"]').value.trim(),
|
||||
option_display_type: g.querySelector('[name="option_display_type"]').value,
|
||||
option_value: []
|
||||
};
|
||||
if (entry.option_name !== orig.option_name || entry.option_display_type !== (orig.option_display_type || "S")) changed = true;
|
||||
g.querySelectorAll(".cf24-opt-value").forEach(function (row, vi) {
|
||||
var ov = orig.option_value[vi] || {};
|
||||
var item = {
|
||||
option_text: row.querySelector('[name="option_text"]').value.trim(),
|
||||
option_image_file: row.querySelector('[name="option_image_file"]').value,
|
||||
option_link_image: row.querySelector('[name="option_link_image"]').value,
|
||||
option_color: ov.option_color || ""
|
||||
};
|
||||
if (item.option_text !== ov.option_text || item.option_image_file !== (ov.option_image_file || "") ||
|
||||
item.option_link_image !== (ov.option_link_image || "")) changed = true;
|
||||
entry.option_value.push(item);
|
||||
});
|
||||
edited.push(entry);
|
||||
});
|
||||
if (!changed) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
|
||||
window.erpConfirm("옵션명·옵션값을 카페24에 바로 반영합니다. 계속할까요?").then(function (ok) {
|
||||
if (!ok) return;
|
||||
setMsg(msg, "저장 중…", "");
|
||||
apiJson("PUT", "/cafe24/products/" + no + "/options", { options: edited, option_list_type: state.option.option_list_type })
|
||||
.then(function (data) {
|
||||
state.option = data.option;
|
||||
state.variants = data.variants || state.variants;
|
||||
render();
|
||||
setMsg(body.querySelector("#cf24-opt-msg"), "카페24에 반영했습니다.", "is-ok");
|
||||
})
|
||||
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); });
|
||||
});
|
||||
}
|
||||
|
||||
function bindVariants() {
|
||||
var saveBtn = body.querySelector("#cf24-var-save");
|
||||
if (!saveBtn) return;
|
||||
var wrap = body.querySelector(".cf24-variants-wrap");
|
||||
trackDirty(wrap);
|
||||
// 품목코드 클릭 = 클립보드 복사 (HTTPS 가 아니면 execCommand 로 대체)
|
||||
wrap.querySelectorAll(".cf24-v-copy").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var text = btn.dataset.copyText || btn.textContent;
|
||||
var done = function () {
|
||||
var old = btn.textContent;
|
||||
btn.textContent = "복사됨";
|
||||
btn.classList.add("is-copied");
|
||||
setTimeout(function () { btn.textContent = old; btn.classList.remove("is-copied"); }, 1200);
|
||||
};
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(done, function () { fallbackCopy(text, done); });
|
||||
} else {
|
||||
fallbackCopy(text, done);
|
||||
}
|
||||
});
|
||||
});
|
||||
function fallbackCopy(text, done) {
|
||||
var ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { if (document.execCommand("copy")) done(); } catch (e) { /* 복사 불가 — 조용히 무시 */ }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
wrap.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var on = btn.dataset.on !== "1";
|
||||
btn.dataset.on = on ? "1" : "0";
|
||||
btn.classList.toggle("is-on", on);
|
||||
btn.textContent = btn.dataset.flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
|
||||
btn.classList.toggle("is-dirty", btn.dataset.on !== btn.dataset.initial);
|
||||
});
|
||||
});
|
||||
saveBtn.addEventListener("click", function () {
|
||||
var msg = body.querySelector("#cf24-var-msg");
|
||||
var rows = [];
|
||||
var lines = [];
|
||||
wrap.querySelectorAll(".cf24-v-item[data-code]").forEach(function (tr) {
|
||||
var row = { variant_code: tr.dataset.code };
|
||||
var any = false;
|
||||
var code = tr.querySelector('[name="custom_variant_code"]');
|
||||
var amount = tr.querySelector('[name="additional_amount"]');
|
||||
if (code.value !== code.defaultValue) { row.custom_variant_code = code.value.trim(); any = true; }
|
||||
if (amount.value !== amount.defaultValue) { row.additional_amount = amount.value.trim() || "0"; any = true; }
|
||||
tr.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
|
||||
if (btn.dataset.on !== btn.dataset.initial) { row[btn.dataset.flag] = btn.dataset.on === "1" ? "on" : "off"; any = true; }
|
||||
});
|
||||
if (any) {
|
||||
rows.push(row);
|
||||
lines.push(tr.querySelector(".cf24-v-name").textContent.trim());
|
||||
}
|
||||
});
|
||||
if (!rows.length) { setMsg(msg, "바뀐 품목이 없습니다.", ""); return; }
|
||||
window.erpConfirm("품목 " + rows.length + "건을 카페24에 바로 반영합니다. 계속할까요?\n\n" + lines.slice(0, 8).join("\n") + (lines.length > 8 ? "\n…" : ""))
|
||||
.then(function (ok) {
|
||||
if (!ok) return;
|
||||
saveBtn.disabled = true;
|
||||
setMsg(msg, "저장 중…", "");
|
||||
apiJson("PUT", "/cafe24/products/" + no + "/variants", { rows: rows })
|
||||
.then(function (data) {
|
||||
var updated = data.updated || {};
|
||||
Object.keys(updated).forEach(function (code) {
|
||||
var u = updated[code];
|
||||
var tr = wrap.querySelector('.cf24-v-item[data-code="' + code + '"]');
|
||||
if (!tr) return;
|
||||
var codeEl = tr.querySelector('[name="custom_variant_code"]');
|
||||
var amountEl = tr.querySelector('[name="additional_amount"]');
|
||||
if (u.custom_variant_code !== undefined) codeEl.value = u.custom_variant_code;
|
||||
if (u.additional_amount !== undefined) amountEl.value = String(parseInt(u.additional_amount, 10) || 0);
|
||||
tr.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
|
||||
var flag = btn.dataset.flag;
|
||||
if (u[flag] === undefined) return;
|
||||
var on = !!u[flag];
|
||||
btn.dataset.on = on ? "1" : "0";
|
||||
btn.dataset.initial = btn.dataset.on;
|
||||
btn.classList.toggle("is-on", on);
|
||||
btn.classList.remove("is-dirty");
|
||||
btn.textContent = flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
|
||||
});
|
||||
// 상태 보관 — 접었다 펴도 응답값이 유지되게
|
||||
state.variants.forEach(function (v) {
|
||||
if (v.variant_code !== code) return;
|
||||
if (u.custom_variant_code !== undefined) v.custom_variant_code = u.custom_variant_code;
|
||||
if (u.additional_amount !== undefined) v.additional_amount = u.additional_amount;
|
||||
if (u.display !== undefined) v.display = !!u.display;
|
||||
if (u.selling !== undefined) v.selling = !!u.selling;
|
||||
});
|
||||
});
|
||||
commitValues(wrap);
|
||||
setMsg(msg, "카페24에 반영했습니다.", "is-ok");
|
||||
})
|
||||
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); })
|
||||
.then(function () { saveBtn.disabled = false; });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteOptions() {
|
||||
window.erpConfirm("옵션을 전부 삭제합니다. 카페24가 이 상품의 **품목도 모두 삭제**하며 되돌릴 수 없습니다.\n정말 삭제할까요?")
|
||||
.then(function (ok) {
|
||||
if (!ok) return;
|
||||
body.innerHTML = '<p class="cf24-muted">삭제 중…</p>';
|
||||
apiJson("DELETE", "/cafe24/products/" + no + "/options")
|
||||
.then(function (data) { state.option = data.option; state.variants = []; render(); })
|
||||
.catch(function (err) {
|
||||
body.innerHTML = '<p class="cf24-err">삭제 실패: ' + escHtml(err.message) + "</p>";
|
||||
loaded = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
// 공용 확인창(erp-dialog.js)은 비동기다 — 기본 confirm() 과 달리 Promise 를
|
||||
|
||||
@@ -1094,6 +1094,33 @@ def test_build_variant_updates():
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("bad code accepted")
|
||||
# 드래그 정렬 → display_order (1~300)
|
||||
assert store.build_variant_updates([{"variant_code": "P000000R000A", "display_order": "3"}]) == [
|
||||
{"variant_code": "P000000R000A", "display_order": 3}
|
||||
]
|
||||
for bad in ("0", "301", "x"):
|
||||
try:
|
||||
store.build_variant_updates([{"variant_code": "P000000R000A", "display_order": bad}])
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(bad)
|
||||
|
||||
|
||||
def test_parse_option_values_accepts_list():
|
||||
assert store.parse_option_values(["빨강, 큰 것", " 파랑 ", "", "빨강, 큰 것"]) == ["빨강, 큰 것", "파랑"]
|
||||
|
||||
|
||||
def test_wait_for_variants_retries_until_expected():
|
||||
calls = {"n": 0}
|
||||
|
||||
class _Client:
|
||||
def get(self, path, **kw):
|
||||
calls["n"] += 1
|
||||
return {"variants": [{"variant_code": "A"}] * (2 if calls["n"] >= 3 else 1)}
|
||||
|
||||
out = products.wait_for_variants(_Client(), 7, 2, attempts=5)
|
||||
assert len(out) == 2 and calls["n"] == 3
|
||||
|
||||
|
||||
class _RouteClient:
|
||||
|
||||
@@ -0,0 +1,785 @@
|
||||
/* 카페24 상품관리 — 옵션/품목 모달 (products.html 에서 window.cf24SetupOptions 로 호출).
|
||||
|
||||
통합 목록: 옵션이 1개(옵션명 하나)인 상품은 옵션값과 품목이 1:1 이므로 한 행에
|
||||
썸네일 · 옵션값 이름 · 품목코드 · 자체 품목코드 · 추가금액 · 진열 · 판매 를 놓고
|
||||
「저장」 한 번으로 옵션(PUT options) → 품목(PUT variants) 순서로 반영한다.
|
||||
옵션이 없는 상품: 행을 자유롭게 추가/삭제해 두고 저장하면 옵션 생성(POST options,
|
||||
카페24가 품목을 자동 생성) → 부여된 품목코드를 받아 → 자체코드/추가금액을 품목에
|
||||
PUT → 썸네일이 있으면 옵션 PUT. 저장 뒤 다시 읽어 품목코드가 바로 보인다.
|
||||
옵션이 2개 이상(조합)인 상품: 옵션값과 품목이 1:1 이 아니므로 예전 2열 화면
|
||||
(왼쪽 옵션 정의 / 오른쪽 품목 표)을 그대로 쓴다.
|
||||
순서 드래그: 품목의 display_order(카페24 문서 — 조합형 품목 진열순서)로 저장한다.
|
||||
옵션값 배열 자체를 재배열해 PUT 하지 않는다 — 카페24는 original_options 와
|
||||
options 를 **위치**로 짝지어 이름을 바꾸므로, 재배열하면 품목코드와 이름이
|
||||
뒤바뀔 위험이 있다(품목코드는 주문 이력이 참조한다).
|
||||
옵션값 불러오기: 탭/공백으로 나뉜 텍스트(이름 · 가격 · 추가금액 · 자체코드)를
|
||||
붙여넣으면 행에 채운다. 가격 열은 무시한다.
|
||||
화면은 언제나 **응답값**으로 다시 그린다(카페24 GET 은 쓰기 직후 예전 값을 돌려줄
|
||||
수 있어, 서버가 스냅샷으로 보정한다). */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var DEFAULT_TYPE = "S";
|
||||
|
||||
window.cf24SetupOptions = function (ctx) {
|
||||
var root = ctx.root, no = ctx.productNo, modal = ctx.modal;
|
||||
var apiJson = ctx.apiJson, esc = ctx.escHtml, setMsg = ctx.setMsg;
|
||||
var trackDirty = ctx.trackDirty, commitValues = ctx.commitValues;
|
||||
|
||||
var openBtn = root.querySelector("#cf24-options-open");
|
||||
var body = modal.body;
|
||||
var count = root.querySelector("#cf24-options-count");
|
||||
if (!openBtn || !body) return;
|
||||
|
||||
var state = { option: null, variants: [], displayTypes: {} };
|
||||
|
||||
openBtn.addEventListener("click", function () {
|
||||
var nameEl = document.getElementById("cf24-name-text");
|
||||
modal.open((nameEl ? nameEl.textContent : "") + " (상품번호 " + no + ")");
|
||||
load();
|
||||
});
|
||||
|
||||
function load() {
|
||||
body.innerHTML = '<p class="cf24-muted">카페24에서 불러오는 중…</p>';
|
||||
return apiJson("GET", "/cafe24/products/" + no + "/options")
|
||||
.then(function (data) {
|
||||
state.option = data.option;
|
||||
state.variants = data.variants || [];
|
||||
state.displayTypes = data.display_types || {};
|
||||
render();
|
||||
})
|
||||
.catch(function (err) {
|
||||
body.innerHTML = '<p class="cf24-err">옵션을 불러오지 못했습니다: ' + esc(err.message) +
|
||||
'</p><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-retry">다시 시도</button>';
|
||||
body.querySelector("#cf24-opt-retry").addEventListener("click", load);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 공통 조각 ── */
|
||||
function typeSelect(current, name) {
|
||||
var out = '<select name="' + name + '">';
|
||||
Object.keys(state.displayTypes).forEach(function (k) {
|
||||
out += '<option value="' + k + '"' + (k === current ? " selected" : "") + ">" + esc(state.displayTypes[k]) + "</option>";
|
||||
});
|
||||
return out + "</select>";
|
||||
}
|
||||
|
||||
function toggleBtn(flag, on) {
|
||||
var label = flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
|
||||
return '<button type="button" class="cf24-v-toggle' + (on ? " is-on" : "") + '" data-flag="' + flag +
|
||||
'" data-on="' + (on ? 1 : 0) + '" data-initial="' + (on ? 1 : 0) + '">' + label + "</button>";
|
||||
}
|
||||
|
||||
function paintToggle(btn, on, resetInitial) {
|
||||
btn.dataset.on = on ? "1" : "0";
|
||||
if (resetInitial) btn.dataset.initial = btn.dataset.on;
|
||||
btn.classList.toggle("is-on", on);
|
||||
btn.classList.toggle("is-dirty", btn.dataset.on !== btn.dataset.initial);
|
||||
btn.textContent = btn.dataset.flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
|
||||
}
|
||||
|
||||
function bindToggles(container) {
|
||||
container.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () { paintToggle(btn, btn.dataset.on !== "1", false); });
|
||||
});
|
||||
}
|
||||
|
||||
function copyText(text, done) {
|
||||
function fallback() {
|
||||
var ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { if (document.execCommand("copy")) done(); } catch (e) { /* 복사 불가 */ }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(done, fallback);
|
||||
} else {
|
||||
fallback();
|
||||
}
|
||||
}
|
||||
|
||||
function bindCopy(container) {
|
||||
container.querySelectorAll(".cf24-v-copy").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
copyText(btn.dataset.copyText || btn.textContent, function () {
|
||||
var old = btn.textContent;
|
||||
btn.textContent = "복사됨";
|
||||
btn.classList.add("is-copied");
|
||||
setTimeout(function () { btn.textContent = old; btn.classList.remove("is-copied"); }, 1200);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 옵션값 썸네일 — 버튼 이미지 → 연결 이미지 → 그 옵션값을 가진 품목 image
|
||||
function variantImageFor(name, value) {
|
||||
for (var i = 0; i < state.variants.length; i++) {
|
||||
var v = state.variants[i];
|
||||
if (!v.image) continue;
|
||||
for (var j = 0; j < v.options.length; j++) {
|
||||
if (v.options[j].name === name && v.options[j].value === value) return v.image;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function thumbHtml(src, title) {
|
||||
return src
|
||||
? '<img class="cf24-opt-thumb" src="' + esc(src) + '" alt="" title="' + esc(title || "") + '" />'
|
||||
: '<div class="cf24-opt-thumb-empty">없음</div>';
|
||||
}
|
||||
|
||||
// 썸네일 파일 선택 → 업로드 → hidden 두 개(option_image_file, option_link_image)에 경로
|
||||
function bindThumbUploads(container, msgEl) {
|
||||
container.querySelectorAll(".cf24-opt-value, .cf24-u-row").forEach(function (row) {
|
||||
var fileEl = row.querySelector('input[type="file"]');
|
||||
if (!fileEl) return;
|
||||
fileEl.addEventListener("change", function () {
|
||||
var f = fileEl.files && fileEl.files[0];
|
||||
if (!f) return;
|
||||
var slot = row.querySelector(".cf24-opt-thumb-slot");
|
||||
setMsg(msgEl, "썸네일 업로드 중…", "");
|
||||
var fd = new FormData();
|
||||
fd.append("file", f);
|
||||
apiJson("POST", "/cafe24/products/" + no + "/options/image", fd)
|
||||
.then(function (data) {
|
||||
row.querySelector('[name="option_image_file"]').value = data.path;
|
||||
row.querySelector('[name="option_link_image"]').value = data.path;
|
||||
slot.innerHTML = '<img class="cf24-opt-thumb is-dirty" src="' + esc(data.path) + '" alt="" />';
|
||||
row.classList.add("is-image-dirty");
|
||||
setMsg(msgEl, "썸네일을 올렸습니다. 「저장」을 눌러야 카페24에 반영됩니다.", "is-ok");
|
||||
})
|
||||
.catch(function (err) { setMsg(msgEl, "썸네일 업로드 실패: " + err.message, "is-err"); })
|
||||
.then(function () { fileEl.value = ""; });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function amountPlain(v) { return String(parseInt(String(v || "0").replace(/,/g, ""), 10) || 0); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
화면 분기
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
function render() {
|
||||
var opt = state.option || { has_option: false, options: [] };
|
||||
if (count) count.textContent = opt.has_option ? "(" + state.variants.length + "품목)" : "(옵션 없음)";
|
||||
if (!opt.has_option) return renderUnified(true);
|
||||
if (opt.options.length === 1) return renderUnified(false);
|
||||
return renderMulti();
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
통합 목록 — 옵션값 1 : 품목 1
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
function unifiedRows() {
|
||||
var opt = state.option;
|
||||
if (!opt || !opt.has_option) return [];
|
||||
var group = opt.options[0];
|
||||
return group.option_value.map(function (v, vi) {
|
||||
var variant = null;
|
||||
for (var i = 0; i < state.variants.length; i++) {
|
||||
var cand = state.variants[i];
|
||||
if (cand.options.length && cand.options[0].value === v.option_text) { variant = cand; break; }
|
||||
}
|
||||
return { index: vi, value: v, variant: variant };
|
||||
});
|
||||
}
|
||||
|
||||
function rowHtml(r, isNew, canDrag) {
|
||||
var v = r.value || { option_text: "", option_image_file: "", option_link_image: "", option_color: "" };
|
||||
var variant = r.variant;
|
||||
var thumbSrc = v.option_image_file || v.option_link_image ||
|
||||
(state.option && state.option.has_option ? variantImageFor(state.option.options[0].option_name, v.option_text) : "");
|
||||
var code = variant ? variant.variant_code : "";
|
||||
return '<tr class="cf24-u-row" data-index="' + (r.index == null ? "" : r.index) + '" data-code="' + esc(code) + '">' +
|
||||
'<td class="cf24-u-drag">' + (canDrag ? '<span class="cf24-drag-handle" draggable="true" title="드래그하여 순서 변경">⋮⋮</span>' : "") + "</td>" +
|
||||
'<td class="cf24-u-thumb"><span class="cf24-opt-thumb-slot">' + thumbHtml(thumbSrc, v.option_image_file ? "옵션 버튼 이미지" : v.option_link_image ? "옵션 연결 이미지" : "품목 이미지") + "</span>" +
|
||||
'<input type="hidden" name="option_image_file" value="' + esc(v.option_image_file || "") + '" />' +
|
||||
'<input type="hidden" name="option_link_image" value="' + esc(v.option_link_image || "") + '" />' +
|
||||
'<label class="cf24-file-pick cf24-u-pick" title="썸네일 업로드"><input type="file" accept="image/jpeg,image/png,image/gif,image/webp" /><span class="erp-btn erp-btn-outline">썸네일</span></label></td>' +
|
||||
'<td class="cf24-u-name"><input type="text" name="option_text" value="' + esc(v.option_text) + '" placeholder="옵션값 이름" /></td>' +
|
||||
'<td class="cf24-v-syscode">' + (code
|
||||
? '<button type="button" class="cf24-v-copy" data-copy-text="' + esc(code) + '" title="클릭하면 품목코드를 복사합니다">' + esc(code) + "</button>"
|
||||
: '<span class="cf24-muted">' + (isNew ? "저장 시 부여" : "—") + "</span>") + "</td>" +
|
||||
'<td class="cf24-v-code"><input type="text" name="custom_variant_code" maxlength="40" value="' + esc(variant ? variant.custom_variant_code : "") + '" /></td>' +
|
||||
'<td class="cf24-v-amount"><input type="text" name="additional_amount" inputmode="numeric" value="' + esc(amountPlain(variant ? variant.additional_amount : "0")) + '" /></td>' +
|
||||
'<td class="cf24-v-flag">' + toggleBtn("display", variant ? !!variant.display : true) + "</td>" +
|
||||
'<td class="cf24-v-flag">' + toggleBtn("selling", variant ? !!variant.selling : true) + "</td>" +
|
||||
'<td class="cf24-u-del">' + (isNew ? '<button type="button" class="cf24-u-remove" title="행 삭제">✕</button>' : "") + "</td>" +
|
||||
"</tr>";
|
||||
}
|
||||
|
||||
function renderUnified(isNew) {
|
||||
var opt = state.option;
|
||||
var group = isNew ? { option_name: "", option_display_type: DEFAULT_TYPE } : opt.options[0];
|
||||
var rows = isNew ? [] : unifiedRows();
|
||||
// 순서 변경은 조합형(T) 품목의 display_order 로만 저장할 수 있다.
|
||||
var canDrag = isNew || opt.option_type === "T";
|
||||
|
||||
var html = '<div class="cf24-u-wrap">' +
|
||||
'<div class="cf24-u-head">' +
|
||||
' <label class="cf24-side-field cf24-u-optname"><span>옵션명</span><input type="text" name="option_name" value="' + esc(group.option_name) + '" placeholder="예: 구성" /></label>' +
|
||||
' <label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect(group.option_display_type || DEFAULT_TYPE, "option_display_type") + "</label>" +
|
||||
' <span class="cf24-u-head-actions">' +
|
||||
' <button type="button" class="erp-btn erp-btn-outline" id="cf24-u-import">옵션값 불러오기</button>' +
|
||||
(isNew ? ' <button type="button" class="erp-btn erp-btn-outline" id="cf24-u-add">+ 행 추가</button>' : "") +
|
||||
" </span>" +
|
||||
"</div>" +
|
||||
'<div class="cf24-u-tablewrap"><table class="cf24-variants cf24-u-table"><thead><tr>' +
|
||||
'<th class="cf24-u-drag"></th><th class="cf24-u-thumb">썸네일</th><th>옵션값 이름</th><th class="cf24-v-syscode">품목코드</th>' +
|
||||
'<th class="cf24-v-code">자체 품목코드</th><th class="cf24-v-amount">추가금액</th><th class="cf24-v-flag">진열</th><th class="cf24-v-flag">판매</th><th class="cf24-u-del"></th>' +
|
||||
"</tr></thead><tbody id=\"cf24-u-body\">";
|
||||
rows.forEach(function (r) { html += rowHtml(r, false, canDrag); });
|
||||
if (isNew) html += rowHtml({ index: null, value: null, variant: null }, true, true);
|
||||
html += "</tbody></table></div>" +
|
||||
'<p class="cf24-side-note cf24-u-note">' +
|
||||
(isNew
|
||||
? "저장하면 옵션(조합형)을 만들고 카페24가 품목을 자동 생성합니다. 부여된 품목코드를 받아 자체 품목코드·추가금액·썸네일까지 함께 반영합니다."
|
||||
: "옵션값 추가·삭제는 카페24 API 가 지원하지 않습니다(옵션 전체 삭제 후 다시 만들어야 합니다). " +
|
||||
(canDrag ? "행을 드래그하면 품목 진열순서로 저장됩니다. " : "") +
|
||||
"썸네일은 표시방식이 「미리보기(이미지)」일 때 쇼핑몰에 보입니다.") +
|
||||
"</p>" +
|
||||
'<div class="cf24-side-actions cf24-u-actions"><span class="cf24-muted" id="cf24-u-msg"></span>' +
|
||||
' <span>' + (isNew ? "" : '<button type="button" class="erp-btn erp-btn-outline cf24-btn-danger" id="cf24-opt-delete">옵션 전체 삭제</button> ') +
|
||||
' <button type="button" class="erp-btn erp-btn-primary" id="cf24-u-save">저장</button></span></div>' +
|
||||
"</div>";
|
||||
body.innerHTML = html;
|
||||
|
||||
var wrap = body.querySelector(".cf24-u-wrap");
|
||||
var tbody = body.querySelector("#cf24-u-body");
|
||||
var msg = body.querySelector("#cf24-u-msg");
|
||||
trackDirty(wrap);
|
||||
bindToggles(tbody);
|
||||
bindCopy(tbody);
|
||||
bindThumbUploads(tbody, msg);
|
||||
if (canDrag) bindDrag(tbody);
|
||||
if (isNew) {
|
||||
body.querySelector("#cf24-u-add").addEventListener("click", function () {
|
||||
tbody.insertAdjacentHTML("beforeend", rowHtml({ index: null, value: null, variant: null }, true, true));
|
||||
var tr = tbody.lastElementChild;
|
||||
bindRowNew(tr, tbody, msg);
|
||||
tr.querySelector('[name="option_text"]').focus();
|
||||
});
|
||||
tbody.querySelectorAll("tr").forEach(function (tr) { bindRowNew(tr, tbody, msg); });
|
||||
}
|
||||
body.querySelector("#cf24-u-import").addEventListener("click", function () { openImport(isNew, tbody, msg); });
|
||||
body.querySelector("#cf24-u-save").addEventListener("click", function () { saveUnified(isNew, tbody, msg); });
|
||||
var del = body.querySelector("#cf24-opt-delete");
|
||||
if (del) del.addEventListener("click", deleteOptions);
|
||||
}
|
||||
|
||||
function bindRowNew(tr, tbody, msg) {
|
||||
var rm = tr.querySelector(".cf24-u-remove");
|
||||
if (rm && !rm.dataset.bound) {
|
||||
rm.dataset.bound = "1";
|
||||
rm.addEventListener("click", function () {
|
||||
if (tbody.children.length <= 1) { tr.querySelector('[name="option_text"]').value = ""; return; }
|
||||
tr.remove();
|
||||
});
|
||||
}
|
||||
trackDirty(tr);
|
||||
bindToggles(tr);
|
||||
bindThumbUploads(tr, msg);
|
||||
}
|
||||
|
||||
/* ── 드래그 정렬 (HTML5 DnD, 핸들에서만 시작) ── */
|
||||
function bindDrag(tbody) {
|
||||
var dragging = null;
|
||||
tbody.querySelectorAll(".cf24-drag-handle").forEach(function (h) {
|
||||
h.addEventListener("dragstart", function (e) {
|
||||
dragging = h.closest("tr");
|
||||
dragging.classList.add("is-dragging");
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
try { e.dataTransfer.setData("text/plain", dragging.dataset.code || "row"); } catch (err) { /* IE */ }
|
||||
});
|
||||
h.addEventListener("dragend", function () {
|
||||
if (dragging) dragging.classList.remove("is-dragging");
|
||||
dragging = null;
|
||||
markOrder(tbody);
|
||||
});
|
||||
});
|
||||
tbody.addEventListener("dragover", function (e) {
|
||||
if (!dragging) return;
|
||||
e.preventDefault();
|
||||
var over = e.target.closest("tr");
|
||||
if (!over || over === dragging) return;
|
||||
var rect = over.getBoundingClientRect();
|
||||
var after = (e.clientY - rect.top) > rect.height / 2;
|
||||
tbody.insertBefore(dragging, after ? over.nextSibling : over);
|
||||
});
|
||||
tbody.addEventListener("drop", function (e) { e.preventDefault(); });
|
||||
}
|
||||
|
||||
// 순서가 처음과 달라졌는지 표시(저장 시 display_order 전송 여부)
|
||||
function markOrder(tbody) {
|
||||
var changed = false;
|
||||
Array.prototype.forEach.call(tbody.children, function (tr, i) {
|
||||
var orig = tr.dataset.index === "" ? null : parseInt(tr.dataset.index, 10);
|
||||
if (orig !== null && orig !== i) changed = true;
|
||||
});
|
||||
tbody.classList.toggle("is-order-dirty", changed);
|
||||
Array.prototype.forEach.call(tbody.children, function (tr) {
|
||||
var h = tr.querySelector(".cf24-drag-handle");
|
||||
if (h) h.classList.toggle("is-dirty", changed);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 옵션값 불러오기 (텍스트 붙여넣기) ── */
|
||||
function parseImport(text) {
|
||||
var out = [];
|
||||
String(text || "").split(/\r?\n/).forEach(function (line) {
|
||||
if (!line.trim()) return;
|
||||
var cols = line.indexOf("\t") >= 0 ? line.split("\t") : line.split(/\s{2,}/);
|
||||
cols = cols.map(function (c) { return c.trim(); });
|
||||
while (cols.length && !cols[cols.length - 1]) cols.pop();
|
||||
if (!cols.length) return;
|
||||
var name = cols.shift();
|
||||
if (!name) return;
|
||||
var code = "";
|
||||
if (cols.length && /[A-Za-z가-힣]/.test(cols[cols.length - 1])) code = cols.pop();
|
||||
var nums = cols.map(function (c) {
|
||||
var t = c.replace(/[,\s원]/g, "");
|
||||
if (t === "-" || t === "") return 0;
|
||||
var n = parseInt(t, 10);
|
||||
return isNaN(n) ? null : n;
|
||||
}).filter(function (n) { return n !== null; });
|
||||
// 열이 2개 이상이면 첫 번째는 판매가(무시), 마지막이 추가금액
|
||||
var amount = nums.length >= 2 ? nums[nums.length - 1] : (nums.length === 1 ? nums[0] : null);
|
||||
out.push({ name: name, amount: amount, code: code });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function openImport(isNew, tbody, msg) {
|
||||
var dlg = document.createElement("div");
|
||||
dlg.className = "cf24-modal cf24-import";
|
||||
dlg.innerHTML = '<div class="cf24-modal-box cf24-import-box" role="dialog" aria-modal="true">' +
|
||||
'<div class="cf24-modal-head"><h3 class="cf24-modal-title">옵션값 불러오기</h3>' +
|
||||
'<button type="button" class="cf24-modal-close" aria-label="닫기">✕</button></div>' +
|
||||
'<div class="cf24-modal-body">' +
|
||||
'<p class="cf24-side-note">한 줄에 하나씩, 탭(엑셀 복사) 또는 두 칸 이상 공백으로 구분: <code>이름 · 판매가 · 추가금액 · 자체 품목코드</code>. 판매가는 무시합니다. 추가금액 「-」는 0.</p>' +
|
||||
'<textarea class="cf24-import-text" placeholder="미라클통 퓨어 스타터 SET\t9,900\t-\tPE-0002\n미라클통 퓨어 스몰 SET\t29,900\t20,000\tPE-0003"></textarea>' +
|
||||
'<p class="cf24-muted cf24-import-preview"></p>' +
|
||||
'<div class="cf24-side-actions"><span class="cf24-muted"></span><span>' +
|
||||
'<button type="button" class="erp-btn erp-btn-outline cf24-import-cancel">취소</button> ' +
|
||||
'<button type="button" class="erp-btn erp-btn-primary cf24-import-apply">적용</button></span></div>' +
|
||||
"</div></div>";
|
||||
document.body.appendChild(dlg);
|
||||
var ta = dlg.querySelector("textarea");
|
||||
var preview = dlg.querySelector(".cf24-import-preview");
|
||||
function close() { dlg.remove(); }
|
||||
dlg.querySelector(".cf24-modal-close").addEventListener("click", close);
|
||||
dlg.querySelector(".cf24-import-cancel").addEventListener("click", close);
|
||||
dlg.addEventListener("click", function (e) { if (e.target === dlg) close(); });
|
||||
ta.addEventListener("input", function () {
|
||||
var parsed = parseImport(ta.value);
|
||||
preview.textContent = parsed.length
|
||||
? parsed.length + "행 인식 — 예: " + parsed[0].name + " / 추가금액 " + (parsed[0].amount == null ? "(없음)" : parsed[0].amount.toLocaleString("ko-KR")) + " / 코드 " + (parsed[0].code || "(없음)")
|
||||
: "";
|
||||
});
|
||||
dlg.querySelector(".cf24-import-apply").addEventListener("click", function () {
|
||||
var parsed = parseImport(ta.value);
|
||||
if (!parsed.length) { window.erpAlert("인식된 줄이 없습니다."); return; }
|
||||
applyImport(parsed, isNew, tbody, msg);
|
||||
close();
|
||||
});
|
||||
ta.focus();
|
||||
}
|
||||
|
||||
function setRowValues(tr, item) {
|
||||
var nameEl = tr.querySelector('[name="option_text"]');
|
||||
var codeEl = tr.querySelector('[name="custom_variant_code"]');
|
||||
var amtEl = tr.querySelector('[name="additional_amount"]');
|
||||
if (item.name != null && nameEl) { nameEl.value = item.name; nameEl.dispatchEvent(new Event("input")); }
|
||||
if (item.code && codeEl) { codeEl.value = item.code; codeEl.dispatchEvent(new Event("input")); }
|
||||
if (item.amount != null && amtEl) { amtEl.value = String(item.amount); amtEl.dispatchEvent(new Event("input")); }
|
||||
}
|
||||
|
||||
function norm(s) { return String(s || "").replace(/\s+/g, "").toLowerCase(); }
|
||||
|
||||
function applyImport(parsed, isNew, tbody, msg) {
|
||||
if (isNew) {
|
||||
// 비어 있는 행만 있으면 교체, 내용이 있으면 뒤에 덧붙인다
|
||||
var blank = Array.prototype.every.call(tbody.children, function (tr) {
|
||||
return !tr.querySelector('[name="option_text"]').value.trim();
|
||||
});
|
||||
if (blank) tbody.innerHTML = "";
|
||||
parsed.forEach(function (item) {
|
||||
tbody.insertAdjacentHTML("beforeend", rowHtml({ index: null, value: null, variant: null }, true, true));
|
||||
var tr = tbody.lastElementChild;
|
||||
bindRowNew(tr, tbody, msg);
|
||||
setRowValues(tr, item);
|
||||
});
|
||||
setMsg(msg, parsed.length + "행을 채웠습니다. 「저장」을 누르면 카페24에 반영됩니다.", "is-ok");
|
||||
return;
|
||||
}
|
||||
var rows = Array.prototype.slice.call(tbody.children);
|
||||
var matched = 0, missing = [];
|
||||
parsed.forEach(function (item) {
|
||||
var tr = null;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
if (norm(rows[i].querySelector('[name="option_text"]').value) === norm(item.name)) { tr = rows[i]; break; }
|
||||
}
|
||||
if (!tr) { missing.push(item.name); return; }
|
||||
setRowValues(tr, { code: item.code, amount: item.amount });
|
||||
matched++;
|
||||
});
|
||||
var text = matched + "행에 자체코드·추가금액을 채웠습니다.";
|
||||
if (missing.length) text += " 일치하는 옵션값이 없어 건너뜀: " + missing.join(", ") + " (옵션값 추가는 API 미지원)";
|
||||
setMsg(msg, text, missing.length ? "is-err" : "is-ok");
|
||||
}
|
||||
|
||||
/* ── 저장 (통합) ── */
|
||||
function collectUnified(tbody) {
|
||||
return Array.prototype.map.call(tbody.children, function (tr, i) {
|
||||
return {
|
||||
tr: tr,
|
||||
position: i,
|
||||
index: tr.dataset.index === "" ? null : parseInt(tr.dataset.index, 10),
|
||||
code: tr.dataset.code || "",
|
||||
text: tr.querySelector('[name="option_text"]').value.trim(),
|
||||
image: tr.querySelector('[name="option_image_file"]').value,
|
||||
link: tr.querySelector('[name="option_link_image"]').value,
|
||||
custom: tr.querySelector('[name="custom_variant_code"]'),
|
||||
amount: tr.querySelector('[name="additional_amount"]'),
|
||||
display: tr.querySelector('.cf24-v-toggle[data-flag="display"]'),
|
||||
selling: tr.querySelector('.cf24-v-toggle[data-flag="selling"]')
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function validateNames(rows) {
|
||||
var seen = {};
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
if (!rows[i].text) return "옵션값 이름이 비어 있는 행이 있습니다.";
|
||||
if (seen[rows[i].text]) return "옵션값 이름이 중복됩니다: " + rows[i].text;
|
||||
seen[rows[i].text] = true;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function variantPatch(r, withOrder) {
|
||||
var patch = { variant_code: r.code };
|
||||
var any = false;
|
||||
if (r.custom.value !== r.custom.defaultValue) { patch.custom_variant_code = r.custom.value.trim(); any = true; }
|
||||
if (r.amount.value !== r.amount.defaultValue) { patch.additional_amount = r.amount.value.trim() || "0"; any = true; }
|
||||
if (r.display.dataset.on !== r.display.dataset.initial) { patch.display = r.display.dataset.on === "1" ? "on" : "off"; any = true; }
|
||||
if (r.selling.dataset.on !== r.selling.dataset.initial) { patch.selling = r.selling.dataset.on === "1" ? "on" : "off"; any = true; }
|
||||
if (withOrder) { patch.display_order = r.position + 1; any = true; }
|
||||
return any ? patch : null;
|
||||
}
|
||||
|
||||
function saveUnified(isNew, tbody, msg) {
|
||||
var rows = collectUnified(tbody);
|
||||
var problem = validateNames(rows);
|
||||
if (problem) { window.erpAlert(problem); return; }
|
||||
var optName = body.querySelector('[name="option_name"]').value.trim();
|
||||
var optType = body.querySelector('[name="option_display_type"]').value;
|
||||
if (!optName) { window.erpAlert("옵션명을 입력하세요."); return; }
|
||||
var saveBtn = body.querySelector("#cf24-u-save");
|
||||
|
||||
if (isNew) {
|
||||
window.erpConfirm("옵션 「" + optName + "」과 옵션값 " + rows.length + "개를 만들고 품목을 생성합니다.\n카페24에 바로 반영됩니다. 계속할까요?")
|
||||
.then(function (ok) { if (ok) createFlow(); });
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 옵션 — 옵션(PUT) 은 **원래 순서**로 짝을 맞춰 보낸다(재배열은 display_order 로).
|
||||
var group = state.option.options[0];
|
||||
var optionsChanged = optName !== group.option_name || optType !== (group.option_display_type || DEFAULT_TYPE);
|
||||
var edited = group.option_value.map(function (ov, vi) {
|
||||
var r = null;
|
||||
for (var i = 0; i < rows.length; i++) if (rows[i].index === vi) { r = rows[i]; break; }
|
||||
if (!r) return { option_text: ov.option_text, option_image_file: ov.option_image_file, option_link_image: ov.option_link_image, option_color: ov.option_color };
|
||||
if (r.text !== ov.option_text || r.image !== (ov.option_image_file || "") || r.link !== (ov.option_link_image || "")) optionsChanged = true;
|
||||
return { option_text: r.text, option_image_file: r.image, option_link_image: r.link, option_color: ov.option_color || "" };
|
||||
});
|
||||
var orderChanged = tbody.classList.contains("is-order-dirty");
|
||||
var patches = [];
|
||||
rows.forEach(function (r) {
|
||||
if (!r.code) return;
|
||||
var p = variantPatch(r, orderChanged);
|
||||
if (p) patches.push(p);
|
||||
});
|
||||
if (!optionsChanged && !patches.length) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
|
||||
|
||||
var lines = [];
|
||||
if (optionsChanged) lines.push("옵션명·옵션값 이름/썸네일/표시방식");
|
||||
if (patches.length) lines.push("품목 " + patches.length + "건 (자체코드·추가금액·진열/판매" + (orderChanged ? "·순서" : "") + ")");
|
||||
window.erpConfirm("카페24에 바로 반영합니다. 계속할까요?\n\n- " + lines.join("\n- ")).then(function (ok) {
|
||||
if (!ok) return;
|
||||
saveBtn.disabled = true;
|
||||
setMsg(msg, "저장 중…", "");
|
||||
var chain = Promise.resolve();
|
||||
if (optionsChanged) {
|
||||
chain = chain.then(function () {
|
||||
return apiJson("PUT", "/cafe24/products/" + no + "/options", {
|
||||
options: [{ option_name: optName, option_display_type: optType, option_value: edited }],
|
||||
option_list_type: state.option.option_list_type
|
||||
}).then(function (data) {
|
||||
state.option = data.option;
|
||||
if (data.variants) state.variants = data.variants;
|
||||
});
|
||||
});
|
||||
}
|
||||
if (patches.length) {
|
||||
chain = chain.then(function () {
|
||||
return apiJson("PUT", "/cafe24/products/" + no + "/variants", { rows: patches }).then(function (data) {
|
||||
var updated = data.updated || {};
|
||||
state.variants.forEach(function (v) {
|
||||
var u = updated[v.variant_code];
|
||||
if (!u) return;
|
||||
if (u.custom_variant_code !== undefined) v.custom_variant_code = u.custom_variant_code;
|
||||
if (u.additional_amount !== undefined) v.additional_amount = u.additional_amount;
|
||||
if (u.display !== undefined) v.display = !!u.display;
|
||||
if (u.selling !== undefined) v.selling = !!u.selling;
|
||||
});
|
||||
if (orderChanged) {
|
||||
// 화면 순서를 상태에도 반영(다시 그려도 지금 순서 유지)
|
||||
var byCode = {};
|
||||
state.variants.forEach(function (v) { byCode[v.variant_code] = v; });
|
||||
var reordered = [];
|
||||
rows.forEach(function (r) { if (byCode[r.code]) reordered.push(byCode[r.code]); });
|
||||
state.variants.forEach(function (v) { if (reordered.indexOf(v) < 0) reordered.push(v); });
|
||||
state.variants = reordered;
|
||||
var g = state.option.options[0];
|
||||
var vals = [];
|
||||
rows.forEach(function (r) { if (r.index !== null && g.option_value[r.index]) vals.push(g.option_value[r.index]); });
|
||||
g.option_value.forEach(function (ov) { if (vals.indexOf(ov) < 0) vals.push(ov); });
|
||||
g.option_value = vals;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
chain
|
||||
.then(function () {
|
||||
render();
|
||||
setMsg(body.querySelector("#cf24-u-msg"), "카페24에 반영했습니다.", "is-ok");
|
||||
if (count) count.textContent = "(" + state.variants.length + "품목)";
|
||||
})
|
||||
.catch(function (err) {
|
||||
setMsg(msg, "실패: " + err.message + " — 화면을 다시 불러옵니다.", "is-err");
|
||||
setTimeout(load, 1500);
|
||||
})
|
||||
.then(function () { saveBtn.disabled = false; });
|
||||
});
|
||||
|
||||
function createFlow() {
|
||||
saveBtn.disabled = true;
|
||||
setMsg(msg, "옵션 생성 중… (카페24가 품목을 만들고 코드를 부여합니다)", "");
|
||||
var names = rows.map(function (r) { return r.text; });
|
||||
apiJson("POST", "/cafe24/products/" + no + "/options", {
|
||||
option_name: optName, values: names, display_type: optType
|
||||
})
|
||||
.then(function (data) {
|
||||
state.option = data.option;
|
||||
state.variants = data.variants || [];
|
||||
// 부여된 품목코드를 옵션값 이름으로 짝지어 자체코드·추가금액·진열/판매를 보낸다
|
||||
var byValue = {};
|
||||
state.variants.forEach(function (v) { if (v.options.length) byValue[v.options[0].value] = v.variant_code; });
|
||||
var patches = [];
|
||||
var unmatched = 0;
|
||||
rows.forEach(function (r) {
|
||||
var code = byValue[r.text];
|
||||
if (!code) { unmatched++; return; }
|
||||
var p = { variant_code: code };
|
||||
var any = false;
|
||||
if (r.custom.value.trim()) { p.custom_variant_code = r.custom.value.trim(); any = true; }
|
||||
if (amountPlain(r.amount.value) !== "0") { p.additional_amount = r.amount.value.trim(); any = true; }
|
||||
if (r.display.dataset.on !== "1") { p.display = "off"; any = true; }
|
||||
if (r.selling.dataset.on !== "1") { p.selling = "off"; any = true; }
|
||||
if (any) patches.push(p);
|
||||
});
|
||||
var chain = Promise.resolve();
|
||||
if (patches.length) {
|
||||
setMsg(msg, "품목 " + patches.length + "건에 자체코드·추가금액 반영 중…", "");
|
||||
chain = chain.then(function () { return apiJson("PUT", "/cafe24/products/" + no + "/variants", { rows: patches }); });
|
||||
}
|
||||
var images = rows.filter(function (r) { return r.image || r.link; });
|
||||
if (images.length) {
|
||||
chain = chain.then(function () {
|
||||
setMsg(msg, "썸네일 반영 중…", "");
|
||||
var edited = state.option.options[0].option_value.map(function (ov) {
|
||||
var r = null;
|
||||
for (var i = 0; i < rows.length; i++) if (rows[i].text === ov.option_text) { r = rows[i]; break; }
|
||||
return { option_text: ov.option_text, option_image_file: r ? r.image : "", option_link_image: r ? r.link : "", option_color: "" };
|
||||
});
|
||||
return apiJson("PUT", "/cafe24/products/" + no + "/options", {
|
||||
options: [{ option_name: optName, option_display_type: optType, option_value: edited }],
|
||||
option_list_type: state.option.option_list_type
|
||||
});
|
||||
});
|
||||
}
|
||||
return chain.then(function () { return unmatched; });
|
||||
})
|
||||
.then(function (unmatched) {
|
||||
return load().then(function () {
|
||||
var m = body.querySelector("#cf24-u-msg");
|
||||
setMsg(m, unmatched
|
||||
? "옵션을 만들었습니다. 품목 " + unmatched + "건은 카페24 품목 조회가 늦어 자체코드/추가금액을 넣지 못했습니다 — 다시 저장하세요."
|
||||
: "옵션·품목을 만들고 카페24에 반영했습니다.", unmatched ? "is-err" : "is-ok");
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
setMsg(msg, "실패: " + err.message, "is-err");
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
옵션 2개 이상(조합) — 예전 2열 화면
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
function renderMulti() {
|
||||
var opt = state.option;
|
||||
var variants = state.variants;
|
||||
var html = '<div class="cf24-modal-grid"><section class="cf24-modal-col cf24-modal-col-options">' +
|
||||
'<h4 class="cf24-side-title">옵션 정의</h4><form class="cf24-side-form" id="cf24-opt-form">';
|
||||
opt.options.forEach(function (o, gi) {
|
||||
html += '<div class="cf24-opt-group" data-group="' + gi + '"><div class="cf24-opt-head">' +
|
||||
'<label class="cf24-side-field"><span>옵션명</span><input type="text" name="option_name" value="' + esc(o.option_name) + '" /></label>' +
|
||||
'<label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect(o.option_display_type || DEFAULT_TYPE, "option_display_type") + "</label></div>";
|
||||
o.option_value.forEach(function (v, vi) {
|
||||
var src = v.option_image_file || v.option_link_image || variantImageFor(o.option_name, v.option_text);
|
||||
html += '<div class="cf24-opt-value" data-value="' + vi + '"><span class="cf24-opt-thumb-slot">' + thumbHtml(src) + "</span>" +
|
||||
'<input type="text" name="option_text" value="' + esc(v.option_text) + '" placeholder="옵션값 이름" />' +
|
||||
'<input type="hidden" name="option_image_file" value="' + esc(v.option_image_file) + '" />' +
|
||||
'<input type="hidden" name="option_link_image" value="' + esc(v.option_link_image) + '" />' +
|
||||
'<label class="cf24-file-pick"><input type="file" accept="image/jpeg,image/png,image/gif,image/webp" /><span class="erp-btn erp-btn-outline">썸네일</span></label></div>';
|
||||
});
|
||||
html += "</div>";
|
||||
});
|
||||
html += '<p class="cf24-side-note">옵션이 2개 이상인 조합 상품입니다. 옵션값 추가·삭제는 카페24 API 가 지원하지 않습니다.</p>' +
|
||||
'<div class="cf24-side-actions"><span class="cf24-muted" id="cf24-opt-msg"></span><button class="erp-btn erp-btn-primary" type="submit">옵션 저장</button></div></form>' +
|
||||
'<div class="cf24-opt-danger"><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-delete">옵션 전체 삭제</button></div></section>';
|
||||
html += '<section class="cf24-modal-col cf24-modal-col-variants"><h4 class="cf24-side-title">품목 (' + variants.length + ')</h4>';
|
||||
if (!variants.length) {
|
||||
html += '<p class="cf24-muted">품목이 아직 조회되지 않았습니다. 잠시 뒤 창을 닫았다 다시 여세요.</p>';
|
||||
} else {
|
||||
html += '<div class="cf24-variants-wrap"><table class="cf24-variants"><thead><tr><th class="cf24-v-imgcol"></th><th>옵션</th><th class="cf24-v-syscode">품목코드</th>' +
|
||||
'<th class="cf24-v-code">자체 품목코드</th><th class="cf24-v-amount">추가금액</th><th class="cf24-v-flag">진열</th><th class="cf24-v-flag">판매</th></tr></thead><tbody>';
|
||||
variants.forEach(function (v) {
|
||||
var label = v.options.map(function (o) { return o.value; }).join(" / ") || v.variant_code;
|
||||
html += '<tr class="cf24-v-item" data-code="' + esc(v.variant_code) + '">' +
|
||||
'<td class="cf24-v-imgcol">' + (v.image ? '<img class="cf24-v-img" src="' + esc(v.image) + '" alt="" />' : '<span class="cf24-v-img cf24-v-img-empty"></span>') + "</td>" +
|
||||
'<td class="cf24-v-name" title="' + esc(label) + '">' + esc(label) + "</td>" +
|
||||
'<td class="cf24-v-syscode"><button type="button" class="cf24-v-copy" data-copy-text="' + esc(v.variant_code) + '">' + esc(v.variant_code) + "</button></td>" +
|
||||
'<td class="cf24-v-code"><input type="text" name="custom_variant_code" maxlength="40" value="' + esc(v.custom_variant_code) + '" /></td>' +
|
||||
'<td class="cf24-v-amount"><input type="text" name="additional_amount" inputmode="numeric" value="' + esc(amountPlain(v.additional_amount)) + '" /></td>' +
|
||||
'<td class="cf24-v-flag">' + toggleBtn("display", !!v.display) + "</td><td class=\"cf24-v-flag\">" + toggleBtn("selling", !!v.selling) + "</td></tr>";
|
||||
});
|
||||
html += "</tbody></table></div><div class=\"cf24-side-actions\" style=\"margin-top:8px;\"><span class=\"cf24-muted\" id=\"cf24-var-msg\"></span>" +
|
||||
'<button type="button" class="erp-btn erp-btn-primary" id="cf24-var-save">품목 저장</button></div>';
|
||||
}
|
||||
html += "</section></div>";
|
||||
body.innerHTML = html;
|
||||
|
||||
var optForm = body.querySelector("#cf24-opt-form");
|
||||
trackDirty(optForm);
|
||||
bindThumbUploads(optForm, optForm.querySelector("#cf24-opt-msg"));
|
||||
optForm.addEventListener("submit", function (e) { e.preventDefault(); saveMultiOptions(optForm); });
|
||||
body.querySelector("#cf24-opt-delete").addEventListener("click", deleteOptions);
|
||||
var wrap = body.querySelector(".cf24-variants-wrap");
|
||||
if (wrap) {
|
||||
trackDirty(wrap);
|
||||
bindToggles(wrap);
|
||||
bindCopy(wrap);
|
||||
body.querySelector("#cf24-var-save").addEventListener("click", function () { saveMultiVariants(wrap); });
|
||||
}
|
||||
}
|
||||
|
||||
function saveMultiOptions(optForm) {
|
||||
var msg = optForm.querySelector("#cf24-opt-msg");
|
||||
var edited = [];
|
||||
var changed = false;
|
||||
optForm.querySelectorAll(".cf24-opt-group[data-group]").forEach(function (g, gi) {
|
||||
var orig = state.option.options[gi];
|
||||
var entry = { option_name: g.querySelector('[name="option_name"]').value.trim(),
|
||||
option_display_type: g.querySelector('[name="option_display_type"]').value, option_value: [] };
|
||||
if (entry.option_name !== orig.option_name || entry.option_display_type !== (orig.option_display_type || DEFAULT_TYPE)) changed = true;
|
||||
g.querySelectorAll(".cf24-opt-value").forEach(function (row, vi) {
|
||||
var ov = orig.option_value[vi] || {};
|
||||
var item = { option_text: row.querySelector('[name="option_text"]').value.trim(),
|
||||
option_image_file: row.querySelector('[name="option_image_file"]').value,
|
||||
option_link_image: row.querySelector('[name="option_link_image"]').value,
|
||||
option_color: ov.option_color || "" };
|
||||
if (item.option_text !== ov.option_text || item.option_image_file !== (ov.option_image_file || "") || item.option_link_image !== (ov.option_link_image || "")) changed = true;
|
||||
entry.option_value.push(item);
|
||||
});
|
||||
edited.push(entry);
|
||||
});
|
||||
if (!changed) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
|
||||
window.erpConfirm("옵션명·옵션값을 카페24에 바로 반영합니다. 계속할까요?").then(function (ok) {
|
||||
if (!ok) return;
|
||||
setMsg(msg, "저장 중…", "");
|
||||
apiJson("PUT", "/cafe24/products/" + no + "/options", { options: edited, option_list_type: state.option.option_list_type })
|
||||
.then(function (data) {
|
||||
state.option = data.option;
|
||||
state.variants = data.variants || state.variants;
|
||||
render();
|
||||
setMsg(body.querySelector("#cf24-opt-msg"), "카페24에 반영했습니다.", "is-ok");
|
||||
})
|
||||
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); });
|
||||
});
|
||||
}
|
||||
|
||||
function saveMultiVariants(wrap) {
|
||||
var msg = body.querySelector("#cf24-var-msg");
|
||||
var saveBtn = body.querySelector("#cf24-var-save");
|
||||
var rows = [];
|
||||
wrap.querySelectorAll("tr[data-code]").forEach(function (tr) {
|
||||
var r = { code: tr.dataset.code, custom: tr.querySelector('[name="custom_variant_code"]'), amount: tr.querySelector('[name="additional_amount"]'),
|
||||
display: tr.querySelector('.cf24-v-toggle[data-flag="display"]'), selling: tr.querySelector('.cf24-v-toggle[data-flag="selling"]') };
|
||||
var p = variantPatch(r, false);
|
||||
if (p) rows.push(p);
|
||||
});
|
||||
if (!rows.length) { setMsg(msg, "바뀐 품목이 없습니다.", ""); return; }
|
||||
window.erpConfirm("품목 " + rows.length + "건을 카페24에 바로 반영합니다. 계속할까요?").then(function (ok) {
|
||||
if (!ok) return;
|
||||
saveBtn.disabled = true;
|
||||
setMsg(msg, "저장 중…", "");
|
||||
apiJson("PUT", "/cafe24/products/" + no + "/variants", { rows: rows })
|
||||
.then(function (data) {
|
||||
var updated = data.updated || {};
|
||||
Object.keys(updated).forEach(function (code) {
|
||||
var u = updated[code];
|
||||
var tr = wrap.querySelector('tr[data-code="' + code + '"]');
|
||||
if (!tr) return;
|
||||
if (u.custom_variant_code !== undefined) tr.querySelector('[name="custom_variant_code"]').value = u.custom_variant_code;
|
||||
if (u.additional_amount !== undefined) tr.querySelector('[name="additional_amount"]').value = amountPlain(u.additional_amount);
|
||||
tr.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
|
||||
if (u[btn.dataset.flag] !== undefined) paintToggle(btn, !!u[btn.dataset.flag], true);
|
||||
});
|
||||
state.variants.forEach(function (v) {
|
||||
if (v.variant_code !== code) return;
|
||||
if (u.custom_variant_code !== undefined) v.custom_variant_code = u.custom_variant_code;
|
||||
if (u.additional_amount !== undefined) v.additional_amount = u.additional_amount;
|
||||
if (u.display !== undefined) v.display = !!u.display;
|
||||
if (u.selling !== undefined) v.selling = !!u.selling;
|
||||
});
|
||||
});
|
||||
commitValues(wrap);
|
||||
setMsg(msg, "카페24에 반영했습니다.", "is-ok");
|
||||
})
|
||||
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); })
|
||||
.then(function () { saveBtn.disabled = false; });
|
||||
});
|
||||
}
|
||||
|
||||
function deleteOptions() {
|
||||
window.erpConfirm("옵션을 전부 삭제합니다. 카페24가 이 상품의 **품목도 모두 삭제**하며 되돌릴 수 없습니다.\n정말 삭제할까요?")
|
||||
.then(function (ok) {
|
||||
if (!ok) return;
|
||||
body.innerHTML = '<p class="cf24-muted">삭제 중…</p>';
|
||||
apiJson("DELETE", "/cafe24/products/" + no + "/options")
|
||||
.then(function (data) { state.option = data.option; state.variants = []; render(); })
|
||||
.catch(function (err) { body.innerHTML = '<p class="cf24-err">삭제 실패: ' + esc(err.message) + "</p>"; });
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -1288,6 +1288,115 @@ body.cf24-modal-open {
|
||||
color: var(--color-success-green, #10c22b);
|
||||
}
|
||||
|
||||
/* ── 통합 목록(옵션값 1 : 품목 1) — 모달 전체 폭 사용, 표만 스크롤 ── */
|
||||
.cf24-u-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
gap: var(--sp-10, 10px);
|
||||
}
|
||||
|
||||
.cf24-u-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-10, 10px);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cf24-u-optname { flex: 0 1 320px; }
|
||||
|
||||
.cf24-u-head-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: var(--sp-8, 8px);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.cf24-u-tablewrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border-top: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||
}
|
||||
|
||||
.cf24-u-note { flex: 0 0 auto; margin: 0; }
|
||||
.cf24-u-actions { flex: 0 0 auto; }
|
||||
|
||||
.cf24-u-table .cf24-u-drag { width: 26px; text-align: center; }
|
||||
.cf24-u-table .cf24-u-thumb { width: 118px; }
|
||||
.cf24-u-table .cf24-u-del { width: 30px; text-align: center; }
|
||||
.cf24-u-table .cf24-u-name input[type="text"] { font-weight: 500; }
|
||||
|
||||
.cf24-u-thumb .cf24-opt-thumb-slot { display: inline-block; vertical-align: middle; margin-right: 6px; }
|
||||
.cf24-u-thumb .cf24-opt-thumb,
|
||||
.cf24-u-thumb .cf24-opt-thumb-empty { width: 40px; height: 40px; }
|
||||
.cf24-u-pick { display: inline-flex; vertical-align: middle; }
|
||||
.cf24-u-pick .erp-btn { padding: 3px 8px; font-size: 12px; }
|
||||
|
||||
.cf24-drag-handle {
|
||||
cursor: grab;
|
||||
color: var(--color-midtone-gray, #737373);
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
padding: 4px 2px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.cf24-drag-handle:active { cursor: grabbing; }
|
||||
.cf24-drag-handle.is-dirty { color: #f0b429; }
|
||||
|
||||
tr.cf24-u-row.is-dragging {
|
||||
opacity: 0.45;
|
||||
background: #fffbea;
|
||||
}
|
||||
|
||||
.cf24-u-remove {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-midtone-gray, #737373);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 4px;
|
||||
border-radius: var(--r-sm, 4px);
|
||||
}
|
||||
|
||||
.cf24-u-remove:hover {
|
||||
background: #fdefec;
|
||||
color: var(--color-callout-red, #c22b10);
|
||||
}
|
||||
|
||||
.cf24-btn-danger {
|
||||
color: var(--color-callout-red, #c22b10);
|
||||
border-color: var(--color-callout-red, #c22b10);
|
||||
}
|
||||
|
||||
/* 옵션값 불러오기 대화상자 — 옵션 모달 위에 한 겹 더 */
|
||||
.cf24-import { z-index: 1100; }
|
||||
|
||||
.cf24-import-box {
|
||||
width: min(720px, 94vw);
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.cf24-import-text {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 220px;
|
||||
padding: var(--sp-10, 10px);
|
||||
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||
border-radius: var(--r-md, 6px);
|
||||
font-family: var(--font-geist-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.cf24-import-preview { margin: 6px 0 10px; min-height: 16px; }
|
||||
|
||||
.cf24-v-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
Reference in New Issue
Block a user