feat(M01): 인력 한 표 · 왼쪽 「전체」 + 구분 → 상세구분 두 단 거름 · 준용 모든 줄 고르기

- 인력 열 순서 = 키 … 옛이름 · 비고 · 준용 · 상태 읽기 전용
- 구분·상세구분 목록은 서버 /subs · 고른 거름은 sessionStorage 기억

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
2026-09-20 15:18:26 +09:00
co-authored by Claude Sonnet 5
parent ef040a5749
commit 02b9c8be92
4 changed files with 105 additions and 10 deletions
+11 -8
View File
@@ -34,7 +34,7 @@ const SIZE = 50;
const MARKET = "재료_시중물가.json";
const LINKED = "재료_품셈재료.json";
const JOB = ["인력.json"];
const NOTE = "비고";
const NOTE = "비고"; // 끝 열(인력은 준용 앞) · 줄에 없으면 빈 칸 · 눌러 적음
const SUPPLY = ["값", "조달"];
const SOURCE = [...SUPPLY, "출처"];
const NARA = "나라장터:";
@@ -68,7 +68,7 @@ function hidden(file: string, col: string): boolean {
/** 키(한 번 주면 안 바뀜) · 빌더가 원문에서 채운 칸(구분 · 상세구분 · 옛이름) — 읽기 전용. */
const fixed = (file: string, col: string): boolean =>
col === "키" ||
(JOB.includes(file) && ["구분", "상세구분", "옛이름"].includes(col.split(SEP)[0]));
(JOB.includes(file) && ["구분", "상세구분", "상태", "옛이름"].includes(col.split(SEP)[0]));
const HEADS: Record<string, string[]> = {
[MARKET]: ["조달 값", "조달 기준", "M01_ProcureCol"],
@@ -115,14 +115,16 @@ export function renderRows(
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 })),
...(HEADS[file] ?? []).map((h) =>
el("th", { text: h.startsWith("M01_") ? L(h as "M01_PriceCol") : h }),
),
el("th", { text: NOTE }), // 비고 — 항상 끝 열(줄에 없으면 빈 칸 · 눌러 적음)
...(JOB.includes(file) ? [] : heads),
el("th", { text: NOTE }),
...(JOB.includes(file) ? heads : []),
],
});
const body = el("tbody");
@@ -145,6 +147,7 @@ export function renderRows(
onEdit: (text) => setAdd(file, i, withNote(row, text)),
}),
);
if (JOB.includes(file)) tr.append(el("td"));
body.append(tr);
});
@@ -178,7 +181,6 @@ export function renderRows(
: (next: Row): void => editRow(file, d.version, key, row, next);
if (file === MARKET) tr.append(...marketCells(row, cur, put));
if (file === LINKED) tr.append(linkedCell(row, cur, put));
if (JOB.includes(file)) tr.append(wageCell(row, cur, put));
tr.append(
buildCell(show(cur[NOTE]), {
changed: !same(cur[NOTE], row[NOTE]),
@@ -188,6 +190,7 @@ export function renderRows(
: (text) => editRow(file, d.version, key, row, withNote(cur, text)),
}),
);
if (JOB.includes(file)) tr.append(wageCell(row, cur, put));
body.append(tr);
}
@@ -328,7 +331,7 @@ function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
function wageCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
const text = show(cur["준용"]);
const td = buildCell(named(text), { changed: text !== show(row["준용"]) });
if (put && row["값"] === null) {
if (put) {
const btn = rowBtn(L("M01_JobFind"));
btn.addEventListener("click", () =>
openPickModal({
+80 -2
View File
@@ -35,6 +35,8 @@ export interface SideHandle {
setActive: (id: string | null) => void;
}
const LABOR_FILTER = "m01.laborFilter";
const LABOR_ID = "인력|";
const MATERIAL_NAMES: Record<string, string> = { : "나라장터", : "유가" };
/** 파일 이름 → 목록 글자 — 「소요량_건설품셈_10장_창호…」 → 「건설품셈_10장_창호…」 */
@@ -98,6 +100,78 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
return button;
};
/** 인력 — 「전체」 한 줄 + 구분 → 상세구분 두 단 거름(서버가 준 목록 · 고른 것은 기억) */
const laborFilter = (
file: FileInfo,
open: (file: FileInfo, sub: string, label: string, detail?: string) => void,
): HTMLElement => {
const list = kinds.get("인력") ?? [];
const saved = (() => {
try {
return JSON.parse(sessionStorage.getItem(LABOR_FILTER) ?? "[]") as string[];
} catch {
return [];
}
})();
const pickSub = list.find((k) => k.name === saved[0]);
const choose = (name: string, values: string[], value: string): HTMLSelectElement => {
const select = el("select", {
className: "m01-side__select",
attrs: { "aria-label": name },
children: [L("M01_All"), ...values].map((v, i) =>
el("option", { text: v, attrs: { value: i ? v : "" } }),
),
});
select.value = value;
return select;
};
const sub = choose(
L("M01_LaborSub"),
list.map((k) => k.name),
pickSub?.name ?? "",
);
const detail = choose(L("M01_LaborDetail"), pickSub?.details ?? [], saved[1] ?? "");
detail.disabled = !pickSub?.details.length;
const run = (): void => {
try {
sessionStorage.setItem(LABOR_FILTER, JSON.stringify([sub.value, detail.value]));
} catch {
/* 기억 못 해도 거름은 됨 */
}
const label = [sub.value, detail.value].filter(Boolean).join(" · ") || L("M01_All");
setActive(LABOR_ID);
open(file, sub.value, label, detail.value);
};
sub.addEventListener("change", () => {
const details = list.find((k) => k.name === sub.value)?.details ?? [];
detail.replaceChildren(
...[L("M01_All"), ...details].map((v, i) =>
el("option", { text: v, attrs: { value: i ? v : "" } }),
),
);
detail.disabled = details.length === 0;
run();
});
detail.addEventListener("change", run);
const all = make({ id: LABOR_ID, label: L("M01_All"), count: file.rows, run }, "", 0, () => {
sub.value = detail.value = "";
sub.dispatchEvent(new Event("change"));
});
return el("div", {
children: [
all,
el("label", {
className: "m01-side__filter",
children: [el("span", { text: L("M01_LaborSub") }), sub],
}),
el("label", {
className: "m01-side__filter",
children: [el("span", { text: L("M01_LaborDetail") }), detail],
}),
],
});
};
const drawGroup = (group: Group): void => {
const files = lists.get(group) ?? [];
const body = bodies.get(group);
@@ -105,7 +179,11 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
const open = (file: FileInfo, sub: string, label: string, detail = ""): void =>
onOpen({ group, file, sub, detail, label });
const one = files[0];
if (group === "인력" || group === "기계") {
if (group === "인력" && one) {
body.replaceChildren(laborFilter(one, open));
return;
}
if (group === "기계") {
const list = kinds.get(group) ?? [];
const all: TreeNode[] = one
? [
@@ -170,7 +248,7 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
const subs = await fetchSubs(file);
kinds.set(group, subs);
await Promise.all(
subs.map(async (k) =>
(group === "기계" ? subs : []).map(async (k) =>
counts.set(`${group}\n${k.name}`, (await fetchRows(file, 1, 1, "", false, k.name)).total),
),
);
@@ -299,3 +299,15 @@
flex-wrap: wrap;
gap: 8px;
}
.m01-side__filter {
display: flex;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
}
.m01-side__select {
flex: 1;
min-width: 0;
}
+2
View File
@@ -56,6 +56,8 @@ export const ui_locales_m1 = {
M01_JobTitle: ["준용 직종 고르기", "Pick substitute job"],
M01_JobCol: ["준용", "Substitute"],
M01_All: ["전체", "All"],
M01_LaborSub: ["구분", "Category"],
M01_LaborDetail: ["상세구분", "Detail"],
M01_GroupLabor: ["인력", "Labor"],
M01_GroupMaterial: ["재료", "Materials"],
M01_GroupMachine: ["기계", "Machines"],