(function (root, factory) { "use strict"; const workflow = typeof module === "object" && module.exports ? require("./manual-financial-workflow.js") : root && root.MbnManualFinancialWorkflow; const api = Object.freeze(factory(workflow, root)); if (typeof module === "object" && module.exports) module.exports = api; if (root) root.MbnManualFinancialUi = api; })(typeof globalThis === "object" ? globalThis : this, function (workflow, root) { "use strict"; if (!workflow || !workflow.bridgeContract) { throw new Error("MbnManualFinancialWorkflow must be loaded before manual-financial-ui.js."); } const contract = workflow.bridgeContract; const STOCK_SEARCH_REQUEST = "search-stocks"; const STOCK_SEARCH_RESULT = "stock-search-results"; const STOCK_SEARCH_ERROR = "stock-search-error"; const STOCK_SEARCH_MAXIMUM_QUERY_LENGTH = 64; const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; const UNSAFE_TEXT_PATTERN = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u; const LEGACY_ACTION_SCREENS = Object.freeze({ "manual-revenue-mix": "revenue-composition", "manual-growth-metrics": "growth-metrics", "manual-sales": "sales", "manual-operating-profit": "operating-profit" }); const SCREEN_LABELS = Object.freeze({ "revenue-composition": "주요매출 구성", "growth-metrics": "성장성 지표", sales: "매출액", "operating-profit": "영업이익" }); const GROWTH_SERIES = Object.freeze([ ["salesGrowth", "매출액 증가율"], ["operatingProfitGrowth", "영업이익 증가율"], ["netAssetGrowth", "순자산 증가율"], ["netIncomeGrowth", "순이익 증가율"] ]); let processWriteQuarantine = null; let controllerOrdinal = 0; function hasExactKeys(value, expectedKeys) { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const actual = Object.keys(value).sort(); const expected = [...expectedKeys].sort(); return actual.length === expected.length && actual.every((key, index) => key === expected[index]); } function safeText(value, maximumLength, allowEmpty = false) { return typeof value === "string" && value === value.trim() && value.length <= maximumLength && (allowEmpty || value.length > 0) && !UNSAFE_TEXT_PATTERN.test(value); } function textValue(value) { return value === undefined || value === null ? "" : String(value).trim(); } function freezeObject(value) { return Object.freeze({ ...value }); } function resolveScreen(value) { if (typeof value === "string") { if (workflow.getScreenDefinition(value)) return value; return LEGACY_ACTION_SCREENS[value] || null; } if (!value || typeof value !== "object" || Array.isArray(value)) return null; if (typeof value.screen === "string" && workflow.getScreenDefinition(value.screen)) { return value.screen; } return typeof value.id === "string" ? LEGACY_ACTION_SCREENS[value.id] || null : null; } function emptyForm(screen, stockName = "") { if (!workflow.getScreenDefinition(screen)) return null; const result = { stockName: textValue(stockName) }; if (screen === "revenue-composition") { result.baseDate = ""; for (let index = 1; index <= 5; index += 1) { result[`slice${index}Label`] = ""; result[`slice${index}Percentage`] = ""; } } else if (screen === "growth-metrics") { for (let index = 1; index <= 4; index += 1) { result[`period${index}`] = ""; for (const [series] of GROWTH_SERIES) result[`${series}${index}`] = ""; } } else { for (let index = 1; index <= 6; index += 1) { result[`quarter${index}`] = ""; result[`value${index}`] = ""; } } return freezeObject(result); } function recordToForm(screen, value) { const record = workflow.normalizeRecord(screen, value); if (!record) return null; const result = { stockName: record.stockName }; if (screen === "revenue-composition") { result.baseDate = record.baseDate; record.slices.forEach((slice, index) => { result[`slice${index + 1}Label`] = slice ? slice.label : ""; result[`slice${index + 1}Percentage`] = slice ? slice.percentageText : ""; }); } else if (screen === "growth-metrics") { record.periods.forEach((period, index) => { result[`period${index + 1}`] = period; }); for (const [series] of GROWTH_SERIES) { record[series].forEach((item, index) => { result[`${series}${index + 1}`] = item === null ? "" : String(item); }); } } else { record.quarters.forEach((item, index) => { result[`quarter${index + 1}`] = item.quarter; result[`value${index + 1}`] = String(item.value); }); } return freezeObject(result); } function optionalNumber(value) { const raw = textValue(value); if (raw === "") return null; if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw)) return undefined; const parsed = Number(raw); return Number.isFinite(parsed) ? parsed : undefined; } function requiredInteger(value) { const raw = textValue(value); if (!/^[+-]?\d+$/.test(raw)) return undefined; const parsed = Number(raw); return Number.isSafeInteger(parsed) ? parsed : undefined; } function formToRecord(screen, form) { if (!workflow.getScreenDefinition(screen) || !form || typeof form !== "object" || Array.isArray(form)) return null; const stockName = textValue(form.stockName); let candidate; if (screen === "revenue-composition") { const slices = []; for (let index = 1; index <= 5; index += 1) { const label = textValue(form[`slice${index}Label`]); const percentageText = textValue(form[`slice${index}Percentage`]); if (!label && !percentageText) { slices.push(null); continue; } const percentage = percentageText === "-" ? 0 : optionalNumber(percentageText); if (!label || !percentageText || percentage === undefined || percentage === null) return null; slices.push({ label, percentageText, percentage }); } candidate = { kind: screen, stockName, baseDate: textValue(form.baseDate), slices }; } else if (screen === "growth-metrics") { candidate = { kind: screen, stockName }; for (const [series] of GROWTH_SERIES) { const values = []; for (let index = 1; index <= 4; index += 1) { const parsed = optionalNumber(form[`${series}${index}`]); if (parsed === undefined) return null; values.push(parsed); } candidate[series] = values; } candidate.periods = []; for (let index = 1; index <= 4; index += 1) { candidate.periods.push(textValue(form[`period${index}`])); } } else { const quarters = []; for (let index = 1; index <= 6; index += 1) { const quarter = textValue(form[`quarter${index}`]); const value = requiredInteger(form[`value${index}`]); if (!quarter || value === undefined) return null; quarters.push({ quarter, value }); } candidate = { kind: screen, stockName, quarters }; } return workflow.normalizeRecord(screen, candidate); } function latchWriteQuarantine(message) { if (!processWriteQuarantine) { processWriteQuarantine = freezeObject({ active: true, message: safeText(message, 512) ? message : "수동 재무 DB 쓰기 결과를 확인할 수 없습니다. 앱을 다시 시작하고 DB를 대조하세요." }); } return processWriteQuarantine; } function getProcessWriteQuarantine() { return processWriteQuarantine || Object.freeze({ active: false, message: "" }); } function createPendingCoordinator() { const slots = Object.create(null); function set(slot, request, context = {}) { if (typeof slot !== "string" || !request || !REQUEST_ID_PATTERN.test(request.requestId)) { throw new TypeError("A correlated request is required."); } slots[slot] = Object.freeze({ request, context: Object.freeze({ ...context }) }); return slots[slot]; } function get(slot) { return slots[slot] || null; } function matches(slot, requestId) { const pending = get(slot); return !!pending && pending.request.requestId === requestId; } function clear(slot, requestId) { if (!Object.prototype.hasOwnProperty.call(slots, slot)) return false; if (requestId !== undefined && slots[slot].request.requestId !== requestId) return false; delete slots[slot]; return true; } function clearReads() { for (const slot of ["list", "load", "stock", "selection"]) delete slots[slot]; } function snapshot() { const result = {}; for (const [slot, pending] of Object.entries(slots)) { result[slot] = pending.request.requestId; } return Object.freeze(result); } function acceptMutation(kind, payload) { const pending = get("write"); if (!pending) return Object.freeze({ status: "stale", value: null, pending: null }); const requestId = payload && typeof payload === "object" ? payload.requestId : null; if (typeof requestId === "string" && REQUEST_ID_PATTERN.test(requestId) && requestId !== pending.request.requestId) { return Object.freeze({ status: "stale", value: null, pending: null }); } const value = kind === "result" ? workflow.normalizeMutationResult(payload, pending.request) : workflow.normalizeMutationError(payload, pending.request); clear("write"); if (!value) { const quarantine = latchWriteQuarantine( "수동 재무 DB 쓰기 응답의 상관관계 또는 결과 형식을 확인할 수 없습니다."); return Object.freeze({ status: "invalid", value: null, pending, quarantine }); } if (kind === "error" && value.outcomeUnknown) { latchWriteQuarantine(value.message); } return Object.freeze({ status: "accepted", value, pending }); } return Object.freeze({ set, get, matches, clear, clearReads, snapshot, acceptMutationResult: payload => acceptMutation("result", payload), acceptMutationError: payload => acceptMutation("error", payload) }); } function normalizeStockSearchError(value, expectedRequest) { if (!hasExactKeys(value, ["requestId", "query", "message"]) || !REQUEST_ID_PATTERN.test(value.requestId) || !safeText(value.query, contract.maximumQueryLength, true) || !safeText(value.message, 512) || !expectedRequest || value.requestId !== expectedRequest.requestId || value.query !== expectedRequest.query) return null; return freezeObject(value); } function preferredStockIdentity(raw, response, stockName) { const exact = response.results.filter(item => item.name === stockName); if (exact.length !== 1) return null; const only = exact[0]; if (raw && typeof raw === "object" && !Array.isArray(raw) && raw.name === only.name && raw.market === only.market && raw.code === only.code && (raw.source === undefined || raw.source === only.source)) return only; return only; } function createController(options) { if (!options || typeof options !== "object" || Array.isArray(options)) { throw new TypeError("Manual-financial UI dependencies are required."); } const required = [ "postNative", "appendPlaylistEntry", "isLocked", "getSelectedStock", "showToast", "addLog", "createId" ]; for (const name of required) { if (typeof options[name] !== "function") { throw new TypeError(`Manual-financial UI dependency ${name} must be a function.`); } } const pending = createPendingCoordinator(); const state = { mounted: false, visible: false, screen: null, profile: null, query: "", status: "idle", message: "", items: [], snapshot: null, form: null, stockStatus: "idle", verifiedStock: null, selectedStockIdentity: null, locked: false }; const refs = Object.create(null); const instanceId = `manual-financial-${++controllerOrdinal}`; function externallyLocked() { let applicationLocked = true; try { applicationLocked = options.isLocked() !== false; } catch { applicationLocked = true; } return state.locked || applicationLocked; } function writeBlocked() { return externallyLocked() || !!pending.get("write") || !!pending.get("load") || !!pending.get("stock") || !!pending.get("selection") || getProcessWriteQuarantine().active; } function notify(message) { if (safeText(message, 512)) options.showToast(message); } function log(message) { if (safeText(message, 512)) options.addLog(message); } function nextRequestId() { const value = String(options.createId()); if (!REQUEST_ID_PATTERN.test(value)) { throw new TypeError("createId must return a safe unique request id."); } return value; } function post(type, payload) { options.postNative(type, payload); } function seedStockName() { try { const stock = options.getSelectedStock(); return stock && typeof stock.name === "string" ? stock.name : ""; } catch { return ""; } } function stateView() { const quarantine = getProcessWriteQuarantine(); return Object.freeze({ mounted: state.mounted, visible: state.visible, screen: state.screen, status: state.status, message: state.message, query: state.query, itemCount: state.items.length, selectedStockName: state.snapshot?.stockName || state.form?.stockName || "", hasSnapshot: !!state.snapshot, stockStatus: state.stockStatus, verifiedStock: state.verifiedStock ? freezeObject(state.verifiedStock) : null, pending: pending.snapshot(), locked: externallyLocked(), writeQuarantined: quarantine.active, quarantineMessage: quarantine.message, canWrite: !writeBlocked() }); } function setStatus(status, message = "") { state.status = status; state.message = safeText(message, 512, true) ? message : ""; } function requestList(query = state.query) { if (!state.screen) return false; if (pending.get("write")) { notify("DB 쓰기 결과를 기다리는 동안 목록을 다시 조회할 수 없습니다."); return false; } try { const request = workflow.createListRequest( nextRequestId(), state.screen, textValue(query), contract.defaultMaximumResults); state.query = request.query; pending.set("list", request); setStatus("loading", "수동 재무 목록을 조회하고 있습니다."); post(contract.listRequestType, request); render(); return true; } catch { setStatus("error", "목록 검색 조건이 올바르지 않습니다."); render(); return false; } } function requestLoad(stockName) { if (!state.screen) return false; if (pending.get("write")) { notify("DB 쓰기 결과를 기다리는 동안 행을 다시 조회할 수 없습니다."); return false; } try { const request = workflow.createLoadRequest(nextRequestId(), state.screen, stockName); pending.set("load", request); setStatus("loading", `${stockName} 데이터를 다시 확인하고 있습니다.`); post(contract.loadRequestType, request); render(); return true; } catch { setStatus("error", "선택한 수동 재무 행을 조회할 수 없습니다."); render(); return false; } } function syncFormFromDom() { if (!state.form || !refs.formInputs) return; const next = { ...state.form }; for (const [key, input] of refs.formInputs.entries()) next[key] = input.value; state.form = freezeObject(next); } function currentRecord() { syncFormFromDom(); return formToRecord(state.screen, state.form); } function beginStockVerification(intent) { if (intent === "verify-only" && (pending.get("write") || pending.get("load") || pending.get("selection"))) { notify("진행 중인 GraphE 요청이 끝난 뒤 종목을 다시 확인하세요."); return false; } if (!state.screen || ["create", "update", "delete", "playlist"].includes(intent) && writeBlocked()) { notify(getProcessWriteQuarantine().active ? getProcessWriteQuarantine().message : "송출 또는 다른 쓰기 작업 중에는 수동 재무 데이터를 변경할 수 없습니다."); return false; } if ((intent === "update" || intent === "delete" || intent === "playlist") && !state.snapshot) { notify("DB 목록에서 먼저 행을 선택하세요."); return false; } const record = currentRecord(); if (!record) { notify("화면 입력값과 필수 개수를 확인하세요."); return false; } if (state.snapshot && record.stockName !== state.snapshot.stockName) { notify("기존 행의 종목명은 변경할 수 없습니다."); return false; } if (record.stockName.length > STOCK_SEARCH_MAXIMUM_QUERY_LENGTH) { notify(`종목명은 재검증을 위해 ${STOCK_SEARCH_MAXIMUM_QUERY_LENGTH}자 이하여야 합니다.`); return false; } let preferred = null; try { preferred = options.getSelectedStock(); } catch { preferred = null; } let request; try { request = Object.freeze({ requestId: nextRequestId(), query: record.stockName, maximumResults: contract.defaultMaximumResults }); } catch { notify("종목 검색 요청 ID를 만들 수 없습니다."); return false; } pending.set("stock", request, { intent, record, snapshot: state.snapshot, preferred }); state.verifiedStock = null; state.stockStatus = "loading"; setStatus("loading", `${record.stockName} 종목 identity를 DB에서 다시 확인하고 있습니다.`); post(STOCK_SEARCH_REQUEST, request); render(); return true; } function postMutation(operation, record, snapshot) { if (writeBlocked()) return false; let request; let type; try { if (operation === "create") { request = workflow.createCreateRequest(nextRequestId(), record, state.verifiedStock); type = contract.createRequestType; } else if (operation === "update") { request = workflow.createUpdateRequest(nextRequestId(), snapshot, record); type = contract.updateRequestType; } else if (operation === "delete-one") { request = workflow.createDeleteRequest(nextRequestId(), snapshot); type = contract.deleteRequestType; } else { return false; } } catch { notify("DB 쓰기 요청을 안전하게 만들 수 없습니다."); return false; } pending.set("write", request, { operation }); setStatus("writing", "DB 쓰기 결과를 기다리고 있습니다. 자동 재시도하지 않습니다."); post(type, request); render(); return true; } function postSelection(snapshot, verifiedStock) { if (externallyLocked() || !snapshot || snapshot !== state.snapshot) return false; try { const request = workflow.createSelectionRequest(nextRequestId(), snapshot, verifiedStock); pending.set("selection", request, { snapshot, verifiedStock }); setStatus("loading", "플레이리스트 장면 identity를 네이티브에서 확인하고 있습니다."); post(contract.selectionRequestType, request); render(); return true; } catch { notify("플레이리스트 선택 요청을 만들 수 없습니다."); return false; } } function continueAfterStockVerification(context, verifiedStock) { state.verifiedStock = verifiedStock; state.selectedStockIdentity = freezeObject(verifiedStock); state.stockStatus = "verified"; if (context.intent === "verify-only") { setStatus("ready", `${verifiedStock.name} 종목 identity를 확인했습니다.`); notify("종목 identity를 DB에서 확인했습니다."); render(); return true; } if (context.snapshot !== state.snapshot) { setStatus("error", "행 선택이 변경되어 작업을 중단했습니다."); render(); return false; } if (context.intent === "create") return postMutation("create", context.record, null); if (context.intent === "update") return postMutation("update", context.record, context.snapshot); if (context.intent === "delete") return postMutation("delete-one", context.record, context.snapshot); if (context.intent === "playlist") return postSelection(context.snapshot, verifiedStock); return false; } function deleteAll() { if (!state.screen || writeBlocked()) { notify(getProcessWriteQuarantine().active ? getProcessWriteQuarantine().message : "현재는 전체 삭제를 실행할 수 없습니다."); return false; } const profile = workflow.getScreenDefinition(state.screen); const confirmFunction = root && typeof root.confirm === "function" ? root.confirm.bind(root) : null; if (!confirmFunction || !confirmFunction(`${SCREEN_LABELS[state.screen]} 전체 행을 삭제할까요?`)) { return false; } try { const request = workflow.createDeleteAllRequest( nextRequestId(), state.screen, profile.deleteAllConfirmation); pending.set("write", request, { operation: "delete-all" }); setStatus("writing", "전체 삭제 결과를 기다리고 있습니다. 자동 재시도하지 않습니다."); post(contract.deleteAllRequestType, request); render(); return true; } catch { notify("전체 삭제 요청을 안전하게 만들 수 없습니다."); return false; } } function consumeRead(slot, payload, normalizer, onAccepted) { const requestId = payload && typeof payload === "object" ? payload.requestId : null; const active = pending.get(slot); if (!active || active.request.requestId !== requestId) return false; const normalized = normalizer(payload, active.request); pending.clear(slot); if (!normalized) { setStatus("error", "네이티브 응답 형식 또는 상관관계가 올바르지 않습니다."); render(); return true; } onAccepted(normalized, active); render(); return true; } function handleListResult(payload) { return consumeRead("list", payload, workflow.normalizeListResponse, normalized => { state.items = [...normalized.items]; setStatus("ready", `${normalized.totalRowCount}개 행을 조회했습니다.`); }); } function handleLoadResult(payload) { return consumeRead("load", payload, workflow.normalizeLoadResponse, normalized => { state.snapshot = normalized.snapshot; state.form = recordToForm(state.screen, normalized.snapshot.record); state.verifiedStock = null; state.stockStatus = "idle"; setStatus("ready", `${normalized.snapshot.stockName} 행을 불러왔습니다.`); }); } function handleStockResult(payload) { const active = pending.get("stock"); const requestId = payload && typeof payload === "object" ? payload.requestId : null; if (!active || requestId !== active.request.requestId) return false; const normalized = workflow.normalizeStockSearchResponse(payload); pending.clear("stock"); if (!normalized || normalized.query !== active.request.query || normalized.truncated || normalized.totalRowCount > active.request.maximumResults) { state.stockStatus = "error"; setStatus("error", "종목 검색 응답 형식 또는 상관관계가 올바르지 않습니다."); render(); return true; } const identity = preferredStockIdentity( active.context.preferred, normalized, active.context.record.stockName); const verified = identity && workflow.verifyStockForRecord( active.context.record, normalized, identity.market, identity.code); if (!verified) { state.stockStatus = "error"; setStatus("error", "같은 이름의 종목이 없거나 시장 identity가 모호합니다."); render(); return true; } continueAfterStockVerification(active.context, verified); return true; } function handleSelectionResult(payload) { return consumeRead("selection", payload, workflow.normalizeSelectionResponse, (normalized, active) => { try { const entry = workflow.createPlaylistEntry( active.context.snapshot, active.context.verifiedStock, normalized, { id: nextRequestId(), fadeDuration: 6 }); if (!workflow.isPlaylistEntryPlayable(entry)) throw new TypeError("Untrusted entry."); if (options.appendPlaylistEntry(entry) === false) { throw new Error("The host rejected the playlist entry."); } setStatus("ready", `${entry.title} ${SCREEN_LABELS[state.screen]} 항목을 추가했습니다.`); notify("플레이리스트에 추가했습니다."); log(`GraphE playlist add · ${entry.code} · ${entry.title}`); } catch { setStatus("error", "네이티브 확인 결과로 플레이리스트 항목을 만들 수 없습니다."); } }); } function handleReadError(slot, payload, normalizer) { const active = pending.get(slot); const requestId = payload && typeof payload === "object" ? payload.requestId : null; if (!active || requestId !== active.request.requestId) return false; const normalized = normalizer(payload, active.request); pending.clear(slot); setStatus("error", normalized?.message || "네이티브 오류 응답 형식이 올바르지 않습니다."); render(); return true; } function handleStockError(payload) { const active = pending.get("stock"); const requestId = payload && typeof payload === "object" ? payload.requestId : null; if (!active || requestId !== active.request.requestId) return false; const normalized = normalizeStockSearchError(payload, active.request); pending.clear("stock"); state.stockStatus = "error"; setStatus("error", normalized?.message || "종목 검색 오류 응답 형식이 올바르지 않습니다."); render(); return true; } function handleMutation(kind, payload) { const outcome = kind === "result" ? pending.acceptMutationResult(payload) : pending.acceptMutationError(payload); if (outcome.status === "stale") return false; if (outcome.status === "invalid") { setStatus("quarantined", getProcessWriteQuarantine().message); notify(getProcessWriteQuarantine().message); render(); return true; } if (kind === "error") { setStatus(outcome.value.outcomeUnknown ? "quarantined" : "error", outcome.value.message); notify(outcome.value.message); render(); return true; } const result = outcome.value; log(`GraphE DB ${result.operation} committed · ${result.screen} · ${result.affectedRows}`); notify("수동 재무 DB 변경이 커밋되었습니다."); if (result.operation === "delete-one" || result.operation === "delete-all") { state.snapshot = null; state.form = emptyForm(state.screen, seedStockName()); state.verifiedStock = null; } setStatus("ready", "DB 변경을 확인했습니다."); requestList(state.query); if (result.operation === "create" || result.operation === "update") { requestLoad(result.stockName); } render(); return true; } function handleGenericRequestError(payload) { const operation = payload && typeof payload === "object" ? payload.operation : null; const slot = operation === "list" ? "list" : operation === "load" ? "load" : operation === "selection" ? "selection" : ["create", "update", "delete-one", "delete-all"].includes(operation) ? "write" : null; const active = slot && pending.get(slot); if (!active || payload.requestId !== active.request.requestId) return false; const expectedOperation = slot === "write" ? active.context.operation : operation; const normalized = workflow.normalizeRequestError(payload, expectedOperation); pending.clear(slot); if (slot === "write" && !normalized) { latchWriteQuarantine( "수동 재무 DB 쓰기 요청 오류의 상관관계를 확인할 수 없습니다."); } const quarantined = slot === "write" && !normalized; setStatus( quarantined ? "quarantined" : "error", quarantined ? getProcessWriteQuarantine().message : normalized?.message || "네이티브 요청 오류 형식이 올바르지 않습니다."); render(); return true; } function handleMessage(type, payload) { switch (type) { case contract.listResultType: return handleListResult(payload); case contract.listErrorType: return handleReadError("list", payload, workflow.normalizeListError); case contract.loadResultType: return handleLoadResult(payload); case contract.loadErrorType: return handleReadError("load", payload, workflow.normalizeLoadError); case STOCK_SEARCH_RESULT: return handleStockResult(payload); case STOCK_SEARCH_ERROR: return handleStockError(payload); case contract.selectionResultType: return handleSelectionResult(payload); case contract.selectionErrorType: return handleReadError("selection", payload, workflow.normalizeSelectionError); case contract.mutationResultType: return handleMutation("result", payload); case contract.mutationErrorType: return handleMutation("error", payload); case contract.requestErrorType: return handleGenericRequestError(payload); default: return false; } } function makeElement(documentObject, tag, className, text) { const element = documentObject.createElement(tag); if (className) element.className = className; if (text !== undefined) element.textContent = text; return element; } function makeButton(documentObject, text, className, handler) { const button = makeElement(documentObject, "button", className, text); button.type = "button"; button.addEventListener("click", handler); return button; } function mount(host = typeof document === "object" ? document.body : null) { if (state.mounted) return refs.overlay; if (!host || typeof host.appendChild !== "function") { throw new TypeError("A DOM host is required to mount the manual-financial UI."); } const documentObject = host.ownerDocument || (root && root.document); if (!documentObject || typeof documentObject.createElement !== "function") { throw new TypeError("A DOM document is required to mount the manual-financial UI."); } const overlay = makeElement(documentObject, "div", "mfui-overlay"); overlay.hidden = true; const dialog = makeElement(documentObject, "section", "mfui-dialog"); dialog.setAttribute("role", "dialog"); dialog.setAttribute("aria-modal", "true"); dialog.setAttribute("aria-labelledby", `${instanceId}-title`); const header = makeElement(documentObject, "header", "mfui-header"); const heading = makeElement(documentObject, "div", "mfui-heading"); const title = makeElement(documentObject, "h2", "mfui-title", "수동 재무 입력"); title.id = `${instanceId}-title`; const subtitle = makeElement(documentObject, "p", "mfui-subtitle", "GraphE DB 행을 안전하게 조회합니다."); heading.append(title, subtitle); const close = makeButton(documentObject, "닫기", "mfui-close", () => { state.visible = false; render(); }); header.append(heading, close); const body = makeElement(documentObject, "div", "mfui-body"); const listPanel = makeElement(documentObject, "aside", "mfui-list-panel"); const searchForm = makeElement(documentObject, "form", "mfui-search"); const query = makeElement(documentObject, "input", "mfui-search-input"); query.type = "search"; query.maxLength = contract.maximumQueryLength; query.placeholder = "저장 종목명 검색"; query.setAttribute("aria-label", "수동 재무 저장 종목 검색"); const searchButton = makeElement(documentObject, "button", "mfui-search-button", "조회"); searchButton.type = "submit"; searchForm.append(query, searchButton); searchForm.addEventListener("submit", event => { event.preventDefault(); requestList(query.value); }); const listState = makeElement(documentObject, "p", "mfui-list-state", "목록을 열어 주세요."); const list = makeElement(documentObject, "div", "mfui-list"); list.setAttribute("role", "listbox"); listPanel.append(searchForm, listState, list); const editor = makeElement(documentObject, "article", "mfui-editor"); const editorTitle = makeElement(documentObject, "h3", "mfui-editor-title", "입력값"); const stockStatus = makeElement(documentObject, "p", "mfui-stock-state", "종목 identity 미확인"); const form = makeElement(documentObject, "div", "mfui-form"); const actions = makeElement(documentObject, "div", "mfui-actions"); const reset = makeButton(documentObject, "새 입력", "mfui-button", () => { if (writeBlocked()) { notify("진행 중인 GraphE 작업이 끝난 뒤 새 입력을 시작하세요."); return; } state.snapshot = null; state.form = emptyForm(state.screen, seedStockName()); state.verifiedStock = null; state.stockStatus = "idle"; setStatus("ready", "새 수동 재무 행을 입력할 수 있습니다."); render(); }); const verify = makeButton(documentObject, "종목 재검증", "mfui-button", () => beginStockVerification("verify-only")); const create = makeButton(documentObject, "신규 저장", "mfui-button mfui-primary", () => beginStockVerification("create")); const update = makeButton(documentObject, "수정 저장", "mfui-button", () => beginStockVerification("update")); const remove = makeButton(documentObject, "선택 삭제", "mfui-button mfui-danger", () => beginStockVerification("delete")); const removeAll = makeButton(documentObject, "전체 삭제", "mfui-button mfui-danger", deleteAll); const playlist = makeButton(documentObject, "플레이리스트 추가", "mfui-button mfui-accent", () => beginStockVerification("playlist")); actions.append(reset, verify, create, update, remove, removeAll, playlist); editor.append(editorTitle, stockStatus, form, actions); body.append(listPanel, editor); const footer = makeElement(documentObject, "footer", "mfui-footer"); const status = makeElement(documentObject, "p", "mfui-status", "준비됨"); const quarantine = makeElement(documentObject, "p", "mfui-quarantine"); footer.append(status, quarantine); dialog.append(header, body, footer); overlay.append(dialog); host.appendChild(overlay); Object.assign(refs, { overlay, dialog, title, subtitle, close, query, searchButton, listState, list, editorTitle, stockStatus, form, actions, reset, verify, create, update, remove, removeAll, playlist, status, quarantine, formInputs: new Map() }); state.mounted = true; render(); return overlay; } function appendField(container, labelText, key, options = {}) { const documentObject = container.ownerDocument; const field = makeElement(documentObject, "label", "mfui-field"); const label = makeElement(documentObject, "span", "mfui-field-label", labelText); const input = makeElement(documentObject, "input", "mfui-input"); input.type = options.type || "text"; input.inputMode = options.inputMode || "text"; input.maxLength = options.maxLength || 200; input.value = state.form?.[key] || ""; input.readOnly = options.readOnly === true; input.addEventListener("input", () => { state.form = freezeObject({ ...state.form, [key]: input.value }); if (key === "stockName") { state.verifiedStock = null; state.stockStatus = "idle"; } }); field.append(label, input); container.append(field); refs.formInputs.set(key, input); return input; } function appendGroup(titleText) { const documentObject = refs.form.ownerDocument; const group = makeElement(documentObject, "fieldset", "mfui-group"); const legend = makeElement(documentObject, "legend", "mfui-group-title", titleText); group.append(legend); refs.form.append(group); return group; } function renderForm() { if (!state.mounted) return; refs.form.replaceChildren(); refs.formInputs = new Map(); if (!state.screen || !state.form) return; appendField(refs.form, "종목명", "stockName", { readOnly: !!state.snapshot }); if (state.screen === "revenue-composition") { appendField(refs.form, "기준일", "baseDate"); for (let index = 1; index <= 5; index += 1) { const group = appendGroup(`구성 ${index}`); appendField(group, "구성명", `slice${index}Label`); appendField(group, "비율", `slice${index}Percentage`, { inputMode: "decimal", maxLength: 64 }); } } else if (state.screen === "growth-metrics") { const periods = appendGroup("분기"); for (let index = 1; index <= 4; index += 1) { appendField(periods, `분기 ${index}`, `period${index}`); } for (const [series, label] of GROWTH_SERIES) { const group = appendGroup(label); for (let index = 1; index <= 4; index += 1) { appendField(group, `값 ${index}`, `${series}${index}`, { inputMode: "decimal", maxLength: 64 }); } } } else { for (let index = 1; index <= 6; index += 1) { const group = appendGroup(`분기·금액 ${index}`); appendField(group, "분기", `quarter${index}`); appendField(group, "금액", `value${index}`, { inputMode: "numeric", maxLength: 16 }); } } } function renderList() { if (!state.mounted) return; refs.list.replaceChildren(); const documentObject = refs.list.ownerDocument; for (const snapshot of state.items) { const row = makeButton(documentObject, snapshot.stockName, "mfui-list-row", () => { requestLoad(snapshot.stockName); }); row.setAttribute("role", "option"); row.setAttribute("aria-selected", state.snapshot?.stockName === snapshot.stockName ? "true" : "false"); refs.list.append(row); } if (!state.items.length) { refs.list.append(makeElement(documentObject, "p", "mfui-empty", "조회된 행이 없습니다.")); } } function render() { const view = stateView(); if (!state.mounted) return view; refs.overlay.hidden = !state.visible; refs.title.textContent = state.screen ? SCREEN_LABELS[state.screen] : "수동 재무 입력"; refs.subtitle.textContent = state.profile ? `${state.profile.table} · ${state.profile.builderKey}/${state.profile.code}` : "GraphE DB 행을 안전하게 조회합니다."; refs.query.value = state.query; refs.listState.textContent = state.status === "loading" && pending.get("list") ? "목록 조회 중" : `${state.items.length}개 행`; refs.editorTitle.textContent = state.snapshot ? `${state.snapshot.stockName} 수정` : "신규 입력"; refs.stockStatus.textContent = state.stockStatus === "verified" && state.verifiedStock ? `${state.verifiedStock.market} · ${state.verifiedStock.code} 확인됨` : state.stockStatus === "loading" ? "종목 identity 확인 중" : state.stockStatus === "error" ? "종목 identity 확인 실패" : "종목 identity 미확인"; refs.status.textContent = state.message || state.status; refs.status.dataset.status = state.status; refs.quarantine.hidden = !view.writeQuarantined; refs.quarantine.textContent = view.quarantineMessage; const blocked = writeBlocked(); refs.searchButton.disabled = !!pending.get("list"); refs.verify.disabled = externallyLocked() || !!pending.get("stock") || !!pending.get("write") || !!pending.get("load") || !!pending.get("selection"); refs.reset.disabled = writeBlocked(); refs.create.disabled = blocked || !!state.snapshot; refs.update.disabled = blocked || !state.snapshot; refs.remove.disabled = blocked || !state.snapshot; refs.removeAll.disabled = blocked; refs.playlist.disabled = writeBlocked() || !state.snapshot; renderList(); renderForm(); return view; } function open(value) { if (pending.get("write")) { notify("DB 쓰기 결과를 기다리는 동안 다른 GraphE 화면으로 전환할 수 없습니다."); return false; } const screen = resolveScreen(value); if (!screen) { notify("지원하지 않는 수동 재무 화면입니다."); return false; } pending.clearReads(); state.screen = screen; state.profile = workflow.getScreenDefinition(screen); state.visible = true; state.query = ""; state.items = []; state.snapshot = null; state.form = emptyForm(screen, seedStockName()); state.stockStatus = "idle"; state.verifiedStock = null; setStatus("idle", "수동 재무 목록을 조회합니다."); render(); return requestList(""); } function setLocked(value) { if (typeof value !== "boolean") throw new TypeError("setLocked requires a boolean value."); state.locked = value; return render(); } return Object.freeze({ mount, open, handleMessage, render, setLocked }); } /** * App integration hook (intentionally not wired here): * 1. Load manual-financial-workflow.js, this file and manual-financial-ui.css. * 2. Construct one controller with createController(...), then call mount(document.body). * 3. Route the four legacy manual action clicks to controller.open(action). * 4. Offer every WebView message to controller.handleMessage(type, payload); it returns false * for unrelated/shared stock messages that do not match its active request. * 5. Mirror playlist PREPARED/PROGRAM/command locks through controller.setLocked(boolean). * The host must keep this controller alive while a native request is pending so correlation and * the process-lifetime OutcomeUnknown quarantine cannot be lost. */ return { createController, resolveScreen, emptyForm, recordToForm, formToRecord, createPendingCoordinator, getProcessWriteQuarantine }; });