Files
MBN_STOCK_WEBVIEW/Web/operator-catalog-ui.js

1019 lines
40 KiB
JavaScript

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