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