Files
Aislo/M01_MasterData/M01_MasterData_UI_Rows.ts
T
eomsangdonandClaude Opus 5.5 64affbb00e feat(830-2): 재료 DB 가격 열 재정의 · 항목 통합 이어 감 — 조달청 · 일반가격 · 상하차 · 재료 아닌 줄 삭제
- 열 한 벌 = 키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 상하차 · 조달청 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 일반가격 · 출처 · 비고 · 면수(material_merge.COLUMNS 한 곳 · check_master 가 씀)
- 「관급」 칸 → 「조달청」(값 0 · EST 가격정보 값 안 씀) · 새 칸 「일반가격」(빈칸 · 시스템 관리자 손 입력 · 빌더가 원문번호로 이어 받음) · 상하차 = 단위 바로 뒤
- 남는 줄 지역 기준 = 전국 › 지역 없음 › 서울 › 서울 권역 › 경기 › 그 밖(값 중간) · 기준 지역은 비고 「통합: 기준 지역 …」(지역 없는 묶음은 안 적음)
- 재료 아닌 줄 삭제(요구 832) — 품질시험 수수료 60(가격정보 출처 「2026(경북)」) · 일반 도로용 블록 5(「~26.10.16」) · 치즐 2 줄은 남김 · 값 없음
- 재료_자재품목 24,092 → 17,692 줄(통합 6,335 · 재료 아닌 줄 65) · 남는 줄 키 = 830-1 키(7565ee787 이 지운 대장 키를 되살려 다시 지음 · 새 키 0) · 남는 줄 물가지 값 바뀐 것 0 · 빌더 두 번 = 같은 파일
- 가장 낮은 값 = 사급 칸(물가지 넷 + 일반가격) — master_material.SLOTS · M02_Elem_Alias.PRICES · M02 PRICE_SLOTS · 조달청 칸 안 봄 · M01 고르기 「관급」 표시 = 조달청 칸 · B09 대비표 「관급」 출처 = M01 조달청 칸(이름 그대로)
- M01 재료 화면 상하차 = 고르기(부품 createSelectField) · 새 줄 기본 「-」 · 로직 고르기 표 · 시험 계산 값 줄에 일반가격
- check_master 틀 · 로직 · 조합 0 · 단위 27 · 배 7(전과 같음) · _틀.md · _갱신절차.md 열 정의
- 시험: 새 test_830_2_material_merge · test_830_1 지움(EST 그대로 상태 = 830-2 로 끝남 · 남는 검사는 830_2 로) · 지역 줄 · 관급 이름 · 관급 칸을 보던 시험 기대값 고침(m01_api · m01_pick · masterdata_check · 814_1 · 815_1 · 827_2 · 827_3 · 750_1 · m02_hopyo_match · m02_labor_doc) · 「시멘트 전국」 검색어 시험 = xfail(830-3 연결 할 일)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 18:36:51 +09:00

394 lines
14 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 { saveView, 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";
import { createEmpty } from "@ui/ui_template_empty";
import { createSelectField } from "@ui/ui_template_input";
import { guessRows, pageKeepFirst, watchFit } from "@ui/ui_template_pager";
/** 표 칸 위에 오는 도구줄(단추 줄 + 간격) 높이 — 첫 줄 수 어림에만 씀 */
const BAR_H = 52;
const MARKET = "재료_자재품목.json";
const LINKED = "재료_품셈재료.json";
const JOB = ["인력.json"];
const NOTE = "비고"; // 끝 열(인력은 준용 앞) · 줄에 없으면 빈 칸 · 눌러 적음
// 별칭(815-2 · 요구 764) = 비고 앞 한 열 · 콤마로 여러 개 · 빈 글이면 칸을 지움 · 이 넷만
const ALIAS = "별칭";
const ALIASED = ["인력.json", "기계.json", MARKET, "재료_유가전력.json"];
const FALLBACK: Row = { 키: "", 원문번호: "", 이름: "", 규격: "", 단위: "", 값: null, 출처: "" };
// 자재품목 상하차(830-2 · 요구 828) = 고르기 칸 · 값 셋 = material_merge.LOADS(첫째 = 기본 · 모름)
const LOAD = "상하차";
const LOADS = ["-", "상차", "하차"];
/** 상하차 칸 — 고르기(부품 상자) · 노랑(고침) · onPick 없으면 잠금. */
function loadCell(
value: unknown,
changed: boolean,
onPick?: (value: string) => void,
): HTMLTableCellElement {
const td = el("td");
td.classList.toggle("is-changed", changed);
const field = createSelectField({
options: LOADS.map((v) => ({ value: v, text: v })),
value: String(value ?? LOADS[0]),
compact: true,
disabled: !onPick,
ariaLabel: LOAD,
onChange: (v) => onPick?.(v),
});
td.append(field.root);
return td;
}
/** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 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 if (k !== ALIAS) out[k] = typeof v === "string" && k !== "값" ? "" : null;
}
return out;
}
/** 비고 · 별칭 칸 고침 — 빈 글이면 칸을 지움(별칭은 앞뒤 빈칸 뗌) */
function withNote(row: Row, text: string, col = NOTE): Row {
const out = { ...row };
if (text.trim()) out[col] = col === ALIAS ? text.trim() : text;
else delete out[col];
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);
};
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. `focusKey` = 그 키 줄이 있는 쪽을 찾아 강조(요율 805-1). */
export function renderRows(
host: HTMLElement,
pick: Pick,
q: string,
focusKey?: string,
): () => void {
const { sub, detail, state } = pick;
const file = pick.file.file;
let size = guessRows(host, BAR_H); // 화면 높이로 셈 — 그린 뒤 다시 재서 어긋나면 바로잡음
// 키 번호 순서로 놓인 파일이라 번호 ÷ 쪽 크기로 어림 — 틀리면 그린 뒤 이웃 쪽으로 찾아감
let page = focusKey
? Math.max(1, Math.ceil(Number(focusKey.replace(/\D/g, "")) / size))
: (pick.view?.page ?? 1);
let scrolled = false;
let data: RowsPage | null = null;
let unlinked = false;
let ticket = 0; // 늦게 온 옛 응답이 새 목록을 덮지 않게(829-1)
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 && k !== ALIAS && !cols.includes(k)) cols.push(k);
}
const aliased = ALIASED.includes(file);
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),
...(aliased ? [el("th", { text: ALIAS })] : []),
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) {
const onEdit = (text: string): void =>
setAdd(file, i, withCell(row, c, coerce(text, flat[c])));
tr.append(
file === MARKET && c === LOAD
? loadCell(flat[c], true, onEdit)
: buildCell(show(flat[c]), { changed: true, onEdit }),
);
}
for (const col of aliased ? [ALIAS, NOTE] : [NOTE])
tr.append(
buildCell(show(row[col]), {
changed: true,
onEdit: (text) => setAdd(file, i, withNote(row, text, col)),
}),
);
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.dataset.key = key; // 요율 화면 — 오른쪽 클릭 「시트에서 보기」 · 강조(805-1)
tr.classList.toggle("is-focus", key === focusKey);
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);
const changed = !same(now[c], orig[c]);
const onEdit = editable
? (text: string): void =>
editRow(file, d.version, key, row, withCell(cur, c, coerce(text, orig[c])))
: undefined;
tr.append(
file === MARKET && c === LOAD
? loadCell(now[c], changed, onEdit)
: buildCell(show(now[c]), { changed, onEdit }),
);
}
const put = deleted
? undefined
: (next: Row): void => editRow(file, d.version, key, row, next);
if (file === LINKED) tr.append(linkedCell(row, cur, put));
for (const col of aliased ? [ALIAS, NOTE] : [NOTE])
tr.append(
buildCell(show(cur[col]), {
changed: !same(cur[col], row[col]),
onEdit:
deleted || !isScalar(row[col])
? undefined
: (text) => editRow(file, d.version, key, row, withNote(cur, text, col)),
}),
);
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);
if (file === MARKET) fresh[LOAD] = LOADS[0];
const col = ["구분", "세부분류"].find((c) => c in fresh);
addRow(file, d.version, sub && col ? { ...fresh, [col]: sub } : fresh);
});
const empty =
d.rows.length || adds.length ? [] : [createEmpty({ text: L("M01_NoRows"), small: true })];
host.replaceChildren(
el("div", {
className: "ui-toolbar m01-master__bar",
children: [add, ...only],
}),
el("div", {
className: "ui-table-wrap m01-master__grid-wrap",
children: [
el("table", {
className: "ui-table m01-master__grid",
children: [el("thead", { children: [head] }), body],
}),
],
}),
buildPager(d.total, size, d.page, go),
...empty,
);
settleFocus(d);
fit.ask();
};
/** 강조할 줄이 이 쪽에 없으면 이웃 쪽으로 · 있으면 한 번 보이는 곳으로 */
const settleFocus = (d: RowsPage): void => {
if (!focusKey || scrolled) return;
const row = host.querySelector<HTMLElement>(`tr.is-focus`);
if (row) {
scrolled = true;
row.scrollIntoView({ block: "nearest" });
return;
}
const keys = d.rows.map((r) => String(r["키"]));
if (focusKey > (keys.at(-1) ?? "") && d.page * size < d.total) go(d.page + 1);
else if (focusKey < (keys[0] ?? "") && d.page > 1) go(d.page - 1);
else scrolled = true; // 못 찾음 — 그만
};
const go = (next: number): void => {
page = next;
void load();
};
const load = async (): Promise<void> => {
const mine = ++ticket;
try {
const got = await fetchRows(file, page, size, q, unlinked, sub, detail, state);
if (mine !== ticket) return;
data = got;
for (const [key, name] of Object.entries(data.refs ?? {})) names.set(key, name);
saveView(pick.group, { page: data.page });
paint();
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
}
};
const fit = watchFit(
host,
() => host.querySelector<HTMLElement>(".m01-master__grid-wrap"),
(rows) => {
if (rows === size) return;
page = pageKeepFirst(data?.page ?? page, size, rows);
size = rows;
void load();
},
);
const off = onDraftChange(paint);
void load();
return () => {
ticket++;
fit.stop();
off();
};
}
function actionCell(label: string, onClick: () => void): HTMLTableCellElement {
return el("td", { children: [createButton({ label, variant: "ghost", size: "sm", onClick })] });
}
type Put = (row: Row) => void;
const rowBtn = (label: string): HTMLButtonElement =>
createButton({ label, variant: "ghost", size: "sm" });
/** 품셈재료 — 가격 연결(자재품목 키 · 후보 조건 · 없음) · 고르기 모달. */
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;
}