(function (root, factory) { "use strict"; let workflow = root && root.MbnOperatorCatalogWorkflow; if (!workflow && typeof module === "object" && module.exports) { workflow = require("./operator-catalog-workflow.js"); } const api = Object.freeze(factory(workflow)); if (typeof module === "object" && module.exports) module.exports = api; if (root) root.MbnOperatorCatalogUi = api; })(typeof globalThis === "object" ? globalThis : this, function (workflow) { "use strict"; if (!workflow) throw new Error("MbnOperatorCatalogWorkflow is required."); const globalScope = typeof globalThis === "object" ? globalThis : null; const fixedRequestIdPattern = /^[A-Za-z0-9_-]{1,128}$/; const themeStockCodePattern = /^[A-Za-z0-9]{1,12}$/; const expertStockCodePattern = /^[A-Za-z0-9]{1,32}$/; const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; const themePrefixes = Object.freeze({ krx: Object.freeze(["P", "D"]), nxt: Object.freeze(["X", "T"]) }); function exactOptions(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const required = ["postNative", "isLocked", "showToast", "addLog", "createId"]; return required.every(key => typeof value[key] === "function") && (value.requestStockSearch === undefined || typeof value.requestStockSearch === "function"); } function safeText(value, maximumLength) { return typeof value === "string" && value.length > 0 && value.length <= maximumLength && value === value.trim() && !unsafeTextPattern.test(value); } function clone(value) { if (value === null || value === undefined) return value; return JSON.parse(JSON.stringify(value)); } function createController(options) { if (!exactOptions(options)) { throw new TypeError("Operator catalog UI requires closed native and operator callbacks."); } const contract = workflow.bridgeContract; const coordinator = workflow.createMutationCoordinator(); const state = { mounted: false, open: false, entity: "theme", mode: "list", forcedLocked: false, processQuarantined: false, schema: null, canMutate: { theme: null, expert: null }, activeRead: null, readQueue: [], themeQuery: "", expertQuery: "", nxtSession: "preMarket", themeMarketForNew: "krx", themeResults: [], expertResults: [], selectedCatalogIndex: -1, edit: null, status: "대기", error: "" }; let dom = null; function nextRequestId(kind) { const value = `catalog-${kind}-${String(options.createId())}`; if (!fixedRequestIdPattern.test(value)) { throw new TypeError("createId returned an unsafe operator-catalog identifier."); } return value; } function locked() { let external = true; try { external = options.isLocked() === true; } catch { external = true; } return state.forcedLocked || external; } function quarantined() { return state.processQuarantined || coordinator.getState().quarantined; } function notifyError(message) { state.error = message; state.status = "차단됨"; options.showToast(message); render(); return false; } function latchQuarantine(message) { state.processQuarantined = true; state.error = message || "DB 쓰기 결과를 확인할 수 없습니다. 앱 재시작 전까지 쓰기를 차단합니다."; state.status = "WRITE 잠금"; options.addLog(`ThemeA/EList WRITE quarantine · ${state.error}`); } function expectedReadError(entity, operation, requestId) { return Object.freeze({ requestId, entity, operation, identityCode: "" }); } function enqueueRead(key, type, request, expectedError) { state.readQueue = state.readQueue.filter(item => item.key !== key); if (state.activeRead?.key === key) state.activeRead.superseded = true; state.readQueue.push({ key, type, request, expectedError, superseded: false }); pumpReadQueue(); render(); } function pumpReadQueue() { if (state.activeRead || state.readQueue.length === 0) return; const pending = state.readQueue.shift(); state.activeRead = pending; state.status = "DB 조회 중"; state.error = ""; try { options.postNative(pending.type, pending.request); } catch { state.activeRead = null; state.error = "Native Bridge로 DB 조회 요청을 전달하지 못했습니다."; state.status = "조회 실패"; pumpReadQueue(); } } function completeRead(pending, apply) { if (state.activeRead !== pending) return false; if (!pending.superseded) apply(); state.activeRead = null; if (!state.error) state.status = "준비됨"; pumpReadQueue(); render(); return true; } function requestSchemaPreflight() { if (state.schema || state.activeRead?.key === "schema" || state.readQueue.some(item => item.key === "schema")) return; const request = workflow.createSchemaPreflightRequest(nextRequestId("schema")); enqueueRead( "schema", contract.requests.schemaPreflight, request, expectedReadError("catalog", "schemaPreflight", request.requestId)); } function requestCatalogList(query) { if (state.entity === "theme") { const request = workflow.createThemeCatalogListRequest( nextRequestId("theme-list"), query ?? state.themeQuery, state.nxtSession, 500); state.themeQuery = request.query; enqueueRead( "catalog-list", contract.requests.themeList, request, expectedReadError("theme", "list", request.requestId)); return request; } const request = workflow.createExpertCatalogListRequest( nextRequestId("expert-list"), query ?? state.expertQuery, 500); state.expertQuery = request.query; enqueueRead( "catalog-list", contract.requests.expertList, request, expectedReadError("expert", "list", request.requestId)); return request; } function normalizeEditContext(context, entity) { if (!context || typeof context !== "object" || Array.isArray(context) || Object.keys(context).sort().join("|") !== "preview|selection") { throw new TypeError("Editing requires exactly one read-only selection and complete preview."); } return entity === "theme" ? workflow.createThemeEditDraft(context.selection, context.preview) : workflow.createExpertEditDraft(context.selection, context.preview); } function openEntity(entity, context) { state.open = true; state.entity = entity; state.selectedCatalogIndex = -1; state.error = ""; if (context !== undefined && context !== null) { state.edit = normalizeEditContext(context, entity); state.mode = "edit"; state.status = "편집 준비"; } else { state.edit = null; state.mode = "list"; state.status = "목록 준비"; } requestSchemaPreflight(); if (state.mode === "list") requestCatalogList(); render(); return snapshot(); } function openTheme(context) { return openEntity("theme", context); } function openExpert(context) { return openEntity("expert", context); } function beginNewTheme(market) { if (market !== "krx" && market !== "nxt") return notifyError("KRX 또는 NXT 시장을 선택하세요."); if (locked() || quarantined()) return notifyError("현재 ThemeA 쓰기를 시작할 수 없습니다."); state.themeMarketForNew = market; const request = workflow.createThemeNextCodeRequest(nextRequestId("theme-next"), market); enqueueRead( "next-code", contract.requests.themeNextCode, request, expectedReadError("theme", "nextCode", request.requestId)); return true; } function beginNewExpert() { if (locked() || quarantined()) return notifyError("현재 EList 쓰기를 시작할 수 없습니다."); const request = workflow.createExpertNextCodeRequest(nextRequestId("expert-next")); enqueueRead( "next-code", contract.requests.expertNextCode, request, expectedReadError("expert", "nextCode", request.requestId)); return true; } function currentDraft() { return state.edit?.draft || null; } function updateDraft(values) { const draft = currentDraft(); if (!draft || !values || typeof values !== "object" || Array.isArray(values)) return false; if (state.entity === "theme" && Object.keys(values).every(key => key === "themeTitle") && typeof values.themeTitle === "string") { draft.themeTitle = values.themeTitle; } else if (state.entity === "expert" && Object.keys(values).every(key => key === "expertName") && typeof values.expertName === "string") { draft.expertName = values.expertName; } else { return false; } state.error = ""; render(); return true; } function addThemeItem(prefix, stockCode, stockName) { const draft = currentDraft(); const allowed = draft && themePrefixes[draft.market]; if (!draft || state.entity !== "theme" || !allowed?.includes(prefix) || typeof stockCode !== "string" || !themeStockCodePattern.test(stockCode) || !safeText(stockName, 200) || draft.items.length >= workflow.limits.maximumThemeItems) { return notifyError("시장별 prefix와 종목 코드·이름을 정확히 입력하세요."); } const itemCode = `${prefix}${stockCode}`; const candidate = { market: draft.market, themeCode: draft.themeCode, themeTitle: safeText(draft.themeTitle, 128) ? draft.themeTitle : "검증", items: [...draft.items, { inputIndex: draft.items.length, itemCode, itemName: stockName }] }; if (!workflow.normalizeThemeDraft(candidate)) { return notifyError("중복되거나 허용되지 않은 ThemeA 종목입니다."); } draft.items.push({ inputIndex: draft.items.length, itemCode, itemName: stockName }); state.error = ""; render(); return true; } function addExpertRecommendation(stockCode, stockName, buyAmount) { const draft = currentDraft(); const amount = typeof buyAmount === "number" ? buyAmount : Number(buyAmount); if (!draft || state.entity !== "expert" || typeof stockCode !== "string" || !expertStockCodePattern.test(stockCode) || !safeText(stockName, 200) || !Number.isSafeInteger(amount) || amount <= 0 || draft.recommendations.length >= workflow.limits.maximumRecommendations) { return notifyError("종목 코드·이름과 1 이상의 정수 매수가를 정확히 입력하세요."); } const candidate = { expertCode: draft.expertCode, expertName: safeText(draft.expertName, 128) ? draft.expertName : "검증", recommendations: [ ...draft.recommendations, { playIndex: draft.recommendations.length, stockCode, stockName, buyAmount: amount } ] }; if (!workflow.normalizeExpertDraft(candidate)) { return notifyError("중복되거나 허용되지 않은 EList 추천 종목입니다."); } draft.recommendations.push({ playIndex: draft.recommendations.length, stockCode, stockName, buyAmount: amount }); state.error = ""; render(); return true; } function reindexDraftRows(rows, key) { rows.forEach((item, index) => { item[key] = index; }); } function moveDraftItem(index, direction) { const draft = currentDraft(); const rows = state.entity === "theme" ? draft?.items : draft?.recommendations; const target = index + direction; if (!rows || !Number.isInteger(index) || ![-1, 1].includes(direction) || index < 0 || target < 0 || index >= rows.length || target >= rows.length) return false; [rows[index], rows[target]] = [rows[target], rows[index]]; reindexDraftRows(rows, state.entity === "theme" ? "inputIndex" : "playIndex"); render(); return true; } function removeDraftItem(index) { const draft = currentDraft(); const rows = state.entity === "theme" ? draft?.items : draft?.recommendations; if (!rows || !Number.isInteger(index) || index < 0 || index >= rows.length) return false; rows.splice(index, 1); reindexDraftRows(rows, state.entity === "theme" ? "inputIndex" : "playIndex"); render(); return true; } function canWrite() { if (locked() || quarantined() || coordinator.getState().active || state.activeRead || state.readQueue.length > 0 || !state.schema) return false; return state.entity === "theme" ? state.schema.themeCanMutate === true && state.canMutate.theme !== false : state.schema.expertCanMutate === true && state.canMutate.expert !== false; } function sendMutation(type, payload) { if (!canWrite()) return notifyError("송출 IDLE 및 schema preflight 완료 후 DB 쓰기를 실행하세요."); try { coordinator.begin(type, payload); options.postNative(type, payload); state.status = "DB 반영 확인 중"; state.error = ""; options.addLog(`${type} 요청 · 자동 재시도 없음`); render(); return true; } catch (error) { if (coordinator.getState().active) { coordinator.loseCorrelation(); latchQuarantine("DB 쓰기 요청 전달 여부를 확인할 수 없습니다. 앱을 재시작하세요."); } return notifyError(error?.message || "DB 쓰기 요청을 만들 수 없습니다."); } } function submit() { if (!state.edit || (state.mode !== "new" && state.mode !== "edit")) return false; try { const id = nextRequestId(`${state.entity}-${state.mode}`); if (state.entity === "theme") { const request = state.mode === "new" ? workflow.createThemeCreateRequest(id, state.edit.draft) : workflow.createThemeReplaceRequest(id, state.edit); const type = state.mode === "new" ? contract.requests.themeCreate : contract.requests.themeReplace; return sendMutation(type, request); } const request = state.mode === "new" ? workflow.createExpertCreateRequest(id, state.edit.draft) : workflow.createExpertReplaceRequest(id, state.edit); const type = state.mode === "new" ? contract.requests.expertCreate : contract.requests.expertReplace; return sendMutation(type, request); } catch (error) { return notifyError(error?.message || "편집 내용을 검증할 수 없습니다."); } } function selectedCatalog() { const rows = state.entity === "theme" ? state.themeResults : state.expertResults; return rows[state.selectedCatalogIndex] || null; } function selectCatalog(index) { const rows = state.entity === "theme" ? state.themeResults : state.expertResults; if (!Number.isInteger(index) || index < 0 || index >= rows.length) return false; state.selectedCatalogIndex = index; render(); return true; } function deleteCurrent() { try { const id = nextRequestId(`${state.entity}-delete`); if (state.entity === "theme") { const source = state.mode === "edit" ? state.edit?.expectedIdentity : selectedCatalog(); if (!source) return notifyError("삭제할 ThemeA 정의를 선택하세요."); const identity = { market: source.market, session: source.session, themeCode: source.themeCode, themeTitle: source.themeTitle }; return sendMutation( contract.requests.themeDelete, workflow.createThemeDeleteRequest(id, identity)); } const source = state.mode === "edit" ? state.edit?.expectedIdentity : selectedCatalog(); if (!source) return notifyError("삭제할 EList 정의를 선택하세요."); return sendMutation( contract.requests.expertDelete, workflow.createExpertDeleteRequest(id, { expertCode: source.expertCode, expertName: source.expertName })); } catch (error) { return notifyError(error?.message || "삭제 요청을 검증할 수 없습니다."); } } function handleSchemaResult(payload) { const pending = state.activeRead; if (!pending || pending.type !== contract.requests.schemaPreflight || payload?.requestId !== pending.request.requestId) return; const result = workflow.normalizeSchemaResult(payload, pending.request); if (!result) return failCurrentRead(pending, "schema preflight 응답이 현재 요청과 일치하지 않습니다."); completeRead(pending, () => { state.schema = result; state.canMutate.theme = result.themeCanMutate; state.canMutate.expert = result.expertCanMutate; if (result.writeQuarantined) latchQuarantine("Native catalog 쓰기가 이미 격리되었습니다."); }); } function handleThemeListResult(payload) { const pending = state.activeRead; if (!pending || pending.type !== contract.requests.themeList || payload?.requestId !== pending.request.requestId) return; const result = workflow.normalizeThemeListResult(payload, pending.request); if (!result) return failCurrentRead(pending, "ThemeA 목록 응답이 현재 검색과 일치하지 않습니다."); completeRead(pending, () => { state.themeResults = [...result.results]; state.canMutate.theme = state.canMutate.theme !== false && result.canMutate; state.selectedCatalogIndex = -1; if (result.writeQuarantined) latchQuarantine("ThemeA 쓰기가 Native에서 격리되었습니다."); }); } function handleExpertListResult(payload) { const pending = state.activeRead; if (!pending || pending.type !== contract.requests.expertList || payload?.requestId !== pending.request.requestId) return; const result = workflow.normalizeExpertListResult(payload, pending.request); if (!result) return failCurrentRead(pending, "EList 목록 응답이 현재 검색과 일치하지 않습니다."); completeRead(pending, () => { state.expertResults = [...result.results]; state.canMutate.expert = state.canMutate.expert !== false && result.canMutate; state.selectedCatalogIndex = -1; if (result.writeQuarantined) latchQuarantine("EList 쓰기가 Native에서 격리되었습니다."); }); } function handleThemeNextCodeResult(payload) { const pending = state.activeRead; if (!pending || pending.type !== contract.requests.themeNextCode || payload?.requestId !== pending.request.requestId) return; const result = workflow.normalizeThemeNextCodeResult(payload, pending.request); if (!result) return failCurrentRead(pending, "ThemeA 다음 코드 응답이 현재 시장과 일치하지 않습니다."); completeRead(pending, () => { if (result.writeQuarantined) latchQuarantine("ThemeA 쓰기가 Native에서 격리되었습니다."); state.canMutate.theme = state.canMutate.theme !== false && result.canMutate; state.entity = "theme"; state.mode = "new"; state.edit = { expectedIdentity: null, draft: workflow.createBlankThemeDraft(result.market, result.themeCode) }; }); } function handleExpertNextCodeResult(payload) { const pending = state.activeRead; if (!pending || pending.type !== contract.requests.expertNextCode || payload?.requestId !== pending.request.requestId) return; const result = workflow.normalizeExpertNextCodeResult(payload, pending.request); if (!result) return failCurrentRead(pending, "EList 다음 코드 응답이 현재 요청과 일치하지 않습니다."); completeRead(pending, () => { if (result.writeQuarantined) latchQuarantine("EList 쓰기가 Native에서 격리되었습니다."); state.canMutate.expert = state.canMutate.expert !== false && result.canMutate; state.entity = "expert"; state.mode = "new"; state.edit = { expectedIdentity: null, draft: workflow.createBlankExpertDraft(result.expertCode) }; }); } function failCurrentRead(pending, message) { completeRead(pending, () => { state.error = message; state.status = "응답 차단"; }); } function handleMutationResult(payload) { const active = coordinator.getState().active; if (!active || payload?.requestId !== active.requestId) return; const result = coordinator.acceptResult(payload); if (!result) { coordinator.loseCorrelation(); latchQuarantine("DB 쓰기 결과 형식이 현재 요청과 일치하지 않습니다. 앱을 재시작하세요."); render(); return; } state.status = "DB 반영 완료"; state.error = ""; state.mode = "list"; state.edit = null; options.addLog(`${result.entity} ${result.operation} 완료 · ${result.identityCode} · ${result.operationId}`); options.showToast("ThemeA/EList DB 작업이 완료되었습니다."); render(); } function handleCatalogError(payload) { const activeMutation = coordinator.getState().active; if (activeMutation) { if (payload?.requestId !== activeMutation.requestId) return; const error = coordinator.acceptError(payload); if (!error) { coordinator.loseCorrelation(); latchQuarantine("DB 쓰기 오류 응답을 현재 요청과 연결할 수 없습니다. 앱을 재시작하세요."); } else { if (error.outcomeUnknown || error.writeQuarantined) latchQuarantine(error.message); else { state.error = `${error.message} [${error.code}]`; state.status = "DB 반영 실패"; } } render(); return; } const pending = state.activeRead; if (!pending || payload?.requestId !== pending.request.requestId) return; const error = workflow.normalizeCatalogError(payload, pending.expectedError); if (!error) return failCurrentRead(pending, "DB 조회 오류 응답을 현재 요청과 연결할 수 없습니다."); completeRead(pending, () => { if (error.writeQuarantined || error.outcomeUnknown) latchQuarantine(error.message); state.error = `${error.message} [${error.code}]`; state.status = "조회 실패"; }); } function handleMessage(type, payload) { switch (type) { case contract.events.schemaPreflight: handleSchemaResult(payload); return true; case contract.events.themeList: handleThemeListResult(payload); return true; case contract.events.expertList: handleExpertListResult(payload); return true; case contract.events.themeNextCode: handleThemeNextCodeResult(payload); return true; case contract.events.expertNextCode: handleExpertNextCodeResult(payload); return true; case contract.events.mutation: handleMutationResult(payload); return true; case contract.events.error: handleCatalogError(payload); return true; default: return false; } } function search(query, nxtSession) { if (state.entity === "theme" && nxtSession !== undefined) { if (nxtSession !== "preMarket" && nxtSession !== "afterMarket") { return notifyError("NXT 세션을 명시적으로 선택하세요."); } state.nxtSession = nxtSession; } try { requestCatalogList(query); return true; } catch (error) { return notifyError(error?.message || "카탈로그 검색 요청이 올바르지 않습니다."); } } function loseCorrelation() { if (coordinator.getState().active) { coordinator.loseCorrelation(); latchQuarantine("Browser 상관관계를 잃어 DB 쓰기 결과를 확인할 수 없습니다."); } state.activeRead = null; state.readQueue = []; render(); } function setLocked(value = true) { state.forcedLocked = value === true; render(); return locked(); } function close() { state.open = false; render(); } function snapshot() { return Object.freeze({ mounted: state.mounted, open: state.open, entity: state.entity, mode: state.mode, locked: locked(), quarantined: quarantined(), status: state.status, error: state.error, schema: clone(state.schema), canMutate: clone(state.canMutate), activeRead: clone(state.activeRead && { key: state.activeRead.key, type: state.activeRead.type, request: state.activeRead.request, superseded: state.activeRead.superseded }), queuedReadCount: state.readQueue.length, mutation: clone(coordinator.getState().active), themeResults: clone(state.themeResults), expertResults: clone(state.expertResults), selectedCatalogIndex: state.selectedCatalogIndex, edit: clone(state.edit), draft: clone(currentDraft()) }); } function makeElement(documentValue, tagName, className, text) { const element = documentValue.createElement(tagName); if (className) element.className = className; if (text !== undefined) element.textContent = text; return element; } function assignRole(element, role) { element.setAttribute("data-role", role); return element; } function addStylesheet(documentValue) { if (!documentValue.head || documentValue.getElementById?.("mbn-operator-catalog-ui-css")) return; const link = documentValue.createElement("link"); link.id = "mbn-operator-catalog-ui-css"; link.rel = "stylesheet"; link.href = "operator-catalog-ui.css"; documentValue.head.appendChild(link); } function mount(host) { if (state.mounted) return snapshot(); const target = host ?? (typeof document === "object" ? document.body : null); const documentValue = target?.ownerDocument ?? (typeof document === "object" ? document : null); if (!target || !documentValue || typeof documentValue.createElement !== "function" || typeof target.appendChild !== "function") { throw new TypeError("mount requires a DOM host."); } addStylesheet(documentValue); const dialog = makeElement(documentValue, "dialog", "mbn-operator-catalog-dialog"); dialog.setAttribute("aria-labelledby", "mbnOperatorCatalogTitle"); const shell = makeElement(documentValue, "section", "mbn-operator-catalog-shell"); const header = makeElement(documentValue, "header", "mbn-operator-catalog-header"); const title = makeElement(documentValue, "h2", "", "ThemeA / EList 운영 관리"); title.id = "mbnOperatorCatalogTitle"; const status = assignRole(makeElement(documentValue, "span", "mbn-operator-catalog-status"), "status"); status.setAttribute("aria-live", "polite"); const closeButton = assignRole(makeElement(documentValue, "button", "", "닫기"), "close"); closeButton.type = "button"; header.append(title, status, closeButton); const tabs = makeElement(documentValue, "nav", "mbn-operator-catalog-tabs"); const themeTab = assignRole(makeElement(documentValue, "button", "", "ThemeA"), "theme-tab"); const expertTab = assignRole(makeElement(documentValue, "button", "", "EList"), "expert-tab"); themeTab.type = expertTab.type = "button"; tabs.append(themeTab, expertTab); const toolbar = makeElement(documentValue, "form", "mbn-operator-catalog-toolbar"); const query = assignRole(makeElement(documentValue, "input"), "query"); query.type = "search"; query.maxLength = workflow.limits.maximumQueryLength; query.autocomplete = "off"; const session = assignRole(makeElement(documentValue, "select"), "nxt-session"); for (const [value, label] of [["preMarket", "NXT 프리마켓"], ["afterMarket", "NXT 애프터마켓"]]) { const option = makeElement(documentValue, "option", "", label); option.value = value; session.appendChild(option); } const searchButton = assignRole(makeElement(documentValue, "button", "", "검색"), "search"); searchButton.type = "submit"; const market = assignRole(makeElement(documentValue, "select"), "new-market"); for (const [value, label] of [["krx", "KRX"], ["nxt", "NXT"]]) { const option = makeElement(documentValue, "option", "", label); option.value = value; market.appendChild(option); } const newButton = assignRole(makeElement(documentValue, "button", "", "새 정의"), "new"); newButton.type = "button"; const listDeleteButton = assignRole( makeElement(documentValue, "button", "mbn-danger", "선택 정의 삭제"), "delete-selected"); listDeleteButton.type = "button"; toolbar.append(query, session, searchButton, market, newButton, listDeleteButton); const body = makeElement(documentValue, "div", "mbn-operator-catalog-body"); const catalog = assignRole(makeElement(documentValue, "ol", "mbn-operator-catalog-list"), "catalog-list"); const editor = assignRole(makeElement(documentValue, "section", "mbn-operator-catalog-editor"), "editor"); const identity = makeElement(documentValue, "div", "mbn-operator-catalog-identity"); const code = assignRole(makeElement(documentValue, "output"), "identity-code"); const name = assignRole(makeElement(documentValue, "input"), "identity-name"); name.type = "text"; name.maxLength = 128; name.autocomplete = "off"; identity.append(code, name); const itemForm = makeElement(documentValue, "form", "mbn-operator-catalog-item-form"); const prefix = assignRole(makeElement(documentValue, "select"), "item-prefix"); const stockCode = assignRole(makeElement(documentValue, "input"), "stock-code"); stockCode.type = "text"; stockCode.maxLength = 32; stockCode.autocomplete = "off"; const stockName = assignRole(makeElement(documentValue, "input"), "stock-name"); stockName.type = "text"; stockName.maxLength = 200; stockName.autocomplete = "off"; const buyAmount = assignRole(makeElement(documentValue, "input"), "buy-amount"); buyAmount.type = "number"; buyAmount.min = "1"; buyAmount.step = "1"; const addButton = assignRole(makeElement(documentValue, "button", "", "추가"), "add-item"); addButton.type = "submit"; itemForm.append(prefix, stockCode, stockName, buyAmount, addButton); const draftList = assignRole(makeElement(documentValue, "ol", "mbn-operator-catalog-draft"), "draft-list"); const actions = makeElement(documentValue, "footer", "mbn-operator-catalog-actions"); const saveButton = assignRole(makeElement(documentValue, "button", "", "DB 반영"), "save"); const deleteButton = assignRole(makeElement(documentValue, "button", "mbn-danger", "정의 삭제"), "delete"); const cancelButton = assignRole(makeElement(documentValue, "button", "", "목록으로"), "cancel-edit"); saveButton.type = deleteButton.type = cancelButton.type = "button"; actions.append(saveButton, deleteButton, cancelButton); editor.append(identity, itemForm, draftList, actions); body.append(catalog, editor); const error = assignRole(makeElement(documentValue, "p", "mbn-operator-catalog-error"), "error"); shell.append(header, tabs, toolbar, body, error); dialog.appendChild(shell); target.appendChild(dialog); dom = { document: documentValue, dialog, status, closeButton, themeTab, expertTab, query, session, market, newButton, listDeleteButton, toolbar, catalog, editor, code, name, itemForm, prefix, stockCode, stockName, buyAmount, draftList, saveButton, deleteButton, cancelButton, error }; closeButton.addEventListener("click", close); themeTab.addEventListener("click", () => openTheme()); expertTab.addEventListener("click", () => openExpert()); toolbar.addEventListener("submit", event => { event.preventDefault(); search(query.value, session.value); }); newButton.addEventListener("click", () => state.entity === "theme" ? beginNewTheme(market.value) : beginNewExpert()); listDeleteButton.addEventListener("click", () => { const confirmDelete = typeof globalScope?.confirm !== "function" || globalScope.confirm("선택한 ThemeA/EList 정의를 DB에서 삭제할까요?"); if (confirmDelete) deleteCurrent(); }); name.addEventListener("input", () => updateDraft(state.entity === "theme" ? { themeTitle: name.value } : { expertName: name.value })); itemForm.addEventListener("submit", event => { event.preventDefault(); const added = state.entity === "theme" ? addThemeItem(prefix.value, stockCode.value, stockName.value) : addExpertRecommendation(stockCode.value, stockName.value, buyAmount.value); if (added) { stockCode.value = ""; stockName.value = ""; buyAmount.value = ""; } }); saveButton.addEventListener("click", submit); deleteButton.addEventListener("click", () => { const confirmDelete = typeof globalScope?.confirm !== "function" || globalScope.confirm("선택한 ThemeA/EList 정의를 DB에서 삭제할까요?"); if (confirmDelete) deleteCurrent(); }); cancelButton.addEventListener("click", () => { state.mode = "list"; state.edit = null; render(); }); state.mounted = true; render(); return snapshot(); } function renderCatalogRows() { if (!dom) return; dom.catalog.replaceChildren(); const rows = state.entity === "theme" ? state.themeResults : state.expertResults; rows.forEach((item, index) => { const row = makeElement(dom.document, "li", "mbn-operator-catalog-row"); const button = makeElement( dom.document, "button", index === state.selectedCatalogIndex ? "selected" : "", state.entity === "theme" ? `${item.themeCode} · ${item.themeTitle} · ${item.market.toUpperCase()}` : `${item.expertCode} · ${item.expertName}`); button.type = "button"; button.addEventListener("click", () => selectCatalog(index)); row.appendChild(button); dom.catalog.appendChild(row); }); } function renderDraftRows() { if (!dom) return; dom.draftList.replaceChildren(); const draft = currentDraft(); const rows = state.entity === "theme" ? draft?.items : draft?.recommendations; (rows || []).forEach((item, index) => { const row = makeElement(dom.document, "li", "mbn-operator-catalog-draft-row"); const label = makeElement( dom.document, "span", "", state.entity === "theme" ? `${item.itemCode} · ${item.itemName}` : `${item.stockCode} · ${item.stockName} · ${item.buyAmount}`); const up = makeElement(dom.document, "button", "", "↑"); const down = makeElement(dom.document, "button", "", "↓"); const remove = makeElement(dom.document, "button", "mbn-danger", "삭제"); up.type = down.type = remove.type = "button"; up.disabled = index === 0 || locked(); down.disabled = index + 1 === rows.length || locked(); remove.disabled = locked(); up.addEventListener("click", () => moveDraftItem(index, -1)); down.addEventListener("click", () => moveDraftItem(index, 1)); remove.addEventListener("click", () => removeDraftItem(index)); row.append(label, up, down, remove); dom.draftList.appendChild(row); }); } function render() { if (!dom) return snapshot(); const isLocked = locked(); const isQuarantined = quarantined(); const mutationActive = Boolean(coordinator.getState().active); dom.status.textContent = isQuarantined ? "WRITE 잠금" : state.status; dom.error.textContent = state.error; dom.error.hidden = !state.error; dom.themeTab.setAttribute("aria-pressed", String(state.entity === "theme")); dom.expertTab.setAttribute("aria-pressed", String(state.entity === "expert")); dom.query.value = state.entity === "theme" ? state.themeQuery : state.expertQuery; dom.session.value = state.nxtSession; dom.session.hidden = state.entity !== "theme"; dom.market.hidden = state.entity !== "theme"; dom.market.value = state.themeMarketForNew; dom.newButton.textContent = state.entity === "theme" ? "새 ThemeA" : "새 EList"; dom.newButton.disabled = isLocked || isQuarantined || mutationActive; dom.listDeleteButton.disabled = !canWrite() || !selectedCatalog(); dom.catalog.hidden = state.mode !== "list"; dom.editor.hidden = state.mode === "list"; const draft = currentDraft(); dom.code.textContent = draft ? (state.entity === "theme" ? draft.themeCode : draft.expertCode) : ""; dom.name.value = draft ? (state.entity === "theme" ? draft.themeTitle : draft.expertName) : ""; dom.name.placeholder = state.entity === "theme" ? "ThemeA 이름" : "전문가 이름"; dom.name.disabled = isLocked || mutationActive; dom.prefix.hidden = state.entity !== "theme"; dom.buyAmount.hidden = state.entity !== "expert"; if (draft && state.entity === "theme") { const previous = dom.prefix.value; dom.prefix.replaceChildren(); for (const value of themePrefixes[draft.market]) { const option = makeElement(dom.document, "option", "", `${value} prefix`); option.value = value; dom.prefix.appendChild(option); } dom.prefix.value = themePrefixes[draft.market].includes(previous) ? previous : themePrefixes[draft.market][0]; } for (const control of [dom.stockCode, dom.stockName, dom.buyAmount, dom.prefix]) { control.disabled = isLocked || mutationActive; } dom.saveButton.disabled = !canWrite() || !draft; dom.deleteButton.disabled = !canWrite() || (state.mode !== "edit" && !selectedCatalog()); dom.cancelButton.disabled = mutationActive; dom.closeButton.disabled = mutationActive; renderCatalogRows(); renderDraftRows(); if (state.open) { if (typeof dom.dialog.showModal === "function" && !dom.dialog.open) dom.dialog.showModal(); else { dom.dialog.hidden = false; dom.dialog.setAttribute("open", ""); } } else if (typeof dom.dialog.close === "function" && dom.dialog.open) { dom.dialog.close(); } else { dom.dialog.hidden = true; dom.dialog.removeAttribute("open"); } return snapshot(); } return Object.freeze({ mount, openTheme, openExpert, handleMessage, render, setLocked, close, search, beginNewTheme, beginNewExpert, updateDraft, addThemeItem, addExpertRecommendation, moveDraftItem, removeDraftItem, selectCatalog, submit, deleteCurrent, loseCorrelation }); } return Object.freeze({ createController }); });