- 표지 = 기계 줄 맨 위 칸 `비활성: true`(없음 = 켜짐 · 값 묶음 밖) · 틀 검사 = 기계 줄에만 true(check_master OFF) - M01 기계 목록 = 끝 열 「사용」 스위치(초안 → 페이지 [저장] · 자동저장 없음) · 끈 줄 흐림 · 머리 「비활성 같이 보기」(끄면 서버 rows state 「켜짐」) - 뺌 = 요소 찾기 창(elements · 찾아 넣기 · 단가산출 기계 창) · logic-lib rows · M01 시험 계산 기계 고르기 표 · 내 기계 「비슷한 기계에서 시작」 · 불러오기 기계 잇기(M02_Machine_Find 표) · 기계경비식 「기계」 옵션(새 GET /api/m02/df/machine-off · 고른 값은 남기고 「· 비활성 기계」 꼬리) - 셈 길은 그대로 — 끈 기계를 쓰던 단가산출 · 사본 값 같음 - 자료 = 해상장비 79 대(펌프 준설선 13 · 그래브 준설선 10 · 하천골재채취선 7 · 기중기선 5 · 예선 9 · 양묘선 11 · 토운선 6 · 이우선 2 · 대선 16) 끔 - 시험 = test_842_2_machine_off 새로 · test_804_10 옵션 글 단언을 새 글로 고침(끈 기계 거름이 들어간 줄) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
223 lines
8.5 KiB
TypeScript
223 lines
8.5 KiB
TypeScript
/* =============================================================================
|
||
* M01_MasterData_UI_Machine.ts
|
||
* M01 기계 목록(PLAN 821-2 · 요구 790-2) — 표는 목록용 열만(이름 · 규격 · 구분 · 가격 · 손료계수 ·
|
||
* 주연료 · 조종원 · 시간당 경비(서버 값) · 별칭 · 비고) · 줄을 누르면 상세 페이지 · [새 기계] = 양식.
|
||
* 단위 · 출처 열 없음 · 고친 것 · 새 줄 · 지울 줄은 초안(노랑 · 새 · 지움 표시) — 저장은 페이지 [저장].
|
||
* 「사용」 열(842-2) = 스위치 · 끄면 줄 맨 위 칸 `비활성: true`(초안 → [저장]) · 끈 줄은 흐림 ·
|
||
* 머리 「비활성 같이 보기」 끄면 서버가 끈 줄을 뺌(`rows` state 「켜짐」).
|
||
* ========================================================================== */
|
||
|
||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||
import { t as L } from "@ui/ui_template_locale";
|
||
import { createEmpty } from "@ui/ui_template_empty";
|
||
import { createToggle } from "@ui/ui_template_toggle";
|
||
import { guessRows, pageKeepFirst, watchFit } from "@ui/ui_template_pager";
|
||
import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch";
|
||
import { editRow, onDraftChange, peek, same } from "./M01_MasterData_Draft";
|
||
import { fetchMachineHourly } from "./M01_MasterData_Machine_Api";
|
||
import { buildPager } from "./M01_MasterData_UI_Cells";
|
||
import { saveView, type Pick } from "./M01_MasterData_UI_Side";
|
||
|
||
const BAR_H = 52;
|
||
const num = (v: unknown): string =>
|
||
typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "";
|
||
|
||
/** 목록 열 — 이름 · 줄 → 글 */
|
||
const COLS: [string, (r: Row) => string][] = [
|
||
["이름", (r) => String(r["이름"] ?? "")],
|
||
["규격", (r) => String(r["규격"] ?? "")],
|
||
["구분", (r) => [r["구분"], r["상세구분"]].filter(Boolean).join(" · ")],
|
||
["가격(천원)", (r) => num(val(r)["가격"])],
|
||
["손료계수", (r) => num(val(r)["손료계수"])],
|
||
[
|
||
"주연료",
|
||
(r) =>
|
||
val(r)["주연료"] ? `${val(r)["주연료종류"] ?? ""} ${num(val(r)["주연료"])}ℓ`.trim() : "",
|
||
],
|
||
[
|
||
"조종원",
|
||
(r) =>
|
||
val(r)["조종원"] ? `${num(val(r)["조종원"])}인 ${val(r)["조종원직종"] ?? ""}`.trim() : "",
|
||
],
|
||
];
|
||
const val = (r: Row): Record<string, unknown> => (r["값"] ?? {}) as Record<string, unknown>;
|
||
const OFF = "비활성"; // 842-2 기계 줄 맨 위 칸 — true 만 · 켜짐 = 칸 없음
|
||
let showOff = true; // 「비활성 같이 보기」(페이지 안에서 기억)
|
||
|
||
/** 돌려받은 함수 = 걷기. `open(target)` = 상세(키 · 새 줄 `add:<차례>` · 새 기계 `new`). */
|
||
export function renderMachines(
|
||
host: HTMLElement,
|
||
pick: Pick,
|
||
q: string,
|
||
open: (target: string) => void,
|
||
): () => void {
|
||
const file = pick.file.file;
|
||
let size = guessRows(host, BAR_H);
|
||
let page = pick.view?.page ?? 1;
|
||
let data: RowsPage | null = null;
|
||
const hourly = new Map<string, string>();
|
||
|
||
const hourText = (key: string): string => hourly.get(key) ?? "…";
|
||
/** 「사용」 칸 — 스위치(새 줄은 빈 칸) · 바꾸면 초안 */
|
||
const useCell = (row: Row, cur: Row, key: string): HTMLElement => {
|
||
const td = el("td", { className: "m01-machine__use" });
|
||
if (!key || !data) return td;
|
||
const version = data.version;
|
||
const sw = createToggle({
|
||
label: "",
|
||
size: 20,
|
||
checked: !cur[OFF],
|
||
onChange: (on) => {
|
||
const next: Row = { ...cur };
|
||
if (on) delete next[OFF];
|
||
else next[OFF] = true;
|
||
editRow(file, version, key, row, next);
|
||
},
|
||
});
|
||
sw.root.setAttribute("aria-label", L("M01_MachineUse"));
|
||
sw.root.addEventListener("click", (e) => e.stopPropagation());
|
||
td.classList.toggle("is-changed", !!cur[OFF] !== !!row[OFF]);
|
||
td.append(sw.root);
|
||
return td;
|
||
};
|
||
const line = (row: Row, cur: Row, cls: string, target: string, key = ""): HTMLElement => {
|
||
const tr = el("tr", {
|
||
className: `m01-machine__row ${cls} ${cur[OFF] ? "is-off" : ""}`.trim(),
|
||
attrs: { "data-key": key || target },
|
||
children: [
|
||
...COLS.map(([, get]) => {
|
||
const text = get(cur);
|
||
const td = el("td", { text, attrs: { title: text } });
|
||
td.classList.toggle("is-changed", cls !== "is-new" && text !== get(row));
|
||
return td;
|
||
}),
|
||
el("td", {
|
||
className: "m01-machine__money",
|
||
text: key ? hourText(key) : "",
|
||
attrs: key ? { "data-hourly": key } : {},
|
||
}),
|
||
el("td", { text: String(cur["별칭"] ?? ""), attrs: { title: String(cur["별칭"] ?? "") } }),
|
||
el("td", { text: String(cur["비고"] ?? ""), attrs: { title: String(cur["비고"] ?? "") } }),
|
||
useCell(row, cur, key),
|
||
],
|
||
});
|
||
tr.addEventListener("click", () => open(target));
|
||
return tr;
|
||
};
|
||
|
||
const paint = (): void => {
|
||
if (!data) return;
|
||
const d = data;
|
||
const draft = peek(file);
|
||
const body = el("tbody");
|
||
(draft?.adds ?? []).forEach((r, i) => body.append(line(r, r, "is-new", `add:${i}`)));
|
||
for (const row of d.rows) {
|
||
const key = String(row["키"]);
|
||
const cur = draft?.edits[key] ?? row;
|
||
const cls = draft?.deletes.includes(key) ? "is-deleted" : same(cur, row) ? "" : "is-changed";
|
||
body.append(line(row, cur, cls, key, key));
|
||
}
|
||
const add = createButton({ label: L("M01_MachineNew"), variant: "ghost", iconStart: "plus" });
|
||
add.addEventListener("click", () => open("new"));
|
||
const offSwitch = createToggle({
|
||
label: L("M01_MachineShowOff"),
|
||
checked: showOff,
|
||
onChange: (on) => {
|
||
showOff = on;
|
||
page = 1;
|
||
void load();
|
||
},
|
||
});
|
||
const empty =
|
||
d.rows.length || draft?.adds.length
|
||
? []
|
||
: [createEmpty({ text: L("M01_NoRows"), small: true })];
|
||
host.replaceChildren(
|
||
el("div", { className: "ui-toolbar m01-master__bar", children: [add, offSwitch.root] }),
|
||
el("div", {
|
||
className: "ui-table-wrap m01-master__grid-wrap m01-machine__wrap",
|
||
children: [
|
||
el("table", {
|
||
className: "ui-table m01-master__grid m01-machine__grid",
|
||
children: [
|
||
el("thead", {
|
||
children: [
|
||
el("tr", {
|
||
children: [
|
||
...COLS.map(([h]) => el("th", { text: h })),
|
||
el("th", { text: `${L("M01_MachineHourly")}(원)` }),
|
||
el("th", { text: "별칭" }),
|
||
el("th", { text: "비고" }),
|
||
el("th", { className: "m01-machine__use", text: L("M01_MachineUse") }),
|
||
],
|
||
}),
|
||
],
|
||
}),
|
||
body,
|
||
],
|
||
}),
|
||
],
|
||
}),
|
||
buildPager(d.total, size, d.page, go),
|
||
...empty,
|
||
);
|
||
fit.ask();
|
||
};
|
||
|
||
/** 시간당 경비 — 표를 그린 뒤 서버 값으로 채움(마스터 판마다 서버가 한 번 셈) */
|
||
const fillHourly = async (keys: string[]): Promise<void> => {
|
||
const want = keys.filter((k) => !hourly.has(k));
|
||
if (!want.length) return;
|
||
try {
|
||
const got = await fetchMachineHourly(want);
|
||
for (const [k, h] of Object.entries(got))
|
||
hourly.set(k, h.계 === undefined ? (h.reason ?? "—") : num(h.계));
|
||
} catch {
|
||
for (const k of want) hourly.set(k, "—");
|
||
}
|
||
host.querySelectorAll<HTMLElement>("td[data-hourly]").forEach((td) => {
|
||
const text = hourly.get(td.dataset.hourly!);
|
||
if (text) td.textContent = text;
|
||
});
|
||
};
|
||
|
||
const go = (next: number): void => {
|
||
page = next;
|
||
void load();
|
||
};
|
||
|
||
let ticket = 0; // 늦게 온 옛 응답이 새 목록을 덮지 않게(829-1)
|
||
const load = async (): Promise<void> => {
|
||
const mine = ++ticket;
|
||
try {
|
||
const state = showOff ? pick.state : "켜짐";
|
||
const got = await fetchRows(file, page, size, q, false, pick.sub, pick.detail, state);
|
||
if (mine !== ticket) return;
|
||
data = got;
|
||
saveView(pick.group, { page: data.page });
|
||
paint();
|
||
void fillHourly(data.rows.map((r) => String(r["키"])));
|
||
} 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();
|
||
};
|
||
}
|