Migrate remaining legacy operator workflows
This commit is contained in:
@@ -15,19 +15,22 @@
|
||||
|
||||
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"])
|
||||
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") &&
|
||||
(value.requestStockSearch === undefined || typeof value.requestStockSearch === "function");
|
||||
return required.every(key => typeof value[key] === "function");
|
||||
}
|
||||
|
||||
function safeText(value, maximumLength) {
|
||||
@@ -35,6 +38,17 @@
|
||||
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));
|
||||
@@ -65,7 +79,15 @@
|
||||
themeResults: [],
|
||||
expertResults: [],
|
||||
selectedCatalogIndex: -1,
|
||||
selectedDraftIndex: -1,
|
||||
edit: null,
|
||||
stockSearch: {
|
||||
query: "",
|
||||
status: "idle",
|
||||
results: [],
|
||||
selectedIndex: -1,
|
||||
error: ""
|
||||
},
|
||||
status: "대기",
|
||||
error: ""
|
||||
};
|
||||
@@ -195,11 +217,23 @@
|
||||
: 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";
|
||||
@@ -251,6 +285,68 @@
|
||||
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;
|
||||
@@ -269,38 +365,46 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function addThemeItem(prefix, stockCode, stockName) {
|
||||
function addThemeItem() {
|
||||
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 identity = selectedStockIdentity();
|
||||
if (!draft || state.entity !== "theme" || !stockIsCompatibleWithDraft(identity, draft) ||
|
||||
draft.items.length >= workflow.limits.maximumThemeItems) {
|
||||
return notifyError("검색 결과에서 현재 ThemeA 시장의 종목 하나를 선택하세요.");
|
||||
}
|
||||
const itemCode = `${prefix}${stockCode}`;
|
||||
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: stockName }]
|
||||
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: stockName });
|
||||
draft.items.push({ inputIndex: draft.items.length, itemCode, itemName: storedName });
|
||||
resetStockSearch();
|
||||
state.error = "";
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function addExpertRecommendation(stockCode, stockName, buyAmount) {
|
||||
function addExpertRecommendation(buyAmount) {
|
||||
const draft = currentDraft();
|
||||
const identity = selectedStockIdentity();
|
||||
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 ||
|
||||
if (!draft || state.entity !== "expert" || !identity ||
|
||||
!Number.isSafeInteger(amount) || amount <= 0 ||
|
||||
draft.recommendations.length >= workflow.limits.maximumRecommendations) {
|
||||
return notifyError("종목 코드·이름과 1 이상의 정수 매수가를 정확히 입력하세요.");
|
||||
return notifyError("검색 결과에서 종목을 선택하고 1 이상의 정수 매수가를 입력하세요.");
|
||||
}
|
||||
const candidate = {
|
||||
expertCode: draft.expertCode,
|
||||
@@ -309,8 +413,8 @@
|
||||
...draft.recommendations,
|
||||
{
|
||||
playIndex: draft.recommendations.length,
|
||||
stockCode,
|
||||
stockName,
|
||||
stockCode: identity.code,
|
||||
stockName: identity.name,
|
||||
buyAmount: amount
|
||||
}
|
||||
]
|
||||
@@ -320,10 +424,11 @@
|
||||
}
|
||||
draft.recommendations.push({
|
||||
playIndex: draft.recommendations.length,
|
||||
stockCode,
|
||||
stockName,
|
||||
stockCode: identity.code,
|
||||
stockName: identity.name,
|
||||
buyAmount: amount
|
||||
});
|
||||
resetStockSearch();
|
||||
state.error = "";
|
||||
render();
|
||||
return true;
|
||||
@@ -340,6 +445,8 @@
|
||||
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;
|
||||
@@ -348,13 +455,74 @@
|
||||
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;
|
||||
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;
|
||||
@@ -506,6 +674,7 @@
|
||||
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)
|
||||
@@ -524,6 +693,7 @@
|
||||
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)
|
||||
@@ -532,12 +702,57 @@
|
||||
}
|
||||
|
||||
function failCurrentRead(pending, message) {
|
||||
completeRead(pending, () => {
|
||||
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;
|
||||
@@ -552,6 +767,7 @@
|
||||
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();
|
||||
@@ -588,6 +804,12 @@
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -674,6 +896,11 @@
|
||||
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())
|
||||
});
|
||||
@@ -766,23 +993,38 @@
|
||||
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 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 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(prefix, stockCode, stockName, buyAmount, addButton);
|
||||
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");
|
||||
@@ -790,7 +1032,14 @@
|
||||
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);
|
||||
editor.append(
|
||||
identity,
|
||||
stockSearchForm,
|
||||
stockStatus,
|
||||
stockResults,
|
||||
itemForm,
|
||||
draftList,
|
||||
actions);
|
||||
body.append(catalog, editor);
|
||||
|
||||
const error = assignRole(makeElement(documentValue, "p", "mbn-operator-catalog-error"), "error");
|
||||
@@ -815,11 +1064,15 @@
|
||||
editor,
|
||||
code,
|
||||
name,
|
||||
stockSearchForm,
|
||||
stockQuery,
|
||||
stockSearchButton,
|
||||
stockStatus,
|
||||
stockResults,
|
||||
selectedStock,
|
||||
itemForm,
|
||||
prefix,
|
||||
stockCode,
|
||||
stockName,
|
||||
buyAmount,
|
||||
addButton,
|
||||
draftList,
|
||||
saveButton,
|
||||
deleteButton,
|
||||
@@ -845,14 +1098,17 @@
|
||||
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(prefix.value, stockCode.value, stockName.value)
|
||||
: addExpertRecommendation(stockCode.value, stockName.value, buyAmount.value);
|
||||
? addThemeItem()
|
||||
: addExpertRecommendation(buyAmount.value);
|
||||
if (added) {
|
||||
stockCode.value = "";
|
||||
stockName.value = "";
|
||||
buyAmount.value = "";
|
||||
}
|
||||
});
|
||||
@@ -865,8 +1121,11 @@
|
||||
cancelButton.addEventListener("click", () => {
|
||||
state.mode = "list";
|
||||
state.edit = null;
|
||||
state.selectedDraftIndex = -1;
|
||||
resetStockSearch();
|
||||
render();
|
||||
});
|
||||
dialog.addEventListener("keydown", handleDraftDeleteKey);
|
||||
|
||||
state.mounted = true;
|
||||
render();
|
||||
@@ -899,7 +1158,14 @@
|
||||
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 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",
|
||||
@@ -913,15 +1179,44 @@
|
||||
up.type = down.type = remove.type = "button";
|
||||
up.disabled = index === 0 || locked();
|
||||
down.disabled = index + 1 === rows.length || locked();
|
||||
remove.disabled = 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", () => removeDraftItem(index));
|
||||
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();
|
||||
@@ -951,29 +1246,33 @@
|
||||
: "";
|
||||
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.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) {
|
||||
@@ -1003,6 +1302,9 @@
|
||||
beginNewTheme,
|
||||
beginNewExpert,
|
||||
updateDraft,
|
||||
updateStockQuery,
|
||||
searchStocks,
|
||||
selectStockResult,
|
||||
addThemeItem,
|
||||
addExpertRecommendation,
|
||||
moveDraftItem,
|
||||
|
||||
Reference in New Issue
Block a user