Complete legacy operator UI and playout migration
This commit is contained in:
919
Web/manual-lists-ui.js
Normal file
919
Web/manual-lists-ui.js
Normal file
@@ -0,0 +1,919 @@
|
||||
(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,
|
||||
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,
|
||||
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 && !locked() && !state.quarantined;
|
||||
}
|
||||
|
||||
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 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
|
||||
});
|
||||
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
|
||||
});
|
||||
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;
|
||||
if (normalized.writeQuarantined) {
|
||||
state.quarantined = true;
|
||||
state.quarantineMessage = normalized.message || "수동 저장 결과를 확인할 수 없습니다.";
|
||||
}
|
||||
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)
|
||||
};
|
||||
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)
|
||||
};
|
||||
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",
|
||||
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;
|
||||
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.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" });
|
||||
header.append(title, 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",
|
||||
"수동 데이터 저장소를 사용할 수 없습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
requestViRead: () => requestViRead(),
|
||||
close: () => {
|
||||
state.open = false;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
mount,
|
||||
openNetSell,
|
||||
openVi,
|
||||
handleMessage,
|
||||
render,
|
||||
setLocked,
|
||||
actions,
|
||||
getState: snapshot
|
||||
});
|
||||
}
|
||||
|
||||
return { createController };
|
||||
});
|
||||
Reference in New Issue
Block a user