Merge commit 'da1fe4020' into sub_laptop_4
This commit is contained in:
@@ -136,20 +136,18 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 줄 접고 폄 표(51-2) · 고른 줄 하위 행 — 이름 들여쓰기 · 흐린 바탕 */
|
||||
.m02-labrow__fold {
|
||||
margin-right: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m02-labrow__sub td {
|
||||
/* 줄 접고 폄 표(51-2) · 고른 줄 하위 행 — 이름 들여쓰기 · 흐린 바탕 · 위 줄보다 촘촘(51-7) */
|
||||
.m02-labrow .m02-labrow__sub td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
background: var(--color-bg);
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.m02-labrow__sub .m02-labrow__subname {
|
||||
.m02-labrow .m02-labrow__sub .m02-labrow__subname {
|
||||
padding-left: var(--spacing-24);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 별칭 칸(44-6) — 칸 안 글상자 · 누르면 바로 고침 */
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
|
||||
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
|
||||
* · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) ·
|
||||
* 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) · 사전조합 = [추가] · 수량 손 입력(기본 1) · 순서 · 삭제
|
||||
* 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) · 사전조합 = [로직 추가](같은 고르기 창 · 서고 줄만) · 수량 손 입력(기본 1) · 순서 · 삭제
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
@@ -378,22 +378,20 @@ export function mountLaborDetail(
|
||||
changed();
|
||||
},
|
||||
};
|
||||
/** 사전조합 [추가] — 서고 줄만 고르기(위 층만 · 자기 자신 · 순환은 [저장] 때 서버 422) */
|
||||
const addRef = createButton({
|
||||
label: tc("Lib_AddRow"),
|
||||
variant: "filled",
|
||||
onClick: () => openLogicPicker(() => undefined, { ...library, only: true }),
|
||||
});
|
||||
const addLogic = createButton({
|
||||
label: tc("AddLogic"),
|
||||
variant: "filled",
|
||||
onClick: () =>
|
||||
openLogicPicker((x) => {
|
||||
if (cur.담은로직.some((r) => r.로직 === x.키))
|
||||
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
|
||||
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null });
|
||||
void loadLogics().then(() => changed());
|
||||
}, library),
|
||||
openLogicPicker(
|
||||
(x) => {
|
||||
if (cur.담은로직.some((r) => r.로직 === x.키))
|
||||
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
|
||||
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null });
|
||||
void loadLogics().then(() => changed());
|
||||
// 사전조합 = 같은 창 · 서고 줄만(위 층만 · 자기 자신 · 순환은 [저장] 때 서버 422)
|
||||
},
|
||||
lib ? { ...library, only: true } : library,
|
||||
),
|
||||
});
|
||||
const addMat = createButton({
|
||||
label: tc("Labor_AddMat"),
|
||||
@@ -478,12 +476,8 @@ export function mountLaborDetail(
|
||||
],
|
||||
}),
|
||||
]),
|
||||
// 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 = [추가] 서고 줄만 고르기(51-6)
|
||||
section(
|
||||
tc("Logics_Title"),
|
||||
opt.card || promote ? null : lib ? addRef : addLogic,
|
||||
logicHost,
|
||||
),
|
||||
// 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 = 같은 [로직 추가] · 서고 줄만(51-10)
|
||||
section(tc("Logics_Title"), opt.card || promote ? null : addLogic, logicHost),
|
||||
...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]),
|
||||
...(opt.card || lib ? [] : [dgBox]),
|
||||
],
|
||||
|
||||
@@ -5,13 +5,8 @@
|
||||
* 프로젝트 · 카드엔 서고 줄 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { createFindList } from "@ui/ui_template_find_list";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
@@ -65,9 +60,6 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
const sub = createSelectField({ options: opts([]), compact: true, label: tc("Sub") });
|
||||
const detail = createSelectField({ options: opts([]), compact: true, label: tc("Detail") });
|
||||
detail.select.disabled = true;
|
||||
const searchField = createInputField({ type: "search", placeholder: tc("Pick_Search") });
|
||||
const list = el("div", { className: "m01-logic__pick-list" });
|
||||
const hint = el("p", { className: "m01-logic__muted" });
|
||||
const close = (): void => backdrop.remove();
|
||||
|
||||
/* 한 목록 — 구분(단가산출 · 단가승격 · 일위조합) 칸 · 층 단추는 서고 줄(승격 · 조합)의 층 */
|
||||
@@ -77,31 +69,15 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
let libRows: { kind: "Kind_Promote" | "Kind_Combo"; row: LibraryRow }[] = [];
|
||||
let loading = false;
|
||||
let seq = 0;
|
||||
const pickRow = (
|
||||
key: string,
|
||||
kind: string,
|
||||
title: string,
|
||||
sub2: string,
|
||||
unit: string,
|
||||
): HTMLElement =>
|
||||
el("button", {
|
||||
className: "m01-logic__pick-row m01-logic__pick-row--button",
|
||||
attrs: { type: "button", "data-pick": key },
|
||||
children: [
|
||||
el("span", { className: "m01-logic__muted", attrs: { "data-pick-kind": "" }, text: kind }),
|
||||
el("div", {
|
||||
className: "m01-logic__stack",
|
||||
children: [
|
||||
el("strong", { text: title }),
|
||||
el("span", { className: "m01-logic__muted", text: sub2 }),
|
||||
],
|
||||
}),
|
||||
el("span", { text: unit }),
|
||||
],
|
||||
});
|
||||
/* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */
|
||||
const finder = createFindList({
|
||||
placeholder: tc("Pick_Search"),
|
||||
filters: lib?.only ? [] : [sub.root, detail.root],
|
||||
extra: [layerBar],
|
||||
});
|
||||
|
||||
const find = (): void => {
|
||||
const q = searchField.input.value.trim().toLowerCase();
|
||||
const q = finder.search.value.trim().toLowerCase();
|
||||
const found = all.filter(
|
||||
(x) =>
|
||||
(!sub.select.value || x.구분 === sub.select.value) &&
|
||||
@@ -113,38 +89,38 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
: libRows.filter((x) => !q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q));
|
||||
const total = all.length + libRows.length;
|
||||
const shown = found.length + libFound.length;
|
||||
hint.textContent = loading ? tc("Loading") : shown ? `${shown} / ${total}` : tc("Pick_None");
|
||||
list.replaceChildren(
|
||||
...found.slice(0, 100).map((x) => {
|
||||
const row = pickRow(
|
||||
x.키,
|
||||
tc("Kind_Logic"),
|
||||
x.이름,
|
||||
`${x.키} · ${x.구분} ${x.상세구분}`.trim(),
|
||||
`${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`,
|
||||
);
|
||||
row.addEventListener("click", () => {
|
||||
finder.hint.textContent = loading
|
||||
? tc("Loading")
|
||||
: shown
|
||||
? `${shown} / ${total}`
|
||||
: tc("Pick_None");
|
||||
finder.setRows([
|
||||
...found.slice(0, 100).map((x) => ({
|
||||
key: x.키,
|
||||
tag: tc("Kind_Logic"),
|
||||
name: x.이름,
|
||||
sub: `${x.키} · ${x.구분} ${x.상세구분}`.trim(),
|
||||
unit: `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`,
|
||||
onPick: () => {
|
||||
close();
|
||||
done(x);
|
||||
});
|
||||
return row;
|
||||
}),
|
||||
},
|
||||
})),
|
||||
...libFound.map((x) => {
|
||||
const at = scope as Scope;
|
||||
const row = pickRow(
|
||||
x.row.이름,
|
||||
tc(x.kind),
|
||||
x.row.제목 || x.row.이름,
|
||||
x.row.이름,
|
||||
x.row.단위 ?? "",
|
||||
);
|
||||
row.addEventListener("click", () => {
|
||||
close();
|
||||
lib?.pick({ ...at, 키: x.row.이름 }, x.row.제목 || x.row.이름);
|
||||
});
|
||||
return row;
|
||||
return {
|
||||
key: x.row.이름,
|
||||
tag: tc(x.kind),
|
||||
name: x.row.제목 || x.row.이름,
|
||||
sub: x.row.이름,
|
||||
unit: x.row.단위 ?? "",
|
||||
onPick: () => {
|
||||
close();
|
||||
lib?.pick({ ...at, 키: x.row.이름 }, x.row.제목 || x.row.이름);
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
]);
|
||||
};
|
||||
const loadLib = async (): Promise<void> => {
|
||||
if (!lib || !scope) return;
|
||||
@@ -191,12 +167,8 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
find();
|
||||
});
|
||||
detail.select.addEventListener("change", find);
|
||||
searchField.input.addEventListener("input", find);
|
||||
finder.search.addEventListener("input", find);
|
||||
|
||||
const filters = el("div", {
|
||||
className: "m01-logic__filters",
|
||||
children: [...(lib?.only ? [] : [sub.root, detail.root]), searchField.root],
|
||||
});
|
||||
layerBar.style.display = "none";
|
||||
if (lib)
|
||||
void upper(lib.layer, lib.owner).then((got) => {
|
||||
@@ -219,17 +191,14 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
createButton({ label: tc("Pick_Close"), variant: "ghost", onClick: close }),
|
||||
],
|
||||
}),
|
||||
filters,
|
||||
layerBar,
|
||||
hint,
|
||||
list,
|
||||
finder.root,
|
||||
],
|
||||
});
|
||||
const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] });
|
||||
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
|
||||
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
|
||||
document.body.append(backdrop);
|
||||
searchField.input.focus();
|
||||
finder.search.focus();
|
||||
|
||||
if (lib?.only) return;
|
||||
void Promise.all([fetchLogicSubs(), fetchLogics()])
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3).
|
||||
* 금액은 calc 답(없으면 「…」).
|
||||
* 줄 누름 · 줄 단추(위로 · 아래로 · 옵션 · 삭제 · 묶음 `ui-btn-group`)는 `on` 이 있을 때만(보기 갈래 = 없음) ·
|
||||
* 상세는 줄 누름 = 고름(`selected` 줄 하이라이트 · 이름 앞 ▸/▾ · 그 줄 바로 아래 하위 행 · 51-2) · 넣을 값 모달은 [옵션](47-5).
|
||||
* 상세는 줄 누름 = 고름(`selected` 줄 하이라이트 · 그 줄 바로 아래 하위 행 · 51-2 · 화살표 없음 51-7) · 넣을 값 모달은 [옵션](47-5).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
@@ -323,7 +323,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
...(on ? [el("span", { className: "m02-labrow__fold", text: sel ? "▾" : "▸" })] : []),
|
||||
el("span", { className: "m02-labrow__key", text: row.참조?.키 ?? row.로직 }),
|
||||
el("strong", {
|
||||
text: (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직,
|
||||
|
||||
@@ -124,7 +124,6 @@ const TEXT = {
|
||||
Lib_Unit: ["단위", "Unit"],
|
||||
Lib_Base: ["기준 금액", "Base amount"],
|
||||
Lib_Date: ["고친 날", "Modified"],
|
||||
Lib_AddRow: ["추가", "Add"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type ComboTextKey = keyof typeof TEXT;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 일위대가 조합 서고 목록표(51-4) — 좌측 항목(단가산출 승격 · 일위대가 사전조합)을 누르면 상세 칸에 뜸.
|
||||
* 표 = 고름 · 키 · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세.
|
||||
* 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로).
|
||||
* 꼴 = M01 단가산출 로직 목록 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표(`m01-master__grid`) 를 M01 에서 import.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -14,6 +15,9 @@ import {
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells";
|
||||
import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Style.css";
|
||||
import {
|
||||
deleteLayerTemplate,
|
||||
deleteTemplate,
|
||||
@@ -41,6 +45,7 @@ export interface LibraryListOptions {
|
||||
open: (key: string, label: string) => void;
|
||||
}
|
||||
|
||||
const SIZE = 30;
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const money = (v: number | null | undefined): string =>
|
||||
v == null ? "" : v.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
|
||||
@@ -54,7 +59,10 @@ export function mountLibraryList(
|
||||
): { getDoc: () => null; destroy: () => void } {
|
||||
let rows: Row[] = [];
|
||||
let picked: string | null = null;
|
||||
let page = 1;
|
||||
const searchField = createInputField({ type: "search", placeholder: tc("Search") });
|
||||
const body = el("tbody");
|
||||
let pager = buildPager(0, SIZE, 1, () => {});
|
||||
const btnNew = createButton({ label: tc("Lib_Create"), variant: "filled" });
|
||||
const btnCopy = createButton({ label: tc("Lib_Copy"), variant: "ghost" });
|
||||
const btnDel = createButton({ label: tc("Lib_Delete"), variant: "danger" });
|
||||
@@ -63,9 +71,18 @@ export function mountLibraryList(
|
||||
};
|
||||
|
||||
const draw = (): void => {
|
||||
const q = searchField.input.value.trim().toLowerCase();
|
||||
const shown = rows.filter((r) => !q || libraryLabel(r).toLowerCase().includes(q));
|
||||
page = Math.min(page, Math.max(1, Math.ceil(shown.length / SIZE)));
|
||||
const next = buildPager(shown.length, SIZE, page, (p) => {
|
||||
page = p;
|
||||
draw();
|
||||
});
|
||||
pager.replaceWith(next);
|
||||
pager = next;
|
||||
body.replaceChildren(
|
||||
...(rows.length
|
||||
? rows.map((row) => {
|
||||
...(shown.length
|
||||
? shown.slice((page - 1) * SIZE, page * SIZE).map((row) => {
|
||||
const radio = el("input", {
|
||||
attrs: { type: "radio", name: `m02-lib-${opt.kind}`, "aria-label": row.이름 },
|
||||
}) as HTMLInputElement;
|
||||
@@ -78,7 +95,7 @@ export function mountLibraryList(
|
||||
paintButtons();
|
||||
});
|
||||
const tr = el("tr", {
|
||||
className: "m02-lib__row",
|
||||
className: "m01-master__table-row",
|
||||
attrs: { "data-lib-key": row.이름 },
|
||||
children: [
|
||||
pick,
|
||||
@@ -200,14 +217,19 @@ export function mountLibraryList(
|
||||
}
|
||||
});
|
||||
|
||||
searchField.input.addEventListener("input", () => {
|
||||
page = 1;
|
||||
draw();
|
||||
});
|
||||
const heads = ["", "Lib_Key", "Lib_Name", "Lib_Unit", "Lib_Base", "Lib_Date"] as const;
|
||||
host.replaceChildren(
|
||||
buildHeadFilters([searchField.root, pager]),
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
className: "m01-master__grid-wrap",
|
||||
attrs: { "data-lib-list": opt.kind },
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-lib__table",
|
||||
className: "m01-master__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
|
||||
@@ -56,16 +56,8 @@
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 일위대가 조합 서고 목록표(51-4) — 줄 누름 = 상세 · 첫 칸 = 고름 */
|
||||
.m02-lib__row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-lib__row:hover {
|
||||
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.m02-lib__table .m02-lib__pick {
|
||||
/* 일위대가 조합 서고 목록표(51-4) — 표 꼴은 M01 목록(`m01-master__grid`) · 첫 칸 = 고름 */
|
||||
.m01-master__grid .m02-lib__pick {
|
||||
width: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -123,6 +115,7 @@
|
||||
/* 제목 줄 오른쪽 끝 도구(일위대가 「새 조합」 · PLAN 30-5) */
|
||||
.m02-main__tools {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-8); /* M01 머리 줄(`m01-head`)과 같은 틈 */
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,21 +11,44 @@
|
||||
"담은로직": [
|
||||
{
|
||||
"로직": "GF000219",
|
||||
"별칭": null,
|
||||
"메모": "메쌓기 — 돌쌓기 인력",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000119",
|
||||
"별칭": null,
|
||||
"메모": "노체포설 — 굴착기 0.7㎥",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000137",
|
||||
"별칭": null,
|
||||
"메모": "운반 — 중기운반 트레일러(20ton)",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"참조": {
|
||||
"층": "system",
|
||||
"주인": null,
|
||||
"키": "SP-000001"
|
||||
},
|
||||
"별칭": null,
|
||||
"메모": null,
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"참조": {
|
||||
"층": "system",
|
||||
"주인": null,
|
||||
"키": "SC-000001"
|
||||
},
|
||||
"별칭": null,
|
||||
"메모": null,
|
||||
"수량": null
|
||||
}
|
||||
],
|
||||
"자재": [],
|
||||
@@ -43,6 +66,28 @@
|
||||
"경비": 0,
|
||||
"계": 79279.668
|
||||
},
|
||||
"줄": [
|
||||
{
|
||||
"이름": "석공",
|
||||
"단위": "인",
|
||||
"수량": 0.195,
|
||||
"단가": 268908,
|
||||
"금액": 52437.06,
|
||||
"비목": {
|
||||
"노무비": 52437.06
|
||||
}
|
||||
},
|
||||
{
|
||||
"이름": "보통인부",
|
||||
"단위": "인",
|
||||
"수량": 0.156,
|
||||
"단가": 172068,
|
||||
"금액": 26842.608,
|
||||
"비목": {
|
||||
"노무비": 26842.608
|
||||
}
|
||||
}
|
||||
],
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
@@ -59,6 +104,20 @@
|
||||
"경비": 1012.3273551844981,
|
||||
"계": 4522.956900158043
|
||||
},
|
||||
"줄": [
|
||||
{
|
||||
"이름": "굴착기(무한궤도) 0.7㎥",
|
||||
"단위": "시간",
|
||||
"수량": 0.04122861265718408,
|
||||
"단가": 109704.31961333334,
|
||||
"금액": 4522.956900158043,
|
||||
"비목": {
|
||||
"노무비": 2433.321308321308,
|
||||
"재료비": 1077.3082366522367,
|
||||
"경비": 1012.3273551844981
|
||||
}
|
||||
}
|
||||
],
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
@@ -75,18 +134,110 @@
|
||||
"경비": 13280.479259259258,
|
||||
"계": 83943.98719017902
|
||||
},
|
||||
"줄": [
|
||||
{
|
||||
"이름": "트럭 트랙터 및 평판트레일러 20ton",
|
||||
"단위": "시간",
|
||||
"수량": 0.7670370370370371,
|
||||
"단가": 109439.28798333333,
|
||||
"금액": 83943.98719017902,
|
||||
"비목": {
|
||||
"노무비": 38181.82530864197,
|
||||
"재료비": 32481.682622277778,
|
||||
"경비": 13280.479259259258
|
||||
}
|
||||
},
|
||||
{
|
||||
"이름": "트럭 10.5ton",
|
||||
"단위": "시간",
|
||||
"수량": 0,
|
||||
"단가": 97740.38995333333,
|
||||
"금액": 0,
|
||||
"비목": {
|
||||
"노무비": 0,
|
||||
"재료비": 0,
|
||||
"경비": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"참조": {
|
||||
"층": "system",
|
||||
"주인": null,
|
||||
"키": "SP-000001"
|
||||
},
|
||||
"이름": "돌쌓기 메쌓기(인력)",
|
||||
"알약": "참조|system||SP-000001",
|
||||
"분모": "원/㎡",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 79279.668,
|
||||
"재료비": 0,
|
||||
"경비": 0,
|
||||
"계": 79279.668
|
||||
},
|
||||
"까닭": null,
|
||||
"원로직": "GF000219",
|
||||
"줄": [
|
||||
{
|
||||
"이름": "석공",
|
||||
"단위": "인",
|
||||
"수량": 0.195,
|
||||
"단가": 268908,
|
||||
"금액": 52437.06,
|
||||
"비목": {
|
||||
"노무비": 52437.06
|
||||
}
|
||||
},
|
||||
{
|
||||
"이름": "보통인부",
|
||||
"단위": "인",
|
||||
"수량": 0.156,
|
||||
"단가": 172068,
|
||||
"금액": 26842.608,
|
||||
"비목": {
|
||||
"노무비": 26842.608
|
||||
}
|
||||
}
|
||||
],
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"참조": {
|
||||
"층": "system",
|
||||
"주인": null,
|
||||
"키": "SC-000001"
|
||||
},
|
||||
"이름": "테스트 사전조합",
|
||||
"알약": "참조|system||SC-000001",
|
||||
"분모": null,
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 0,
|
||||
"재료비": 0,
|
||||
"경비": 0,
|
||||
"계": 0
|
||||
},
|
||||
"까닭": null,
|
||||
"안": [],
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
}
|
||||
],
|
||||
"자재": [],
|
||||
"기준합": {
|
||||
"노무비": 119894.81461696328,
|
||||
"노무비": 199174.48261696327,
|
||||
"재료비": 33558.99085893002,
|
||||
"경비": 14292.806614443756,
|
||||
"계": 167746.61209033706
|
||||
"계": 247026.28009033707
|
||||
},
|
||||
"반영합": {
|
||||
"노무비": 0,
|
||||
|
||||
@@ -80,9 +80,9 @@ def test_일위대가_카드는_상세_카드_갈래():
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert "...(opt.card ? {} : { move })" in detail # 순서 단추 없음 · 빼기는 그대로
|
||||
# 서고 모드(50-3 · lib)도 더하기 · 다이어그램 없음
|
||||
assert (
|
||||
"opt.card || promote ? null : lib ? addRef : addLogic"
|
||||
) # 51-6 사전조합 = 줄 고르기 in detail
|
||||
# 51-10 사전조합 = 같은 [로직 추가] · 서고 줄만
|
||||
assert "opt.card || promote ? null : addLogic" in detail
|
||||
assert "lib ? { ...library, only: true } : library" in detail
|
||||
assert "...(opt.card || lib ? [] : [dgBox])," in detail
|
||||
assert "opt.card ? null : addMat" in detail # 49-6 — 카드엔 [자재 추가] 없음 · 상세는 그대로
|
||||
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
|
||||
|
||||
@@ -77,7 +77,10 @@ def test_sub2_가_부르는_이름과_모양():
|
||||
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;\s*\}",
|
||||
body,
|
||||
)
|
||||
assert re.search(r'export type Kind = [^;]*"area" \| "labor";', body) # 재료산출 없앰
|
||||
assert re.search(r'export type Kind = [^;]*"area" \| "labor"', body) # 재료산출 없앰
|
||||
assert '"material"' not in re.search(r"export type Kind = [^;]*;", body).group(
|
||||
0
|
||||
) # 50-3 서고 종류 더함
|
||||
|
||||
|
||||
def test_집계표만_컨테이너를_붙임():
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/* 51-10 — 서고 목록표(단가산출 승격 · 일위대가 사전조합) = M01 단가산출 로직 목록 표 양식 · 단추 꼴 ·
|
||||
* 사전조합 상세 [로직 추가] = 일위대가 상세 [로직 추가] 와 같은 고르기 창(서고 줄만)
|
||||
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=일위대가조합&root=master) · 시스템 관리자
|
||||
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
|
||||
* 쓰기 없음 — 서고 목록 · 문서 읽기는 가짜 응답(fetch 바꿔 끼움 · 끝에 되돌림)
|
||||
* 기대 수치:
|
||||
* - table.wrap = "m01-master__grid-wrap" · table.table = "m01-master__grid" · table.row = "m01-master__table-row"
|
||||
* - table.filters = true(찾기 칸 + 쪽 `m01-master__pager`) · table.sameCell = true(M01 표 칸과 padding · 글자 · 밑줄 같음)
|
||||
* - table.search = 1(「값 없음」 치면 한 줄)
|
||||
* - buttons.variants = ["filled", "ghost", "danger"](M01 [신규 로직] · [로직 복사/생성] · [로직 지우기] 와 같음) · buttons.redDelete = true
|
||||
* - precombo.add = "로직 추가" · picker.label = "로직 고르기" · picker.kinds ⊂ {단가승격, 일위조합} · picker.logicRows = 0
|
||||
*/
|
||||
(async () => {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 4000) => {
|
||||
for (let t = 0; t < ms; t += 100) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await wait(100);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const SYS = { 층: "system", 주인: null };
|
||||
const rows = [
|
||||
{
|
||||
종류: "promote",
|
||||
이름: "SP-999901",
|
||||
판: "a",
|
||||
수정일: "2026-10-05",
|
||||
제목: "시험 승격",
|
||||
단위: "㎡",
|
||||
기준금액: 1234.5,
|
||||
},
|
||||
{
|
||||
종류: "promote",
|
||||
이름: "SP-999902",
|
||||
판: "b",
|
||||
수정일: "2026-10-05",
|
||||
제목: "값 없음",
|
||||
단위: null,
|
||||
기준금액: null,
|
||||
},
|
||||
{
|
||||
종류: "precombo",
|
||||
이름: "SC-999901",
|
||||
판: "c",
|
||||
수정일: "2026-10-05",
|
||||
제목: "시험 묶음",
|
||||
단위: "개소",
|
||||
기준금액: 10,
|
||||
},
|
||||
];
|
||||
const doc = {
|
||||
종류: "사전조합",
|
||||
키: "SC-999901",
|
||||
이름: "시험 묶음",
|
||||
단위: "개소",
|
||||
담은로직: [{ 참조: { ...SYS, 키: "SP-999901" }, 수량: { 손: 1 } }],
|
||||
};
|
||||
const json = (body, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
const real = window.fetch;
|
||||
window.fetch = async (input, init = {}) => {
|
||||
const url = String(input?.url ?? input);
|
||||
const method = (init.method ?? "GET").toUpperCase();
|
||||
if (method === "GET" && /\/m02\/layers\/system\/templates(\?|$)/.test(url)) {
|
||||
const got = await (await real(input, init)).json();
|
||||
return json({ ...got, 양식: [...got.양식.filter((r) => !/^S[PC]-/.test(r.이름)), ...rows] });
|
||||
}
|
||||
if (method === "GET" && url.includes("/templates/precombo/SC-999901"))
|
||||
return json({ 종류: "precombo", 이름: "SC-999901", 판: "c", 문서: doc });
|
||||
if (method !== "GET" && !url.includes("/calc"))
|
||||
return json({ detail: "시험 — 쓰기 막음" }, 400);
|
||||
return real(input, init);
|
||||
};
|
||||
const sec = [...document.querySelectorAll(".m02-side__lists > section")].find(
|
||||
(s) => s.querySelector(".ui-collapsible__title")?.textContent?.trim() === "일위대가 조합",
|
||||
);
|
||||
const items = [...sec.querySelectorAll("[data-precombo]")];
|
||||
const list = (kind) => document.querySelector(`[data-lib-list="${kind}"]`);
|
||||
const tools = () => [...document.querySelectorAll(".m02-main__slot .m02-main__tools button")];
|
||||
const box = (n) => {
|
||||
const s = getComputedStyle(n);
|
||||
return [s.paddingTop, s.paddingLeft, s.fontSize, s.borderBottomWidth].join("|");
|
||||
};
|
||||
try {
|
||||
items[0].click();
|
||||
await until(() => list("promote")?.querySelector(`[data-lib-key="SP-999902"]`));
|
||||
const wrap = list("promote");
|
||||
const td = wrap.querySelector("[data-lib-key] td:nth-child(3)");
|
||||
// M01 목록 표와 같은 클래스로 짠 견본 칸 — 같은 CSS 면 같은 칸 꼴
|
||||
const ref = document.createElement("div");
|
||||
ref.className = "m01-master__grid-wrap";
|
||||
ref.innerHTML =
|
||||
'<table class="m01-master__grid"><tbody><tr class="m01-master__table-row"><td>시험 승격</td></tr></tbody></table>';
|
||||
wrap.parentElement.append(ref);
|
||||
const table = {
|
||||
wrap: wrap.className,
|
||||
table: wrap.querySelector("table").className,
|
||||
row: wrap.querySelector("[data-lib-key]").className,
|
||||
filters:
|
||||
!!wrap.parentElement.querySelector(".m01-head__filters input[type=search]") &&
|
||||
!!wrap.parentElement.querySelector(".m01-head__filters .m01-master__pager"),
|
||||
sameCell: box(td) === box(ref.querySelector("td")),
|
||||
cell: box(td),
|
||||
};
|
||||
ref.remove();
|
||||
const search = wrap.parentElement.querySelector(".m01-head__filters input[type=search]");
|
||||
search.value = "값 없음";
|
||||
search.dispatchEvent(new Event("input"));
|
||||
await wait(100);
|
||||
table.search = list("promote").querySelectorAll("[data-lib-key]").length;
|
||||
search.value = "";
|
||||
search.dispatchEvent(new Event("input"));
|
||||
const v = (b) => ["filled", "ghost", "danger"].find((x) => b.className.includes(`--${x}`));
|
||||
const buttons = {
|
||||
labels: tools().map((b) => b.textContent),
|
||||
variants: tools().map(v),
|
||||
redDelete: /rgb\((1\d\d|2\d\d), (\d|[1-9]\d), (\d|[1-9]\d)\)/.test(
|
||||
getComputedStyle(tools()[2]).backgroundColor + getComputedStyle(tools()[2]).color,
|
||||
),
|
||||
deleteColor: getComputedStyle(tools()[2]).backgroundColor,
|
||||
};
|
||||
// 사전조합 상세 [로직 추가]
|
||||
items[1].click();
|
||||
const row = await until(() =>
|
||||
document.querySelector('[data-lib-key="SC-999901"] td:nth-child(3)'),
|
||||
);
|
||||
row.click();
|
||||
const edit = await until(() => document.querySelector(".m02-main__host [data-labor]"));
|
||||
await wait(300);
|
||||
const add = [...edit.querySelectorAll("button")].find((b) => b.textContent === "로직 추가");
|
||||
const precombo = { add: add?.textContent ?? null };
|
||||
add?.click();
|
||||
const pick = await until(() => document.querySelector(".m01-logic__pick"));
|
||||
await until(() => pick?.querySelectorAll("[data-pick]").length, 3000);
|
||||
const kinds = [...(pick?.querySelectorAll("[data-pick-kind]") ?? [])].map((x) => x.textContent);
|
||||
const picker = {
|
||||
label: pick?.getAttribute("aria-label") ?? null,
|
||||
kinds: [...new Set(kinds)],
|
||||
logicRows: kinds.filter((k) => k === "단가산출").length,
|
||||
rows: kinds.length,
|
||||
};
|
||||
pick?.closest(".m01-logic__backdrop")?.remove();
|
||||
return JSON.stringify({ table, buttons, precombo, picker });
|
||||
} finally {
|
||||
window.fetch = real;
|
||||
}
|
||||
})();
|
||||
@@ -1,7 +1,7 @@
|
||||
/* 51-1 — 오른쪽 패널(공용 ui_template_side_panels) 높이 = innerHeight − 머리띠 · 스크롤 처음/중간/끝 화면 안
|
||||
* 시작 화면: 패널이 이미 열린 상세 화면 하나(M02 일위대가 상세 · M02 면적산출 · M01 로직 상세 중 하나) · 화면마다 한 번씩 부름
|
||||
* 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌)
|
||||
* 기대: ok = true · h = want(innerHeight − max(머리띠, 칸 위)) ±2 · 칸이 올라가 붙은 뒤는 innerHeight − 머리띠 · inView = 처음·중간·끝 모두 true
|
||||
* 기대: ok = true · h = want(innerHeight − max(머리띠, 칸 위)) ±2 · 칸이 올라가 붙은 뒤는 innerHeight − 머리띠 · inView = 그대로·처음·중간·끝 모두 true
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -38,7 +38,16 @@
|
||||
};
|
||||
};
|
||||
const was = scroller.scrollTop;
|
||||
// 스크롤 알림 없이 지금 높이 — 내용이 늦게 차도 스스로 다시 쟀는지(알림을 쏘면 가려짐 · 가린 탭은 먼저 orca screenshot 으로 한 장 그림)
|
||||
const r0 = sp.getBoundingClientRect();
|
||||
const still = {
|
||||
name: "그대로",
|
||||
h: Math.round(r0.height),
|
||||
want: Math.round(innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top)),
|
||||
inView: r0.top >= head - 2 && r0.bottom <= innerHeight + 2,
|
||||
};
|
||||
const rows = [
|
||||
still,
|
||||
await sample("처음", 0),
|
||||
await sample("중간", Math.round(max / 2)),
|
||||
await sample("끝", max),
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* 기대 수치(카드 · 상세 같음):
|
||||
* - lines[*].subs ≥ 1 · lines[*].cells = head(칸 수 = 머리 칸 수) · lines[*].next = true(고른 줄 바로 아래)
|
||||
* - lines[*].base ≈ lines[*].subBase · lines[*].applied ≈ lines[*].subApplied(반올림 차 ≤ 하위 행 수)
|
||||
* - lines[*].open = 1 · lines[*].arrow = "▾"(한 줄만 열림)
|
||||
* - lines[*].open = 1(한 줄만 열림) · lines[*].arrow = 0(이름 앞 화살표 없음 · 51-7)
|
||||
* - lines[*].font = 하위 행 이름 글자 px · lines[*].h = 하위 행 높이 px · lines[*].topH = 위 줄 높이 px(51-7 촘촘)
|
||||
* 전 font 12 · h 61 · 칸 여백 12/16 → 후 font 14 · h 40 · 칸 여백 4/8(위 줄 font 16 · topH 85 그대로)
|
||||
* - again.subs = 0 · again.selected = 0(같은 줄 다시 = 닫힘) · box = 0(따로 상자 없음)
|
||||
*/
|
||||
(async () => {
|
||||
@@ -58,9 +60,12 @@
|
||||
subBase: Math.round(subs.reduce((a, s) => a + total(s, ".m02-labrow__base"), 0)),
|
||||
applied: total(tr, ".m02-labrow__applied"),
|
||||
subApplied: Math.round(subs.reduce((a, s) => a + total(s, ".m02-labrow__applied"), 0)),
|
||||
open: [...tb.querySelectorAll(".m02-labrow__fold")].filter((f) => f.innerText === "▾")
|
||||
open: rowsOf(tb).filter((r) => r.classList.contains("is-selected")).length,
|
||||
arrow: [...tb.querySelectorAll(".m02-labrow__name")].filter((d) => /[▸▾]/.test(d.innerText))
|
||||
.length,
|
||||
arrow: tr.querySelector(".m02-labrow__fold")?.innerText,
|
||||
font: parseFloat(getComputedStyle(subs[0].querySelector(".m02-labrow__subname")).fontSize),
|
||||
h: Math.round(subs[0].getBoundingClientRect().height),
|
||||
topH: Math.round(tr.getBoundingClientRect().height),
|
||||
names: subs.map((s) => s.querySelector(".m02-labrow__subname").innerText).slice(0, 4),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
await wait(300);
|
||||
};
|
||||
const pickerRow = async (key) => {
|
||||
btn(edit(), "추가").click();
|
||||
btn(edit(), "로직 추가").click();
|
||||
const row = await until(() => document.querySelector(`.m01-logic__pick [data-pick="${key}"]`));
|
||||
return row;
|
||||
};
|
||||
@@ -123,13 +123,13 @@
|
||||
// 단가승격 상세 — 한 줄 고정
|
||||
await openDoc(0, "SP-999901");
|
||||
const promote = {
|
||||
add: !!btn(edit(), "추가"),
|
||||
add: !!btn(edit(), "로직 추가"),
|
||||
rows: lines().length,
|
||||
move: edit().querySelectorAll(".m02-labrow__move button").length,
|
||||
};
|
||||
// 사전조합 상세
|
||||
await openDoc(1, "SC-999901");
|
||||
const precombo = { add: !!btn(edit(), "추가"), rows: lines().length };
|
||||
const precombo = { add: !!btn(edit(), "로직 추가"), rows: lines().length };
|
||||
const first = await pickerRow("SP-999902");
|
||||
const pick = document.querySelector(".m01-logic__pick");
|
||||
const picker = {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/* 51-9 — [로직 추가] 고르기 창이 공용 찾기 양식(ui_template_find_list)을 씀
|
||||
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · item1 있음
|
||||
* 부르는 법: orca eval --expression "$(tr '
|
||||
' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌)
|
||||
* 하는 일: item1 → 일위대가 [수정] → [로직 추가] → 칸 배치 · 줄 맞춤 · ↓ Enter 점검 → 창 닫음(저장 안 함)
|
||||
* 기대 수치:
|
||||
* - template = true · filters = 2 · sameLine = true(구분 · 상세구분 top 같음 · left 다름)
|
||||
* - nameAlign = "left" · nameLeftGap ≤ 꼬리표 폭 + 16 · rowHeight ≤ 40 · oneLine = true
|
||||
* - active0 = 0 · activeAfterDown = 1 · enterPicked = true(Enter 로 창이 닫힘) · undone = true
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 8000) => {
|
||||
for (let t = 0; t < ms; t += 150) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await w(150);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const out = {};
|
||||
const node = await until(() => document.querySelector('[data-node="i|item1"]'));
|
||||
if (!node) return JSON.stringify({ error: "item1 없음" });
|
||||
node.click();
|
||||
const card = await until(() =>
|
||||
[...document.querySelectorAll(".m02-struct__card")].find((c) =>
|
||||
c.querySelector("table.m02-labrow tbody tr[data-labor-logic]"),
|
||||
),
|
||||
);
|
||||
if (!card) return JSON.stringify({ error: "일위대가 카드 없음" });
|
||||
[...card.querySelectorAll(".m02-struct__card-tools button")]
|
||||
.find((b) => b.innerText.trim() === "수정")
|
||||
?.click();
|
||||
const table = () =>
|
||||
[...document.querySelectorAll("table.m02-labrow")].find(
|
||||
(t) => !t.closest(".m02-struct__card") && t.querySelector("tr[data-labor-logic]"),
|
||||
);
|
||||
await until(() => table() && !/…/.test(table().querySelector("tbody").innerText), 12000);
|
||||
const host = table().closest("[data-labor]");
|
||||
const before = table().querySelectorAll("tbody tr[data-labor-logic]").length;
|
||||
const openPick = async () => {
|
||||
[...host.querySelectorAll(".m01-logic__section-head button")]
|
||||
.find((b) => b.innerText.trim() === "로직 추가")
|
||||
.click();
|
||||
const dlg = await until(() => document.querySelector(".m01-logic__pick"));
|
||||
await until(() => dlg.querySelectorAll(".ui-find__row").length > 1, 20000);
|
||||
return dlg;
|
||||
};
|
||||
const dlg = await openPick();
|
||||
out.template = !!dlg.querySelector(".ui-find .ui-find__filters") && !!dlg.querySelector(".ui-find__list");
|
||||
const sels = [...dlg.querySelectorAll(".ui-find__filters > *")];
|
||||
out.filters = sels.length;
|
||||
const [a, b] = sels.map((x) => x.getBoundingClientRect());
|
||||
out.sameLine = Math.abs(a.top - b.top) < 2 && b.left > a.right - 1;
|
||||
const row = dlg.querySelector(".ui-find__row");
|
||||
const name = row.querySelector(".ui-find__name");
|
||||
const tag = row.querySelector(".ui-find__tag");
|
||||
out.nameAlign = getComputedStyle(name).textAlign;
|
||||
out.rowAlign = getComputedStyle(row).textAlign;
|
||||
out.nameLeftGap = Math.round(name.getBoundingClientRect().left - row.getBoundingClientRect().left);
|
||||
out.tagWidth = Math.round(tag.getBoundingClientRect().width);
|
||||
out.rowHeight = Math.round(row.getBoundingClientRect().height);
|
||||
out.oneLine = getComputedStyle(row).whiteSpace === "nowrap";
|
||||
const rows = () => [...dlg.querySelectorAll(".ui-find__row")];
|
||||
const active = () => rows().findIndex((r) => r.classList.contains("is-active"));
|
||||
out.active0 = active();
|
||||
const search = dlg.querySelector('input[type="search"]');
|
||||
search.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
out.activeAfterDown = active();
|
||||
out.rows = rows().length;
|
||||
/* Enter = 고름 — 창이 닫히고 표에 붙은 줄은 바로 지움 */
|
||||
const pickedKey = rows()[active()].dataset.pick;
|
||||
search.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
out.enterPicked = !!(await until(() => !document.querySelector(".m01-logic__pick") || null));
|
||||
out.pickedKey = pickedKey;
|
||||
await until(() => table().querySelectorAll("tbody tr[data-labor-logic]").length > before, 8000);
|
||||
const added = [...table().querySelectorAll("tbody tr[data-labor-logic]")].slice(before);
|
||||
for (const tr of added) {
|
||||
[...tr.querySelectorAll("button")].find((x) => x.innerText.trim() === "삭제")?.click();
|
||||
await w(600);
|
||||
}
|
||||
out.undone = table().querySelectorAll("tbody tr[data-labor-logic]").length === before;
|
||||
document.querySelector('[data-node="i|item1"]')?.click();
|
||||
await w(800);
|
||||
const ask = [...document.querySelectorAll(".ui-modal button, dialog button")].find((x) =>
|
||||
/확인|버림|떠나기/.test(x.innerText),
|
||||
);
|
||||
ask?.click();
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,73 @@
|
||||
/* 공용 찾기 목록(ui_template_find_list.ts) — 거름 2열 · 결과 한 줄 · 이름 왼쪽 맞춤 */
|
||||
.ui-find {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ui-find__filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-find__hint {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-find__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ui-find__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: none;
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-find__row.is-active {
|
||||
background: var(--color-mist-violet);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.ui-find__tag,
|
||||
.ui-find__sub,
|
||||
.ui-find__unit {
|
||||
flex: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-find__tag {
|
||||
min-width: 4.5em;
|
||||
}
|
||||
|
||||
.ui-find__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ui-find__sub {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/* =============================================================================
|
||||
* ui_template_find_list.ts
|
||||
* 공용 찾기 목록 — 걸러내기 고르기 칸(2열 격자) · 찾기 칸 · 알림 줄 · 결과 목록(이름 왼쪽 맞춤 · 한 줄).
|
||||
* 키보드 — 찾기 칸에서 ↑ ↓ 로 옮기고 Enter 로 고름. 창 겉틀은 부르는 쪽 몫.
|
||||
*
|
||||
* const find = createFindList({ placeholder, filters: [sub.root, detail.root] });
|
||||
* find.setRows([{ key, tag, name, sub, unit, onPick }]);
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_find_list.css";
|
||||
import { createInputField, el } from "./ui_template_elements";
|
||||
|
||||
export interface FindRow {
|
||||
/** 줄 표지(`data-pick`) */
|
||||
key: string;
|
||||
name: string;
|
||||
/** 앞 흐린 꼬리표(종류 등) */
|
||||
tag?: string;
|
||||
/** 이름 뒤 흐린 보조 글(키 · 구분 등) */
|
||||
sub?: string;
|
||||
/** 끝 흐린 글(단위 등) */
|
||||
unit?: string;
|
||||
onPick: () => void;
|
||||
}
|
||||
|
||||
export interface FindList {
|
||||
root: HTMLElement;
|
||||
search: HTMLInputElement;
|
||||
hint: HTMLElement;
|
||||
setRows: (rows: FindRow[]) => void;
|
||||
}
|
||||
|
||||
export function createFindList(opts: {
|
||||
placeholder: string;
|
||||
filters?: HTMLElement[];
|
||||
/** 찾기 칸과 알림 줄 사이(층 단추 등) */
|
||||
extra?: HTMLElement[];
|
||||
}): FindList {
|
||||
const field = createInputField({ type: "search", placeholder: opts.placeholder });
|
||||
const hint = el("p", { className: "ui-find__hint" });
|
||||
const list = el("div", { className: "ui-find__list", attrs: { role: "listbox" } });
|
||||
let rows: FindRow[] = [];
|
||||
let at = -1;
|
||||
|
||||
const mark = (i: number): void => {
|
||||
list.children[at]?.classList.remove("is-active");
|
||||
at = i;
|
||||
const row = list.children[at];
|
||||
row?.classList.add("is-active");
|
||||
row?.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
field.input.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
|
||||
ev.preventDefault();
|
||||
if (!rows.length) return;
|
||||
const step = ev.key === "ArrowDown" ? 1 : -1;
|
||||
mark((at + step + rows.length) % rows.length);
|
||||
} else if (ev.key === "Enter" && rows[at]) {
|
||||
ev.preventDefault();
|
||||
rows[at].onPick();
|
||||
}
|
||||
});
|
||||
|
||||
const setRows = (next: FindRow[]): void => {
|
||||
rows = next;
|
||||
at = -1;
|
||||
list.replaceChildren(
|
||||
...rows.map((r, i) => {
|
||||
const row = el("button", {
|
||||
className: "ui-find__row",
|
||||
attrs: { type: "button", role: "option", "data-pick": r.key, title: r.name },
|
||||
children: [
|
||||
...(r.tag
|
||||
? [
|
||||
el("span", {
|
||||
className: "ui-find__tag",
|
||||
attrs: { "data-pick-kind": "" },
|
||||
text: r.tag,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
el("span", { className: "ui-find__name", text: r.name }),
|
||||
el("span", { className: "ui-find__sub", text: r.sub ?? "" }),
|
||||
el("span", { className: "ui-find__unit", text: r.unit ?? "" }),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", r.onPick);
|
||||
row.addEventListener("mousemove", () => at !== i && mark(i));
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
if (rows.length) mark(0);
|
||||
};
|
||||
|
||||
const filters = opts.filters?.length
|
||||
? [el("div", { className: "ui-find__filters", children: opts.filters })]
|
||||
: [];
|
||||
const root = el("div", {
|
||||
className: "ui-find",
|
||||
children: [...filters, field.root, ...(opts.extra ?? []), hint, list],
|
||||
});
|
||||
return { root, search: field.input, hint, setRows };
|
||||
}
|
||||
@@ -185,10 +185,18 @@ function scroller(node: HTMLElement): HTMLElement | null {
|
||||
*/
|
||||
function followView(node: HTMLElement): void {
|
||||
const ref = new WeakRef(node); // 떼었다 다시 붙이는 화면(M01)도 있어 떼어져도 듣기는 남김 · 버려지면 그침
|
||||
// 다시 붙음 · 칸 내용이 늦게 차 칸 크기가 바뀜(스크롤 없이) → 다시 잼
|
||||
const sized = new ResizeObserver(() => place());
|
||||
let watched: Element | null = null;
|
||||
const place = (): void => {
|
||||
const root = ref.deref();
|
||||
if (!root) return stop();
|
||||
if (!root.isConnected) return;
|
||||
if (root.parentElement && root.parentElement !== watched) {
|
||||
if (watched) sized.unobserve(watched);
|
||||
watched = root.parentElement;
|
||||
sized.observe(watched);
|
||||
}
|
||||
const box = scroller(root)?.getBoundingClientRect();
|
||||
const head = document.querySelector(".app-header")?.getBoundingClientRect().bottom ?? 0;
|
||||
const view = Math.max(head, box?.top ?? 0);
|
||||
@@ -206,8 +214,10 @@ function followView(node: HTMLElement): void {
|
||||
const stop = (): void => {
|
||||
window.removeEventListener("scroll", place, true);
|
||||
window.removeEventListener("resize", place);
|
||||
sized.disconnect();
|
||||
};
|
||||
window.addEventListener("scroll", place, true);
|
||||
window.addEventListener("resize", place);
|
||||
sized.observe(node);
|
||||
setTimeout(place); // 붙은 뒤 한 번
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user