(function (root, factory) { "use strict"; const api = Object.freeze(factory(root)); if (typeof module === "object" && module.exports) module.exports = api; if (root) root.MbnManualListsUi = api; })(typeof globalThis === "object" ? globalThis : this, function (root) { "use strict"; const NET_KEYS = Object.freeze(["leftName", "leftAmount", "rightName", "rightAmount"]); const STOCK_MARKETS = Object.freeze(["kospi", "kosdaq", "nxt-kospi", "nxt-kosdaq"]); const STOCK_SOURCES = Object.freeze(["oracle", "mariaDb"]); const MAXIMUM_STOCK_RESULTS = 200; const DEFAULT_FADE_DURATION = 6; const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/; const stockCodePattern = /^[A-Za-z0-9._-]{1,63}$/; const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; function exactKeys(value, keys) { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const actual = Object.keys(value).sort(); const expected = [...keys].sort(); return actual.length === expected.length && actual.every((key, index) => key === expected[index]); } function safeText(value, maximumLength, allowEmpty = true) { return typeof value === "string" && value.length <= maximumLength && (allowEmpty || value.length > 0) && !value.includes("^") && !unsafeTextPattern.test(value); } function copyRows(rows) { return rows.map(row => Object.freeze({ leftName: row.leftName, leftAmount: row.leftAmount, rightName: row.rightName, rightAmount: row.rightAmount })); } function copyItems(items) { return items.map(item => Object.freeze({ code: item.code, name: item.name })); } function rowsEqual(left, right) { return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((row, index) => NET_KEYS.every(key => row[key] === right[index][key])); } function itemsEqual(left, right) { return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((item, index) => item.code === right[index].code && item.name === right[index].name); } function emptyRows() { return Array.from({ length: 5 }, () => ({ leftName: "", leftAmount: "", rightName: "", rightAmount: "" })); } function resolveWorkflow() { if (root && root.MbnManualListsWorkflow) return root.MbnManualListsWorkflow; if (typeof require === "function") return require("./manual-lists-workflow.js"); throw new Error("MbnManualListsWorkflow must be loaded before the manual-list UI."); } function normalizeStockResult(value) { if (!exactKeys(value, ["market", "source", "name", "code"]) || !STOCK_MARKETS.includes(value.market) || !STOCK_SOURCES.includes(value.source) || !safeText(value.name, 200, false) || value.name !== value.name.trim() || value.name.includes(",") || typeof value.code !== "string" || !stockCodePattern.test(value.code)) return null; return Object.freeze({ market: value.market, source: value.source, name: value.name, code: value.code }); } function normalizeStockSearchResponse(value, expected) { if (!exactKeys(value, [ "requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results" ]) || !expected || value.requestId !== expected.requestId || value.query !== expected.query || !identifierPattern.test(value.requestId) || !safeText(value.query, 100) || typeof value.retrievedAt !== "string" || !Number.isFinite(Date.parse(value.retrievedAt)) || !Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 || typeof value.truncated !== "boolean" || !Array.isArray(value.results) || value.results.length > expected.maximumResults || value.totalRowCount < value.results.length) return null; const results = value.results.map(normalizeStockResult); if (!results.every(Boolean)) return null; const identities = new Set(results.map(item => `${item.market}\u0000${item.code}\u0000${item.name}`)); if (identities.size !== results.length) return null; return Object.freeze({ requestId: value.requestId, query: value.query, retrievedAt: value.retrievedAt, totalRowCount: value.totalRowCount, truncated: value.truncated, results: Object.freeze(results) }); } function normalizeStockSearchError(value, expected) { if (!exactKeys(value, ["requestId", "query", "message"]) || !expected || value.requestId !== expected.requestId || value.query !== expected.query || !identifierPattern.test(value.requestId) || !safeText(value.query, 100) || !safeText(value.message, 512, false)) return null; return Object.freeze({ ...value }); } function createController(dependencies) { const workflow = resolveWorkflow(); if (!exactKeys(dependencies, [ "postNative", "appendPlaylistEntry", "isLocked", "showToast", "addLog", "createId" ]) || !Object.values(dependencies).every(value => typeof value === "function")) { throw new TypeError("Closed manual-list UI dependencies are required."); } const { postNative, appendPlaylistEntry, isLocked, showToast, addLog, createId } = dependencies; const state = { host: null, root: null, open: false, mode: null, manualLocked: false, available: null, unavailableCode: "", unavailableMessage: "", quarantined: false, quarantineMessage: "", audience: "INDIVIDUAL", netRows: emptyRows(), netRevision: 0, netVerified: null, viItems: [], viBaseVersion: null, viVersion: null, viRevision: 0, viVerified: null, searchQuery: "", searchResults: [], searchMessage: "", pending: { status: null, legacyImport: null, netRead: null, netSave: null, viRead: null, viSave: null, stockSearch: null } }; function locked() { try { return state.manualLocked || Boolean(isLocked()); } catch { return true; } } function canMutate() { return state.available === true && !state.pending.status && !state.pending.legacyImport && !locked() && !state.quarantined; } function hasPendingManualDataOperation() { return Boolean( state.pending.status || state.pending.legacyImport || state.pending.netRead || state.pending.netSave || state.pending.viRead || state.pending.viSave); } function notify(message, kind = "info") { try { showToast(message, kind); } catch { /* host notification is best effort */ } } function log(message) { try { addLog(message); } catch { /* host logging is best effort */ } } function nextId() { const value = createId(); if (typeof value !== "string" || !identifierPattern.test(value)) { throw new TypeError("createId returned an unsafe identifier."); } return value; } function send(request) { postNative(request.type, request.payload); return request.payload.requestId; } function requestStatus() { const requestId = nextId(); state.pending.status = { requestId }; send(workflow.createStatusRequest(requestId)); } function importLegacyManualData() { if (!canMutate() || hasPendingManualDataOperation()) return false; const confirmation = "원본 프로젝트의 개인.dat, 외국인.dat, 기관.dat, VI발동.dat를 " + "앱 전용 폴더로 한 번만 가져옵니다. 기존 대상 파일이 하나라도 있으면 " + "아무것도 덮어쓰지 않고 중단합니다. 계속하시겠습니까?"; let confirmed = false; try { confirmed = root && typeof root.confirm === "function" && root.confirm(confirmation) === true; } catch { confirmed = false; } if (!confirmed) return false; const request = workflow.createLegacyImportRequest(nextId()); state.pending.legacyImport = { requestId: request.payload.requestId }; state.netVerified = null; state.viVerified = null; send(request); render(); return true; } function requestNetRead(audience, verification = null) { state.netVerified = null; const requestId = nextId(); state.pending.netRead = { requestId, audience, revision: state.netRevision, verification }; send(workflow.createNetSellReadRequest(requestId, audience)); } function requestViRead(verification = null) { state.viVersion = null; state.viVerified = null; const requestId = nextId(); state.pending.viRead = { requestId, revision: state.viRevision, verification }; send(workflow.createViReadRequest(requestId)); } function invalidateNetVerification() { state.netRevision += 1; state.netVerified = null; } function invalidateViVerification() { state.viRevision += 1; state.viVersion = null; state.viVerified = null; } function setAudience(audience) { const normalized = workflow.normalizeAudience(audience); if (!normalized) throw new RangeError("Unknown FSell audience."); state.audience = normalized; state.netRows = emptyRows(); invalidateNetVerification(); requestNetRead(normalized); render(); } function setNetCell(rowIndex, key, value) { if (locked() || !Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= 5 || !NET_KEYS.includes(key) || typeof value !== "string" || value.length > 256) return false; state.netRows[rowIndex][key] = value; invalidateNetVerification(); updateGateButtons(); return true; } function saveNetSell() { if (!canMutate() || state.pending.netRead || state.pending.netSave) return false; let request; try { request = workflow.createNetSellSaveRequest(nextId(), state.audience, state.netRows); } catch { notify("순매도 입력 5행의 값을 확인하세요.", "error"); return false; } const snapshot = copyRows(request.payload.rows); state.pending.netSave = { requestId: request.payload.requestId, audience: request.payload.audience, rows: snapshot, revision: state.netRevision }; state.netVerified = null; send(request); render(); return true; } function addNetSellCut() { const verified = state.netVerified; if (!canMutate() || !verified || verified.audience !== state.audience || !rowsEqual(verified.rows, state.netRows)) return false; try { const entry = workflow.createNetSellPlaylistEntry(state.audience, { id: nextId(), fadeDuration: DEFAULT_FADE_DURATION }, verified.freshRead); appendPlaylistEntry(entry); notify("수동 순매도 컷을 playlist에 추가했습니다.", "success"); log(`수동 순매도 컷 추가 · ${state.audience}`); return true; } catch { notify("수동 순매도 컷을 추가하지 못했습니다.", "error"); return false; } } function searchStocks(query = state.searchQuery) { const normalizedQuery = typeof query === "string" ? query.trim() : ""; if (!safeText(normalizedQuery, 100)) return false; const requestId = nextId(); const request = { requestId, query: normalizedQuery, maximumResults: MAXIMUM_STOCK_RESULTS }; state.searchQuery = normalizedQuery; state.searchResults = []; state.searchMessage = "검색 중…"; state.pending.stockSearch = request; postNative("search-stocks", request); render(); return true; } function toViItem(stock) { const prefix = stock.market.endsWith("kospi") ? "P" : "D"; return workflow.normalizeViItem({ code: `${prefix}${stock.code}`, name: stock.name }); } function addSearchResult(index) { if (locked() || !Number.isInteger(index) || index < 0 || index >= state.searchResults.length || state.viItems.length >= workflow.maximumViItems) return false; const item = toViItem(state.searchResults[index]); if (!item) return false; state.viItems.push({ code: item.code, name: item.name }); invalidateViVerification(); render(); return true; } function removeViItem(index) { if (locked() || !Number.isInteger(index) || index < 0 || index >= state.viItems.length) return false; state.viItems.splice(index, 1); invalidateViVerification(); render(); return true; } function moveViItem(index, delta) { if (locked() || !Number.isInteger(index) || ![-1, 1].includes(delta)) return false; const target = index + delta; if (index < 0 || index >= state.viItems.length || target < 0 || target >= state.viItems.length) return false; [state.viItems[index], state.viItems[target]] = [state.viItems[target], state.viItems[index]]; invalidateViVerification(); render(); return true; } function clearViItems() { if (locked() || state.viItems.length === 0) return false; state.viItems = []; invalidateViVerification(); render(); return true; } function saveVi() { if (!canMutate() || !state.viBaseVersion || state.pending.viRead || state.pending.viSave) return false; let request; try { request = workflow.createViSaveRequest(nextId(), state.viItems, state.viBaseVersion); } catch { notify("VI 목록 값을 확인하세요.", "error"); return false; } state.pending.viSave = { requestId: request.payload.requestId, expectedVersion: request.payload.expectedVersion, items: copyItems(request.payload.items), revision: state.viRevision }; state.viVerified = null; send(request); render(); return true; } function addViCut() { const verified = state.viVerified; if (!canMutate() || state.viItems.length === 0 || !verified || verified.version !== state.viVersion || !itemsEqual(verified.items, state.viItems)) return false; try { const entry = workflow.createViPlaylistEntry(state.viItems, state.viVersion, { id: nextId(), fadeDuration: DEFAULT_FADE_DURATION }, verified.freshRead); appendPlaylistEntry(entry); notify("VI 발동 컷을 playlist에 추가했습니다.", "success"); log(`VI 발동 컷 추가 · ${state.viItems.length}종목 · ${entry.pageCount}페이지`); return true; } catch { notify("최신 VI 목록을 다시 조회한 뒤 추가하세요.", "error"); return false; } } function handleStatus(payload) { const pending = state.pending.status; if (!pending) return false; const normalized = workflow.normalizeStatusResponse(payload, pending.requestId); if (!normalized) return false; state.pending.status = null; state.available = normalized.available; state.unavailableCode = normalized.available ? "" : normalized.code; state.unavailableMessage = normalized.available ? "" : (normalized.message || ""); if (normalized.writeQuarantined) { state.quarantined = true; state.quarantineMessage = normalized.message || "수동 저장 결과를 확인할 수 없습니다."; } render(); return true; } function handleLegacyImport(payload) { const pending = state.pending.legacyImport; if (!pending) return false; const normalized = workflow.normalizeLegacyImportResponse(payload, pending.requestId); if (!normalized) return false; state.pending.legacyImport = null; state.netVerified = null; state.viVerified = null; notify( `원본 수동 데이터를 가져왔습니다. 순매도 ${normalized.netSellRowCount}행 · VI ${normalized.viItemCount}종목`, "success"); log(`원본 수동 데이터 가져오기 완료 · marker v${normalized.markerVersion}`); if (state.mode === "net") requestNetRead(state.audience); if (state.mode === "vi") requestViRead(); render(); return true; } function handleNetData(payload) { const pending = state.pending.netRead; if (!pending) return false; const normalized = workflow.normalizeNetSellDataResponse(payload, pending); if (!normalized) return false; state.pending.netRead = null; if (pending.audience !== state.audience || pending.revision !== state.netRevision) return true; state.netRows = copyRows(normalized.rows).map(row => ({ ...row })); if (pending.verification && pending.verification.audience === state.audience && rowsEqual(pending.verification.rows, normalized.rows)) { state.netVerified = { audience: state.audience, rows: copyRows(normalized.rows), freshRead: normalized }; notify("저장 파일 재조회가 입력과 일치합니다.", "success"); } else { state.netVerified = null; } render(); return true; } function handleNetSave(payload) { const pending = state.pending.netSave; if (!pending) return false; const normalized = workflow.normalizeNetSellSaveResponse(payload, pending); if (!normalized) return false; state.pending.netSave = null; requestNetRead(pending.audience, { audience: pending.audience, rows: pending.rows }); notify("저장했습니다. 파일을 다시 확인하고 있습니다.", "success"); render(); return true; } function handleViData(payload) { const pending = state.pending.viRead; if (!pending) return false; const normalized = workflow.normalizeViDataResponse(payload, pending.requestId); if (!normalized) return false; state.pending.viRead = null; if (pending.revision !== state.viRevision) return true; state.viItems = copyItems(normalized.items).map(item => ({ ...item })); state.viBaseVersion = normalized.version; state.viVersion = normalized.version; const verification = pending.verification; if (verification && verification.persisted && (verification.version !== normalized.version || !itemsEqual(verification.items, normalized.items))) { state.viVerified = null; notify("저장 후 VI 파일 내용이 달라 컷 추가를 차단했습니다.", "error"); } else { state.viVerified = { version: normalized.version, items: copyItems(normalized.items), freshRead: normalized }; if (verification) { notify( verification.persisted ? "저장 파일 재조회가 VI 입력과 일치합니다." : "빈 목록은 원본 동작에 따라 파일을 변경하지 않았습니다.", "success"); } } render(); return true; } function handleViSave(payload) { const pending = state.pending.viSave; if (!pending) return false; const normalized = workflow.normalizeViSaveResponse(payload, pending.requestId); if (!normalized) return false; state.pending.viSave = null; requestViRead({ items: pending.items, version: normalized.version, persisted: normalized.persisted }); notify( normalized.persisted ? "VI 목록을 저장했습니다. 파일을 다시 확인하고 있습니다." : "빈 VI 목록은 원본 동작에 따라 저장 파일을 변경하지 않았습니다.", "success"); render(); return true; } const pendingOperations = Object.freeze({ status: "status", legacyImport: "import-legacy-manual-data", netRead: "read-net-sell", netSave: "save-net-sell", viRead: "read-vi", viSave: "save-vi" }); function handleManualError(payload) { for (const [key, operation] of Object.entries(pendingOperations)) { const pending = state.pending[key]; if (!pending || payload?.requestId !== pending.requestId) continue; const normalized = workflow.normalizeError(payload, pending.requestId); if (!normalized || normalized.operation !== operation) return false; state.pending[key] = null; if (key === "status") { state.available = false; state.unavailableCode = normalized.code; state.unavailableMessage = normalized.message; } if (key === "viSave" && normalized.code === "STALE_DATA") { state.viBaseVersion = null; state.viVersion = null; state.viVerified = null; } if (normalized.outcomeUnknown) { state.quarantined = true; state.quarantineMessage = normalized.message; state.netVerified = null; state.viVerified = null; notify("저장 결과가 불명확하여 앱 재시작 전까지 재저장을 차단합니다.", "error"); } else { notify(normalized.message, "error"); } log(`수동 목록 오류 · ${normalized.operation} · ${normalized.code}`); render(); return true; } return false; } function handleStockResults(payload) { const pending = state.pending.stockSearch; if (!pending) return false; const normalized = normalizeStockSearchResponse(payload, pending); if (!normalized) return false; state.pending.stockSearch = null; state.searchResults = [...normalized.results]; state.searchMessage = normalized.results.length === 0 ? "검색 결과가 없습니다." : `${normalized.results.length}건${normalized.truncated ? " · 일부 결과" : ""}`; render(); return true; } function handleStockError(payload) { const pending = state.pending.stockSearch; if (!pending) return false; const normalized = normalizeStockSearchError(payload, pending); if (!normalized) return false; state.pending.stockSearch = null; state.searchResults = []; state.searchMessage = normalized.message; notify(normalized.message, "error"); render(); return true; } function handleMessage(type, payload) { switch (type) { case workflow.bridgeContract.statusResultType: return handleStatus(payload); case workflow.bridgeContract.legacyImportResultType: return handleLegacyImport(payload); case workflow.bridgeContract.netSellDataType: return handleNetData(payload); case workflow.bridgeContract.netSellSaveResultType: return handleNetSave(payload); case workflow.bridgeContract.viDataType: return handleViData(payload); case workflow.bridgeContract.viSaveResultType: return handleViSave(payload); case workflow.bridgeContract.errorType: return handleManualError(payload); case "stock-search-results": return handleStockResults(payload); case "stock-search-error": return handleStockError(payload); default: return false; } } function element(tag, className, text) { const node = state.root.ownerDocument.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function button(text, action, options = {}) { const node = element("button", options.className || "manual-lists-button", text); node.type = "button"; node.disabled = Boolean(options.disabled); if (options.title) node.title = options.title; node.addEventListener("click", action); return node; } function renderHeader(panel) { const header = element("header", "manual-lists-header"); const title = element( "h2", "manual-lists-title", state.mode === "net" ? "수동 순매도 상위" : "VI 발동 목록"); const close = button("닫기", () => { state.open = false; render(); }, { className: "manual-lists-close" }); const legacyImport = button("원본 데이터 가져오기", importLegacyManualData, { className: "manual-lists-button", disabled: !canMutate() || hasPendingManualDataOperation(), title: "원본 프로젝트의 FSell/VI 파일을 앱 전용 폴더로 한 번만 가져옵니다." }); header.append(title, legacyImport, close); panel.append(header); } function renderBanner(panel) { if (state.quarantined) { const banner = element( "div", "manual-lists-banner manual-lists-banner-danger", state.quarantineMessage || "저장 결과가 불명확하여 앱을 다시 시작하기 전까지 저장할 수 없습니다."); banner.setAttribute("role", "alert"); panel.append(banner); } else if (locked()) { panel.append(element( "div", "manual-lists-banner", "PREPARE 또는 송출 중에는 목록을 변경할 수 없습니다.")); } else if (state.available === false) { panel.append(element( "div", "manual-lists-banner manual-lists-banner-danger", state.unavailableMessage || "수동 데이터 저장소를 사용할 수 없습니다.")); } } function renderNet(panel) { const tabs = element("div", "manual-lists-tabs"); for (const audience of workflow.audiences) { const tab = button(workflow.audienceLabels[audience], () => setAudience(audience), { className: audience === state.audience ? "manual-lists-tab is-active" : "manual-lists-tab", disabled: Boolean(state.pending.netSave) }); tab.setAttribute("aria-pressed", String(audience === state.audience)); tabs.append(tab); } panel.append(tabs); const table = element("table", "manual-lists-table manual-lists-net-table"); const head = element("thead"); const headRow = element("tr"); for (const text of ["#", "좌측 종목", "좌측 금액", "우측 종목", "우측 금액"]) { headRow.append(element("th", "", text)); } head.append(headRow); table.append(head); const body = element("tbody"); state.netRows.forEach((row, rowIndex) => { const tr = element("tr"); tr.append(element("th", "manual-lists-row-number", String(rowIndex + 1))); for (const key of NET_KEYS) { const td = element("td"); const input = element("input", "manual-lists-input"); input.type = "text"; input.maxLength = 256; input.value = row[key]; input.disabled = locked() || Boolean(state.pending.netSave); input.setAttribute("aria-label", `${rowIndex + 1}행 ${key}`); input.addEventListener("input", event => setNetCell(rowIndex, key, event.currentTarget.value)); td.append(input); tr.append(td); } body.append(tr); }); table.append(body); const tableWrap = element("div", "manual-lists-table-wrap"); tableWrap.append(table); panel.append(tableWrap); const actions = element("div", "manual-lists-actions"); actions.append( button("재조회", () => requestNetRead(state.audience), { disabled: Boolean(state.pending.netRead || state.pending.netSave) }), button("저장", saveNetSell, { className: "manual-lists-button is-primary", disabled: !canMutate() || Boolean(state.pending.netRead || state.pending.netSave) }), button("s5025 추가", addNetSellCut, { className: "manual-lists-button is-accent", disabled: !canMutate() || !state.netVerified || !rowsEqual(state.netVerified.rows, state.netRows) }) ); panel.append(actions); } function renderSearch(panel) { const section = element("section", "manual-lists-search"); const controls = element("div", "manual-lists-search-controls"); const input = element("input", "manual-lists-input manual-lists-search-input"); input.type = "search"; input.maxLength = 100; input.placeholder = "종목명 검색"; input.value = state.searchQuery; input.addEventListener("input", event => { state.searchQuery = event.currentTarget.value; }); input.addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); searchStocks(event.currentTarget.value); } }); controls.append(input, button("검색", () => searchStocks(input.value), { disabled: Boolean(state.pending.stockSearch) })); section.append(controls); section.append(element("p", "manual-lists-search-state", state.searchMessage)); const results = element("div", "manual-lists-search-results"); state.searchResults.forEach((stock, index) => { results.append(button( `${stock.name} · ${stock.code} · ${stock.market}`, () => addSearchResult(index), { className: "manual-lists-search-result", disabled: locked() || state.viItems.length >= workflow.maximumViItems })); }); section.append(results); panel.append(section); } function renderVi(panel) { renderSearch(panel); const summary = element( "div", "manual-lists-summary", `${state.viItems.length}/${workflow.maximumViItems}종목 · ${state.viItems.length === 0 ? 0 : Math.ceil(state.viItems.length / workflow.viPageSize)}페이지`); panel.append(summary); const tableWrap = element("div", "manual-lists-table-wrap"); const table = element("table", "manual-lists-table manual-lists-vi-table"); const head = element("thead"); const headRow = element("tr"); for (const text of ["#", "코드", "종목명", "순서/삭제"]) headRow.append(element("th", "", text)); head.append(headRow); table.append(head); const body = element("tbody"); state.viItems.forEach((item, index) => { const row = element("tr"); row.append( element("td", "manual-lists-row-number", String(index + 1)), element("td", "manual-lists-code", item.code), element("td", "manual-lists-name", item.name) ); const controls = element("td", "manual-lists-row-actions"); controls.append( button("↑", () => moveViItem(index, -1), { disabled: locked() || index === 0, title: "위로" }), button("↓", () => moveViItem(index, 1), { disabled: locked() || index === state.viItems.length - 1, title: "아래로" }), button("삭제", () => removeViItem(index), { disabled: locked() }) ); row.append(controls); body.append(row); }); table.append(body); tableWrap.append(table); panel.append(tableWrap); const actions = element("div", "manual-lists-actions"); actions.append( button("전체 삭제", clearViItems, { disabled: locked() || state.viItems.length === 0 }), button("재조회", () => requestViRead(), { disabled: Boolean(state.pending.viRead || state.pending.viSave) }), button("저장", saveVi, { className: "manual-lists-button is-primary", disabled: !canMutate() || !state.viBaseVersion || Boolean(state.pending.viRead || state.pending.viSave) }), button("s5074 추가", addViCut, { className: "manual-lists-button is-accent", disabled: !canMutate() || state.viItems.length === 0 || !state.viVerified || state.viVerified.version !== state.viVersion || !itemsEqual(state.viVerified.items, state.viItems) }) ); panel.append(actions); } function updateGateButtons() { if (!state.root) return; const add = state.root.querySelector(".manual-lists-button.is-accent"); if (!add) return; add.disabled = state.mode === "net" ? !canMutate() || !state.netVerified || !rowsEqual(state.netVerified.rows, state.netRows) : !canMutate() || state.viItems.length === 0 || !state.viVerified || state.viVerified.version !== state.viVersion || !itemsEqual(state.viVerified.items, state.viItems); } function snapshot() { return Object.freeze({ open: state.open, mode: state.mode, locked: locked(), available: state.available, unavailableCode: state.unavailableCode, unavailableMessage: state.unavailableMessage, quarantined: state.quarantined, audience: state.audience, netRows: Object.freeze(copyRows(state.netRows)), netVerified: Boolean(state.netVerified && rowsEqual(state.netVerified.rows, state.netRows)), viItems: Object.freeze(copyItems(state.viItems)), viBaseVersion: state.viBaseVersion, viVersion: state.viVersion, viVerified: Boolean(state.viVerified && state.viVerified.version === state.viVersion && itemsEqual(state.viVerified.items, state.viItems)), searchResults: Object.freeze(state.searchResults.map(item => ({ ...item }))), pending: Object.freeze(Object.fromEntries( Object.entries(state.pending).map(([key, value]) => [key, value?.requestId || null]))) }); } function render() { if (!state.root) return snapshot(); state.root.hidden = !state.open; state.root.replaceChildren(); if (!state.open) return snapshot(); const backdrop = element("div", "manual-lists-backdrop"); const panel = element("section", "manual-lists-dialog"); panel.setAttribute("role", "dialog"); panel.setAttribute("aria-modal", "true"); renderHeader(panel); renderBanner(panel); if (state.mode === "net") renderNet(panel); if (state.mode === "vi") renderVi(panel); backdrop.append(panel); state.root.append(backdrop); return snapshot(); } function mount(host) { const resolvedHost = host || root?.document?.body; if (!resolvedHost || typeof resolvedHost.append !== "function" || !resolvedHost.ownerDocument) { throw new TypeError("A DOM host is required to mount the manual-list UI."); } if (state.root?.parentNode) state.root.remove(); state.host = resolvedHost; state.root = resolvedHost.ownerDocument.createElement("div"); state.root.className = "manual-lists-root"; state.root.hidden = true; resolvedHost.append(state.root); render(); return state.root; } function openNetSell(audience = state.audience) { const normalized = workflow.normalizeAudience(audience); if (!normalized) throw new RangeError("Unknown FSell audience."); state.open = true; state.mode = "net"; state.audience = normalized; state.netRows = emptyRows(); invalidateNetVerification(); requestStatus(); requestNetRead(normalized); render(); return snapshot(); } function openVi() { state.open = true; state.mode = "vi"; requestStatus(); requestViRead(); render(); return snapshot(); } function setLocked(value) { state.manualLocked = Boolean(value); render(); return state.manualLocked; } const actions = Object.freeze({ setAudience, setNetCell, saveNetSell, addNetSellCut, requestNetRead: () => requestNetRead(state.audience), searchStocks, addSearchResult, removeViItem, moveViItem, clearViItems, saveVi, addViCut, importLegacyManualData, requestViRead: () => requestViRead(), close: () => { state.open = false; render(); } }); return Object.freeze({ mount, openNetSell, openVi, handleMessage, render, setLocked, actions, getState: snapshot }); } return { createController }; });