Files
MBN_STOCK_WEBVIEW/Web/manual-financial-ui.js

1266 lines
49 KiB
JavaScript

(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 DISPLAY_SORT_ASCENDING = "ascending";
const DISPLAY_SORT_DESCENDING = "descending";
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 compareDisplayStockNames(left, right) {
const foldedLeft = left.toUpperCase();
const foldedRight = right.toUpperCase();
if (foldedLeft < foldedRight) return -1;
if (foldedLeft > foldedRight) return 1;
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
function sortDisplaySnapshots(items, direction = DISPLAY_SORT_ASCENDING) {
if (!Array.isArray(items) ||
![DISPLAY_SORT_ASCENDING, DISPLAY_SORT_DESCENDING].includes(direction) ||
items.some(item => !item || typeof item.stockName !== "string")) {
throw new TypeError("Closed GraphE display rows and sort direction are required.");
}
const sign = direction === DISPLAY_SORT_ASCENDING ? 1 : -1;
return Object.freeze(items
.map((item, sourceIndex) => ({ item, sourceIndex }))
.sort((left, right) => {
const byName = compareDisplayStockNames(left.item.stockName, right.item.stockName);
return byName === 0 ? left.sourceIndex - right.sourceIndex : byName * sign;
})
.map(value => value.item));
}
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: "",
findQuery: "",
hasLoadedList: false,
sortDirection: DISPLAY_SORT_ASCENDING,
sourceItems: Object.freeze([]),
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,
findQuery: state.findQuery,
hasLoadedList: state.hasLoadedList,
sortDirection: state.sortDirection,
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;
state.findQuery = request.query;
state.hasLoadedList = false;
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 toggleDisplaySort() {
state.sortDirection = state.sortDirection === DISPLAY_SORT_ASCENDING
? DISPLAY_SORT_DESCENDING
: DISPLAY_SORT_ASCENDING;
state.items = sortDisplaySnapshots(state.sourceItems, state.sortDirection);
render();
return true;
}
function findBlocked() {
return !state.visible || !state.screen || !state.hasLoadedList ||
externallyLocked() || !!pending.get("list") || !!pending.get("load") ||
!!pending.get("write") || !!pending.get("stock") || !!pending.get("selection");
}
function requestNextMatchingLoad(query) {
if (findBlocked()) return false;
state.findQuery = textValue(query);
const needle = state.findQuery.toUpperCase();
if (!needle) return false;
const selectedName = state.snapshot?.stockName || null;
const selectedIndex = selectedName === null
? -1
: state.items.findIndex(item => item.stockName === selectedName);
for (let index = selectedIndex + 1; index < state.items.length; index += 1) {
const candidate = state.items[index];
if (candidate.stockName.toUpperCase().includes(needle)) {
return requestLoad(candidate.stockName);
}
}
return false;
}
function requestPreviousMatchingLoad(query) {
if (findBlocked() || !state.snapshot) return false;
state.findQuery = textValue(query);
const needle = state.findQuery.toUpperCase();
if (!needle) return false;
const selectedIndex = state.items.findIndex(
item => item.stockName === state.snapshot.stockName);
if (selectedIndex < 0) return false;
for (let index = selectedIndex - 1; index >= 0; index -= 1) {
const candidate = state.items[index];
if (candidate.stockName.toUpperCase().includes(needle)) {
return requestLoad(candidate.stockName);
}
}
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.sourceItems = normalized.items;
state.sortDirection = DISPLAY_SORT_ASCENDING;
state.items = sortDisplaySnapshots(state.sourceItems, state.sortDirection);
state.hasLoadedList = true;
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", "수동 재무 저장 종목 검색");
query.addEventListener("input", () => {
state.findQuery = query.value;
});
query.addEventListener("keydown", event => {
if (event.key !== "Enter" || event.isComposing || !state.hasLoadedList) return;
event.preventDefault();
requestNextMatchingLoad(query.value);
});
const searchButton = makeElement(documentObject, "button", "mfui-search-button", "조회");
searchButton.type = "submit";
const findPrevious = makeButton(
documentObject,
"위로 찾기",
"mfui-find-button mfui-find-previous",
() => requestPreviousMatchingLoad(query.value));
findPrevious.setAttribute("aria-label", "현재 선택 이전의 일치 종목 찾기");
const findNext = makeButton(
documentObject,
"아래로 찾기",
"mfui-find-button mfui-find-next",
() => requestNextMatchingLoad(query.value));
findNext.setAttribute("aria-label", "현재 선택 다음의 일치 종목 찾기");
searchForm.append(query, searchButton, findPrevious, findNext);
searchForm.addEventListener("submit", event => {
event.preventDefault();
requestList(query.value);
});
const listState = makeElement(documentObject, "p", "mfui-list-state", "목록을 열어 주세요.");
const sortHeader = makeButton(
documentObject,
"종목명 ↕",
"mfui-list-sort",
toggleDisplaySort);
sortHeader.setAttribute("aria-label", "종목명 오름차순, 내림차순으로 전환");
const list = makeElement(documentObject, "div", "mfui-list");
list.setAttribute("role", "listbox");
listPanel.append(searchForm, listState, sortHeader, 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,
findPrevious,
findNext,
listState,
sortHeader,
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.findQuery;
refs.listState.textContent = state.status === "loading" && pending.get("list")
? "목록 조회 중"
: `${state.items.length}개 행`;
refs.sortHeader.dataset.direction = state.sortDirection;
refs.sortHeader.setAttribute(
"aria-pressed",
state.sortDirection === DISPLAY_SORT_DESCENDING ? "true" : "false");
refs.sortHeader.setAttribute(
"aria-label",
state.sortDirection === DISPLAY_SORT_ASCENDING
? "종목명 오름차순, 내림차순으로 전환"
: "종목명 내림차순, 오름차순으로 전환");
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.findPrevious.disabled = findBlocked();
refs.findNext.disabled = findBlocked();
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.findQuery = "";
state.hasLoadedList = false;
state.sortDirection = DISPLAY_SORT_ASCENDING;
state.sourceItems = Object.freeze([]);
state.items = Object.freeze([]);
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,
sortDisplaySnapshots,
createPendingCoordinator,
getProcessWriteQuarantine
};
});