- 새 UI_Side_Sangeun.ts — 서고 칸(716-4) kind sangeun · 목록표 고름 sangeunList - 새 Sangeun_View.ts — 이름 · 단위 · 바탕 · 덮개 · 더한 줄 · 뺀 줄 · 열린 입력 요약 + 상세 자리표(717-2b 가 갈음) - UI_Main 종류 sangeun 갈래 · 목록표 조건에 sangeun · Library_List 열 표(단가산출 = 바탕 · 고친 줄 · 쓰는 곳) · sangeunBlank(단위 식 · 바탕 없음) - UI_Page 단가산출 층 상태 sk · UI_Side 맨 위 칸 · reveal 「단가산출」 · Layer open 「단가산출」 · Api Kind sangeun - m_settings_nav 대시보드 마스터 템플릿 묶음 「단가산출」 줄(?open=단가산출) · locale_m2 키 - 낡은 글 대조 시험 넷 고침(197_1 · b01_settings_cards · m02_layer_screen · m02_precombo_scope) — 대시보드 줄 하나 늘고 OPENS · reveal · 목록 열 표 · 칸 차례가 바뀜 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* M02_MasterTemplete_Library_List.ts
|
|
* 서고 목록표(51-4 · 717-2a) — 좌측 일위대가 · 단가산출 층 단추를 누르면 상세 칸에 뜸.
|
|
* 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세 ·
|
|
* 단가산출 = 구분 둘 대신 바탕(M01 로직 · 없으면 「바탕 없음」) · 고친 줄 수 · 쓰는 곳 수(서버 `summary`).
|
|
* 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로).
|
|
* 꼴 = M01 단가산출 로직 목록 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표(`m01-master__grid`) 를 M01 에서 import.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
createButton,
|
|
createInputField,
|
|
el,
|
|
showConfirmDialog,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import { t as L } from "@ui/ui_template_locale";
|
|
import { openModal } from "@ui/ui_template_modal";
|
|
import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells";
|
|
import { pageKeepFirst, watchFit } from "@ui/ui_template_pager";
|
|
import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head";
|
|
import "../M01_MasterData/M01_MasterData_UI_Style.css";
|
|
import {
|
|
deleteLayerTemplate,
|
|
deleteTemplate,
|
|
listTemplates,
|
|
newLibrary,
|
|
readTemplate,
|
|
saveTemplate,
|
|
type Layer,
|
|
type LibraryBody,
|
|
type LibraryKind,
|
|
type TemplateInfo,
|
|
} from "./M02_MasterTemplete_Api_Fetch";
|
|
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
|
|
|
/** 목록 줄 — 서버가 문서 `이름`(제목) · 단위 · 1 단위 기준 계를 실어 보냄 */
|
|
type Row = TemplateInfo & {
|
|
제목?: string | null;
|
|
단위?: string | null;
|
|
기준금액?: number | null;
|
|
구분?: string | null;
|
|
상세구분?: string | null;
|
|
바탕?: string | null;
|
|
고친줄수?: number;
|
|
쓰는곳수?: number;
|
|
};
|
|
|
|
export interface LibraryListOptions {
|
|
layer: Layer;
|
|
/** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) */
|
|
owner: string | null;
|
|
kind: LibraryKind;
|
|
canEdit: boolean;
|
|
/** 줄 누름 · 생성 · 복사 뒤 — 그 문서 상세 */
|
|
open: (key: string, label: string) => void;
|
|
/** 생성 · 복사 · 삭제 뒤 — 좌측 나무를 다시 그림 */
|
|
changed: () => void;
|
|
}
|
|
|
|
const SIZE = 30;
|
|
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
|
const money = (v: number | null | undefined): string =>
|
|
v == null ? "" : v.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
|
|
export const libraryLabel = (row: Row): string => (row.제목 ? `${row.이름} ${row.제목}` : row.이름);
|
|
|
|
/** 새 단가산출 몸(717-2a) — 바탕 없음 · 빈 더한줄 · 단위 「식」(서버 필수 · 상세에서 고침 · 717-2b) */
|
|
export const sangeunBlank = (name: string): Record<string, unknown> => ({
|
|
이름: name,
|
|
단위: "식",
|
|
바탕: null,
|
|
더한줄: [],
|
|
});
|
|
/** 표 열 — 머리 글 · 줄 값(일위대가 = 구분 둘 · 단가산출 = 바탕 · 고친 줄 · 쓰는 곳) */
|
|
const COLUMNS: Record<LibraryKind, () => [string, (row: Row) => string][]> = {
|
|
precombo: () => [
|
|
[tc("Lib_Key"), (r) => r.이름],
|
|
[tc("Sub"), (r) => r.구분 || tc("Lib_Unsorted")], // 빈 구분 = 미분류(149-2 · 나무와 같은 말)
|
|
[tc("Detail"), (r) => r.상세구분 ?? ""],
|
|
[tc("Lib_Name"), (r) => r.제목 ?? ""],
|
|
[tc("Lib_Unit"), (r) => r.단위 ?? ""],
|
|
[tc("Lib_Base"), (r) => money(r.기준금액)],
|
|
[tc("Lib_Date"), (r) => r.수정일],
|
|
],
|
|
sangeun: () => [
|
|
[tc("Lib_Key"), (r) => r.이름],
|
|
[tc("Lib_Name"), (r) => r.제목 ?? ""],
|
|
[tc("Lib_Unit"), (r) => r.단위 ?? ""],
|
|
[L("M02_SangeunBase"), (r) => r.바탕 || L("M02_SangeunNoBase")],
|
|
[L("M02_SangeunEdited"), (r) => String(r.고친줄수 ?? 0)],
|
|
[L("M02_SangeunUsers"), (r) => String(r.쓰는곳수 ?? 0)],
|
|
[tc("Lib_Base"), (r) => money(r.기준금액)],
|
|
[tc("Lib_Date"), (r) => r.수정일],
|
|
],
|
|
};
|
|
|
|
/** 이름 창(생성 · 복사 · 사전조합 나무 폴더 메뉴 149-1) — [생성] = `onOk` 성공하면 닫힘 */
|
|
export const nameDialog = (
|
|
title: string,
|
|
start: string,
|
|
onOk: (name: string) => Promise<void>,
|
|
): void => {
|
|
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
|
|
name.input.value = start;
|
|
openModal({
|
|
title,
|
|
closeLabel: L("M02_Close"),
|
|
dialogClass: "m02-modal",
|
|
mount: (box, close) => {
|
|
const ok = createButton({ label: L("M02_Create"), variant: "filled" });
|
|
ok.addEventListener("click", async () => {
|
|
const text = name.input.value.trim();
|
|
if (!text) return showToast(L("M02_NameNeeded"), "error");
|
|
ok.disabled = true; // 연달아 눌러도 한 번만
|
|
try {
|
|
await onOk(text);
|
|
close();
|
|
} catch (error) {
|
|
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
|
} finally {
|
|
ok.disabled = false;
|
|
}
|
|
});
|
|
name.input.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter" && !event.isComposing) ok.click();
|
|
});
|
|
box.append(name.root, ok);
|
|
},
|
|
});
|
|
};
|
|
|
|
/** `tools` = 상세 머리 단추 자리(페이지 slot) · 돌려준 `destroy` 는 안 씀(칸째 비움) */
|
|
export function mountLibraryList(
|
|
host: HTMLElement,
|
|
tools: HTMLElement,
|
|
opt: LibraryListOptions,
|
|
): { getDoc: () => null; destroy: () => void } {
|
|
const columns = COLUMNS[opt.kind]();
|
|
let rows: Row[] = [];
|
|
let picked: string | null = null;
|
|
let page = 1;
|
|
let size = SIZE; // 붙은 뒤 화면 높이로 다시 셈
|
|
const searchField = createInputField({ type: "search", placeholder: tc("Search") });
|
|
const body = el("tbody");
|
|
let pager = buildPager(0, SIZE, 1, () => {});
|
|
const btnNew = createButton({ label: tc("Lib_Create"), variant: "filled" });
|
|
const btnCopy = createButton({ label: tc("Lib_Copy"), variant: "ghost" });
|
|
const btnDel = createButton({ label: tc("Lib_Delete"), variant: "danger" });
|
|
const paintButtons = (): void => {
|
|
btnCopy.disabled = btnDel.disabled = !rows.some((r) => r.이름 === picked);
|
|
};
|
|
|
|
const draw = (): void => {
|
|
const q = searchField.input.value.trim().toLowerCase();
|
|
const shown = rows.filter((r) => !q || libraryLabel(r).toLowerCase().includes(q));
|
|
page = Math.min(page, Math.max(1, Math.ceil(shown.length / size)));
|
|
const next = buildPager(shown.length, size, page, (p) => {
|
|
page = p;
|
|
draw();
|
|
});
|
|
pager.replaceWith(next);
|
|
pager = next;
|
|
body.replaceChildren(
|
|
...(shown.length
|
|
? shown.slice((page - 1) * size, page * size).map((row) => {
|
|
const radio = el("input", {
|
|
attrs: { type: "radio", name: `m02-lib-${opt.kind}`, "aria-label": row.이름 },
|
|
}) as HTMLInputElement;
|
|
radio.checked = row.이름 === picked;
|
|
const pick = el("td", { className: "m02-lib__pick", children: [radio] });
|
|
pick.addEventListener("click", (event) => {
|
|
event.stopPropagation(); // 고름 칸은 열지 않음
|
|
picked = row.이름;
|
|
radio.checked = true;
|
|
paintButtons();
|
|
});
|
|
const tr = el("tr", {
|
|
className: "m01-master__table-row",
|
|
attrs: { "data-lib-key": row.이름 },
|
|
children: [
|
|
pick,
|
|
...columns
|
|
.map(([, value]) => value(row))
|
|
.map((v) => el("td", { text: v, attrs: { title: v } })),
|
|
],
|
|
});
|
|
tr.addEventListener("click", () => opt.open(row.이름, libraryLabel(row)));
|
|
return tr;
|
|
})
|
|
: [
|
|
el("tr", {
|
|
children: [
|
|
el("td", {
|
|
text: L("M02_NoTemplates"),
|
|
attrs: { colspan: String(columns.length + 1) },
|
|
}),
|
|
],
|
|
}),
|
|
]),
|
|
);
|
|
paintButtons();
|
|
};
|
|
|
|
const load = async (): Promise<void> => {
|
|
try {
|
|
rows = (await listTemplates(opt.layer, null, opt.owner)).filter(
|
|
(r) => r.종류 === opt.kind,
|
|
) as Row[];
|
|
} catch (error) {
|
|
rows = [];
|
|
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
|
|
}
|
|
draw();
|
|
};
|
|
|
|
/** 새로 — 서버가 그 층 다음 키로 씀 → 그 문서 상세 */
|
|
const made = (key: string, label: string): void => {
|
|
showToast(L("M02_Created"), "success");
|
|
opt.changed();
|
|
opt.open(key, label);
|
|
};
|
|
const create = async (body: LibraryBody, label: string): Promise<void> =>
|
|
made((await newLibrary(opt.layer, opt.kind, body, opt.owner)).이름, label);
|
|
|
|
/** 사전조합 = 이름 · 구분 · 상세구분 빈 값(미분류 · 134-5 · 상세에서 고름) · 빈 묶음 */
|
|
btnNew.addEventListener("click", () => {
|
|
nameDialog(L("M02_NewTitle"), "", async (name) => {
|
|
const 문서 =
|
|
opt.kind === "sangeun"
|
|
? sangeunBlank(name)
|
|
: { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] };
|
|
await create({ 문서 }, name);
|
|
});
|
|
});
|
|
|
|
/** 복사 = 같은 층 새 키(서버 · 본뜬 = 원래 키) → 고른 이름으로 고쳐 씀 */
|
|
btnCopy.addEventListener("click", () => {
|
|
const from = rows.find((r) => r.이름 === picked);
|
|
if (!from) return;
|
|
nameDialog(L("M02_CopyTitle"), `${from.제목 ?? from.이름} (2)`, async (name) => {
|
|
const got = await newLibrary(
|
|
opt.layer,
|
|
opt.kind,
|
|
{ 본뜬: { 층: opt.layer, 주인: opt.owner, 키: from.이름 } },
|
|
opt.owner,
|
|
);
|
|
const doc = await readTemplate(opt.layer, opt.kind, got.이름, null, opt.owner);
|
|
await saveTemplate(
|
|
opt.layer,
|
|
opt.kind,
|
|
got.이름,
|
|
null,
|
|
got.판,
|
|
{ ...(doc.문서 as object), 이름: name },
|
|
opt.owner,
|
|
);
|
|
made(got.이름, name);
|
|
});
|
|
});
|
|
|
|
btnDel.addEventListener("click", async () => {
|
|
await load(); // 판을 최신으로
|
|
const info = rows.find((r) => r.이름 === picked);
|
|
if (!info) return;
|
|
const label = libraryLabel(info);
|
|
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", label)))) return;
|
|
try {
|
|
if (opt.layer === "system") await deleteTemplate(opt.kind, info.이름, info.판);
|
|
else await deleteLayerTemplate(opt.layer, opt.kind, info.이름, info.판, opt.owner);
|
|
showToast(L("M02_Deleted"), "success");
|
|
opt.changed();
|
|
picked = null;
|
|
await load();
|
|
} catch (error) {
|
|
// 쓰는 곳이 있으면 서버가 막음 — 까닭(쓰는 곳 이름) 그대로
|
|
showToast(why(error) || L("M02_ActionFailed").replace("{value}", ""), "error");
|
|
}
|
|
});
|
|
|
|
searchField.input.addEventListener("input", () => {
|
|
page = 1;
|
|
draw();
|
|
});
|
|
const heads = ["", ...columns.map(([head]) => head)];
|
|
const gridBox = el("div", {
|
|
className: "ui-table-wrap m01-master__grid-wrap",
|
|
attrs: { "data-lib-list": opt.kind },
|
|
children: [
|
|
el("table", {
|
|
className: "ui-table m01-master__grid",
|
|
children: [
|
|
el("thead", {
|
|
children: [el("tr", { children: heads.map((h) => el("th", { text: h })) })],
|
|
}),
|
|
body,
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
host.replaceChildren(buildHeadFilters([searchField.root]), gridBox, pager);
|
|
watchFit(
|
|
host,
|
|
() => gridBox,
|
|
(rows) => {
|
|
if (rows === size) return;
|
|
page = pageKeepFirst(page, size, rows);
|
|
size = rows;
|
|
draw();
|
|
},
|
|
{ once: true },
|
|
);
|
|
if (opt.canEdit)
|
|
tools.replaceChildren(
|
|
el("span", { className: "ui-toolbar m02-main__tools", children: [btnNew, btnCopy, btnDel] }),
|
|
);
|
|
void load();
|
|
return { getDoc: () => null, destroy: () => undefined };
|
|
}
|