feat(M01): 화면 부품 공용화 — 드롭다운·찾기 칸·단추·확인창을 공용 템플릿으로 · 재료 컨테이너 차례

- createSelectField 에 선택 인자 더함(compact · ariaLabel · setOptions) — 기본 동작 그대로
- 인력 2단 거름 · 로직 편집 고르기 · 계산 입력 · 요소 찾기 창 그룹 select 를 공용으로 · m01-side__select 걷음
- 찾기 칸은 createInputField · 표 칸·모달 단추는 createButton · 버리기 확인은 showConfirmDialog
- 재료 컨테이너 = 자재품목 · 유가 · 품셈재료 차례 · 안 쓰는 조달 문구 걷음

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 16:27:06 +09:00
co-authored by Claude Sonnet 5
parent 9de61eb251
commit 21f04c64bb
12 changed files with 141 additions and 142 deletions
+14 -11
View File
@@ -4,7 +4,7 @@
* 고친 로직은 저장 전 줄(`row`)을 같이 보내 메모리에서 셈(`POST /calc`) — 파일에 안 씀
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
runCalc,
type CalcAnswer,
@@ -31,22 +31,25 @@ const SUMS = ["노무비", "재료비", "경비", "계"];
export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
const fields = (ctx.row.입력 ?? []).map((spec) => {
const name = spec.이름;
let control: HTMLInputElement | HTMLSelectElement;
let control: HTMLElement;
if (spec.고르기?.length) {
control = el("select", { className: "m01-logic__input" });
for (const option of ["", ...spec.고르기.map(String)]) {
control.append(el("option", { text: option, attrs: { value: option } }));
}
const pick = createSelectField({
options: ["", ...spec.고르기.map(String)].map((o) => ({ value: o, text: o })),
value: ctx.values[name] ?? "",
compact: true,
onChange: (v) => (ctx.values[name] = v),
});
control = pick.root;
} else {
control = el("input", {
const box = el("input", {
className: "m01-logic__input",
attrs: { type: "text", inputmode: "decimal" },
});
if (spec.범위) control.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`;
if (spec.범위) box.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`;
box.value = ctx.values[name] ?? "";
box.addEventListener("input", () => (ctx.values[name] = box.value));
control = box;
}
control.value = ctx.values[name] ?? "";
control.addEventListener("input", () => (ctx.values[name] = control.value));
control.addEventListener("change", () => (ctx.values[name] = control.value));
const label = spec.단위 ? `${name} (${spec.단위})` : name;
return el("label", {
className: "m01-logic__field",
@@ -7,7 +7,7 @@
* 줄 더하기·지우기 · 요소 고르기처럼 모양이 바뀔 때만 `rerender`.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import type {
CalcLine,
ElementBrief,
@@ -70,14 +70,14 @@ function choice(
options: string[],
value: string | undefined,
onPick: (v: string) => void,
): HTMLSelectElement {
const select = el("select", { className: "m01-logic__input" });
for (const option of value && !options.includes(value) ? [value, ...options] : options) {
select.append(el("option", { text: option, attrs: { value: option } }));
}
select.value = value ?? options[0];
select.addEventListener("change", () => onPick(select.value));
return select;
): HTMLElement {
const all = value && !options.includes(value) ? [value, ...options] : options;
return createSelectField({
options: all.map((o) => ({ value: o, text: o })),
value: value ?? options[0],
compact: true,
onChange: onPick,
}).root;
}
function dropButton(onClick: () => void): HTMLButtonElement {
@@ -4,7 +4,7 @@
* 거르기는 화면에서(목록은 한 번 받음 · 391 줄 남짓)
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createInputField, el } from "@ui/ui_template_elements";
import { renderTree, type MakeRow, type TreeNode } from "./M01_MasterData_UI_Tree";
import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api";
import { tx } from "./M01_MasterData_UI_Logic_Text";
@@ -40,10 +40,8 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
/** 트리에서 고른 범위 — 원문 · 부문 · 장(비면 전체) */
const pick = { book: "", division: "", chapter: "" };
const tree = el("div", { className: "m01-logic__tree" });
const search = el("input", {
className: "m01-logic__input",
attrs: { type: "search", placeholder: tx("List_Search") },
});
const searchField = createInputField({ type: "search", placeholder: tx("List_Search") });
const search = searchField.input;
const blockedOnly = el("input", { attrs: { type: "checkbox" } });
const count = el("span", { className: "m01-logic__muted" });
const list = el("ul", { className: "m01-logic__list" });
@@ -178,7 +176,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
el("div", {
className: "m01-logic__filters",
children: [
search,
searchField.root,
el("label", {
className: "m01-logic__check",
children: [blockedOnly, el("span", { text: tx("List_BlockedOnly") })],
+18 -8
View File
@@ -4,7 +4,13 @@
* 표 고르기(소요량·계수)는 값칸 단추를 눌러 `찾기(…).칸` 으로 수량에 넣음
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { searchElements, type ElementBrief } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber, type PickDone, type PickMode } from "./M01_MasterData_UI_Logic_Edit";
import { tx } from "./M01_MasterData_UI_Logic_Text";
@@ -16,13 +22,14 @@ const GROUPS: Record<PickMode, string[]> = {
export function openPicker(mode: PickMode, group: string, done: PickDone): void {
const groups = GROUPS[mode];
const select = el("select", { className: "m01-logic__input" });
for (const g of groups) select.append(el("option", { text: g, attrs: { value: g } }));
select.value = groups.includes(group) ? group : groups[0];
const search = el("input", {
className: "m01-logic__input",
attrs: { type: "search", placeholder: tx("Pick_Search") },
const groupField = createSelectField({
options: groups.map((g) => ({ value: g, text: g })),
value: groups.includes(group) ? group : groups[0],
compact: true,
});
const select = groupField.select;
const searchField = createInputField({ type: "search", placeholder: tx("Pick_Search") });
const search = searchField.input;
const count = el("span", { className: "m01-logic__muted" });
const list = el("div", { className: "m01-logic__pick-list" });
const close = (): void => backdrop.remove();
@@ -37,7 +44,10 @@ export function openPicker(mode: PickMode, group: string, done: PickDone): void
createButton({ label: tx("Pick_Close"), variant: "ghost", onClick: close }),
],
}),
el("div", { className: "m01-logic__pick-bar", children: [select, search, count] }),
el("div", {
className: "m01-logic__pick-bar",
children: [groupField.root, searchField.root, count],
}),
list,
],
});
+12 -8
View File
@@ -8,7 +8,13 @@
* ========================================================================== */
import "@ui/ui_template_workflow_layout.css";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { ROUTES } from "@config/config_frontend";
@@ -42,10 +48,8 @@ function buildPage(): HTMLElement {
/* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */
const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") });
const search = el("input", {
className: "m01-master__search",
attrs: { type: "search", placeholder: L("M01_Search") },
});
const searchField = createInputField({ type: "search", placeholder: L("M01_Search") });
const search = searchField.input;
const summary = el("span", { className: "m01-master__summary" });
const save = createButton({ label: L("M01_Save"), variant: "filled" });
const drop = createButton({ label: L("M01_Discard"), variant: "ghost" });
@@ -53,7 +57,7 @@ function buildPage(): HTMLElement {
const body = el("div", { className: "m01-master__body" });
const head = el("div", {
className: "m01-master__head",
children: [title, search, summary, drop, save],
children: [title, searchField.root, summary, drop, save],
});
const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const elementView = el("div", {
@@ -98,8 +102,8 @@ function buildPage(): HTMLElement {
}, 300);
});
drop.addEventListener("click", () => {
if (!window.confirm(L("M01_DiscardConfirm"))) return;
drop.addEventListener("click", async () => {
if (!(await showConfirmDialog(L("M01_DiscardConfirm")))) return;
discard();
showNotice([]);
});
+9 -15
View File
@@ -3,7 +3,7 @@
* 고르기 모달 — 가격 연결(자재품목) · 준용 직종을 이름·규격으로 찾아 고르면 ref 를 돌려줌
* ========================================================================== */
import { el, showToast } from "@ui/ui_template_elements";
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { fetchPick, type PickItem, type PickKind } from "./M01_MasterData_Api_Fetch";
import { show } from "./M01_MasterData_UI_Cells";
@@ -28,21 +28,15 @@ export interface PickOptions {
cond?: { 이름: string; 규격: string; onCond: (c: Cond) => void };
}
const button = (text: string): HTMLButtonElement =>
el("button", { className: "m01-master__row-btn", text, attrs: { type: "button" } });
const button = (label: string): HTMLButtonElement => createButton({ label, variant: "ghost" });
const input = (value: string, placeholder: string): HTMLInputElement => {
const box = el("input", {
className: "m01-master__search",
attrs: { type: "search", placeholder },
});
box.value = value;
return box;
};
const field = (value: string, placeholder: string): ReturnType<typeof createInputField> =>
createInputField({ type: "search", placeholder, value });
export function openPickModal(opt: PickOptions): void {
const close = (): void => back.remove();
const search = input(opt.seed, L("M01_ProcureSearch"));
const searchField = field(opt.seed, L("M01_ProcureSearch"));
const search = searchField.input;
const list = el("div", { className: "m01-procure__list" });
const cut = button(L("M01_ProcureCut"));
cut.disabled = !opt.current;
@@ -56,8 +50,8 @@ export function openPickModal(opt: PickOptions): void {
const condRow: HTMLElement[] = [];
if (opt.cond) {
const { onCond } = opt.cond;
const name = input(opt.cond.이름, L("M01_PriceCondName"));
const spec = input(opt.cond.규격, L("M01_PriceCondSpec"));
const name = field(opt.cond.이름, L("M01_PriceCondName")).input;
const spec = field(opt.cond.규격, L("M01_PriceCondSpec")).input;
const go = button(L("M01_PriceCond"));
go.addEventListener("click", () => {
if (!name.value.trim()) return;
@@ -72,7 +66,7 @@ export function openPickModal(opt: PickOptions): void {
children: [
el("h3", { text: opt.title }),
el("p", { className: "m01-master__muted", text: opt.current }),
search,
searchField.root,
list,
...condRow,
el("div", { className: "m01-procure__foot", children: [cut, shut] }),
+2 -9
View File
@@ -250,19 +250,12 @@ export function renderRows(
}
function actionCell(label: string, onClick: () => void): HTMLTableCellElement {
const button = el("button", {
className: "m01-master__row-btn",
text: label,
attrs: { type: "button" },
});
button.addEventListener("click", onClick);
return el("td", { children: [button] });
return el("td", { children: [createButton({ label, variant: "ghost", onClick })] });
}
type Put = (row: Row) => void;
const rowBtn = (text: string): HTMLButtonElement =>
el("button", { className: "m01-master__row-btn", text, attrs: { type: "button" } });
const rowBtn = (label: string): HTMLButtonElement => createButton({ label, variant: "ghost" });
/** 품셈재료 — 가격 연결(자재품목 키 · 후보 조건 · 없음) · 고르기 모달. */
function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
+50 -46
View File
@@ -5,7 +5,7 @@
* 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록
* ========================================================================== */
import { el, showToast } from "@ui/ui_template_elements";
import { createSelectField, el, showToast } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { t as L } from "@ui/ui_template_locale";
import {
@@ -38,6 +38,8 @@ export interface SideHandle {
const LABOR_FILTER = "m01.laborFilter";
const LABOR_ID = "인력|";
const MATERIAL_NAMES: Record<string, string> = { 나라장터자재: "나라장터", 오피넷유가: "유가" };
/** 재료 컨테이너 차례 — 여기 없는 파일은 뒤에 */
const MATERIAL_ORDER = ["자재품목", "오피넷유가", "품셈재료"];
/** 파일 이름 → 목록 글자 — 「소요량_건설품셈_10장_창호…」 → 「건설품셈_10장_창호…」 */
export const fileLabel = (file: string): string =>
@@ -114,64 +116,59 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
}
})();
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 opts = (values: string[]): { value: string; text: string }[] =>
[L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v }));
const sub = createSelectField({
options: opts(list.map((k) => k.name)),
value: pickSub?.name ?? "",
compact: true,
label: L("M01_LaborSub"),
});
const detail = createSelectField({
options: opts(pickSub?.details ?? []),
value: saved[1] ?? "",
compact: true,
label: L("M01_LaborDetail"),
disabled: !pickSub?.details.length,
});
const run = (): void => {
try {
sessionStorage.setItem(LABOR_FILTER, JSON.stringify([sub.value, detail.value]));
sessionStorage.setItem(
LABOR_FILTER,
JSON.stringify([sub.select.value, detail.select.value]),
);
} catch {
/* 기억 못 해도 거름은 됨 */
}
const label = [sub.value, detail.value].filter(Boolean).join(" · ") || L("M01_All");
const label =
[sub.select.value, detail.select.value].filter(Boolean).join(" · ") || L("M01_All");
setActive(LABOR_ID);
open(file, sub.value, label, detail.value);
open(file, sub.select.value, label, detail.select.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;
sub.select.addEventListener("change", () => {
const details = list.find((k) => k.name === sub.select.value)?.details ?? [];
detail.setOptions(opts(details), "");
detail.select.disabled = details.length === 0;
run();
});
detail.addEventListener("change", run);
detail.select.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"));
sub.select.value = "";
sub.select.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],
}),
el("div", { className: "m01-side__filter", children: [sub.root, detail.root] }),
],
});
};
const order = (file: string): number => {
const i = MATERIAL_ORDER.indexOf(fileLabel(file));
return i < 0 ? MATERIAL_ORDER.length : i;
};
const drawGroup = (group: Group): void => {
const files = lists.get(group) ?? [];
const body = bodies.get(group);
@@ -231,11 +228,18 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
}
body.replaceChildren(
...renderTree(
files.map((f) => {
const name = fileLabel(f.file);
const label = group === "재료" ? (MATERIAL_NAMES[name] ?? name) : name;
return { id: `${group}\n${f.file}`, label, count: f.rows, run: () => open(f, "", label) };
}),
[...files]
.sort((a, b) => order(a.file) - order(b.file))
.map((f) => {
const name = fileLabel(f.file);
const label = group === "재료" ? (MATERIAL_NAMES[name] ?? name) : name;
return {
id: `${group}\n${f.file}`,
label,
count: f.rows,
run: () => open(f, "", label),
};
}),
make,
),
);
+3 -13
View File
@@ -198,15 +198,10 @@
padding: 2px var(--spacing-4);
}
.m01-master__row-btn {
/* 표 칸 안 공용 단추 — 줄 높이에 맞춰 작게 */
.m01-master__grid .ui-btn {
padding: 2px var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-caption);
cursor: pointer;
}
.m01-master__pager {
@@ -302,12 +297,7 @@
.m01-side__filter {
display: flex;
align-items: center;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
}
.m01-side__select {
flex: 1;
min-width: 0;
}