- 인력 준용 고르기 — 값 칸이 고른 직종 값으로 바뀌어 노랑 · 비우면 원래 값 - 저장 때 서버가 준용 직종 값을 다시 읽어 값 칸에 채움(화면 값 불신) - 인력 왼쪽 패널 「상태」 드롭다운(목록은 서버) · 재료 유가전력 2단 거름 - 시험 2개 더함 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_Rows.ts
|
|
* 요소 파일(인력·재료·기계·환율·요율) 표 — 한 줄 = 요소 · 이름 찾기 · 쪽 나눔 · 눌러 고침(노랑)
|
|
* ========================================================================== */
|
|
|
|
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
|
import { t as L } from "@ui/ui_template_locale";
|
|
import { openPickModal } from "./M01_MasterData_UI_Pick";
|
|
import type { Pick } from "./M01_MasterData_UI_Side";
|
|
import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch";
|
|
import {
|
|
addRow,
|
|
editRow,
|
|
onDraftChange,
|
|
peek,
|
|
removeAdd,
|
|
same,
|
|
setAdd,
|
|
toggleDelete,
|
|
} from "./M01_MasterData_Draft";
|
|
import {
|
|
buildCell,
|
|
buildPager,
|
|
coerce,
|
|
flatten,
|
|
isScalar,
|
|
SEP,
|
|
show,
|
|
withCell,
|
|
} from "./M01_MasterData_UI_Cells";
|
|
|
|
const SIZE = 50;
|
|
const MARKET = "재료_자재품목.json";
|
|
const LINKED = "재료_품셈재료.json";
|
|
const JOB = ["인력.json"];
|
|
const NOTE = "비고"; // 끝 열(인력은 준용 앞) · 줄에 없으면 빈 칸 · 눌러 적음
|
|
const FALLBACK: Row = { 키: "", 원문번호: "", 이름: "", 규격: "", 단위: "", 값: null, 출처: "" };
|
|
|
|
/** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 null. */
|
|
function blank(sample: Row): Row {
|
|
const out: Row = {};
|
|
for (const [k, v] of Object.entries(sample)) {
|
|
if (v && typeof v === "object" && !Array.isArray(v)) out[k] = blank(v as Row);
|
|
else out[k] = typeof v === "string" && k !== "값" ? "" : null;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 비고 칸 고침 — 빈 글이면 칸을 지움 */
|
|
function withNote(row: Row, text: string): Row {
|
|
const out = { ...row };
|
|
if (text.trim()) out[NOTE] = text;
|
|
else delete out[NOTE];
|
|
return out;
|
|
}
|
|
|
|
/** 요소별 전용 칸으로 그리는 칸 · 접어 두는 칸 — 일반 칸에서 뺌. */
|
|
function hidden(file: string, col: string): boolean {
|
|
if (file === MARKET) return col.split(SEP)[0] === "면수"; // 면수는 접어 둠
|
|
if (file === LINKED) return col.startsWith("연결");
|
|
return JOB.includes(file) && col === "준용";
|
|
}
|
|
|
|
/** 키(한 번 주면 안 바뀜) · 빌더가 원문에서 채운 칸(구분 · 상세구분 · 옛이름) — 읽기 전용. */
|
|
const fixed = (file: string, col: string): boolean =>
|
|
col === "키" ||
|
|
(JOB.includes(file) && ["구분", "상세구분", "상태", "옛이름"].includes(col.split(SEP)[0]));
|
|
|
|
const HEADS: Record<string, string[]> = {
|
|
[LINKED]: ["M01_PriceCol"],
|
|
[JOB[0]]: ["M01_JobCol"],
|
|
};
|
|
|
|
const linkText = (link: unknown): string =>
|
|
link && typeof link === "object"
|
|
? `${L("M01_PriceCondUsed")}: ${(link as Row)["이름"]} ${(link as Row)["규격"] ?? ""}`.trim()
|
|
: String(link ?? "");
|
|
|
|
/** 키 → 이름(서버가 준 것 + 방금 고른 것) — 참조 칸에 키 옆에 같이 보임 */
|
|
const names = new Map<string, string>();
|
|
const KEY = /[A-Z]{2}\d{6}/;
|
|
const named = (text: string): string => {
|
|
const name = names.get(KEY.exec(text)?.[0] ?? "");
|
|
return name ? `${text} · ${name}` : text;
|
|
};
|
|
const remember = (ref: string | null, name?: string): void => {
|
|
const key = KEY.exec(ref ?? "")?.[0];
|
|
if (key && name) names.set(key, name);
|
|
};
|
|
|
|
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
|
|
export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void {
|
|
const { sub, detail, state } = pick;
|
|
const file = pick.file.file;
|
|
let page = 1;
|
|
let data: RowsPage | null = null;
|
|
let unlinked = false;
|
|
|
|
const paint = (): void => {
|
|
if (!data) return;
|
|
const d = data;
|
|
const draft = peek(file);
|
|
const adds = draft?.adds ?? [];
|
|
const cols: string[] = [];
|
|
for (const row of [...adds, ...d.rows]) {
|
|
for (const k of Object.keys(flatten(row)))
|
|
if (!hidden(file, k) && k !== NOTE && !cols.includes(k)) cols.push(k);
|
|
}
|
|
const heads = (HEADS[file] ?? []).map((h) =>
|
|
el("th", { text: h.startsWith("M01_") ? L(h as "M01_PriceCol") : h }),
|
|
);
|
|
const head = el("tr", {
|
|
children: [
|
|
el("th"),
|
|
...cols.map((c) => el("th", { text: c })),
|
|
...(JOB.includes(file) ? [] : heads),
|
|
el("th", { text: NOTE }),
|
|
...(JOB.includes(file) ? heads : []),
|
|
],
|
|
});
|
|
const body = el("tbody");
|
|
|
|
adds.forEach((row, i) => {
|
|
const flat = flatten(row);
|
|
const tr = el("tr", { className: "is-new" });
|
|
tr.append(actionCell(L("M01_RowRemove"), () => removeAdd(file, i)));
|
|
for (const c of cols) {
|
|
tr.append(
|
|
buildCell(show(flat[c]), {
|
|
changed: true,
|
|
onEdit: (text) => setAdd(file, i, withCell(row, c, coerce(text, flat[c]))),
|
|
}),
|
|
);
|
|
}
|
|
tr.append(
|
|
buildCell(show(row[NOTE]), {
|
|
changed: true,
|
|
onEdit: (text) => setAdd(file, i, withNote(row, text)),
|
|
}),
|
|
);
|
|
if (JOB.includes(file)) tr.append(el("td"));
|
|
body.append(tr);
|
|
});
|
|
|
|
for (const row of d.rows) {
|
|
const key = String(row["키"]);
|
|
const cur = draft?.edits[key] ?? row;
|
|
const deleted = draft?.deletes.includes(key) ?? false;
|
|
const orig = flatten(row);
|
|
const now = flatten(cur);
|
|
const tr = el("tr");
|
|
tr.classList.toggle("is-deleted", deleted);
|
|
tr.append(
|
|
actionCell(deleted ? L("M01_RowRestore") : L("M01_RowDelete"), () =>
|
|
toggleDelete(file, d.version, key),
|
|
),
|
|
);
|
|
for (const c of cols) {
|
|
const editable = !deleted && isScalar(orig[c]) && !fixed(file, c);
|
|
tr.append(
|
|
buildCell(show(now[c]), {
|
|
changed: !same(now[c], orig[c]),
|
|
onEdit: editable
|
|
? (text) =>
|
|
editRow(file, d.version, key, row, withCell(cur, c, coerce(text, orig[c])))
|
|
: undefined,
|
|
}),
|
|
);
|
|
}
|
|
const put = deleted
|
|
? undefined
|
|
: (next: Row): void => editRow(file, d.version, key, row, next);
|
|
if (file === LINKED) tr.append(linkedCell(row, cur, put));
|
|
tr.append(
|
|
buildCell(show(cur[NOTE]), {
|
|
changed: !same(cur[NOTE], row[NOTE]),
|
|
onEdit:
|
|
deleted || !isScalar(row[NOTE])
|
|
? undefined
|
|
: (text) => editRow(file, d.version, key, row, withNote(cur, text)),
|
|
}),
|
|
);
|
|
if (JOB.includes(file)) tr.append(wageCell(row, cur, put));
|
|
body.append(tr);
|
|
}
|
|
|
|
const only =
|
|
file === LINKED
|
|
? [
|
|
createButton({
|
|
label: L("M01_OnlyUnlinked"),
|
|
variant: unlinked ? "filled" : "ghost",
|
|
onClick: () => {
|
|
unlinked = !unlinked;
|
|
page = 1;
|
|
void load();
|
|
},
|
|
}),
|
|
]
|
|
: [];
|
|
const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" });
|
|
add.addEventListener("click", () => {
|
|
const fresh = blank(d.rows[0] ?? FALLBACK);
|
|
const col = ["구분", "세부분류"].find((c) => c in fresh);
|
|
addRow(file, d.version, sub && col ? { ...fresh, [col]: sub } : fresh);
|
|
});
|
|
const empty =
|
|
d.rows.length || adds.length
|
|
? []
|
|
: [el("p", { className: "m01-master__empty", text: L("M01_NoRows") })];
|
|
host.replaceChildren(
|
|
el("div", {
|
|
className: "m01-master__bar",
|
|
children: [add, ...only, buildPager(d.total, SIZE, d.page, go)],
|
|
}),
|
|
el("div", {
|
|
className: "m01-master__grid-wrap",
|
|
children: [
|
|
el("table", {
|
|
className: "m01-master__grid",
|
|
children: [el("thead", { children: [head] }), body],
|
|
}),
|
|
],
|
|
}),
|
|
...empty,
|
|
);
|
|
};
|
|
|
|
const go = (next: number): void => {
|
|
page = next;
|
|
void load();
|
|
};
|
|
|
|
const load = async (): Promise<void> => {
|
|
try {
|
|
data = await fetchRows(file, page, SIZE, q, unlinked, sub, detail, state);
|
|
for (const [key, name] of Object.entries(data.refs ?? {})) names.set(key, name);
|
|
paint();
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
|
|
}
|
|
};
|
|
|
|
const off = onDraftChange(paint);
|
|
void load();
|
|
return off;
|
|
}
|
|
|
|
function actionCell(label: string, onClick: () => void): HTMLTableCellElement {
|
|
return el("td", { children: [createButton({ label, variant: "ghost", onClick })] });
|
|
}
|
|
|
|
type Put = (row: Row) => void;
|
|
|
|
const rowBtn = (label: string): HTMLButtonElement => createButton({ label, variant: "ghost" });
|
|
|
|
/** 품셈재료 — 가격 연결(자재품목 키 · 후보 조건 · 없음) · 고르기 모달. */
|
|
function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
|
|
const link = cur["연결"];
|
|
const td = buildCell(named(linkText(link)), {
|
|
changed: JSON.stringify(link ?? null) !== JSON.stringify(row["연결"] ?? null),
|
|
});
|
|
if (put) {
|
|
const cond = link && typeof link === "object" ? (link as Row) : null;
|
|
const name = String(cond?.["이름"] ?? cur["이름"] ?? "");
|
|
const spec = String(cond?.["규격"] ?? cur["규격"] ?? "");
|
|
const btn = rowBtn(L("M01_PriceFind"));
|
|
btn.addEventListener("click", () =>
|
|
openPickModal({
|
|
kind: "price",
|
|
title: `${L("M01_PriceTitle")} · ${cur["이름"] ?? ""}`,
|
|
current: linkText(link),
|
|
seed: `${name} ${spec}`.trim(),
|
|
onPick: (ref, name) => {
|
|
remember(ref, name);
|
|
put(withCell(cur, "연결", ref));
|
|
},
|
|
cond: { 이름: name, 규격: spec, onCond: (c) => put(withCell(cur, "연결", c)) },
|
|
}),
|
|
);
|
|
td.append(btn);
|
|
}
|
|
return td;
|
|
}
|
|
|
|
/** 인력 줄의 준용 = 관리자가 고르는 공표 직종 키(값이 있어도 돌릴 수 있음). */
|
|
function wageCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
|
|
const text = show(cur["준용"]);
|
|
const td = buildCell(named(text), { changed: text !== show(row["준용"]) });
|
|
if (put) {
|
|
const btn = rowBtn(L("M01_JobFind"));
|
|
btn.addEventListener("click", () =>
|
|
openPickModal({
|
|
kind: "job",
|
|
title: `${L("M01_JobTitle")} · ${cur["이름"] ?? ""}`,
|
|
current: text,
|
|
seed: "",
|
|
onPick: (ref, name, value) => {
|
|
remember(ref, name);
|
|
// 준용을 고르면 값도 그 직종 값으로 · 비우면 원래 값 (저장 때 서버가 다시 읽음)
|
|
put(withCell(withCell(cur, "준용", ref), "값", ref ? value : row["값"]));
|
|
},
|
|
}),
|
|
);
|
|
td.append(btn);
|
|
}
|
|
return td;
|
|
}
|