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:
2026-09-18 23:25:52 +09:00
parent be0d44ded6
commit 8125a59366
8 changed files with 1002 additions and 360 deletions
+785
View File
@@ -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>"; });
});
}
};
})();