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

990 lines
36 KiB
JavaScript

(function (root, factory) {
"use strict";
const api = Object.freeze(factory(root));
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnManualListsUi = api;
})(typeof globalThis === "object" ? globalThis : this, function (root) {
"use strict";
const NET_KEYS = Object.freeze(["leftName", "leftAmount", "rightName", "rightAmount"]);
const STOCK_MARKETS = Object.freeze(["kospi", "kosdaq", "nxt-kospi", "nxt-kosdaq"]);
const STOCK_SOURCES = Object.freeze(["oracle", "mariaDb"]);
const MAXIMUM_STOCK_RESULTS = 200;
const DEFAULT_FADE_DURATION = 6;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const stockCodePattern = /^[A-Za-z0-9._-]{1,63}$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
function exactKeys(value, keys) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
function safeText(value, maximumLength, allowEmpty = true) {
return typeof value === "string" && value.length <= maximumLength &&
(allowEmpty || value.length > 0) && !value.includes("^") && !unsafeTextPattern.test(value);
}
function copyRows(rows) {
return rows.map(row => Object.freeze({
leftName: row.leftName,
leftAmount: row.leftAmount,
rightName: row.rightName,
rightAmount: row.rightAmount
}));
}
function copyItems(items) {
return items.map(item => Object.freeze({ code: item.code, name: item.name }));
}
function rowsEqual(left, right) {
return Array.isArray(left) && Array.isArray(right) && left.length === right.length &&
left.every((row, index) => NET_KEYS.every(key => row[key] === right[index][key]));
}
function itemsEqual(left, right) {
return Array.isArray(left) && Array.isArray(right) && left.length === right.length &&
left.every((item, index) => item.code === right[index].code && item.name === right[index].name);
}
function emptyRows() {
return Array.from({ length: 5 }, () => ({
leftName: "",
leftAmount: "",
rightName: "",
rightAmount: ""
}));
}
function resolveWorkflow() {
if (root && root.MbnManualListsWorkflow) return root.MbnManualListsWorkflow;
if (typeof require === "function") return require("./manual-lists-workflow.js");
throw new Error("MbnManualListsWorkflow must be loaded before the manual-list UI.");
}
function normalizeStockResult(value) {
if (!exactKeys(value, ["market", "source", "name", "code"]) ||
!STOCK_MARKETS.includes(value.market) || !STOCK_SOURCES.includes(value.source) ||
!safeText(value.name, 200, false) || value.name !== value.name.trim() || value.name.includes(",") ||
typeof value.code !== "string" || !stockCodePattern.test(value.code)) return null;
return Object.freeze({
market: value.market,
source: value.source,
name: value.name,
code: value.code
});
}
function normalizeStockSearchResponse(value, expected) {
if (!exactKeys(value, [
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
]) || !expected || value.requestId !== expected.requestId || value.query !== expected.query ||
!identifierPattern.test(value.requestId) || !safeText(value.query, 100) ||
typeof value.retrievedAt !== "string" || !Number.isFinite(Date.parse(value.retrievedAt)) ||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
typeof value.truncated !== "boolean" || !Array.isArray(value.results) ||
value.results.length > expected.maximumResults || value.totalRowCount < value.results.length) return null;
const results = value.results.map(normalizeStockResult);
if (!results.every(Boolean)) return null;
const identities = new Set(results.map(item => `${item.market}\u0000${item.code}\u0000${item.name}`));
if (identities.size !== results.length) return null;
return Object.freeze({
requestId: value.requestId,
query: value.query,
retrievedAt: value.retrievedAt,
totalRowCount: value.totalRowCount,
truncated: value.truncated,
results: Object.freeze(results)
});
}
function normalizeStockSearchError(value, expected) {
if (!exactKeys(value, ["requestId", "query", "message"]) || !expected ||
value.requestId !== expected.requestId || value.query !== expected.query ||
!identifierPattern.test(value.requestId) || !safeText(value.query, 100) ||
!safeText(value.message, 512, false)) return null;
return Object.freeze({ ...value });
}
function createController(dependencies) {
const workflow = resolveWorkflow();
if (!exactKeys(dependencies, [
"postNative", "appendPlaylistEntry", "isLocked", "showToast", "addLog", "createId"
]) || !Object.values(dependencies).every(value => typeof value === "function")) {
throw new TypeError("Closed manual-list UI dependencies are required.");
}
const {
postNative,
appendPlaylistEntry,
isLocked,
showToast,
addLog,
createId
} = dependencies;
const state = {
host: null,
root: null,
open: false,
mode: null,
manualLocked: false,
available: null,
unavailableCode: "",
unavailableMessage: "",
quarantined: false,
quarantineMessage: "",
audience: "INDIVIDUAL",
netRows: emptyRows(),
netRevision: 0,
netVerified: null,
viItems: [],
viBaseVersion: null,
viVersion: null,
viRevision: 0,
viVerified: null,
searchQuery: "",
searchResults: [],
searchMessage: "",
pending: {
status: null,
legacyImport: null,
netRead: null,
netSave: null,
viRead: null,
viSave: null,
stockSearch: null
}
};
function locked() {
try {
return state.manualLocked || Boolean(isLocked());
} catch {
return true;
}
}
function canMutate() {
return state.available === true && !state.pending.status && !state.pending.legacyImport &&
!locked() && !state.quarantined;
}
function hasPendingManualDataOperation() {
return Boolean(
state.pending.status || state.pending.legacyImport ||
state.pending.netRead || state.pending.netSave ||
state.pending.viRead || state.pending.viSave);
}
function notify(message, kind = "info") {
try { showToast(message, kind); } catch { /* host notification is best effort */ }
}
function log(message) {
try { addLog(message); } catch { /* host logging is best effort */ }
}
function nextId() {
const value = createId();
if (typeof value !== "string" || !identifierPattern.test(value)) {
throw new TypeError("createId returned an unsafe identifier.");
}
return value;
}
function send(request) {
postNative(request.type, request.payload);
return request.payload.requestId;
}
function requestStatus() {
const requestId = nextId();
state.pending.status = { requestId };
send(workflow.createStatusRequest(requestId));
}
function importLegacyManualData() {
if (!canMutate() || hasPendingManualDataOperation()) return false;
const confirmation =
"원본 프로젝트의 개인.dat, 외국인.dat, 기관.dat, VI발동.dat를 " +
"앱 전용 폴더로 한 번만 가져옵니다. 기존 대상 파일이 하나라도 있으면 " +
"아무것도 덮어쓰지 않고 중단합니다. 계속하시겠습니까?";
let confirmed = false;
try {
confirmed = root && typeof root.confirm === "function" && root.confirm(confirmation) === true;
} catch {
confirmed = false;
}
if (!confirmed) return false;
const request = workflow.createLegacyImportRequest(nextId());
state.pending.legacyImport = { requestId: request.payload.requestId };
state.netVerified = null;
state.viVerified = null;
send(request);
render();
return true;
}
function requestNetRead(audience, verification = null) {
state.netVerified = null;
const requestId = nextId();
state.pending.netRead = {
requestId,
audience,
revision: state.netRevision,
verification
};
send(workflow.createNetSellReadRequest(requestId, audience));
}
function requestViRead(verification = null) {
state.viVersion = null;
state.viVerified = null;
const requestId = nextId();
state.pending.viRead = {
requestId,
revision: state.viRevision,
verification
};
send(workflow.createViReadRequest(requestId));
}
function invalidateNetVerification() {
state.netRevision += 1;
state.netVerified = null;
}
function invalidateViVerification() {
state.viRevision += 1;
state.viVersion = null;
state.viVerified = null;
}
function setAudience(audience) {
const normalized = workflow.normalizeAudience(audience);
if (!normalized) throw new RangeError("Unknown FSell audience.");
state.audience = normalized;
state.netRows = emptyRows();
invalidateNetVerification();
requestNetRead(normalized);
render();
}
function setNetCell(rowIndex, key, value) {
if (locked() || !Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= 5 ||
!NET_KEYS.includes(key) || typeof value !== "string" || value.length > 256) return false;
state.netRows[rowIndex][key] = value;
invalidateNetVerification();
updateGateButtons();
return true;
}
function saveNetSell() {
if (!canMutate() || state.pending.netRead || state.pending.netSave) return false;
let request;
try {
request = workflow.createNetSellSaveRequest(nextId(), state.audience, state.netRows);
} catch {
notify("순매도 입력 5행의 값을 확인하세요.", "error");
return false;
}
const snapshot = copyRows(request.payload.rows);
state.pending.netSave = {
requestId: request.payload.requestId,
audience: request.payload.audience,
rows: snapshot,
revision: state.netRevision
};
state.netVerified = null;
send(request);
render();
return true;
}
function addNetSellCut() {
const verified = state.netVerified;
if (!canMutate() || !verified || verified.audience !== state.audience ||
!rowsEqual(verified.rows, state.netRows)) return false;
try {
const entry = workflow.createNetSellPlaylistEntry(state.audience, {
id: nextId(),
fadeDuration: DEFAULT_FADE_DURATION
}, verified.freshRead);
appendPlaylistEntry(entry);
notify("수동 순매도 컷을 playlist에 추가했습니다.", "success");
log(`수동 순매도 컷 추가 · ${state.audience}`);
return true;
} catch {
notify("수동 순매도 컷을 추가하지 못했습니다.", "error");
return false;
}
}
function searchStocks(query = state.searchQuery) {
const normalizedQuery = typeof query === "string" ? query.trim() : "";
if (!safeText(normalizedQuery, 100)) return false;
const requestId = nextId();
const request = {
requestId,
query: normalizedQuery,
maximumResults: MAXIMUM_STOCK_RESULTS
};
state.searchQuery = normalizedQuery;
state.searchResults = [];
state.searchMessage = "검색 중…";
state.pending.stockSearch = request;
postNative("search-stocks", request);
render();
return true;
}
function toViItem(stock) {
const prefix = stock.market.endsWith("kospi") ? "P" : "D";
return workflow.normalizeViItem({ code: `${prefix}${stock.code}`, name: stock.name });
}
function addSearchResult(index) {
if (locked() || !Number.isInteger(index) || index < 0 ||
index >= state.searchResults.length || state.viItems.length >= workflow.maximumViItems) return false;
const item = toViItem(state.searchResults[index]);
if (!item) return false;
state.viItems.push({ code: item.code, name: item.name });
invalidateViVerification();
render();
return true;
}
function removeViItem(index) {
if (locked() || !Number.isInteger(index) || index < 0 || index >= state.viItems.length) return false;
state.viItems.splice(index, 1);
invalidateViVerification();
render();
return true;
}
function moveViItem(index, delta) {
if (locked() || !Number.isInteger(index) || ![-1, 1].includes(delta)) return false;
const target = index + delta;
if (index < 0 || index >= state.viItems.length || target < 0 || target >= state.viItems.length) return false;
[state.viItems[index], state.viItems[target]] = [state.viItems[target], state.viItems[index]];
invalidateViVerification();
render();
return true;
}
function clearViItems() {
if (locked() || state.viItems.length === 0) return false;
state.viItems = [];
invalidateViVerification();
render();
return true;
}
function saveVi() {
if (!canMutate() || !state.viBaseVersion || state.pending.viRead || state.pending.viSave) return false;
let request;
try {
request = workflow.createViSaveRequest(nextId(), state.viItems, state.viBaseVersion);
} catch {
notify("VI 목록 값을 확인하세요.", "error");
return false;
}
state.pending.viSave = {
requestId: request.payload.requestId,
expectedVersion: request.payload.expectedVersion,
items: copyItems(request.payload.items),
revision: state.viRevision
};
state.viVerified = null;
send(request);
render();
return true;
}
function addViCut() {
const verified = state.viVerified;
if (!canMutate() || state.viItems.length === 0 || !verified ||
verified.version !== state.viVersion || !itemsEqual(verified.items, state.viItems)) return false;
try {
const entry = workflow.createViPlaylistEntry(state.viItems, state.viVersion, {
id: nextId(),
fadeDuration: DEFAULT_FADE_DURATION
}, verified.freshRead);
appendPlaylistEntry(entry);
notify("VI 발동 컷을 playlist에 추가했습니다.", "success");
log(`VI 발동 컷 추가 · ${state.viItems.length}종목 · ${entry.pageCount}페이지`);
return true;
} catch {
notify("최신 VI 목록을 다시 조회한 뒤 추가하세요.", "error");
return false;
}
}
function handleStatus(payload) {
const pending = state.pending.status;
if (!pending) return false;
const normalized = workflow.normalizeStatusResponse(payload, pending.requestId);
if (!normalized) return false;
state.pending.status = null;
state.available = normalized.available;
state.unavailableCode = normalized.available ? "" : normalized.code;
state.unavailableMessage = normalized.available ? "" : (normalized.message || "");
if (normalized.writeQuarantined) {
state.quarantined = true;
state.quarantineMessage = normalized.message || "수동 저장 결과를 확인할 수 없습니다.";
}
render();
return true;
}
function handleLegacyImport(payload) {
const pending = state.pending.legacyImport;
if (!pending) return false;
const normalized = workflow.normalizeLegacyImportResponse(payload, pending.requestId);
if (!normalized) return false;
state.pending.legacyImport = null;
state.netVerified = null;
state.viVerified = null;
notify(
`원본 수동 데이터를 가져왔습니다. 순매도 ${normalized.netSellRowCount}행 · VI ${normalized.viItemCount}종목`,
"success");
log(`원본 수동 데이터 가져오기 완료 · marker v${normalized.markerVersion}`);
if (state.mode === "net") requestNetRead(state.audience);
if (state.mode === "vi") requestViRead();
render();
return true;
}
function handleNetData(payload) {
const pending = state.pending.netRead;
if (!pending) return false;
const normalized = workflow.normalizeNetSellDataResponse(payload, pending);
if (!normalized) return false;
state.pending.netRead = null;
if (pending.audience !== state.audience || pending.revision !== state.netRevision) return true;
state.netRows = copyRows(normalized.rows).map(row => ({ ...row }));
if (pending.verification &&
pending.verification.audience === state.audience &&
rowsEqual(pending.verification.rows, normalized.rows)) {
state.netVerified = {
audience: state.audience,
rows: copyRows(normalized.rows),
freshRead: normalized
};
notify("저장 파일 재조회가 입력과 일치합니다.", "success");
} else {
state.netVerified = null;
}
render();
return true;
}
function handleNetSave(payload) {
const pending = state.pending.netSave;
if (!pending) return false;
const normalized = workflow.normalizeNetSellSaveResponse(payload, pending);
if (!normalized) return false;
state.pending.netSave = null;
requestNetRead(pending.audience, {
audience: pending.audience,
rows: pending.rows
});
notify("저장했습니다. 파일을 다시 확인하고 있습니다.", "success");
render();
return true;
}
function handleViData(payload) {
const pending = state.pending.viRead;
if (!pending) return false;
const normalized = workflow.normalizeViDataResponse(payload, pending.requestId);
if (!normalized) return false;
state.pending.viRead = null;
if (pending.revision !== state.viRevision) return true;
state.viItems = copyItems(normalized.items).map(item => ({ ...item }));
state.viBaseVersion = normalized.version;
state.viVersion = normalized.version;
const verification = pending.verification;
if (verification && verification.persisted &&
(verification.version !== normalized.version ||
!itemsEqual(verification.items, normalized.items))) {
state.viVerified = null;
notify("저장 후 VI 파일 내용이 달라 컷 추가를 차단했습니다.", "error");
} else {
state.viVerified = {
version: normalized.version,
items: copyItems(normalized.items),
freshRead: normalized
};
if (verification) {
notify(
verification.persisted
? "저장 파일 재조회가 VI 입력과 일치합니다."
: "빈 목록은 원본 동작에 따라 파일을 변경하지 않았습니다.",
"success");
}
}
render();
return true;
}
function handleViSave(payload) {
const pending = state.pending.viSave;
if (!pending) return false;
const normalized = workflow.normalizeViSaveResponse(payload, pending.requestId);
if (!normalized) return false;
state.pending.viSave = null;
requestViRead({
items: pending.items,
version: normalized.version,
persisted: normalized.persisted
});
notify(
normalized.persisted
? "VI 목록을 저장했습니다. 파일을 다시 확인하고 있습니다."
: "빈 VI 목록은 원본 동작에 따라 저장 파일을 변경하지 않았습니다.",
"success");
render();
return true;
}
const pendingOperations = Object.freeze({
status: "status",
legacyImport: "import-legacy-manual-data",
netRead: "read-net-sell",
netSave: "save-net-sell",
viRead: "read-vi",
viSave: "save-vi"
});
function handleManualError(payload) {
for (const [key, operation] of Object.entries(pendingOperations)) {
const pending = state.pending[key];
if (!pending || payload?.requestId !== pending.requestId) continue;
const normalized = workflow.normalizeError(payload, pending.requestId);
if (!normalized || normalized.operation !== operation) return false;
state.pending[key] = null;
if (key === "status") {
state.available = false;
state.unavailableCode = normalized.code;
state.unavailableMessage = normalized.message;
}
if (key === "viSave" && normalized.code === "STALE_DATA") {
state.viBaseVersion = null;
state.viVersion = null;
state.viVerified = null;
}
if (normalized.outcomeUnknown) {
state.quarantined = true;
state.quarantineMessage = normalized.message;
state.netVerified = null;
state.viVerified = null;
notify("저장 결과가 불명확하여 앱 재시작 전까지 재저장을 차단합니다.", "error");
} else {
notify(normalized.message, "error");
}
log(`수동 목록 오류 · ${normalized.operation} · ${normalized.code}`);
render();
return true;
}
return false;
}
function handleStockResults(payload) {
const pending = state.pending.stockSearch;
if (!pending) return false;
const normalized = normalizeStockSearchResponse(payload, pending);
if (!normalized) return false;
state.pending.stockSearch = null;
state.searchResults = [...normalized.results];
state.searchMessage = normalized.results.length === 0
? "검색 결과가 없습니다."
: `${normalized.results.length}${normalized.truncated ? " · 일부 결과" : ""}`;
render();
return true;
}
function handleStockError(payload) {
const pending = state.pending.stockSearch;
if (!pending) return false;
const normalized = normalizeStockSearchError(payload, pending);
if (!normalized) return false;
state.pending.stockSearch = null;
state.searchResults = [];
state.searchMessage = normalized.message;
notify(normalized.message, "error");
render();
return true;
}
function handleMessage(type, payload) {
switch (type) {
case workflow.bridgeContract.statusResultType: return handleStatus(payload);
case workflow.bridgeContract.legacyImportResultType: return handleLegacyImport(payload);
case workflow.bridgeContract.netSellDataType: return handleNetData(payload);
case workflow.bridgeContract.netSellSaveResultType: return handleNetSave(payload);
case workflow.bridgeContract.viDataType: return handleViData(payload);
case workflow.bridgeContract.viSaveResultType: return handleViSave(payload);
case workflow.bridgeContract.errorType: return handleManualError(payload);
case "stock-search-results": return handleStockResults(payload);
case "stock-search-error": return handleStockError(payload);
default: return false;
}
}
function element(tag, className, text) {
const node = state.root.ownerDocument.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(text, action, options = {}) {
const node = element("button", options.className || "manual-lists-button", text);
node.type = "button";
node.disabled = Boolean(options.disabled);
if (options.title) node.title = options.title;
node.addEventListener("click", action);
return node;
}
function renderHeader(panel) {
const header = element("header", "manual-lists-header");
const title = element(
"h2",
"manual-lists-title",
state.mode === "net" ? "수동 순매도 상위" : "VI 발동 목록");
const close = button("닫기", () => {
state.open = false;
render();
}, { className: "manual-lists-close" });
const legacyImport = button("원본 데이터 가져오기", importLegacyManualData, {
className: "manual-lists-button",
disabled: !canMutate() || hasPendingManualDataOperation(),
title: "원본 프로젝트의 FSell/VI 파일을 앱 전용 폴더로 한 번만 가져옵니다."
});
header.append(title, legacyImport, close);
panel.append(header);
}
function renderBanner(panel) {
if (state.quarantined) {
const banner = element(
"div",
"manual-lists-banner manual-lists-banner-danger",
state.quarantineMessage || "저장 결과가 불명확하여 앱을 다시 시작하기 전까지 저장할 수 없습니다.");
banner.setAttribute("role", "alert");
panel.append(banner);
} else if (locked()) {
panel.append(element(
"div",
"manual-lists-banner",
"PREPARE 또는 송출 중에는 목록을 변경할 수 없습니다."));
} else if (state.available === false) {
panel.append(element(
"div",
"manual-lists-banner manual-lists-banner-danger",
state.unavailableMessage || "수동 데이터 저장소를 사용할 수 없습니다."));
}
}
function renderNet(panel) {
const tabs = element("div", "manual-lists-tabs");
for (const audience of workflow.audiences) {
const tab = button(workflow.audienceLabels[audience], () => setAudience(audience), {
className: audience === state.audience
? "manual-lists-tab is-active"
: "manual-lists-tab",
disabled: Boolean(state.pending.netSave)
});
tab.setAttribute("aria-pressed", String(audience === state.audience));
tabs.append(tab);
}
panel.append(tabs);
const table = element("table", "manual-lists-table manual-lists-net-table");
const head = element("thead");
const headRow = element("tr");
for (const text of ["#", "좌측 종목", "좌측 금액", "우측 종목", "우측 금액"]) {
headRow.append(element("th", "", text));
}
head.append(headRow);
table.append(head);
const body = element("tbody");
state.netRows.forEach((row, rowIndex) => {
const tr = element("tr");
tr.append(element("th", "manual-lists-row-number", String(rowIndex + 1)));
for (const key of NET_KEYS) {
const td = element("td");
const input = element("input", "manual-lists-input");
input.type = "text";
input.maxLength = 256;
input.value = row[key];
input.disabled = locked() || Boolean(state.pending.netSave);
input.setAttribute("aria-label", `${rowIndex + 1}${key}`);
input.addEventListener("input", event => setNetCell(rowIndex, key, event.currentTarget.value));
td.append(input);
tr.append(td);
}
body.append(tr);
});
table.append(body);
const tableWrap = element("div", "manual-lists-table-wrap");
tableWrap.append(table);
panel.append(tableWrap);
const actions = element("div", "manual-lists-actions");
actions.append(
button("재조회", () => requestNetRead(state.audience), {
disabled: Boolean(state.pending.netRead || state.pending.netSave)
}),
button("저장", saveNetSell, {
className: "manual-lists-button is-primary",
disabled: !canMutate() || Boolean(state.pending.netRead || state.pending.netSave)
}),
button("s5025 추가", addNetSellCut, {
className: "manual-lists-button is-accent",
disabled: !canMutate() || !state.netVerified ||
!rowsEqual(state.netVerified.rows, state.netRows)
})
);
panel.append(actions);
}
function renderSearch(panel) {
const section = element("section", "manual-lists-search");
const controls = element("div", "manual-lists-search-controls");
const input = element("input", "manual-lists-input manual-lists-search-input");
input.type = "search";
input.maxLength = 100;
input.placeholder = "종목명 검색";
input.value = state.searchQuery;
input.addEventListener("input", event => { state.searchQuery = event.currentTarget.value; });
input.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
searchStocks(event.currentTarget.value);
}
});
controls.append(input, button("검색", () => searchStocks(input.value), {
disabled: Boolean(state.pending.stockSearch)
}));
section.append(controls);
section.append(element("p", "manual-lists-search-state", state.searchMessage));
const results = element("div", "manual-lists-search-results");
state.searchResults.forEach((stock, index) => {
results.append(button(
`${stock.name} · ${stock.code} · ${stock.market}`,
() => addSearchResult(index),
{
className: "manual-lists-search-result",
disabled: locked() || state.viItems.length >= workflow.maximumViItems
}));
});
section.append(results);
panel.append(section);
}
function renderVi(panel) {
renderSearch(panel);
const summary = element(
"div",
"manual-lists-summary",
`${state.viItems.length}/${workflow.maximumViItems}종목 · ${state.viItems.length === 0 ? 0 : Math.ceil(state.viItems.length / workflow.viPageSize)}페이지`);
panel.append(summary);
const tableWrap = element("div", "manual-lists-table-wrap");
const table = element("table", "manual-lists-table manual-lists-vi-table");
const head = element("thead");
const headRow = element("tr");
for (const text of ["#", "코드", "종목명", "순서/삭제"]) headRow.append(element("th", "", text));
head.append(headRow);
table.append(head);
const body = element("tbody");
state.viItems.forEach((item, index) => {
const row = element("tr");
row.append(
element("td", "manual-lists-row-number", String(index + 1)),
element("td", "manual-lists-code", item.code),
element("td", "manual-lists-name", item.name)
);
const controls = element("td", "manual-lists-row-actions");
controls.append(
button("↑", () => moveViItem(index, -1), {
disabled: locked() || index === 0,
title: "위로"
}),
button("↓", () => moveViItem(index, 1), {
disabled: locked() || index === state.viItems.length - 1,
title: "아래로"
}),
button("삭제", () => removeViItem(index), { disabled: locked() })
);
row.append(controls);
body.append(row);
});
table.append(body);
tableWrap.append(table);
panel.append(tableWrap);
const actions = element("div", "manual-lists-actions");
actions.append(
button("전체 삭제", clearViItems, {
disabled: locked() || state.viItems.length === 0
}),
button("재조회", () => requestViRead(), {
disabled: Boolean(state.pending.viRead || state.pending.viSave)
}),
button("저장", saveVi, {
className: "manual-lists-button is-primary",
disabled: !canMutate() || !state.viBaseVersion ||
Boolean(state.pending.viRead || state.pending.viSave)
}),
button("s5074 추가", addViCut, {
className: "manual-lists-button is-accent",
disabled: !canMutate() || state.viItems.length === 0 || !state.viVerified ||
state.viVerified.version !== state.viVersion ||
!itemsEqual(state.viVerified.items, state.viItems)
})
);
panel.append(actions);
}
function updateGateButtons() {
if (!state.root) return;
const add = state.root.querySelector(".manual-lists-button.is-accent");
if (!add) return;
add.disabled = state.mode === "net"
? !canMutate() || !state.netVerified || !rowsEqual(state.netVerified.rows, state.netRows)
: !canMutate() || state.viItems.length === 0 || !state.viVerified ||
state.viVerified.version !== state.viVersion || !itemsEqual(state.viVerified.items, state.viItems);
}
function snapshot() {
return Object.freeze({
open: state.open,
mode: state.mode,
locked: locked(),
available: state.available,
unavailableCode: state.unavailableCode,
unavailableMessage: state.unavailableMessage,
quarantined: state.quarantined,
audience: state.audience,
netRows: Object.freeze(copyRows(state.netRows)),
netVerified: Boolean(state.netVerified && rowsEqual(state.netVerified.rows, state.netRows)),
viItems: Object.freeze(copyItems(state.viItems)),
viBaseVersion: state.viBaseVersion,
viVersion: state.viVersion,
viVerified: Boolean(state.viVerified && state.viVerified.version === state.viVersion &&
itemsEqual(state.viVerified.items, state.viItems)),
searchResults: Object.freeze(state.searchResults.map(item => ({ ...item }))),
pending: Object.freeze(Object.fromEntries(
Object.entries(state.pending).map(([key, value]) => [key, value?.requestId || null])))
});
}
function render() {
if (!state.root) return snapshot();
state.root.hidden = !state.open;
state.root.replaceChildren();
if (!state.open) return snapshot();
const backdrop = element("div", "manual-lists-backdrop");
const panel = element("section", "manual-lists-dialog");
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
renderHeader(panel);
renderBanner(panel);
if (state.mode === "net") renderNet(panel);
if (state.mode === "vi") renderVi(panel);
backdrop.append(panel);
state.root.append(backdrop);
return snapshot();
}
function mount(host) {
const resolvedHost = host || root?.document?.body;
if (!resolvedHost || typeof resolvedHost.append !== "function" || !resolvedHost.ownerDocument) {
throw new TypeError("A DOM host is required to mount the manual-list UI.");
}
if (state.root?.parentNode) state.root.remove();
state.host = resolvedHost;
state.root = resolvedHost.ownerDocument.createElement("div");
state.root.className = "manual-lists-root";
state.root.hidden = true;
resolvedHost.append(state.root);
render();
return state.root;
}
function openNetSell(audience = state.audience) {
const normalized = workflow.normalizeAudience(audience);
if (!normalized) throw new RangeError("Unknown FSell audience.");
state.open = true;
state.mode = "net";
state.audience = normalized;
state.netRows = emptyRows();
invalidateNetVerification();
requestStatus();
requestNetRead(normalized);
render();
return snapshot();
}
function openVi() {
state.open = true;
state.mode = "vi";
requestStatus();
requestViRead();
render();
return snapshot();
}
function setLocked(value) {
state.manualLocked = Boolean(value);
render();
return state.manualLocked;
}
const actions = Object.freeze({
setAudience,
setNetCell,
saveNetSell,
addNetSellCut,
requestNetRead: () => requestNetRead(state.audience),
searchStocks,
addSearchResult,
removeViItem,
moveViItem,
clearViItems,
saveVi,
addViCut,
importLegacyManualData,
requestViRead: () => requestViRead(),
close: () => {
state.open = false;
render();
}
});
return Object.freeze({
mount,
openNetSell,
openVi,
handleMessage,
render,
setLocked,
actions,
getState: snapshot
});
}
return { createController };
});