- 기초단가 표: 칸 나갈 때 즉시 저장 없앰 · 고친 칸·새 줄·지울 줄은 초안(sessionStorage z01-draft:base:{kind})에 쌓고 [저장] 한 번에 보냄 · [고친 것 버리기]·[줄 추가]·[줄 삭제]·[초기값으로] · 409 면 초안 지키고 알림 · 서버가 revision 을 줄 때만 고치기 단추 섬
- 옛 덮개 길(칸 단위 PUT · 고친 것 모아 보기 · 덮개 판정 표시)과 그 사전 글자 뺌 — 계약 1-0 에서 서버 쪽 삭제
- 공종 편집(Z01_MasterData_UI_WorkItem.ts · _WorkItem_Rows.ts): 왼쪽 목록(상태별 개수·검색) / 머리 / 구성 줄 표(갈래별 · 자원 찾기 = 기초단가 검색) / 품셈 원문 표+[주] 읽기 전용 / [저장]·[초기값으로]·[검수 완료] · 초안 열쇠 z01-draft:work_item:{key}
- 공종 API(sub_laptop_3 e1c71c3e) 모양에 맞춤 — 줄은 id + 계약 칸 열하나만 보냄(extra=forbid) · 저장·초기값 응답(새 GET 모양)으로 바로 다시 그림 · 저장된 고침(changed_columns·is_added) 표시
- 초안 순수 함수 Z01_MasterData_UI_Draft.ts + 시험 test_z01_master_draft.py
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145cr1DMC5Ft8fouQSXtp69
616 lines
23 KiB
TypeScript
616 lines
23 KiB
TypeScript
/* =============================================================================
|
|
* Z01_MasterData_UI_Rows.ts
|
|
* 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 고침
|
|
*
|
|
* 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `edit` 이 있어 고침.
|
|
* 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 산출근거가 섬
|
|
* (2026-09-15 브레인 — 계산으로 나오는 칸은 못 고치고 왜 못 고치는지 옆에 보일 것).
|
|
* ⚠ 자동저장 금지 — 고친 칸·추가 줄·삭제 줄은 초안(`BaseDraft`)에 쌓고 [저장] 한 번에 보냄(PLAN 1-0).
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
createButton,
|
|
createInputField,
|
|
createSelectField,
|
|
el,
|
|
hideLoadingOverlay,
|
|
showConfirmDialog,
|
|
showLoadingOverlay,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import { t as L } from "@ui/ui_template_locale";
|
|
import {
|
|
ApiError,
|
|
type BaseSave,
|
|
type MasterRows,
|
|
type RowsQuery,
|
|
} from "./Z01_MasterData_Api_Fetch";
|
|
import {
|
|
boldSegments,
|
|
cellInfo,
|
|
cellText,
|
|
clampPage,
|
|
columnTitle,
|
|
filterValue,
|
|
latestOnly,
|
|
nextSort,
|
|
noticeLines,
|
|
outdatedBanner,
|
|
pageCount,
|
|
parseCellInput,
|
|
rowCells,
|
|
shownColumns,
|
|
valueWithUnit,
|
|
type MasterColumn,
|
|
type SortOrder,
|
|
} from "./Z01_MasterData_UI_Cells";
|
|
import { BaseDraft, type AddedRow } from "./Z01_MasterData_UI_Draft";
|
|
|
|
const PAGE_SIZE = 50;
|
|
const SEARCH_DELAY_MS = 300;
|
|
|
|
type Row = Record<string, unknown>;
|
|
type LocaleKey = Parameters<typeof L>[0];
|
|
|
|
export interface RowsSource {
|
|
title: string;
|
|
key: string;
|
|
/** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표 */
|
|
searchable?: boolean;
|
|
load: (query: RowsQuery) => Promise<MasterRows>;
|
|
/** 있으면 기초단가 — 초안에 쌓고 [저장] 한 번에 보냄 · [초기값으로]는 바로 저장 */
|
|
edit?: {
|
|
scope: string;
|
|
save: (body: BaseSave) => Promise<unknown>;
|
|
reset: (rowKeys: string[]) => Promise<unknown>;
|
|
};
|
|
}
|
|
|
|
export interface RowsView {
|
|
root: HTMLElement;
|
|
show: (source: RowsSource) => void;
|
|
}
|
|
|
|
/** 사전 문구의 {value} 자리에 값을 넣음. */
|
|
function fill(key: LocaleKey, value: string): string {
|
|
return L(key).replace("{value}", value);
|
|
}
|
|
|
|
/** 고른 칸 — 서버 줄은 row_key(`@id`) · 저장 전 새 줄은 tmp. */
|
|
interface Picked {
|
|
row: string;
|
|
added: boolean;
|
|
column: string;
|
|
}
|
|
|
|
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 picked: Picked | null = null;
|
|
let draft: BaseDraft | null = null;
|
|
/** 409 를 받음 — 다른 곳에서 먼저 저장해 이 초안은 저장 못 함(조용히 버리지 않고 알림) */
|
|
let stale = false;
|
|
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 action = (key: LocaleKey, onClick: () => void, variant: "filled" | "ghost" = "ghost") =>
|
|
createButton({ label: L(key), variant, onClick });
|
|
const saveButton = action("Z01_MasterData_Save", () => void save(), "filled");
|
|
const discardButton = action("Z01_MasterData_Discard", discard);
|
|
const addButton = action("Z01_MasterData_AddRow", addRow);
|
|
const deleteButton = action("Z01_MasterData_DeleteRow", toggleDelete);
|
|
const resetButton = action("Z01_MasterData_Reset", () => void reset());
|
|
const draftStatus = el("span", { className: "z01-master__draft-status" });
|
|
const actions = el("div", {
|
|
className: "z01-master__toolbar",
|
|
children: [saveButton, discardButton, addButton, deleteButton, resetButton, draftStatus],
|
|
});
|
|
const root = el("section", {
|
|
className: "z01-master__panel",
|
|
children: [
|
|
el("div", { children: [title, titleKey] }),
|
|
notice,
|
|
banner,
|
|
actions,
|
|
toolbar,
|
|
detail,
|
|
grid,
|
|
pager,
|
|
],
|
|
});
|
|
toolbar.hidden = true;
|
|
pager.hidden = true;
|
|
detail.hidden = true;
|
|
notice.hidden = true;
|
|
banner.hidden = true;
|
|
actions.hidden = true;
|
|
// 저장 안 한 고침이 조용히 사라지지 않게 — 나갈 때 물음(자동저장은 안 만듦, CLAUDE.md 5장).
|
|
window.addEventListener("beforeunload", (event) => {
|
|
if (!draft?.count || !root.isConnected) return;
|
|
event.preventDefault();
|
|
event.returnValue = L("Z01_MasterData_Unsaved");
|
|
});
|
|
|
|
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;
|
|
const edit = source.edit;
|
|
if (edit && data.revision !== undefined) {
|
|
// 고친 것이 남은 초안은 쪽을 넘겨도 지킴 — 그새 판이 바뀌었으면 stale 로 알림(조용히 안 버림).
|
|
if (draft?.scope !== edit.scope || !draft.count) {
|
|
draft = new BaseDraft(edit.scope, data.revision);
|
|
draft.onChange = drawActions;
|
|
}
|
|
stale = draft.revision !== data.revision;
|
|
}
|
|
picked = null;
|
|
detail.hidden = true;
|
|
draw();
|
|
}
|
|
|
|
const rowKey = (row: Row, index: number): string =>
|
|
row["@id"] == null ? `#${index}` : String(row["@id"]);
|
|
|
|
const changedColumns = (row: Row): string[] =>
|
|
Array.isArray(row.changed_columns) ? (row.changed_columns as string[]) : [];
|
|
|
|
/** 고른 서버 줄 — 저장 전 새 줄·열쇠 없는 줄이면 없음. */
|
|
function pickedRow(): Row | undefined {
|
|
if (!picked || picked.added) return undefined;
|
|
const key = picked.row;
|
|
return last?.rows.find((row) => row["@id"] != null && String(row["@id"]) === key);
|
|
}
|
|
|
|
/** 새 줄에 적은 글자를 수로 읽을지 — 같은 열 다른 줄 값의 꼴을 따름(코드 칸이 수로 바뀌지 않게). */
|
|
const sample = (column: string): unknown =>
|
|
last?.rows.find((row) => row[column] != null)?.[column];
|
|
|
|
function drawActions(): void {
|
|
// 서버가 판(revision)을 준 표만 고침 — 안 주는 표(공종 축 등 DB 밖)는 단추를 안 세움.
|
|
actions.hidden = !draft;
|
|
const count = draft?.count ?? 0;
|
|
const row = pickedRow();
|
|
saveButton.disabled = count === 0 || stale;
|
|
discardButton.disabled = count === 0 && !stale;
|
|
addButton.disabled = !draft || stale;
|
|
deleteButton.disabled = !draft || !(picked?.added || row);
|
|
const undo = Boolean(row && draft?.deleted.has(String(row["@id"])));
|
|
deleteButton.querySelector(".ui-btn__label")!.textContent = L(
|
|
undo ? "Z01_MasterData_UndeleteRow" : "Z01_MasterData_DeleteRow",
|
|
);
|
|
// 초기값으로는 바로 저장 — 저장 안 한 초안이 있으면 판이 갈려 초안이 버려지니 먼저 저장·버리게 함.
|
|
resetButton.disabled = !row || count > 0 || !(changedColumns(row).length || row.is_added);
|
|
resetButton.title = count > 0 ? L("Z01_MasterData_ResetBlocked") : "";
|
|
draftStatus.classList.toggle("is-warning", stale);
|
|
draftStatus.textContent = stale
|
|
? L("Z01_MasterData_Draft_Stale")
|
|
: count && draft
|
|
? `${L("Z01_MasterData_Draft_Edits")} ${draft.edits.size} · ${L("Z01_MasterData_Draft_Added")} ${draft.added.length} · ${L("Z01_MasterData_Draft_Deleted")} ${draft.deleted.size} — ${L("Z01_MasterData_Draft_Pending")}`
|
|
: L("Z01_MasterData_Draft_None");
|
|
}
|
|
|
|
function headCell(meta: MasterRows, column: MasterColumn): HTMLElement {
|
|
const th = el("th", { children: [el("span", { text: columnTitle(column) })] });
|
|
const marks: [string, string][] = [];
|
|
if (source?.edit && 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");
|
|
// 저장 전 새 줄은 어느 쪽에서나 맨 위 — 쪽을 넘겨도 안 사라짐.
|
|
for (const added of draft?.added ?? []) body.append(addedRow(meta, columns, added));
|
|
meta.rows.forEach((row, index) => body.append(serverRow(meta, columns, row, index)));
|
|
grid.replaceChildren(
|
|
body.children.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;
|
|
drawActions();
|
|
}
|
|
|
|
const isPicked = (row: string, added: boolean, column: string): boolean =>
|
|
picked?.row === row && picked.added === added && picked.column === column;
|
|
|
|
function serverRow(meta: MasterRows, columns: MasterColumn[], row: Row, index: number) {
|
|
const key = rowKey(row, index);
|
|
const texts = rowCells(row, columns);
|
|
const changed = changedColumns(row);
|
|
const deleted = draft?.deleted.has(key) ?? false;
|
|
const tr = el("tr");
|
|
if (row.is_added) tr.classList.add("is-added");
|
|
if (deleted) tr.classList.add("is-deleted");
|
|
columns.forEach((column, i) => {
|
|
const info = cellInfo(meta, row, column.key);
|
|
const stored = row[column.key];
|
|
const pending = draft?.edit(key, column.key);
|
|
const text = pending ? cellText(pending.value, column.unit) : texts[i];
|
|
const td = el("td", { text, attrs: { title: text } });
|
|
// 고친 칸 표시 — 저장 전(초안) · 저장했고 초기값과 다름(서버 changed_columns)
|
|
if (pending) td.classList.add("is-draft");
|
|
else if (changed.includes(column.key)) td.classList.add("is-changed");
|
|
const canEdit = Boolean(draft) && info.editable && row["@id"] != null && !deleted;
|
|
if (canEdit) td.classList.add("is-editable");
|
|
if (isPicked(key, false, column.key)) td.classList.add("is-selected");
|
|
td.addEventListener("click", () => {
|
|
if (td.querySelector("input")) return;
|
|
pick(td, { row: key, added: false, column: column.key });
|
|
if (!canEdit) return;
|
|
startEdit(td, pending ? pending.value : stored, stored ?? sample(column.key), (value) =>
|
|
draft?.setEdit(key, column.key, value, stored),
|
|
);
|
|
});
|
|
tr.append(td);
|
|
});
|
|
return tr;
|
|
}
|
|
|
|
function addedRow(meta: MasterRows, columns: MasterColumn[], added: AddedRow) {
|
|
const tr = el("tr", { className: "is-new", attrs: { title: L("Z01_MasterData_Row_New") } });
|
|
for (const column of columns) {
|
|
const value = added.data[column.key];
|
|
const td = el("td", { text: cellText(value, column.unit) });
|
|
// 새 줄은 식으로 나오는 칸만 빼고 다 채움 — 열쇠·이름도 사람이 넣어야 줄이 섬.
|
|
const canEdit = !meta.formula?.[column.key];
|
|
if (canEdit) td.classList.add("is-editable");
|
|
if (isPicked(added.tmp, true, column.key)) td.classList.add("is-selected");
|
|
td.addEventListener("click", () => {
|
|
if (td.querySelector("input")) return;
|
|
pick(td, { row: added.tmp, added: true, column: column.key });
|
|
if (!canEdit) return;
|
|
startEdit(
|
|
td,
|
|
value,
|
|
sample(column.key),
|
|
(next) => draft?.setAdded(added.tmp, column.key, next),
|
|
true,
|
|
);
|
|
});
|
|
tr.append(td);
|
|
}
|
|
return tr;
|
|
}
|
|
|
|
function pick(td: HTMLElement, next: Picked): void {
|
|
grid.querySelector("td.is-selected")?.classList.remove("is-selected");
|
|
td.classList.add("is-selected");
|
|
picked = next;
|
|
showDetail();
|
|
drawActions();
|
|
}
|
|
|
|
/**
|
|
* 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 저장 전 고침 · 초기값과 다름 · 식과 값 넣은 식.
|
|
* `failure` = 방금 고침이 안 들어간 까닭 — 토스트만 뜨고 사라지면 사람은 들어간 줄 앎(브레인 ③).
|
|
*/
|
|
function showDetail(failure?: string): void {
|
|
const at = picked;
|
|
const column = last?.columns.find((each) => each.key === at?.column);
|
|
if (!last || !at || !column) return;
|
|
const row = at.added
|
|
? (draft?.added.find((each) => each.tmp === at.row)?.data ?? {})
|
|
: (pickedRow() ?? last.rows[Number(at.row.slice(1))] ?? {});
|
|
const info = cellInfo(last, row, column.key);
|
|
const lines: HTMLElement[] = [el("strong", { text: columnTitle(column) })];
|
|
const line = (text: string, warn = false) => {
|
|
const span = el("span", { className: "z01-master__detail-line", text });
|
|
span.classList.toggle("is-warning", warn);
|
|
return span;
|
|
};
|
|
if (failure) lines.push(line(fill("Z01_MasterData_Edit_NotSaved", failure), true));
|
|
if (at.added) lines.push(line(L("Z01_MasterData_Row_New")));
|
|
else if (source?.edit) {
|
|
lines.push(
|
|
line(
|
|
info.editable
|
|
? L("Z01_MasterData_Edit_Can")
|
|
: `${L("Z01_MasterData_Edit_Locked")} — ${info.reason ?? L("Z01_MasterData_Edit_NotListed")}`,
|
|
),
|
|
);
|
|
const pending = draft?.edit(at.row, column.key);
|
|
if (pending) {
|
|
const value = valueWithUnit(pending.value, column.unit);
|
|
lines.push(line(fill("Z01_MasterData_Cell_Draft", value)));
|
|
}
|
|
if (changedColumns(row).includes(column.key))
|
|
lines.push(line(L("Z01_MasterData_Cell_Changed")));
|
|
if (row.is_added) lines.push(line(L("Z01_MasterData_Row_Added")));
|
|
}
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 칸 고치기 — Enter·칸 나감 = 초안에 넣음(서버엔 안 감) · Esc = 그대로.
|
|
* `typeOf` = 수로 읽을지 가르는 본보기 값 · `allowEmpty` = 비우면 칸을 뺌(새 줄).
|
|
*/
|
|
function startEdit(
|
|
td: HTMLElement,
|
|
current: unknown,
|
|
typeOf: unknown,
|
|
apply: (value: unknown) => void,
|
|
allowEmpty = false,
|
|
): void {
|
|
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 = (keep: boolean): void => {
|
|
if (finished) return;
|
|
finished = true;
|
|
const empty = allowEmpty && input.value.trim() === "";
|
|
const value = !keep ? current : empty ? null : parseCellInput(input.value, typeOf);
|
|
if (value === undefined) {
|
|
const why = fill("Z01_MasterData_Edit_Invalid", input.value);
|
|
showToast(why, "error");
|
|
draw();
|
|
showDetail(why);
|
|
return;
|
|
}
|
|
if (keep) apply(value);
|
|
draw();
|
|
showDetail();
|
|
};
|
|
input.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter") finish(true);
|
|
if (event.key === "Escape") finish(false);
|
|
});
|
|
input.addEventListener("blur", () => finish(true));
|
|
}
|
|
|
|
function addRow(): void {
|
|
draft?.addRow();
|
|
draw();
|
|
}
|
|
|
|
function toggleDelete(): void {
|
|
if (!draft || !picked) return;
|
|
if (picked.added) {
|
|
draft.removeAdded(picked.row);
|
|
picked = null;
|
|
detail.hidden = true;
|
|
} else if (pickedRow()) {
|
|
draft.toggleDelete(picked.row);
|
|
}
|
|
draw();
|
|
}
|
|
|
|
function discard(): void {
|
|
draft?.clear();
|
|
stale = false;
|
|
void load();
|
|
}
|
|
|
|
/** [저장] — 초안 한 벌을 보냄. 409 면 초안을 지키고 알림(다른 곳에서 먼저 저장함). */
|
|
async function save(): Promise<void> {
|
|
const edit = source?.edit;
|
|
const active = draft;
|
|
if (!edit || !active?.count) return;
|
|
showLoadingOverlay();
|
|
try {
|
|
await edit.save(active.payload());
|
|
active.clear();
|
|
showToast(L("Z01_MasterData_Saved"), "success");
|
|
void load();
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.status === 409) stale = true;
|
|
showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error");
|
|
drawActions();
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|
|
|
|
/** [초기값으로] — 고른 줄을 seed 로(추가한 줄은 지워짐) · 바로 저장이라 한 번 물음. */
|
|
async function reset(): Promise<void> {
|
|
const edit = source?.edit;
|
|
const row = pickedRow();
|
|
if (!edit || !row || draft?.count) return;
|
|
const ok = await showConfirmDialog(L("Z01_MasterData_ResetConfirm"), L("Z01_MasterData_Reset"));
|
|
if (!ok) return;
|
|
showLoadingOverlay();
|
|
try {
|
|
await edit.reset([String(row["@id"])]);
|
|
showToast(L("Z01_MasterData_ResetDone"), "success");
|
|
void load();
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error");
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|
|
|
|
function show(next: RowsSource): void {
|
|
source = next;
|
|
state.page = 1;
|
|
state.sort = null;
|
|
state.filters = {};
|
|
filterBox.replaceChildren();
|
|
title.textContent = next.title;
|
|
titleKey.textContent = next.key;
|
|
notice.hidden = true;
|
|
banner.hidden = true;
|
|
search.root.hidden = next.searchable === false;
|
|
toolbar.hidden = false;
|
|
pager.hidden = false;
|
|
detail.hidden = true;
|
|
last = null;
|
|
picked = null;
|
|
draft = null;
|
|
stale = false;
|
|
actions.hidden = true;
|
|
grid.replaceChildren();
|
|
void load();
|
|
}
|
|
|
|
return { root, show };
|
|
}
|