(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 unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; const themeStockMarkets = Object.freeze({ krx: Object.freeze(["kospi", "kosdaq"]), nxt: Object.freeze(["nxt-kospi", "nxt-kosdaq"]) }); const themePrefixByStockMarket = Object.freeze({ kospi: "P", kosdaq: "D", "nxt-kospi": "X", "nxt-kosdaq": "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"); } function safeText(value, maximumLength) { return typeof value === "string" && value.length > 0 && value.length <= maximumLength && value === value.trim() && !unsafeTextPattern.test(value); } function themeStoredStockName(identity) { if (!identity || typeof identity.name !== "string") return null; if (identity.market !== "nxt-kospi" && identity.market !== "nxt-kosdaq") { return safeText(identity.name, 200) ? identity.name : null; } const suffix = "(NXT)"; if (!identity.name.endsWith(suffix)) return null; const storedName = identity.name.slice(0, -suffix.length); return safeText(storedName, 200) ? storedName : null; } 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, selectedDraftIndex: -1, edit: null, stockSearch: { query: "", status: "idle", results: [], selectedIndex: -1, error: "" }, 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 resetStockSearch() { state.stockSearch = { query: "", status: "idle", results: [], selectedIndex: -1, error: "" }; } function openEntity(entity, context) { state.open = true; state.entity = entity; state.selectedCatalogIndex = -1; state.selectedDraftIndex = -1; state.error = ""; resetStockSearch(); 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 selectedStockIdentity() { return state.stockSearch.results[state.stockSearch.selectedIndex] || null; } function stockIsCompatibleWithDraft(identity, draft = currentDraft()) { if (!identity || !draft) return false; if (state.entity === "expert") return true; return themeStockMarkets[draft.market]?.includes(identity.market) === true; } function updateStockQuery(value) { if (typeof value !== "string") return false; if (value !== state.stockSearch.query) { state.stockSearch.query = value; state.stockSearch.status = "idle"; state.stockSearch.results = []; state.stockSearch.selectedIndex = -1; state.stockSearch.error = ""; render(); } return true; } function searchStocks(query) { if (!currentDraft() || (state.mode !== "new" && state.mode !== "edit")) { return notifyError("먼저 ThemeA/EList 새 정의 또는 편집을 여세요."); } try { const request = workflow.createStockMasterSearchRequest( nextRequestId("stock-master"), query, workflow.limits.maximumResults); state.stockSearch.query = request.query; state.stockSearch.status = "loading"; state.stockSearch.results = []; state.stockSearch.selectedIndex = -1; state.stockSearch.error = ""; state.error = ""; enqueueRead( "stock-master-search", workflow.stockMasterContract.request, request, null); return true; } catch (error) { return notifyError(error?.message || "종목 master 검색어가 올바르지 않습니다."); } } function selectStockResult(index) { const identity = state.stockSearch.results[index]; if (!Number.isInteger(index) || !identity) return false; if (!stockIsCompatibleWithDraft(identity)) { return notifyError("현재 ThemeA 시장과 일치하는 종목을 선택하세요."); } state.stockSearch.selectedIndex = index; state.stockSearch.error = ""; state.stockSearch.status = "selected"; render(); return true; } 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() { const draft = currentDraft(); const identity = selectedStockIdentity(); if (!draft || state.entity !== "theme" || !stockIsCompatibleWithDraft(identity, draft) || draft.items.length >= workflow.limits.maximumThemeItems) { return notifyError("검색 결과에서 현재 ThemeA 시장의 종목 하나를 선택하세요."); } const storedName = themeStoredStockName(identity); if (!storedName) { return notifyError("NXT 표시명의 끝 (NXT) suffix를 안전하게 변환할 수 없습니다."); } const itemCode = `${themePrefixByStockMarket[identity.market]}${identity.code}`; const candidate = { market: draft.market, themeCode: draft.themeCode, themeTitle: safeText(draft.themeTitle, 128) ? draft.themeTitle : "검증", items: [...draft.items, { inputIndex: draft.items.length, itemCode, itemName: storedName }] }; if (!workflow.normalizeThemeDraft(candidate)) { return notifyError("중복되거나 허용되지 않은 ThemeA 종목입니다."); } draft.items.push({ inputIndex: draft.items.length, itemCode, itemName: storedName }); resetStockSearch(); state.error = ""; render(); return true; } function addExpertRecommendation(buyAmount) { const draft = currentDraft(); const identity = selectedStockIdentity(); const amount = typeof buyAmount === "number" ? buyAmount : Number(buyAmount); if (!draft || state.entity !== "expert" || !identity || !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: identity.code, stockName: identity.name, buyAmount: amount } ] }; if (!workflow.normalizeExpertDraft(candidate)) { return notifyError("중복되거나 허용되지 않은 EList 추천 종목입니다."); } draft.recommendations.push({ playIndex: draft.recommendations.length, stockCode: identity.code, stockName: identity.name, buyAmount: amount }); resetStockSearch(); 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]]; if (state.selectedDraftIndex === index) state.selectedDraftIndex = target; else if (state.selectedDraftIndex === target) state.selectedDraftIndex = 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 (locked() || coordinator.getState().active || !rows || !Number.isInteger(index) || index < 0 || index >= rows.length) return false; rows.splice(index, 1); reindexDraftRows(rows, state.entity === "theme" ? "inputIndex" : "playIndex"); if (rows.length === 0) state.selectedDraftIndex = -1; else if (state.selectedDraftIndex === index) { state.selectedDraftIndex = Math.min(index, rows.length - 1); } else if (state.selectedDraftIndex > index) { state.selectedDraftIndex -= 1; } render(); return true; } function selectDraftItem(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; state.selectedDraftIndex = index; for (const [rowIndex, row] of [...(dom?.draftList?.children || [])].entries()) { row.className = rowIndex === index ? "mbn-operator-catalog-draft-row selected" : "mbn-operator-catalog-draft-row"; row.setAttribute("aria-selected", String(rowIndex === index)); } return true; } function editableDeleteTarget(target) { let current = target; while (current && current !== dom?.dialog) { const tagName = String(current.tagName || "").toUpperCase(); const contentEditable = current.getAttribute?.("contenteditable"); if (tagName === "INPUT" || tagName === "TEXTAREA" || current.isContentEditable === true || (contentEditable !== null && contentEditable !== undefined && String(contentEditable).toLowerCase() !== "false")) return true; current = current.parentElement; } return false; } function draftIndexFromTarget(target) { let current = target; while (current && current !== dom?.dialog) { const value = current.getAttribute?.("data-draft-index"); if (value !== null && value !== undefined && /^\d+$/u.test(String(value))) { return Number(value); } current = current.parentElement; } return -1; } function handleDraftDeleteKey(event) { if (!dom?.dialog || !state.open || event?.key !== "Delete" || editableDeleteTarget(event.target) || (typeof dom.dialog.contains === "function" && !dom.dialog.contains(event.target))) return false; const focusedIndex = draftIndexFromTarget(event.target); const index = focusedIndex >= 0 ? focusedIndex : state.selectedDraftIndex; const draft = currentDraft(); const rows = state.entity === "theme" ? draft?.items : draft?.recommendations; if (!rows || index < 0 || index >= rows.length) return false; event.preventDefault(); event.stopPropagation(); if (locked() || coordinator.getState().active) return false; return removeDraftItem(index); } 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.selectedDraftIndex = -1; 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.selectedDraftIndex = -1; state.edit = { expectedIdentity: null, draft: workflow.createBlankExpertDraft(result.expertCode) }; }); } function failCurrentRead(pending, message) { return completeRead(pending, () => { state.error = message; state.status = "응답 차단"; }); } function handleStockMasterSearchResult(payload) { const pending = state.activeRead; if (!pending || pending.type !== workflow.stockMasterContract.request || payload?.requestId !== pending.request.requestId) return false; const result = workflow.normalizeStockMasterSearchResult(payload, pending.request); if (!result) { completeRead(pending, () => { state.stockSearch.status = "error"; state.stockSearch.results = []; state.stockSearch.selectedIndex = -1; state.stockSearch.error = "잘렸거나 중복·동명이인·형식 오류가 있는 종목 응답을 차단했습니다. 검색어를 좁히세요."; state.error = state.stockSearch.error; state.status = "응답 차단"; }); return true; } completeRead(pending, () => { state.stockSearch.query = result.query; state.stockSearch.status = "ready"; state.stockSearch.results = [...result.results]; state.stockSearch.selectedIndex = -1; state.stockSearch.error = ""; state.error = ""; }); return true; } function handleStockMasterSearchError(payload) { const pending = state.activeRead; if (!pending || pending.type !== workflow.stockMasterContract.request || payload?.requestId !== pending.request.requestId) return false; const error = workflow.normalizeStockMasterSearchError(payload, pending.request); completeRead(pending, () => { state.stockSearch.status = "error"; state.stockSearch.results = []; state.stockSearch.selectedIndex = -1; state.stockSearch.error = error?.message || "현재 종목 검색 요청과 연결할 수 없는 오류 응답을 차단했습니다."; state.error = state.stockSearch.error; state.status = "조회 실패"; }); return true; } 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; state.selectedDraftIndex = -1; 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) { if (type === workflow.stockMasterContract.result) { return handleStockMasterSearchResult(payload); } if (type === workflow.stockMasterContract.error) { return handleStockMasterSearchError(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, selectedDraftIndex: state.selectedDraftIndex, stockSearch: clone({ ...state.stockSearch, selected: selectedStockIdentity() }), 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 stockSearchForm = makeElement( documentValue, "form", "mbn-operator-catalog-stock-search"); const stockQuery = assignRole(makeElement(documentValue, "input"), "stock-query"); stockQuery.type = "search"; stockQuery.maxLength = workflow.limits.maximumQueryLength; stockQuery.autocomplete = "off"; stockQuery.placeholder = "종목명 검색 (직접 입력값은 저장되지 않음)"; const stockSearchButton = assignRole( makeElement(documentValue, "button", "", "종목 검색"), "stock-search"); stockSearchButton.type = "submit"; stockSearchForm.append(stockQuery, stockSearchButton); const stockStatus = assignRole( makeElement(documentValue, "output", "mbn-operator-catalog-stock-status"), "stock-status"); const stockResults = assignRole( makeElement(documentValue, "ol", "mbn-operator-catalog-stock-results"), "stock-results"); const itemForm = makeElement(documentValue, "form", "mbn-operator-catalog-item-form"); const selectedStock = assignRole( makeElement(documentValue, "output", "mbn-operator-catalog-selected-stock"), "selected-stock"); 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(selectedStock, 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, stockSearchForm, stockStatus, stockResults, 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, stockSearchForm, stockQuery, stockSearchButton, stockStatus, stockResults, selectedStock, itemForm, buyAmount, addButton, 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 })); stockQuery.addEventListener("input", () => updateStockQuery(stockQuery.value)); stockSearchForm.addEventListener("submit", event => { event.preventDefault(); searchStocks(stockQuery.value); }); itemForm.addEventListener("submit", event => { event.preventDefault(); const added = state.entity === "theme" ? addThemeItem() : addExpertRecommendation(buyAmount.value); if (added) { 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; state.selectedDraftIndex = -1; resetStockSearch(); render(); }); dialog.addEventListener("keydown", handleDraftDeleteKey); 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 selected = index === state.selectedDraftIndex; const row = makeElement( dom.document, "li", selected ? "mbn-operator-catalog-draft-row selected" : "mbn-operator-catalog-draft-row"); row.tabIndex = 0; row.setAttribute("data-draft-index", String(index)); row.setAttribute("aria-selected", String(selected)); 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() || Boolean(coordinator.getState().active); row.addEventListener("focus", () => selectDraftItem(index)); row.addEventListener("click", event => { if (event.target === row || event.target === label) selectDraftItem(index); }); up.addEventListener("click", () => moveDraftItem(index, -1)); down.addEventListener("click", () => moveDraftItem(index, 1)); remove.addEventListener("click", event => { event.stopPropagation(); removeDraftItem(index); }); row.append(label, up, down, remove); dom.draftList.appendChild(row); }); } function renderStockRows() { if (!dom) return; dom.stockResults.replaceChildren(); state.stockSearch.results.forEach((identity, index) => { const compatible = stockIsCompatibleWithDraft(identity); const row = makeElement(dom.document, "li", "mbn-operator-catalog-stock-row"); const button = makeElement( dom.document, "button", index === state.stockSearch.selectedIndex ? "selected" : "", `${identity.name} · ${identity.code} · ${identity.market} · ${identity.source}`); button.type = "button"; button.disabled = !compatible || locked(); button.title = compatible ? "이 DB 종목 identity를 선택" : "현재 ThemeA 시장과 다른 종목"; button.addEventListener("click", () => selectStockResult(index)); row.appendChild(button); dom.stockResults.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.buyAmount.hidden = state.entity !== "expert"; dom.stockQuery.value = state.stockSearch.query; dom.stockQuery.disabled = isLocked || mutationActive || !draft; dom.stockSearchButton.disabled = isLocked || mutationActive || !draft || state.stockSearch.status === "loading"; dom.stockStatus.textContent = state.stockSearch.error || (state.stockSearch.status === "loading" ? "종목 master 조회 중…" : (state.stockSearch.status === "selected" ? "DB에서 확인된 종목 identity를 선택했습니다." : (state.stockSearch.status === "ready" ? `${state.stockSearch.results.length}건 · 반드시 한 종목을 선택하세요.` : "검색어는 조회에만 사용되며 코드·이름으로 직접 저장되지 않습니다."))); const selectedStock = selectedStockIdentity(); dom.selectedStock.textContent = selectedStock ? `${selectedStock.name} · ${selectedStock.code} · ${selectedStock.market}` : "선택된 DB 종목 없음"; dom.buyAmount.disabled = isLocked || mutationActive || state.entity !== "expert"; dom.addButton.disabled = isLocked || mutationActive || !draft || !selectedStock || !stockIsCompatibleWithDraft(selectedStock, draft); dom.saveButton.disabled = !canWrite() || !draft; dom.deleteButton.disabled = !canWrite() || (state.mode !== "edit" && !selectedCatalog()); dom.cancelButton.disabled = mutationActive; dom.closeButton.disabled = mutationActive; renderCatalogRows(); renderStockRows(); 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, updateStockQuery, searchStocks, selectStockResult, addThemeItem, addExpertRecommendation, moveDraftItem, removeDraftItem, selectCatalog, submit, deleteCurrent, loseCorrelation }); } return Object.freeze({ createController }); });