refactor(M01): Z01_MasterData 폴더째 old_code 로 옮기고 등록·문구 끊음 (PLAN 7-2)
라우터·라우트·문구에서 Z01 을 끊음 — M01 만 남김. 시험 여섯도 old_code/resources/tester 로 같이 옮김. Z01 이 읽던 옛 자료 셋은 디스크에 이미 없음 · master_data/old 는 B08·B09 도 읽어 남김. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
/* =============================================================================
|
||||
* Z01_MasterData_UI_Rows.ts
|
||||
* 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 칸을 고침
|
||||
*
|
||||
* 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `save` 가 있어 editable 칸을 고침.
|
||||
* 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 원래 값 · 산출근거가 섬
|
||||
* (2026-09-15 브레인 — 계산으로 나오는 칸은 못 고치고 왜 못 고치는지 옆에 보일 것).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch";
|
||||
import {
|
||||
boldSegments,
|
||||
cellInfo,
|
||||
clampPage,
|
||||
columnTitle,
|
||||
filterValue,
|
||||
latestOnly,
|
||||
nextSort,
|
||||
noticeLines,
|
||||
outdatedBanner,
|
||||
pageCount,
|
||||
parseCellInput,
|
||||
rowCells,
|
||||
shownColumns,
|
||||
valueWithUnit,
|
||||
type CellInfo,
|
||||
type MasterColumn,
|
||||
type SortOrder,
|
||||
} from "./Z01_MasterData_UI_Cells";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const SEARCH_DELAY_MS = 300;
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export interface RowsSource {
|
||||
title: string;
|
||||
key: string;
|
||||
/** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표(고친 것 모아 보기) */
|
||||
searchable?: boolean;
|
||||
load: (query: RowsQuery) => Promise<MasterRows>;
|
||||
/** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */
|
||||
save?: (rowId: string, values: Row) => Promise<Row>;
|
||||
}
|
||||
|
||||
export interface RowsView {
|
||||
root: HTMLElement;
|
||||
show: (source: RowsSource) => void;
|
||||
}
|
||||
|
||||
/** 사전 문구의 {value} 자리에 값을 넣음. */
|
||||
function fill(key: Parameters<typeof L>[0], value: string): string {
|
||||
return L(key).replace("{value}", value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 뜻이 바뀐 줄의 옛 이름·지금 이름 — 서버가 합쳐 한 줄로 줌(자재는 품명·규격·인도조건이 이어 붙어 옴).
|
||||
* 화면은 쪼개거나 다시 합치지 않음. 안 오면 버그로 드러냄.
|
||||
*/
|
||||
function meaningNames(info: CellInfo): string {
|
||||
if (!info.was || !info.now) return L("Z01_MasterData_State_MeaningMissing");
|
||||
return `${fill("Z01_MasterData_State_Was", info.was)} / ${fill("Z01_MasterData_State_Now", info.now)}`;
|
||||
}
|
||||
|
||||
/** 덮개 판정 한 줄 — 서버 state 그대로 · state 가 없으면 버그로 드러냄. */
|
||||
function stateText(info: CellInfo, column: MasterColumn): string {
|
||||
const original = valueWithUnit(info.original, column.unit);
|
||||
switch (info.state) {
|
||||
case "meaning_changed":
|
||||
// 값이 틀린 것보다 나쁨 — 같은 번호에 딴 물건이 앉음. 옛 이름·지금 이름을 나란히.
|
||||
// ⚠ 이름이 안 오면 물음표로 때우지 않고 버그로 드러냄 — 물음표는 「원래 그런가 보다」로 넘김.
|
||||
return `${L("Z01_MasterData_State_MeaningChanged")} — ${meaningNames(info)} · ${fill("Z01_MasterData_Edit_OriginalWas", original)}`;
|
||||
case "source_changed":
|
||||
return `${fill("Z01_MasterData_State_SourceChanged", valueWithUnit(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`;
|
||||
case "orphan":
|
||||
return L("Z01_MasterData_State_Orphan");
|
||||
case "missing":
|
||||
return L("Z01_MasterData_State_Missing");
|
||||
default:
|
||||
return fill("Z01_MasterData_State_Edited", original);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRowsView(): RowsView {
|
||||
let source: RowsSource | null = null;
|
||||
const state = {
|
||||
page: 1,
|
||||
q: "",
|
||||
sort: null as SortOrder | null,
|
||||
filters: {} as Record<string, string>,
|
||||
};
|
||||
let showHidden = false;
|
||||
let last: MasterRows | null = null;
|
||||
let selected: { index: number; key: string } | null = null;
|
||||
let searchTimer = 0;
|
||||
const fetchLatest = latestOnly((from: RowsSource, query: RowsQuery) => from.load(query));
|
||||
|
||||
const title = el("h2", { className: "z01-master__title", text: L("Z01_MasterData_PickTable") });
|
||||
const titleKey = el("span", { className: "z01-master__key" });
|
||||
const search = createInputField({
|
||||
type: "search",
|
||||
placeholder: L("Z01_MasterData_Search"),
|
||||
onInput: (value) => {
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = window.setTimeout(() => {
|
||||
state.q = value.trim();
|
||||
state.page = 1;
|
||||
void load();
|
||||
}, SEARCH_DELAY_MS);
|
||||
},
|
||||
});
|
||||
const hiddenToggle = el("input", { attrs: { type: "checkbox" } });
|
||||
hiddenToggle.addEventListener("change", () => {
|
||||
showHidden = hiddenToggle.checked;
|
||||
draw();
|
||||
});
|
||||
const hiddenLabel = el("label", {
|
||||
className: "z01-master__check",
|
||||
children: [hiddenToggle, L("Z01_MasterData_ShowHidden")],
|
||||
});
|
||||
const notice = el("div", { className: "z01-master__notice" });
|
||||
const banner = el("div", { className: "z01-master__outdated" });
|
||||
const detail = el("div", { className: "z01-master__detail" });
|
||||
const grid = el("div", { className: "z01-master__grid-wrap" });
|
||||
const pageInput = el("input", {
|
||||
className: "ui-input z01-master__page-input",
|
||||
attrs: { type: "number", min: "1", "aria-label": L("Z01_MasterData_Page") },
|
||||
});
|
||||
pageInput.addEventListener("change", () => goTo(Number(pageInput.value)));
|
||||
const pageInfo = el("span", { className: "z01-master__page-info" });
|
||||
// 큰 표(수만 줄 · 수백 쪽)는 한 칸씩 못 넘김 — 처음·끝 단추와 쪽 번호 칸을 둠(2026-09-15 브레인).
|
||||
const pagerButton = (key: Parameters<typeof L>[0], onClick: () => void) =>
|
||||
createButton({ label: L(key), variant: "ghost", onClick });
|
||||
const first = pagerButton("Z01_MasterData_First", () => goTo(1));
|
||||
const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1));
|
||||
const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1));
|
||||
const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity));
|
||||
const filterBox = el("div", { className: "z01-master__toolbar" });
|
||||
const toolbar = el("div", {
|
||||
className: "z01-master__toolbar",
|
||||
children: [search.root, filterBox, hiddenLabel],
|
||||
});
|
||||
const pager = el("div", {
|
||||
className: "z01-master__pager",
|
||||
children: [first, prev, pageInput, pageInfo, next, end],
|
||||
});
|
||||
const root = el("section", {
|
||||
className: "z01-master__panel",
|
||||
children: [
|
||||
el("div", { children: [title, titleKey] }),
|
||||
notice,
|
||||
banner,
|
||||
toolbar,
|
||||
detail,
|
||||
grid,
|
||||
pager,
|
||||
],
|
||||
});
|
||||
toolbar.hidden = true;
|
||||
pager.hidden = true;
|
||||
detail.hidden = true;
|
||||
notice.hidden = true;
|
||||
banner.hidden = true;
|
||||
|
||||
function goTo(page: number): void {
|
||||
const pages = pageCount(last?.total ?? 0, PAGE_SIZE);
|
||||
state.page = page === Infinity ? pages : clampPage(page, pages);
|
||||
void load();
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!source) return;
|
||||
const asked = state.sort;
|
||||
grid.classList.add("is-loading");
|
||||
let data: MasterRows | undefined;
|
||||
try {
|
||||
data = await fetchLatest(source, {
|
||||
page: state.page,
|
||||
size: PAGE_SIZE,
|
||||
q: state.q,
|
||||
sort: state.sort,
|
||||
filters: state.filters,
|
||||
});
|
||||
} catch (error) {
|
||||
grid.classList.remove("is-loading");
|
||||
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
|
||||
// 서버가 그 열로는 못 세운다고 하면(400) 누른 표시를 지움 — 눌렀는데 안 바뀐 것처럼 두지 않음.
|
||||
if (asked && state.sort === asked) {
|
||||
state.sort = null;
|
||||
draw();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 옛 요청 — 로딩 표시는 뒤 요청 몫이라 그대로 둠.
|
||||
if (!data) return;
|
||||
grid.classList.remove("is-loading");
|
||||
last = data;
|
||||
selected = null;
|
||||
detail.hidden = true;
|
||||
draw();
|
||||
}
|
||||
|
||||
function headCell(meta: MasterRows, column: MasterColumn): HTMLElement {
|
||||
const th = el("th", { children: [el("span", { text: columnTitle(column) })] });
|
||||
const marks: [string, string][] = [];
|
||||
if (source?.save && meta.editable?.includes(column.key)) {
|
||||
marks.push(["✎", L("Z01_MasterData_Edit_Can")]);
|
||||
}
|
||||
const reason = meta.locked?.[column.key];
|
||||
if (reason) marks.push(["🔒", reason]);
|
||||
const rule = meta.formula?.[column.key];
|
||||
if (rule) marks.push(["ƒ", rule]);
|
||||
for (const [mark, tip] of marks) {
|
||||
th.append(el("span", { className: "z01-master__mark", text: mark, attrs: { title: tip } }));
|
||||
}
|
||||
if (column.label !== column.key) {
|
||||
th.append(el("span", { className: "z01-master__key", text: column.key }));
|
||||
}
|
||||
// 차례 — 누르기 전에는 아무 표시도 없음(원본 차례라는 것이 보이게 · 2026-09-16 브레인).
|
||||
if (meta.sortable?.includes(column.key)) {
|
||||
th.classList.add("is-sortable");
|
||||
if (state.sort?.key === column.key) {
|
||||
th.append(el("span", { className: "z01-master__sort", text: state.sort.desc ? "▼" : "▲" }));
|
||||
}
|
||||
th.addEventListener("click", () => {
|
||||
state.sort = nextSort(state.sort, column.key);
|
||||
state.page = 1;
|
||||
void load();
|
||||
});
|
||||
}
|
||||
return th;
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (!last) return;
|
||||
const meta = last;
|
||||
// 표 머리 — 모두 서버 글자 그대로(화면이 사전을 가지면 두 벌이 됨).
|
||||
// ① 알림(첫 줄 「새로 만드는 프로젝트부터」 · 요율은 법정값 경고까지) ② 뒤처진 원본(서버 outdated 만)
|
||||
const notices = noticeLines(meta.notice);
|
||||
notice.replaceChildren(
|
||||
...notices.map((text) =>
|
||||
el("p", {
|
||||
children: boldSegments(text).map((part) =>
|
||||
part.bold ? el("strong", { text: part.text }) : el("span", { text: part.text }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
notice.hidden = notices.length === 0;
|
||||
const outdated = outdatedBanner(meta.source);
|
||||
banner.replaceChildren(...outdated.map((text) => el("p", { text: `⚠ ${text}` })));
|
||||
banner.hidden = outdated.length === 0;
|
||||
// 거르기 — 선택지·이름·줄 수·기본값도, 어느 줄을 거를지 판정도 서버 것(화면은 고른 값만 보냄).
|
||||
filterBox.replaceChildren(
|
||||
...(meta.filters ?? []).map(
|
||||
(filter) =>
|
||||
createSelectField({
|
||||
label: filter.label,
|
||||
options: filter.options.map((option) => ({
|
||||
value: option.value,
|
||||
text:
|
||||
option.rows == null
|
||||
? option.label
|
||||
: `${option.label} (${option.rows.toLocaleString("ko-KR")})`,
|
||||
})),
|
||||
value: filterValue(filter, state.filters),
|
||||
onChange: (value) => {
|
||||
state.filters[filter.key] = value;
|
||||
state.page = 1;
|
||||
void load();
|
||||
},
|
||||
}).root,
|
||||
),
|
||||
);
|
||||
// 숨긴 열이 없는 표(17 중 15)에서는 눌러도 아무 일이 없어 체크를 감춤.
|
||||
hiddenLabel.hidden = !meta.columns.some((column) => column.hidden);
|
||||
const columns = shownColumns(meta.columns, showHidden);
|
||||
const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) });
|
||||
const body = el("tbody");
|
||||
meta.rows.forEach((row, index) => {
|
||||
const texts = rowCells(row, columns);
|
||||
const tr = el("tr");
|
||||
columns.forEach((column, i) => {
|
||||
const info = cellInfo(meta, row, column.key);
|
||||
const td = el("td", { text: texts[i], attrs: { title: texts[i] } });
|
||||
if (info.state) {
|
||||
// 원본이 바뀐 칸은 줄째 또렷이 — 흐리면 옛 값이 조용히 계속 쓰임(2026-09-15 브레인).
|
||||
td.classList.add("is-overridden", `is-${info.state.replaceAll("_", "-")}`);
|
||||
if (info.state !== "edited") {
|
||||
// 뜻 바뀜은 값 어긋남보다 나쁨 — 표시부터 더 세게(2026-09-16 브레인).
|
||||
td.textContent = `${info.state === "meaning_changed" ? "⛔" : "⚠"} ${texts[i]}`;
|
||||
tr.classList.add(info.state === "meaning_changed" ? "has-danger" : "has-warning");
|
||||
}
|
||||
td.title = stateText(info, column);
|
||||
}
|
||||
const canEdit = Boolean(source?.save) && info.editable && row["@id"] != null;
|
||||
if (canEdit) td.classList.add("is-editable");
|
||||
if (selected?.index === index && selected.key === column.key)
|
||||
td.classList.add("is-selected");
|
||||
td.addEventListener("click", () => {
|
||||
if (td.querySelector("input")) return;
|
||||
grid.querySelector("td.is-selected")?.classList.remove("is-selected");
|
||||
selectCell(index, column);
|
||||
td.classList.add("is-selected");
|
||||
if (canEdit) startEdit(td, index, column);
|
||||
});
|
||||
tr.append(td);
|
||||
});
|
||||
body.append(tr);
|
||||
});
|
||||
grid.replaceChildren(
|
||||
meta.rows.length
|
||||
? el("table", {
|
||||
className: "z01-master__grid",
|
||||
children: [el("thead", { children: [headRow] }), body],
|
||||
})
|
||||
: el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") }),
|
||||
);
|
||||
const pages = pageCount(meta.total, PAGE_SIZE);
|
||||
pageInput.value = String(state.page);
|
||||
pageInput.max = String(pages);
|
||||
pageInfo.textContent = `/ ${pages.toLocaleString("ko-KR")} ${L("Z01_MasterData_Page")} · ${meta.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`;
|
||||
first.disabled = prev.disabled = state.page <= 1;
|
||||
next.disabled = end.disabled = state.page >= pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 원래 값(되돌리기) · 식과 값 넣은 식.
|
||||
* `failure` = 방금 저장이 안 된 까닭 — 토스트만 뜨고 사라지면 사람은 저장된 줄 앎(브레인 ③).
|
||||
*/
|
||||
function selectCell(index: number, column: MasterColumn, failure?: string): void {
|
||||
if (!last) return;
|
||||
selected = { index, key: column.key };
|
||||
const row = last.rows[index];
|
||||
const info = cellInfo(last, row, column.key);
|
||||
const lines: HTMLElement[] = [el("strong", { text: columnTitle(column) })];
|
||||
const line = (text: string) => el("span", { className: "z01-master__detail-line", text });
|
||||
if (failure) {
|
||||
const failed = line(fill("Z01_MasterData_Edit_NotSaved", failure));
|
||||
failed.classList.add("is-warning");
|
||||
lines.push(failed);
|
||||
}
|
||||
if (source?.save) {
|
||||
lines.push(
|
||||
line(
|
||||
info.editable
|
||||
? L("Z01_MasterData_Edit_Can")
|
||||
: `${L("Z01_MasterData_Edit_Locked")} — ${info.reason ?? L("Z01_MasterData_Edit_NotListed")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (info.state) {
|
||||
const stateLine = line(stateText(info, column));
|
||||
if (info.state !== "edited") stateLine.classList.add("is-warning");
|
||||
lines.push(stateLine);
|
||||
// 단추 글자에 무엇으로 바뀌는지 값을 넣음 — 안 보이고 누르면 금액이 말없이 바뀜(브레인).
|
||||
// 뜻이 바뀐 줄에서 「새 원본 값으로 받기」는 위험 — 글자를 가르고 한 번 더 물음(브레인 ③).
|
||||
const target =
|
||||
info.state === "meaning_changed"
|
||||
? fill("Z01_MasterData_Edit_DropOnMeaning", valueWithUnit(info.current, column.unit))
|
||||
: info.state === "source_changed"
|
||||
? fill("Z01_MasterData_Edit_TakeSource", valueWithUnit(info.current, column.unit))
|
||||
: info.state === "orphan"
|
||||
? null
|
||||
: fill("Z01_MasterData_Edit_RevertTo", valueWithUnit(info.original, column.unit));
|
||||
if (target && source?.save) {
|
||||
lines.push(
|
||||
createButton({
|
||||
label: target,
|
||||
variant: info.state === "meaning_changed" ? "danger" : "ghost",
|
||||
onClick: () => void drop(index, column, info),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (info.rule) lines.push(line(`${L("Z01_MasterData_Formula")} ${info.rule}`));
|
||||
if (info.filled) lines.push(line(info.filled));
|
||||
detail.replaceChildren(...lines);
|
||||
detail.hidden = false;
|
||||
}
|
||||
|
||||
function startEdit(td: HTMLElement, index: number, column: MasterColumn): void {
|
||||
if (!last) return;
|
||||
const current = last.rows[index][column.key];
|
||||
const input = el("input", { className: "ui-input z01-master__cell-input" });
|
||||
input.value = current == null ? "" : String(current);
|
||||
td.replaceChildren(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
let finished = false;
|
||||
const finish = (save: boolean): void => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
const value = save ? parseCellInput(input.value, current) : current;
|
||||
if (value === undefined) {
|
||||
const why = fill("Z01_MasterData_Edit_Invalid", input.value);
|
||||
showToast(why, "error");
|
||||
draw();
|
||||
selectCell(index, column, why);
|
||||
return;
|
||||
}
|
||||
if (value === current) {
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
void commit(index, column, value);
|
||||
};
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") finish(true);
|
||||
if (event.key === "Escape") finish(false);
|
||||
});
|
||||
input.addEventListener("blur", () => finish(true));
|
||||
}
|
||||
|
||||
/** 고침 버리기 — 뜻이 바뀐 줄은 한 번 더 묻고(딴 물건에 값이 붙은 자리라 되돌리기와 뜻이 다름). */
|
||||
async function drop(index: number, column: MasterColumn, info: CellInfo): Promise<void> {
|
||||
if (info.state === "meaning_changed") {
|
||||
const ok = await showConfirmDialog(
|
||||
`${meaningNames(info)}\n${L("Z01_MasterData_Edit_MeaningConfirm")}`,
|
||||
L("Z01_MasterData_Edit_DropConfirm"),
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
await commit(index, column, null);
|
||||
}
|
||||
|
||||
/** 저장 — null 은 덮개에서 그 칸을 빼 원래 값으로 되돌림. */
|
||||
async function commit(index: number, column: MasterColumn, value: unknown): Promise<void> {
|
||||
const from = source;
|
||||
const table = last;
|
||||
const rowId = table?.rows[index]["@id"];
|
||||
if (!from?.save || !table || rowId == null) return;
|
||||
try {
|
||||
const updated = await from.save(String(rowId), { [column.key]: value });
|
||||
if (from !== source || table !== last) return;
|
||||
table.rows[index] = updated;
|
||||
draw();
|
||||
selectCell(index, column);
|
||||
showToast(
|
||||
L(value === null ? "Z01_MasterData_Edit_Reverted" : "Z01_MasterData_Edit_Saved"),
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
const why = error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed");
|
||||
showToast(why, "error");
|
||||
if (from !== source || table !== last) return;
|
||||
draw();
|
||||
selectCell(index, column, why);
|
||||
}
|
||||
}
|
||||
|
||||
function show(picked: RowsSource): void {
|
||||
source = picked;
|
||||
state.page = 1;
|
||||
state.sort = null;
|
||||
state.filters = {};
|
||||
filterBox.replaceChildren();
|
||||
title.textContent = picked.title;
|
||||
titleKey.textContent = picked.key;
|
||||
notice.hidden = true;
|
||||
banner.hidden = true;
|
||||
search.root.hidden = picked.searchable === false;
|
||||
toolbar.hidden = false;
|
||||
pager.hidden = false;
|
||||
detail.hidden = true;
|
||||
last = null;
|
||||
selected = null;
|
||||
grid.replaceChildren();
|
||||
void load();
|
||||
}
|
||||
|
||||
return { root, show };
|
||||
}
|
||||
Reference in New Issue
Block a user