Merge remote-tracking branch 'origin/dev' into sub_laptop_7
This commit is contained in:
@@ -219,7 +219,7 @@ async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any])
|
||||
" ".join(filter(None, (names[k]["name"], names[k]["spec"], names[k]["unit"])))
|
||||
for k in lost
|
||||
)
|
||||
notes.append(f"2공구 열 맞춤 없음 {len(lost)}종(총괄 끝 줄 그대로) — {listed}")
|
||||
notes.append(f"2공구 열 맞춤 없음 {len(lost)}종 — {listed}")
|
||||
return {
|
||||
"source": source,
|
||||
"overrides": overrides,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton, createCard } from "@ui/ui_template_elements";
|
||||
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { footNote } from "./B08_Quantity_UI_Foot";
|
||||
import { stationInterval } from "./B08_Quantity_UI_Summary";
|
||||
import {
|
||||
renderEarthworkSummary,
|
||||
@@ -216,17 +217,13 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number)
|
||||
host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요."));
|
||||
return;
|
||||
}
|
||||
const c = (kind: string): string => table.conversion_factors[kind]?.compacted.toFixed(2) ?? "";
|
||||
const flagged = table.rows.filter((row) => row.notes.length);
|
||||
const info = [
|
||||
note(
|
||||
`평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` +
|
||||
`측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 암 ` +
|
||||
`${table.conversion_factors.ripping_rock?.compacted.toFixed(3) ?? ""}(연암 리핑 ` +
|
||||
`${table.rock_c.ripping_rock} · 경암 발파 ${table.rock_c.blasting_rock} 비율 가중) · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` +
|
||||
`단위 m · ㎡ · ㎥`,
|
||||
footNote(
|
||||
`노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` +
|
||||
`측점 ${table.station_count} · 단위 m · ㎡ · ㎥`,
|
||||
),
|
||||
...(flagged.length ? [note(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []),
|
||||
...(flagged.length ? [footNote(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []),
|
||||
];
|
||||
const grid = createDataTable({
|
||||
columns: COLUMNS,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
|
||||
import { note, tableCard, type EarthworkTable, type HaulList } from "./B08_Quantity_UI_Earthwork";
|
||||
import { footNote } from "./B08_Quantity_UI_Foot";
|
||||
|
||||
const fmt = (value: number | null | undefined): string =>
|
||||
value == null || !Number.isFinite(value)
|
||||
@@ -26,9 +27,8 @@ const SUMMARY_COLUMNS: DataColumn[] = [
|
||||
|
||||
/** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 */
|
||||
export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void {
|
||||
const info = note(
|
||||
"토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " +
|
||||
"연암 · 경암 = 암 × 좌측 패널 비율 · 「비율 미정」 = 반영 비율 확인 뒤 채움(- = 아직 값 없음)",
|
||||
const info = footNote(
|
||||
"연암 · 경암은 좌측 패널 비율대로 나눈 암 · 「비율 미정」은 비율 확인 뒤 채움",
|
||||
);
|
||||
const grid = createDataTable({
|
||||
columns: SUMMARY_COLUMNS,
|
||||
@@ -50,7 +50,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable)
|
||||
function haulNote(table: EarthworkTable): HTMLElement[] {
|
||||
return table.haul_saved
|
||||
? []
|
||||
: [note("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")];
|
||||
: [footNote("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")];
|
||||
}
|
||||
|
||||
/** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · 아래 평균운반거리 줄 */
|
||||
@@ -58,7 +58,7 @@ function haulSheet(
|
||||
host: HTMLElement,
|
||||
table: EarthworkTable,
|
||||
groups: Array<[string, HaulList]>,
|
||||
info: string,
|
||||
caption: string,
|
||||
): void {
|
||||
const columns: DataColumn[] = [
|
||||
{ key: "no", label: "번호", width: 56 },
|
||||
@@ -98,10 +98,10 @@ function haulSheet(
|
||||
rows,
|
||||
frozen: 1,
|
||||
total,
|
||||
caption: info,
|
||||
caption,
|
||||
fillViewport: true,
|
||||
});
|
||||
host.replaceChildren(tableCard([note(info), ...haulNote(table)], grid.root, averages));
|
||||
host.replaceChildren(tableCard(haulNote(table), grid.root, averages));
|
||||
}
|
||||
|
||||
export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): void {
|
||||
@@ -112,7 +112,7 @@ export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): vo
|
||||
["덤프트럭", table.haul.dump_truck],
|
||||
["불도우저", table.haul.dozer],
|
||||
],
|
||||
"유토곡선 배분의 덤프 · 도저 띠와 떨어진 구간 운반 · 평균 = ΣQ×L ÷ ΣQ (소수 둘째 자리) · 단위 ㎥ · m",
|
||||
"덤프트럭 · 불도우저 평균운반거리 — 단위 ㎥ · m",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,6 @@ export function renderFreeHaul(host: HTMLElement, table: EarthworkTable): void {
|
||||
host,
|
||||
table,
|
||||
[["종방향무대", table.haul.free_haul]],
|
||||
"유토곡선 배분의 무대(20 m 안) 띠 · 평균 = ΣQ×L ÷ ΣQ (소수 둘째 자리) · 단위 ㎥ · m",
|
||||
"종방향 무대운반거리 — 단위 ㎥ · m",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Foot.ts
|
||||
* B08 표 아래 안내 · 경고 줄(PLAN 202-1 · 236-1) — 늘 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문.
|
||||
* 한 줄만 남겨 창 끝까지 셈(199-1)에 한 줄 높이만 듦 → 페이지 스크롤 0.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
|
||||
export function footNote(text: string): HTMLElement {
|
||||
const p = document.createElement("p");
|
||||
p.className = "ui-general-block__empty b08-quantity__foot";
|
||||
p.textContent = text;
|
||||
attachHoverInfo(p, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null));
|
||||
return p;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type MaterialSheets,
|
||||
type SummarySource,
|
||||
} from "./B08_Quantity_Material_Calc";
|
||||
import { footNote } from "./B08_Quantity_UI_Foot";
|
||||
import { liveSurcharge, onOptionsChange } from "./B08_Quantity_UI_Options";
|
||||
|
||||
export type MaterialSheetId = "material" | "concrete" | "quantity";
|
||||
@@ -51,11 +52,9 @@ interface Payload {
|
||||
const BASIS =
|
||||
"구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)";
|
||||
const HINTS: Record<MaterialSheetId, string> = {
|
||||
material:
|
||||
"합계 = 끝수(수량 × (1 + 할증)) — 레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 올림 · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널",
|
||||
concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음",
|
||||
quantity:
|
||||
"수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1 · 콘크리트(기계) · 모르터 1:3 단위수량 → 콘크리트소요",
|
||||
material: "할증 %는 칸에서 고침 · 모래 · 자갈 · 시멘트는 좌측 패널",
|
||||
concrete: "㎥당 배합은 칸에서 고침",
|
||||
quantity: "단위수량은 칸에 수기 입력",
|
||||
};
|
||||
const HIDDEN = "값 없는 줄은 숨김";
|
||||
|
||||
@@ -190,9 +189,7 @@ function excelDiffs(s: MaterialSheets): string {
|
||||
if (want != null && (row.surcharge ?? 0) !== want)
|
||||
out.push(`${row.spec} 할증 ${row.surcharge ?? 0}%(엑셀 ${want}%)`);
|
||||
}
|
||||
return out.length
|
||||
? `2공구 엑셀 수기 입력값과 다름 — ${out.join(" · ")} · 지금 값 = 품셈 근거`
|
||||
: "";
|
||||
return out.length ? `2공구 엑셀 수기 입력값과 다름 — ${out.join(" · ")}` : "";
|
||||
}
|
||||
|
||||
/** 콘크리트소요 원천 = 콘크리트(기계) · 모르터 1:3 열 + 그 둘에 맞는 사본 자재 열(짝 계산 `matched` 그대로) */
|
||||
@@ -213,7 +210,7 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string {
|
||||
const blank = s.quantity.rows.filter((row) => [...keys].every((k) => row.units[k] == null));
|
||||
const copied = blank.filter((row) => source.snapshot?.units?.[row.id]).length;
|
||||
return blank.length
|
||||
? `콘크리트소요 빈 까닭 — 콘크리트(기계) · 모르터 1:3 단위수량 비었음 ${blank.length}줄(사본 자재에 콘크리트 · 모르터 없음 ${copied} · 사본 자재 없음 · 미연결 ${blank.length - copied}) — 자재수량 탭 그 열에 넣음`
|
||||
? `콘크리트소요 빈 까닭 — 콘크리트(기계) · 모르터 1:3 단위수량 빈 ${blank.length}줄(사본에 없음 ${copied} · 미연결 ${blank.length - copied}) — 자재수량 탭에서 넣음`
|
||||
: "";
|
||||
}
|
||||
|
||||
@@ -340,10 +337,7 @@ export async function renderMaterialSheet(
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "b08-material__toolbar";
|
||||
const notes = document.createElement("div");
|
||||
notes.append(
|
||||
note(BASIS),
|
||||
note(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`),
|
||||
);
|
||||
notes.append(footNote(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`));
|
||||
if (sheet !== "quantity") {
|
||||
// 빈 까닭 한 줄(131) — 단위수량 빈 줄 · 서버 알림 · 빈 칸은 빈칸 그대로
|
||||
const blank = blankUnitRows(sheets);
|
||||
@@ -352,12 +346,12 @@ export async function renderMaterialSheet(
|
||||
...(payload.notes ?? []),
|
||||
];
|
||||
if (why.length) {
|
||||
const reason = note(`빈 칸 까닭 — ${why.join(" · ")}`);
|
||||
const reason = footNote(`빈 칸 까닭 — ${why.join(" · ")}`);
|
||||
reason.dataset.b08MaterialWhy = "";
|
||||
notes.append(reason);
|
||||
}
|
||||
}
|
||||
const concreteNote = note(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시
|
||||
const concreteNote = footNote(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시
|
||||
concreteNote.dataset.b08MaterialConcreteWhy = "";
|
||||
if (sheet === "concrete") notes.append(concreteNote);
|
||||
toolbar.append(save);
|
||||
@@ -390,7 +384,7 @@ export async function renderMaterialSheet(
|
||||
},
|
||||
});
|
||||
table.root.dataset.b08Material = sheet;
|
||||
const excel = note("");
|
||||
const excel = footNote("");
|
||||
excel.dataset.b08MaterialExcel = "";
|
||||
const redraw = (): void => {
|
||||
built = BUILD[sheet](sheets, overrides);
|
||||
|
||||
@@ -24,6 +24,16 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* 236-1 표 아래 안내 · 경고 줄 — 늘 한 줄(줄임표 · 잘리면 공용 호버로 전문) */
|
||||
.b08-quantity__foot {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 자재 집계표 셋(121) — 안내 글 · [저장] 한 줄 */
|
||||
.b08-material__toolbar {
|
||||
display: flex;
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from "./B08_Quantity_Summary_Data";
|
||||
import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc";
|
||||
import type { SheetsRequest } from "./B08_Quantity_UI_Drawing";
|
||||
import { footNote } from "./B08_Quantity_UI_Foot";
|
||||
import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
@@ -258,7 +259,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
|
||||
const summary = live();
|
||||
const parts: HTMLElement[] = [];
|
||||
if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`));
|
||||
for (const text of summary?.notes ?? []) parts.push(note(text));
|
||||
if (summary?.notes.length) parts.push(footNote(summary.notes.join(" · "))); // 236-1 한 줄
|
||||
if (!columns.length && !summary?.columns.length)
|
||||
parts.push(note(L("B08_Quantity_Summary_NoCombo")));
|
||||
const draw = (): HTMLElement =>
|
||||
|
||||
@@ -56,7 +56,7 @@ PARTS = ("labor", "material", "expense")
|
||||
COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"}
|
||||
DEPTH = 4 # 하위 산근 깊이 상한
|
||||
_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …)
|
||||
NO_SOURCE = "조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(연결 없음 · 만들지 않음)"
|
||||
NO_SOURCE = "조합 밖 토공 · 운반 · 사토 · 준비공은 산근 없음"
|
||||
EMPTY = "연결된 구조물 조합 사본에 단가산출 줄 없음 — 연결 없음"
|
||||
|
||||
|
||||
|
||||
@@ -81,6 +81,35 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 235-1 설계내역서 고른 줄 — 줄 전체 상자 테두리(공용 is-focus 밑줄 · 배경 대신) */
|
||||
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td {
|
||||
box-shadow:
|
||||
inset 0 2px 0 var(--color-accent),
|
||||
inset 0 -2px 0 var(--color-accent);
|
||||
}
|
||||
|
||||
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td:first-child {
|
||||
box-shadow:
|
||||
inset 0 2px 0 var(--color-accent),
|
||||
inset 0 -2px 0 var(--color-accent),
|
||||
inset 2px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td:last-child {
|
||||
box-shadow:
|
||||
inset 0 2px 0 var(--color-accent),
|
||||
inset 0 -2px 0 var(--color-accent),
|
||||
inset -2px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus:not(:hover) td:not(.is-edited) {
|
||||
background-color: var(--color-canvas);
|
||||
}
|
||||
|
||||
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus.is-alt:not(:hover) td:not(.is-edited) {
|
||||
background-color: var(--dt-zebra);
|
||||
}
|
||||
|
||||
/* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-pick td {
|
||||
--pick-t: inset 0 0 0 0 transparent;
|
||||
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
} from "./B09_Estimation_UI_Tabs_Summary";
|
||||
import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total";
|
||||
import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun";
|
||||
import { isGroupTab, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups";
|
||||
import { isGroupTab, lineHint, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -204,6 +204,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
empty: "",
|
||||
caption: TABS.find(([id]) => id === tab)?.[1],
|
||||
editable: spec.editable,
|
||||
editOnDouble: spec.editOnDouble,
|
||||
onEdit: spec.onEdit,
|
||||
rowClass: extra
|
||||
? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null
|
||||
@@ -243,7 +244,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
);
|
||||
return made;
|
||||
};
|
||||
const hint = spec.hint ? note(spec.hint) : null;
|
||||
const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 236-1 안내 글 = 한 줄
|
||||
// 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 머리 단추로 표
|
||||
if (isGroupTab(tab)) {
|
||||
const redraw = (): void => show(tab);
|
||||
@@ -260,10 +261,10 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
}),
|
||||
);
|
||||
}
|
||||
// 200-2 설계내역서 = 고른 줄 강조 · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래
|
||||
// 200-2 설계내역서 = 고른 줄 상자(235-1) · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래 · Enter · F2 = 입력
|
||||
const bill = (spec as BillTable).menu ? (spec as BillTable) : null;
|
||||
const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined);
|
||||
const level = bill ? levelBar(table.root, bill) : null;
|
||||
const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null;
|
||||
if (level) bars.setTools(level.tools);
|
||||
panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []));
|
||||
if (focus !== undefined) {
|
||||
|
||||
@@ -99,6 +99,8 @@ export interface TabTable {
|
||||
/** 칸 누름 → 갈 줄(호표 칸) */
|
||||
link?: (index: number, key: string) => Link | null;
|
||||
editable?: (index: number, key: string) => boolean;
|
||||
/** 고칠 칸 = 더블클릭 · Enter · F2 때만 글 상자(235-1 · 공용 표 editOnDouble) */
|
||||
editOnDouble?: boolean;
|
||||
onEdit?: (index: number, key: string, text: string) => void;
|
||||
/** 칸 누름 → 그 자리 동작(예 관급 · 별도 표시 바꿈) */
|
||||
onCell?: (index: number, key: string) => void;
|
||||
|
||||
@@ -52,14 +52,14 @@ export const buildLabor: TabBuild = ({ sheets, payload }) =>
|
||||
priceList(
|
||||
sheets.labor || [],
|
||||
"단가(원/인)",
|
||||
`노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [업데이트]`,
|
||||
`굳힌 단가(${madeText(payload)}) 기준 · 바꾸려면 [업데이트]`,
|
||||
);
|
||||
|
||||
export const buildExpense: TabBuild = ({ sheets, payload }) =>
|
||||
priceList(
|
||||
sheets.expense || [],
|
||||
"기준가(천원)",
|
||||
`기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [업데이트]`,
|
||||
`굳힌 단가(${madeText(payload)}) 기준 · 바꾸려면 [업데이트]`,
|
||||
);
|
||||
|
||||
function madeText(payload: Row): string {
|
||||
@@ -104,7 +104,7 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => {
|
||||
rows,
|
||||
frozen: 2,
|
||||
mergeSame: ["part"],
|
||||
hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표",
|
||||
hint: "인건비 줄을 누르면 노무비목록표로 이동",
|
||||
link: (i) => {
|
||||
const row = rows[i];
|
||||
return row?.labor_key ? { tab: "labor", key: String(row.labor_key) } : null;
|
||||
@@ -196,7 +196,7 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
],
|
||||
rows: out,
|
||||
frozen: 2,
|
||||
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸(견적 단가를 넣은 줄만) 누름 = 파일 올리기 · 보기 · 출처 단가 칸에 마우스 = 출처 뜻(책 · 호 · 쪽 · 기준일)",
|
||||
hint: "「적용 단가」 칸을 누르면 출처 고르기 · 「관급 · 별도」 칸을 누르면 구분 바꿈 · 견적 단가는 수기 입력",
|
||||
tip: (i, key) =>
|
||||
key.startsWith("p:") && rows[i]
|
||||
? sourceInfo(
|
||||
@@ -297,7 +297,7 @@ export const buildMaterial: TabBuild = ({ sheets }): TabTable => {
|
||||
? { name: "계", amount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0) }
|
||||
: null,
|
||||
frozen: 2,
|
||||
hint: `줄 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재 + B08 총괄 줄 · 관급 · 별도 줄 · 수량 = 설계내역서 × 호표 역전개 총수량(재료비수량금액집계표와 같음 · B08 할증 값과 다를 수 있음) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · 비고 = 쓰는 곳 · ${LINK_HINT}`,
|
||||
hint: LINK_HINT,
|
||||
link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
@@ -317,7 +317,7 @@ export const buildMachine: TabBuild = ({ sheets }): TabTable => {
|
||||
],
|
||||
rows,
|
||||
frozen: 2,
|
||||
hint: `시간당 = 중기사용료 블록 합계 · ${LINK_HINT}`,
|
||||
hint: LINK_HINT,
|
||||
link: (i) => (rows[i] ? { tab: "machine_blocks", key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
@@ -388,7 +388,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
|
||||
],
|
||||
rows,
|
||||
frozen: 1,
|
||||
hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭",
|
||||
hint: LINK_HINT,
|
||||
link: (i) => links[i] ?? null,
|
||||
level: blockLevel("name", (i) => !String(rows[i]?.key).includes("|")),
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
|
||||
* 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level`
|
||||
* 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
|
||||
* 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) ·
|
||||
* 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
@@ -24,6 +26,8 @@ import {
|
||||
} from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
|
||||
const TEXT_KEYS = ["name", "spec", "unit"];
|
||||
/** 폴더 줄 수식 칸 — 자손 합(셈) */
|
||||
const SUM_KEYS = ["sum", "labor", "material", "expense"];
|
||||
|
||||
export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
const { sheets, user, setUser } = ctx;
|
||||
@@ -60,10 +64,11 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
total: { name: "순공사비 계(관급 제외)", ...direct },
|
||||
frozen: 1,
|
||||
hint:
|
||||
"기본 로직이 짠 내역 = 초기값 · 모든 줄 고침 · 더함 · 지움 · 옮김 자유(우클릭 · 고친 칸 = 색 칸 · 풍선 = 자동 값) · " +
|
||||
"금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 묶음 금액 = 아래 항목 합 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재(관급자재대 밖으로 못 옮김) · " +
|
||||
"조달수수료 율(%)은 수량 칸 · 「연결 ▾」 = 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · 처음으로 = 왼쪽 [내역 초기화] · " +
|
||||
`빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`,
|
||||
"줄을 우클릭하면 추가 · 지움 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" +
|
||||
(direct.blank
|
||||
? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`
|
||||
: ""),
|
||||
editOnDouble: true,
|
||||
editable: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (!r) return false;
|
||||
@@ -86,6 +91,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
cellClass: (i, key) => (edited(rows[i], key) ? "is-edited" : null),
|
||||
tip: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (r && r.kind !== "item" && SUM_KEYS.includes(key)) return "수식 = 아래 항목 합(읽기 전용)";
|
||||
if (!r || !edited(r, key)) return null;
|
||||
const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`];
|
||||
return `자동 값 ${auto ?? "빈칸"} — 우클릭 [자동값으로 되돌리기]`;
|
||||
@@ -101,6 +107,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
key: "name",
|
||||
of: (i) => rows[i]?.level as number | undefined,
|
||||
group: (i) => rows[i]?.kind !== "item",
|
||||
tree: true,
|
||||
},
|
||||
menu: (i) => billMenu(ctx, raw, i),
|
||||
drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before),
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*
|
||||
* 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) · 안내 글 = 표 아래(202-1)
|
||||
* 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음
|
||||
* 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로)
|
||||
* 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로) ·
|
||||
* 꼴 = 줄 전체 상자 테두리(235-1 · 페이지 CSS) · 글 칸도 한 번 누름 = 고름 · 더블클릭 · Enter · F2 = 입력(공용 표)
|
||||
* 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂)
|
||||
* 방향키(204) = → 들여쓰기 · ← 내어쓰기 · ↑↓ 옮기기(같은 메뉴 항목) · 끌기 = `…_Bill_Drag`(놓은 줄 = 고른 줄)
|
||||
* 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름
|
||||
@@ -26,7 +27,8 @@ const ACTS: [string, string][] = [
|
||||
];
|
||||
|
||||
const GUIDE =
|
||||
"레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기";
|
||||
"레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기 · " +
|
||||
"입력 = 더블클릭 · Enter · F2(Esc · Enter = 끝)";
|
||||
const GUIDE_MORE =
|
||||
"레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " +
|
||||
"들여쓰기 = 바로 위 줄 아래로(항목이면 저절로 묶음 · 자식이 다 빠진 묶음 = 다시 항목) · " +
|
||||
@@ -43,6 +45,8 @@ let live: {
|
||||
tools: HTMLElement[];
|
||||
run: (label: string) => void;
|
||||
sync: () => void;
|
||||
edit: (index: number) => void;
|
||||
index: () => number;
|
||||
} | null = null;
|
||||
let keysOn = false;
|
||||
|
||||
@@ -96,6 +100,12 @@ function listenKeys(): void {
|
||||
(at as HTMLElement).blur();
|
||||
return live.root.focus();
|
||||
}
|
||||
// Enter · F2 = 고른 줄 입력 시작(235-1) — 도구 단추 위 Enter 는 단추 몫
|
||||
if ((event.key === "Enter" || event.key === "F2") && !editing(at)) {
|
||||
if (live.tools.includes(at as HTMLElement) || event.isComposing) return;
|
||||
event.preventDefault();
|
||||
return live.edit(live.index());
|
||||
}
|
||||
const label = keyAct(event, editing(at));
|
||||
if (!label) return;
|
||||
event.preventDefault();
|
||||
@@ -107,6 +117,7 @@ function listenKeys(): void {
|
||||
export function levelBar(
|
||||
root: HTMLElement,
|
||||
table: BillTable,
|
||||
edit: (index: number) => void = () => {},
|
||||
): { tools: HTMLElement[]; guide: HTMLElement } {
|
||||
const index = (): number =>
|
||||
picked === null ? -1 : table.rows.findIndex((r) => String(r.key) === picked);
|
||||
@@ -122,7 +133,7 @@ export function levelBar(
|
||||
return button;
|
||||
});
|
||||
const guide = document.createElement("p");
|
||||
guide.className = "ui-general-block__empty";
|
||||
guide.className = "ui-general-block__empty b09-estimation__line-hint";
|
||||
guide.textContent = GUIDE;
|
||||
attachHoverInfo(guide, GUIDE_MORE);
|
||||
const sync = (): void => {
|
||||
@@ -140,7 +151,7 @@ export function levelBar(
|
||||
});
|
||||
attachDrag(root, table, (key) => (picked = key));
|
||||
sync();
|
||||
live = { root, tools: buttons, run, sync };
|
||||
live = { root, tools: buttons, run, sync, edit, index };
|
||||
listenKeys();
|
||||
return { tools: buttons, guide };
|
||||
}
|
||||
|
||||
@@ -178,8 +178,8 @@ function numberFormat(spec: TabTable, key: string): (v: number | null | undefine
|
||||
/* ── 그리기 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 안내 글 = 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */
|
||||
function lineHint(hint: HTMLElement | null): HTMLElement | null {
|
||||
if (!hint) return null;
|
||||
export function lineHint(hint: HTMLElement | null): HTMLElement | null {
|
||||
if (!hint || hint.classList.contains("b09-estimation__line-hint")) return hint;
|
||||
hint.classList.add("b09-estimation__line-hint");
|
||||
attachHoverInfo(hint, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null));
|
||||
return hint;
|
||||
|
||||
@@ -27,8 +27,7 @@ const EMPTY =
|
||||
"연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]";
|
||||
/** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */
|
||||
const CHILD = "ㄴ "; // 들여쓰기 = 공용 표 level(201-1)
|
||||
const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0";
|
||||
const BLANK = "빈 줄 n = 금액 모르는 줄 · 합계 = 아는 줄 합 · 내역 단가엔 안 넘김";
|
||||
const BLANK = "빈 줄은 금액을 모르는 줄(합계는 아는 줄만)";
|
||||
/** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */
|
||||
const blankNote = (b: Row): string => (b.blank ? `빈 줄 ${b.blank}` : "");
|
||||
const blankTip = (b: Row | undefined): string | null =>
|
||||
@@ -65,7 +64,7 @@ export const buildDaegaList: TabBuild = ({ sheets }): TabTable | string => {
|
||||
],
|
||||
rows,
|
||||
frozen: 3,
|
||||
hint: `호표 = 일위대가표 블록 합계 · ${BLANK} · 줄을 누르면 그 블록으로`,
|
||||
hint: `줄을 누르면 그 블록으로 이동 · ${BLANK}`,
|
||||
link: (i: number): Link | null => (rows[i] ? { tab: "dg", key: String(rows[i].key) } : null),
|
||||
tip: (i, key) =>
|
||||
key === "spec"
|
||||
@@ -152,7 +151,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
|
||||
],
|
||||
rows,
|
||||
frozen: 1,
|
||||
hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · ${BLANK} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`,
|
||||
hint: `줄을 누르면 단가를 낸 탭으로 이동 · ${BLANK}`,
|
||||
link: (i: number) => links[i] ?? null,
|
||||
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
|
||||
level: blockLevel("name", (i) => titles.has(i)),
|
||||
|
||||
@@ -56,9 +56,7 @@ export const buildSgList: TabBuild = ({ sheets }): TabTable => {
|
||||
],
|
||||
rows,
|
||||
frozen: 2,
|
||||
hint:
|
||||
"산근 = 연결된 구조물 조합 사본의 단가산출 로직 줄 · 사전조합 안 줄 · 일위대가화 줄의 하위 로직(노무 · 자재 · 중기 줄은 각 목록표) · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" +
|
||||
notes(sheets),
|
||||
hint: "줄을 누르면 그 블록으로 이동" + notes(sheets),
|
||||
link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),
|
||||
tip: (i, key) => (key === "spec" ? raw(rows[i]?.spec, rows[i]?.spec_raw) : null),
|
||||
};
|
||||
@@ -159,8 +157,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
|
||||
rows,
|
||||
frozen: 1,
|
||||
hint:
|
||||
"산출내역 = 값이 든 식(B09 굳힌 단가 * 수량 = 금액 · 작업량 Q = ROUND 2) · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" +
|
||||
notes(sheets),
|
||||
"줄을 누르면 단가를 낸 탭으로 이동 · 자재 적용 단가를 고치면 [저장] 뒤 반영" + notes(sheets),
|
||||
link: (i: number) => links[i] ?? null,
|
||||
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
|
||||
level: blockLevel("text", (i) => titles.has(i)),
|
||||
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
type TabTable,
|
||||
} from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
const HINT =
|
||||
"총수량 = 설계내역서 수량 × 호표(산근 · 대가) 구성 역전개(서버 · [저장] 뒤 다시 셈) · ROUND 2 · 금액 = ROUND(수량 × 단가, 0) · 줄 누름 = 목록표";
|
||||
const HINT = "줄을 누르면 목록표로 이동";
|
||||
|
||||
/** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */
|
||||
const hint = (payload: Row, extra = ""): string => {
|
||||
@@ -28,7 +27,7 @@ const hint = (payload: Row, extra = ""): string => {
|
||||
};
|
||||
|
||||
/** 줄이 없을 때 표 위 맨 앞 안내 */
|
||||
const EXPENSE_NONE = "경비 갈래 자원 없음 — 중기 · 자재로 셈 · 경비 로직이 들어오면 채워짐";
|
||||
const EXPENSE_NONE = "경비 자원 없음";
|
||||
|
||||
function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): TabBuild {
|
||||
return ({ payload }): TabTable => {
|
||||
@@ -86,7 +85,7 @@ export const buildSumMachine: TabBuild = ({ payload }): TabTable => {
|
||||
}
|
||||
: null,
|
||||
frozen: 2,
|
||||
hint: hint(payload, " · 비목마다 ROUND(시간 × 시간당, 0)"),
|
||||
hint: hint(payload),
|
||||
link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -211,11 +211,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
rowClass: (i) => levelClass(rows[i]),
|
||||
level: nameLevel(rows),
|
||||
mergeSame: ["part"],
|
||||
hint:
|
||||
"요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " +
|
||||
"2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " +
|
||||
"수기 입력 = 안전관리비 기초액(빈칸 = 자동 구간 기초액) · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · " +
|
||||
"공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음",
|
||||
hint: "요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값",
|
||||
editable: (i, key) =>
|
||||
(key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) ||
|
||||
(key === "hand" && String(rows[i]?.key) in HAND),
|
||||
@@ -292,7 +288,7 @@ export const buildCost: TabBuild = (ctx): TabTable => {
|
||||
rowClass: (i) => levelClass(rows[i]),
|
||||
level: nameLevel(rows),
|
||||
mergeSame: ["group", "part"],
|
||||
hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄",
|
||||
hint: "고칠 칸은 간접재료비 · 줄을 누르면 총괄 그 줄로 이동",
|
||||
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
|
||||
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
|
||||
link: (i) => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/* 236-1 — 표 아래 안내 글이 한 줄일 때 창 끝까지 셈(`ui_template_data_table.ts` fillViewport)이
|
||||
* 창 크기마다 문서 맨 아래 = 창 끝(페이지 스크롤 0)이 되는지 단위검증 헬퍼. JSON 으로 냄. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
|
||||
const ONE_LINE = 18; // 안내 글 한 줄(caption 글꼴) 높이
|
||||
const win = { innerHeight: 0, scrollY: 0, addEventListener() {}, removeEventListener() {} };
|
||||
const observers = [];
|
||||
class RO {
|
||||
constructor(fn) {
|
||||
observers.push(fn);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
const el = (css = {}, height = 0) => ({
|
||||
css,
|
||||
height,
|
||||
top: 0,
|
||||
parentElement: null,
|
||||
nextElementSibling: null,
|
||||
isConnected: true,
|
||||
style: {},
|
||||
getBoundingClientRect() {
|
||||
return { top: this.top, height: this.height };
|
||||
},
|
||||
});
|
||||
const sty = (n) => ({
|
||||
rowGap: n.css.rowGap ?? "normal",
|
||||
paddingBottom: n.css.paddingBottom ?? "0px",
|
||||
borderBottomWidth: n.css.borderBottomWidth ?? "0px",
|
||||
marginBottom: n.css.marginBottom ?? "0px",
|
||||
});
|
||||
const box = { exports: {} };
|
||||
new Function(
|
||||
"exports",
|
||||
"module",
|
||||
"require",
|
||||
"window",
|
||||
"document",
|
||||
"getComputedStyle",
|
||||
"ResizeObserver",
|
||||
js,
|
||||
)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p)), win, { body: null }, sty, RO);
|
||||
|
||||
const out = [];
|
||||
for (const h of [1080, 768, 600]) {
|
||||
win.innerHeight = h;
|
||||
observers.length = 0;
|
||||
// 문서: body(padding-bottom 8) > card(padding-bottom 12 · gap 8) > [표 칸][안내 한 줄]
|
||||
const body = el({ paddingBottom: "8px" });
|
||||
const card = el({ paddingBottom: "12px", rowGap: "8px" });
|
||||
const root = el();
|
||||
const hint = el({}, ONE_LINE);
|
||||
card.parentElement = body;
|
||||
root.parentElement = card;
|
||||
hint.parentElement = card;
|
||||
root.nextElementSibling = hint;
|
||||
root.top = 200;
|
||||
box.exports.fillViewport(root);
|
||||
observers.forEach((fn) => fn());
|
||||
const grow = parseFloat(root.style.maxHeight);
|
||||
const below = box.exports.spaceBelow(root);
|
||||
out.push({ viewport: h, bottom: root.top + grow + below, grow, below });
|
||||
}
|
||||
console.log(JSON.stringify(out));
|
||||
@@ -68,9 +68,7 @@ def test_알림에_맞춤_없는_이름(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(router, "structure_summary", summary)
|
||||
monkeypatch.setattr(router, "snapshot_units", lambda root, s: SUMMARY["snapshot"])
|
||||
got = asyncio.run(router._sheets("p", tmp_path, clean_overrides({})))
|
||||
assert got["notes"] == [
|
||||
"2공구 열 맞춤 없음 2종(총괄 끝 줄 그대로) — 이형철근 D13 ton · 토목용부직포 ㎡"
|
||||
]
|
||||
assert got["notes"] == ["2공구 열 맞춤 없음 2종 — 이형철근 D13 ton · 토목용부직포 ㎡"]
|
||||
|
||||
|
||||
SC = {"층": "project", "주인": None, "키": "SC-000001"}
|
||||
|
||||
@@ -14,8 +14,8 @@ SPOTS = {
|
||||
"B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
|
||||
"B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
|
||||
"B09_Estimation/B09_Estimation_Sangeun.py": ['"수기 줄" if'],
|
||||
"B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 = 수기 입력"],
|
||||
"B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'],
|
||||
"B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가는 수기 입력"],
|
||||
"B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"'],
|
||||
"ui_template/ui_template_locale_b2.ts": [
|
||||
"수량 원천: 수기 입력(직접비)",
|
||||
"수량 원천: 수기 입력 공종",
|
||||
|
||||
@@ -27,4 +27,4 @@ def test_견적은_단가_견적서_그_밖은_단가_하나():
|
||||
def test_견적_면_수기_칸_없앰():
|
||||
body = _compare()
|
||||
assert 'key === "g:견적"' not in body and "quote_page" not in body
|
||||
assert "견적 단가 = 수기 입력" in body and "견적 단가 · 면" not in body
|
||||
assert "견적 단가는 수기 입력" in body and "견적 단가 · 면" not in body
|
||||
|
||||
@@ -279,7 +279,7 @@ def test_표_칸_강조_번호_연결_칸():
|
||||
assert out["cls"] == ["is-edited", None, None]
|
||||
assert out["tip"] == "자동 값 5 — 우클릭 [자동값으로 되돌리기]"
|
||||
assert out["edit"] == [True, False, True, True, False]
|
||||
assert "엑셀 꼴 고정" not in out["hint"] and "내역 초기화" in out["hint"]
|
||||
assert "엑셀 꼴 고정" not in out["hint"] and "우클릭" in out["hint"]
|
||||
assert out["renamed"] == "절토공" # 번호는 떼고 이름만
|
||||
|
||||
|
||||
|
||||
@@ -111,7 +111,8 @@ def test_CSS_레벨_글꼴_규칙():
|
||||
assert rule(".is-lv5") == "font-size: var(--dt-lv5-size);"
|
||||
# 항목 Lv1 · 2 · Lv3 = 표 기본 14px(크기 규칙 없음) · 굵게 = 묶음만
|
||||
assert not rule(".is-lv1") and not rule(".is-lv2") and not rule(".is-lv3")
|
||||
assert css.count("var(--dt-lv, 0) * var(--dt-indent)") == 2 and "* 1em" not in css
|
||||
# 들여쓰기 = 글 칸 · 글 상자 · 나무 글 상자(235-1 · 아이콘 자리 더함)
|
||||
assert css.count("var(--dt-lv, 0) * var(--dt-indent)") == 3 and "* 1em" not in css
|
||||
# 줄 높이 28px 안 — 가장 큰 글자(16px × 줄 간격 1.3)
|
||||
assert "--dt-row-h: 28px;" in css and 16 * 1.3 < 28
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""235-1 B09 설계내역서 = 접히지 않는 트리 · 더블클릭 입력 · 고른 줄 상자.
|
||||
|
||||
- 폴더 줄(자식 있는 줄 · 제목 · 묶음) = 이름만 고침 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용 · 연결 없음)
|
||||
- 파일 줄(항목) = 이름 · 규격 · 단위 · 수량 입력 + 「연결 ▾」
|
||||
- 트리 안내선(├ is-tee · └ is-end · │ is-bar) · 폴더 · 파일 아이콘(공용 나무 꼴) = 공용 표 level.tree
|
||||
- 공용 표 editOnDouble = 고칠 칸도 글 · 한 번 누름 = 줄 고름 · 더블클릭 · edit() = 그 칸만 글 상자 · Enter · 초점 떠남 = 끝
|
||||
- 레벨 키 = 고른 줄에서 Enter · F2 = 입력 시작(편집 중 · 도구 단추 · 고른 줄 없음 = 안 먹음)
|
||||
- 고른 줄 = 줄 전체 상자 테두리(테마 토큰 · 배경 대신)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from test_182_b09_bill_level import BOOKS, INPUTS, USER # noqa: E402
|
||||
from test_184_3_b09_bill_menu import _node, _ts # noqa: E402
|
||||
from test_200_2_b09_bill_level_bar import BAR_JS # noqa: E402
|
||||
from test_184_3_b09_bill_menu import ROWS as FAKE_ROWS # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
|
||||
TABLE = IMPORTS.sub("", (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8"))
|
||||
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]
|
||||
SUMS = ["sum", "labor", "material", "expense"]
|
||||
FOLDER_SVG, LEAF_SVG = "M1.5 3.5h4", "M3.5 1.5h6"
|
||||
|
||||
|
||||
def _rows():
|
||||
return compute_estimation(INPUTS, USER, None, BOOKS)["bill"]["rows"]
|
||||
|
||||
|
||||
# --- 설계내역서 칸: 폴더 = 수식 · 파일 = 입력 ------------------------------------------------
|
||||
|
||||
SPEC_JS = """
|
||||
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser: () => {} };
|
||||
const spec = buildBill(ctx);
|
||||
const keys = ["name", "spec", "unit", "qty", "price_sum", "sum", "labor", "material", "expense", "connect"];
|
||||
console.log(JSON.stringify({
|
||||
double: spec.editOnDouble, tree: spec.level.tree,
|
||||
rows: spec.rows.map((r, i) => ({ key: r.key, kind: r.kind, connect: r.connect,
|
||||
edit: keys.filter((k) => spec.editable(i, k)),
|
||||
tips: Object.fromEntries(keys.map((k) => [k, spec.tip(i, k) ?? null])) })),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def _spec(rows):
|
||||
return json.loads(
|
||||
_node(_ts(SRC) + SPEC_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False)))
|
||||
)
|
||||
|
||||
|
||||
def test_폴더_줄_이름만_수량_단가_빈칸_금액_자손_합():
|
||||
rows = _rows()
|
||||
by = {r["key"]: r for r in rows}
|
||||
folders = [r for r in rows if r["kind"] != "item"]
|
||||
assert {"sec:1", "sec:1.1", "hand:p", "hand:q"} <= {r["key"] for r in folders}
|
||||
for f in folders:
|
||||
assert f.get("qty") is None and all(f.get(f"price_{p}") is None for p in [*SUMS[1:], "sum"])
|
||||
kids = [r for r in rows if r["kind"] == "item" and _under(by, r, f["key"])]
|
||||
for p in SUMS: # 금액 = 자손 항목 합(셈이 냄)
|
||||
assert f[p] == sum(r[p] or 0 for r in kids), (f["key"], p)
|
||||
out = _spec(rows)
|
||||
assert out["double"] is True and out["tree"] is True
|
||||
for r in out["rows"]:
|
||||
if r["kind"] == "item":
|
||||
continue
|
||||
assert r["edit"] == ["name"], r["key"] # 이름만
|
||||
assert r["connect"] == "" # 연결 없음
|
||||
for p in SUMS:
|
||||
assert r["tips"][p] == "수식 = 아래 항목 합(읽기 전용)"
|
||||
assert r["tips"]["qty"] is None and r["tips"]["name"] is None
|
||||
|
||||
|
||||
def _under(by, row, key):
|
||||
at = by.get(row.get("parent") or "")
|
||||
while at:
|
||||
if at["key"] == key:
|
||||
return True
|
||||
at = by.get(at.get("parent") or "")
|
||||
return False
|
||||
|
||||
|
||||
def test_파일_줄_입력_연결():
|
||||
out = _spec(_rows())
|
||||
items = [r for r in out["rows"] if r["kind"] == "item" and r["key"] != "fee"]
|
||||
assert items
|
||||
for r in items:
|
||||
assert r["edit"] == ["name", "spec", "unit", "qty"], r["key"]
|
||||
assert all(r["tips"][p] is None for p in SUMS)
|
||||
linked = [r for r in items if not r["key"].startswith("mat:")]
|
||||
assert linked and all(r["connect"].endswith("▾") for r in linked)
|
||||
|
||||
|
||||
# --- 트리 안내선 · 아이콘 ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def _guides_py(levels):
|
||||
"""거울 — 아래에 같은 레벨 형제(더 얕은 줄 전까지)가 있으면 ├ · 없으면 └ · 윗대 형제가 있으면 │."""
|
||||
|
||||
def more(i, d):
|
||||
for lv in levels[i + 1 :]:
|
||||
if lv < d:
|
||||
return False
|
||||
if lv == d:
|
||||
return True
|
||||
return False
|
||||
|
||||
out = []
|
||||
for i, lv in enumerate(levels):
|
||||
g = [("is-bar", d - 2) for d in range(2, lv) if more(i, d)]
|
||||
if lv >= 2:
|
||||
g.append(("is-tee" if more(i, lv) else "is-end", lv - 2))
|
||||
out.append(g)
|
||||
return out
|
||||
|
||||
|
||||
GUIDE_JS = """
|
||||
const parse = (h) => [...h.matchAll(/ui-data-table__guide (is-\\w+)" style="--g:(\\d+)"/g)].map((m) => [m[1], Number(m[2])]);
|
||||
const levels = __LEVELS__;
|
||||
const unit = treeGuides(levels).map(parse);
|
||||
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser: () => {} };
|
||||
const spec = buildBill(ctx);
|
||||
const html = renderDataTableHtml({ columns: spec.columns, rows: spec.rows, level: spec.level,
|
||||
editable: spec.editable, editOnDouble: true });
|
||||
const trs = html.split("<tbody>")[1].split("</tbody>")[0].split("</tr>").filter(Boolean);
|
||||
const lvCell = (tr) => (tr.match(/<td class="[^"]*ui-data-table__lv[^"]*"[^>]*>(.*?)<\\/td>/) || [])[1] ?? "";
|
||||
console.log(JSON.stringify({
|
||||
unit,
|
||||
bill: trs.map((tr) => parse(lvCell(tr))),
|
||||
icons: trs.map((tr) => lvCell(tr).includes("__FOLDER__") ? "folder" : lvCell(tr).includes("__LEAF__") ? "leaf" : null),
|
||||
tree: trs.map((tr) => /has-tree/.test(tr)),
|
||||
levels: spec.rows.map((r) => r.level), kinds: spec.rows.map((r) => r.kind),
|
||||
plain: renderDataTableHtml({ columns: spec.columns, rows: spec.rows, level: { ...spec.level, tree: false } })
|
||||
.includes("ui-data-table__guide"),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def _guides(levels, rows):
|
||||
body = (
|
||||
GUIDE_JS.replace("__LEVELS__", json.dumps(levels))
|
||||
.replace("__ROWS__", json.dumps(rows, ensure_ascii=False))
|
||||
.replace("__FOLDER__", FOLDER_SVG)
|
||||
.replace("__LEAF__", LEAF_SVG)
|
||||
)
|
||||
return json.loads(_node(_ts(SRC) + TABLE + body))
|
||||
|
||||
|
||||
def test_트리_안내선_한_칸씩():
|
||||
levels = [1, 2, 3, 3, 2, 3, 1, 2]
|
||||
out = _guides(levels, _rows())
|
||||
assert out["unit"] == [
|
||||
[],
|
||||
[["is-tee", 0]],
|
||||
[["is-bar", 0], ["is-tee", 1]],
|
||||
[["is-bar", 0], ["is-end", 1]],
|
||||
[["is-end", 0]],
|
||||
[["is-end", 1]],
|
||||
[],
|
||||
[["is-end", 0]],
|
||||
]
|
||||
assert out["unit"] == [[list(g) for g in row] for row in _guides_py(levels)]
|
||||
|
||||
|
||||
def test_설계내역서_안내선_아이콘_공용_나무_꼴():
|
||||
out = _guides([1], _rows())
|
||||
assert out["bill"] == [[list(g) for g in row] for row in _guides_py(out["levels"])]
|
||||
assert any(out["bill"]) # 실제로 선이 그려짐
|
||||
assert out["icons"] == ["leaf" if k == "item" else "folder" for k in out["kinds"]]
|
||||
assert all(out["tree"]) and out["plain"] is False # 나무 옵션 없으면 안내선 없음
|
||||
table = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8")
|
||||
tree = (ROOT / "ui_template" / "ui_template_tree.ts").read_text("utf-8")
|
||||
assert FOLDER_SVG in table and LEAF_SVG in table # 아이콘 한 벌 — 공용 나무가 가져다 씀
|
||||
assert 'import { TREE_ICON } from "./ui_template_data_table";' in tree
|
||||
assert "TREE_ICON.folder : TREE_ICON.leaf" in tree and FOLDER_SVG not in tree
|
||||
|
||||
|
||||
def test_안내선_CSS_테마_토큰():
|
||||
css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8")
|
||||
block = css.split(".ui-data-table__guide::before {")[1].split("}")[0]
|
||||
assert "border-left: 1px solid var(--color-border);" in block
|
||||
assert ".ui-data-table__guide.is-end::before {\n bottom: 50%;" in css
|
||||
assert ".ui-data-table__guide.is-tee::after,\n.ui-data-table__guide.is-end::after {" in css
|
||||
assert "left: calc(var(--spacing-8) + var(--g, 0) * var(--dt-indent));" in css
|
||||
assert ".ui-data-table__table td.has-tree:not(.is-frozen) {\n position: relative;" in css
|
||||
|
||||
|
||||
# --- 공용 표 더블클릭 입력 ---------------------------------------------------------------------
|
||||
|
||||
DOM_JS = """
|
||||
const doc = { activeElement: null };
|
||||
globalThis.document = doc;
|
||||
globalThis.CSS = { escape: (s) => s };
|
||||
class FakeInput {
|
||||
constructor(owner, i, k) { this.owner = owner; this.dataset = { i, k }; this.value = "v"; }
|
||||
focus() { doc.activeElement = this; }
|
||||
select() { this.selected = true; }
|
||||
}
|
||||
globalThis.HTMLInputElement = FakeInput;
|
||||
class Root {
|
||||
constructor() { this.on = {}; this.html = ""; this.clientHeight = 600; this.scrollTop = 0; this.dataset = {}; }
|
||||
set innerHTML(h) { this.html = h; }
|
||||
get innerHTML() { return this.html; }
|
||||
addEventListener(t, f) { (this.on[t] ??= []).push(f); }
|
||||
fire(t, ev) { for (const f of this.on[t] ?? []) f(ev); }
|
||||
contains(x) { return x === this || x?.owner === this; }
|
||||
focus() { doc.activeElement = this; }
|
||||
querySelector(sel) {
|
||||
const m = sel.match(/^input\\[data-i="(\\d+)"\\]\\[data-k="(\\w+)"\\]$/);
|
||||
if (!m || !this.html.includes(`data-i="${m[1]}" data-k="${m[2]}"`)) return null;
|
||||
return new FakeInput(this, m[1], m[2]);
|
||||
}
|
||||
}
|
||||
doc.createElement = () => new Root();
|
||||
const target = (root, i, c) => {
|
||||
const td = { dataset: {}, cellIndex: c };
|
||||
const tr = { dataset: { i: String(i) }, owner: root };
|
||||
return { closest: (s) => (s.startsWith("tr") ? tr : td) };
|
||||
};
|
||||
const inputs = (root) => [...root.html.matchAll(/<input[^>]*data-i="(\\d+)" data-k="(\\w+)"/g)].map((m) => `${m[1]}:${m[2]}`);
|
||||
const columns = [{ key: "name", label: "명칭" }, { key: "qty", label: "수량", kind: "number" }];
|
||||
const rows = [{ name: "폴더", qty: null }, { name: "파일", qty: 3 }];
|
||||
const log = [];
|
||||
const t = createDataTable({ columns, rows, editOnDouble: true, editable: (i) => i === 1,
|
||||
onCellClick: (i, k) => log.push(`click ${i}:${k}`), onEdit: (i, k, v) => log.push(`edit ${i}:${k}=${v}`) });
|
||||
const r = t.root;
|
||||
const out = { start: inputs(r) };
|
||||
r.fire("click", { target: target(r, 1, 0) });
|
||||
out.click = [inputs(r), [...log]];
|
||||
r.fire("dblclick", { target: target(r, 0, 0) });
|
||||
out.folderDbl = inputs(r);
|
||||
r.fire("dblclick", { target: target(r, 1, 1) });
|
||||
const input = doc.activeElement;
|
||||
out.dbl = [inputs(r), input?.dataset ?? null, !!input?.selected];
|
||||
let stopped = 0;
|
||||
const key = (k, more = {}) => r.fire("keydown", { key: k, target: input, isComposing: false, ...more,
|
||||
preventDefault() {}, stopPropagation() { stopped += 1; } });
|
||||
key("Enter", { isComposing: true });
|
||||
out.composing = [doc.activeElement === input, stopped];
|
||||
key("Enter");
|
||||
out.enter = [doc.activeElement === r, stopped];
|
||||
r.fire("change", { target: input });
|
||||
r.fire("focusout", { target: input });
|
||||
setTimeout(() => {
|
||||
out.after = [inputs(r), log.at(-1)];
|
||||
out.editName = [t.edit(1), inputs(r)]; // 마지막 누른 열 = 명칭
|
||||
out.editQty = [t.edit(1, "qty"), inputs(r)];
|
||||
out.editFolder = t.edit(0);
|
||||
const plain = createDataTable({ columns, rows, editable: (i) => i === 1 });
|
||||
out.plain = [inputs(plain.root), plain.edit(1)];
|
||||
console.log(JSON.stringify(out));
|
||||
}, 5);
|
||||
"""
|
||||
|
||||
|
||||
def test_한_번_누름_고름_더블클릭_입력_Enter_끝():
|
||||
out = json.loads(_node(TABLE + DOM_JS))
|
||||
assert out["start"] == [] # 고칠 칸도 처음엔 글
|
||||
assert out["click"] == [[], ["click 1:name"]] # 한 번 누름 = 줄 고름(글 상자 안 생김)
|
||||
assert out["folderDbl"] == [] # 못 고치는 칸 더블클릭 = 그대로
|
||||
assert out["dbl"] == [["1:qty"], {"i": "1", "k": "qty"}, True] # 그 칸만 · 초점 · 글 전체 고름
|
||||
assert out["composing"] == [True, 0] # 한글 조합 중 Enter = 조합 끝
|
||||
assert out["enter"] == [True, 1] # Enter = 표에 초점 · 문서로 안 감
|
||||
assert out["after"] == [[], "edit 1:qty=v"] # 초점 떠남 = 입력 끝 · onEdit
|
||||
assert out["editName"] == [True, ["1:name"]]
|
||||
assert out["editQty"] == [True, ["1:qty"]]
|
||||
assert out["editFolder"] is False
|
||||
assert out["plain"] == [["1:name", "1:qty"], False] # 옵션 없는 표 = 예전대로 바로 글 상자
|
||||
|
||||
|
||||
# --- 레벨 키: Enter · F2 = 입력 시작 --------------------------------------------------------
|
||||
|
||||
KEYS_JS = """
|
||||
const edits = [];
|
||||
const out = {};
|
||||
draw();
|
||||
out.none = [key("Enter"), edits.length];
|
||||
pick("earth:5");
|
||||
root.focus();
|
||||
out.enter = [key("Enter"), [...edits]];
|
||||
out.f2 = [key("F2"), [...edits]];
|
||||
doc.activeElement = tr("earth:5").kids[0].kids[0];
|
||||
out.editing = [key("Enter"), edits.length];
|
||||
doc.activeElement = bar.tools[0];
|
||||
out.tool = [key("Enter"), edits.length];
|
||||
root.focus();
|
||||
out.composing = [key("Enter", { isComposing: true }), edits.length];
|
||||
out.order = (user.bill?.rows ?? []).length;
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def test_고른_줄_Enter_F2_입력_시작():
|
||||
setup = BAR_JS.split("const out = {};")[0].replace(
|
||||
"bar = levelBar(root, table);", "bar = levelBar(root, table, (i) => edits.push(i));"
|
||||
)
|
||||
body = (setup + KEYS_JS).replace("__ROWS__", json.dumps(FAKE_ROWS, ensure_ascii=False))
|
||||
names = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill",
|
||||
"UI_Tabs_Bill_Drag", "UI_Tabs_Bill_Level"] # fmt: skip
|
||||
out = json.loads(_node(_ts(names) + body))
|
||||
at = [r["key"] for r in FAKE_ROWS].index("earth:5")
|
||||
assert out["none"] == [False, 0] # 고른 줄 없음
|
||||
assert out["enter"] == [True, [at]]
|
||||
assert out["f2"] == [True, [at, at]]
|
||||
assert out["editing"] == [False, 2] # 글 상자 안 Enter = 공용 표 몫(입력 끝)
|
||||
assert out["tool"] == [False, 2] # 도구 단추 Enter = 단추 몫
|
||||
assert out["composing"] == [False, 2]
|
||||
assert out["order"] == 0 # 줄 차례 · 레벨은 그대로
|
||||
|
||||
|
||||
# --- 고른 줄 상자 · 배선 -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_고른_줄_상자_테두리_테마_토큰():
|
||||
css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
sel = '.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td'
|
||||
top = "inset 0 2px 0 var(--color-accent),\n inset 0 -2px 0 var(--color-accent)"
|
||||
assert f"{sel} {{\n box-shadow:\n {top};" in css
|
||||
assert (
|
||||
f"{sel}:first-child {{\n box-shadow:\n {top},\n inset 2px 0 0 var(--color-accent);"
|
||||
in css
|
||||
)
|
||||
assert (
|
||||
f"{sel}:last-child {{\n box-shadow:\n {top},\n inset -2px 0 0 var(--color-accent);"
|
||||
in css
|
||||
)
|
||||
# 배경 대신 — 고른 줄 배경 = 보통 줄 배경(고친 칸 색은 그대로)
|
||||
bill = '.b09-estimation [data-b09-table="bill"] tbody tr.is-focus'
|
||||
assert (
|
||||
f"{bill}:not(:hover) td:not(.is-edited) {{\n background-color: var(--color-canvas);" in css
|
||||
)
|
||||
assert (
|
||||
f"{bill}.is-alt:not(:hover) td:not(.is-edited) {{\n background-color: var(--dt-zebra);"
|
||||
in css
|
||||
)
|
||||
|
||||
|
||||
def test_Shell_배선():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "editOnDouble: spec.editOnDouble," in shell
|
||||
assert "levelBar(table.root, bill, (i) => table.edit(i))" in shell
|
||||
for name in [
|
||||
"ui_template/ui_template_data_table.ts",
|
||||
"B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts",
|
||||
]:
|
||||
assert len((ROOT / name).read_text("utf-8").splitlines()) <= 700, name
|
||||
@@ -0,0 +1,160 @@
|
||||
"""236-1 B08 · B09 표 아래 안내 글 — 제작 · 셈 설명 지움 · 늘 한 줄 · 페이지 스크롤 0.
|
||||
|
||||
- 안내 글 낱말에 제작 · 셈 설명(ROUND · 「 = 」 · 장 번호 · M01 · 곱셈 나눗셈 기호)이 없음
|
||||
- 안내 글 한 조각 70 자 이하(긴 글은 줄임표 + 공용 호버)
|
||||
- B09 안내 글(lineHint) · 레벨 안내 · B08 안내 줄(footNote) = 한 줄(nowrap · 줄임표)
|
||||
- 창 크기(1080 · 768 · 600)마다 한 줄 안내 + 표 칸 = 창 끝(199-1 fillViewport 셈)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B08 = ROOT / "B08_Quantity"
|
||||
B09 = ROOT / "B09_Estimation"
|
||||
|
||||
STRING = re.compile(r'"([^"\\\n]*)"|`([^`\n]*)`')
|
||||
FORBID = ["ROUND", " = ", "÷", "×", "Σ", "M01", "역전개", "원문"]
|
||||
CHAPTER = re.compile(r"\(\d{2,3}-\d\)|\b\d{1,3}-\d-\d\b|\b\d{2,3}-\d\b")
|
||||
MAX_PIECE = 70
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text("utf-8")
|
||||
|
||||
|
||||
def _pieces(expr: str) -> list[str]:
|
||||
out = []
|
||||
for m in STRING.finditer(expr):
|
||||
text = m.group(1) if m.group(1) is not None else m.group(2)
|
||||
out.append(re.sub(r"\$\{[^}]*\}", "", text).strip())
|
||||
return [p for p in out if p]
|
||||
|
||||
|
||||
def _b09_hints() -> list[tuple[str, str]]:
|
||||
found: list[tuple[str, str]] = []
|
||||
for path in sorted(B09.glob("B09_Estimation_UI_Tabs_*.ts")):
|
||||
src = _read(path)
|
||||
for m in re.finditer(r"\bhint:\s*(.*?),\n\s+\w+[:(]", src, re.S):
|
||||
found += [(path.name, p) for p in _pieces(m.group(1))]
|
||||
for m in re.finditer(
|
||||
r"^const (?:LINK_HINT|BLANK|HINT|EXPENSE_NONE|GUIDE)\s*=\s*(.*?);\n", src, re.S | re.M
|
||||
):
|
||||
found += [(path.name, p) for p in _pieces(m.group(1))]
|
||||
return found
|
||||
|
||||
|
||||
def _b08_hints() -> list[tuple[str, str]]:
|
||||
found: list[tuple[str, str]] = []
|
||||
for name in (
|
||||
"B08_Quantity_UI_Earthwork.ts",
|
||||
"B08_Quantity_UI_Earthwork_Sheets.ts",
|
||||
"B08_Quantity_UI_Material.ts",
|
||||
):
|
||||
src = _read(B08 / name)
|
||||
for m in re.finditer(r"footNote\(((?:\s|\+|\"[^\"\n]*\"|`[^`\n]*`)+)\)", src):
|
||||
found += [(name, p) for p in _pieces(m.group(1))]
|
||||
for m in re.finditer(r"^const HINTS[^=]*=\s*\{(.*?)\n\};", src, re.S | re.M):
|
||||
found += [(name, p) for p in _pieces(m.group(1))]
|
||||
for m in re.finditer(r"^const HIDDEN\s*=\s*(.*?);\n", src, re.S | re.M):
|
||||
found += [(name, p) for p in _pieces(m.group(1))]
|
||||
return found
|
||||
|
||||
|
||||
def _server_notes() -> list[tuple[str, str]]:
|
||||
found: list[tuple[str, str]] = []
|
||||
for name in ("Router_Summary", "Router_Material"):
|
||||
src = _read(B08 / f"B08_Quantity_{name}.py")
|
||||
for m in re.finditer(r"notes\.append\(\s*(.*?)\)\n", src, re.S):
|
||||
found += [(name, p) for p in _pieces(m.group(1).replace('f"', '"'))]
|
||||
sangeun = _read(B09 / "B09_Estimation_Sangeun.py")
|
||||
for m in re.finditer(r'^(?:NO_SOURCE|EMPTY) = "([^"]*)"', sangeun, re.M):
|
||||
found.append(("Sangeun", m.group(1)))
|
||||
return found
|
||||
|
||||
|
||||
def _all() -> list[tuple[str, str]]:
|
||||
return _b09_hints() + _b08_hints() + _server_notes()
|
||||
|
||||
|
||||
def test_안내_글_모음이_비지_않음():
|
||||
assert len(_b09_hints()) >= 20
|
||||
assert len(_b08_hints()) >= 4
|
||||
assert len(_server_notes()) >= 7
|
||||
|
||||
|
||||
def test_제작_셈_설명_낱말_없음():
|
||||
bad = []
|
||||
for where, text in _all():
|
||||
bad += [(where, token, text) for token in FORBID if token in text]
|
||||
if CHAPTER.search(text):
|
||||
bad.append((where, "장 번호", text))
|
||||
assert not bad, bad
|
||||
|
||||
|
||||
def test_안내_글_조각_짧음():
|
||||
long = [(w, t) for w, t in _all() if len(t) > MAX_PIECE]
|
||||
assert not long, long
|
||||
|
||||
|
||||
# ── 늘 한 줄 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
|
||||
assert m, selector
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_B09_안내_글_한_줄():
|
||||
css = _read(B09 / "B09_Estimation_UI_Page.css")
|
||||
block = _block(css, ".b09-estimation__line-hint")
|
||||
assert "white-space: nowrap" in block and "text-overflow: ellipsis" in block
|
||||
shell = _read(B09 / "B09_Estimation_UI_Shell.ts")
|
||||
assert "lineHint(note(spec.hint))" in shell # 표 탭도 묶음 탭과 같은 한 줄
|
||||
assert "export function lineHint" in _read(B09 / "B09_Estimation_UI_Tabs_Groups.ts")
|
||||
level = _read(B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts")
|
||||
assert 'guide.className = "ui-general-block__empty b09-estimation__line-hint"' in level
|
||||
|
||||
|
||||
def test_B08_안내_줄_한_줄():
|
||||
css = _read(B08 / "B08_Quantity_UI_Page.css")
|
||||
block = _block(css, ".b08-quantity__foot")
|
||||
assert "white-space: nowrap" in block and "text-overflow: ellipsis" in block
|
||||
assert "display" not in block # hidden 속성(콘크리트 빈 까닭)을 덮지 않음
|
||||
foot = _read(B08 / "B08_Quantity_UI_Foot.ts")
|
||||
assert "b08-quantity__foot" in foot and "attachHoverInfo" in foot
|
||||
for name in ("Earthwork", "Earthwork_Sheets", "Material", "Summary"):
|
||||
assert "footNote" in _read(B08 / f"B08_Quantity_UI_{name}.ts"), name
|
||||
|
||||
|
||||
def test_B08_표_아래_안내는_footNote만():
|
||||
material = _read(B08 / "B08_Quantity_UI_Material.ts")
|
||||
assert "note(BASIS)" not in material # 「구조물 수량 기준 …」 제작 설명 줄 없음
|
||||
assert "const excel = footNote(" in material
|
||||
assert "const concreteNote = footNote(" in material
|
||||
summary = _read(B08 / "B08_Quantity_UI_Summary.ts")
|
||||
assert 'footNote(summary.notes.join(" · "))' in summary # 서버 알림 = 한 줄로 이어 붙임
|
||||
sheets = _read(B08 / "B08_Quantity_UI_Earthwork_Sheets.ts")
|
||||
assert "tableCard(haulNote(table), grid.root, averages)" in sheets # 제작 설명 줄 없음
|
||||
|
||||
|
||||
# ── 창 끝까지 셈 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_창_크기마다_한_줄_안내_뒤_창_끝():
|
||||
proc = subprocess.run(
|
||||
["node", str(ROOT / "resources" / "tester" / "helper_236_1_fill_sizes.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
rows = json.loads(proc.stdout)
|
||||
assert [r["viewport"] for r in rows] == [1080, 768, 600]
|
||||
for r in rows:
|
||||
assert r["below"] == 18 + 8 + 12 + 8 # 안내 한 줄 + 줄 사이 + 카드 + 본문 여백
|
||||
assert r["bottom"] == r["viewport"], r # 문서 맨 아래 = 창 끝 → 페이지 스크롤 0
|
||||
@@ -195,6 +195,60 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 나무(PLAN 235-1 · level.tree) — 접히지 않는 나무 안내선 · 폴더 · 파일 아이콘(공용 나무 ui_template_tree 꼴)
|
||||
* 안내선 칸 한 단 = 들여쓰기 한 칸 · 선 = 칸 가운데(윗대 아이콘 가운데) · 고정 열 칸은 sticky 가 자리 기준 */
|
||||
.ui-data-table__table td.has-tree:not(.is-frozen) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ui-data-table__guide {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(var(--spacing-8) + var(--g, 0) * var(--dt-indent));
|
||||
width: var(--dt-indent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ui-data-table__guide::before,
|
||||
.ui-data-table__guide::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
/* │ 윗대 형제가 아래에 더 있음 · ├ 형제가 아래에 더 있음 · └ 끝 형제 */
|
||||
.ui-data-table__guide::before {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ui-data-table__guide.is-end::before {
|
||||
bottom: 50%;
|
||||
}
|
||||
|
||||
.ui-data-table__guide.is-tee::after,
|
||||
.ui-data-table__guide.is-end::after {
|
||||
top: 50%;
|
||||
width: 50%;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ui-data-table__icon {
|
||||
display: inline-flex;
|
||||
margin-right: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
.ui-data-table__table td.has-tree .ui-data-table__input {
|
||||
padding-left: calc(
|
||||
var(--spacing-8) + var(--dt-lv, 0) * var(--dt-indent) + 14px + var(--spacing-4)
|
||||
);
|
||||
}
|
||||
|
||||
/* 레벨 글꼴(PLAN 228-1) — 묶음 줄(is-node) 굵게 · 크기는 레벨 · 항목 줄은 14px 보다 크지 않게(Lv1 · 2 항목 = 기본) */
|
||||
.ui-data-table__table td.ui-data-table__lv.is-node {
|
||||
font-weight: var(--font-weight-bold);
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
* 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도)
|
||||
* 글꼴(PLAN 228-1) = 칸 class is-lv1~5 · 묶음(group · 안 주면 바로 아래 줄이 더 깊은 줄) = is-node 굵게
|
||||
* 크기 Lv1 16 · Lv2 15 · Lv3 14 · Lv4 13 · Lv5 12(px) · 항목 줄은 14 보다 크지 않게
|
||||
* 나무(PLAN 235-1 · tree) = 접히지 않는 나무 안내선(├ is-tee · └ is-end · │ is-bar) + 폴더 · 파일 아이콘(공용 나무 꼴)
|
||||
* 더블클릭 입력(PLAN 235-1 · editOnDouble) = 고칠 칸도 글로 그림 · 더블클릭 · t.edit(줄) = 그 칸만 글 상자 ·
|
||||
* Enter(조합 끝난 뒤) · 초점 떠남 = 입력 끝(onEdit) — 한 번 누름 = 줄 고름 · 끌기
|
||||
* 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만
|
||||
* 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c)
|
||||
* ========================================================================== */
|
||||
@@ -76,7 +79,13 @@ export interface DataTableOptions {
|
||||
key: string;
|
||||
of: (index: number) => number | null | undefined;
|
||||
group?: (index: number) => boolean;
|
||||
/** 접히지 않는 나무 안내선 · 폴더(묶음) · 파일 아이콘(235-1) */
|
||||
tree?: boolean;
|
||||
};
|
||||
/** 참이면 고칠 칸 = 글 · 더블클릭 · edit() 때만 글 상자(235-1) */
|
||||
editOnDouble?: boolean;
|
||||
/** 지금 글 상자인 칸(editOnDouble · 표가 정함) */
|
||||
editing?: { index: number; key: string } | null;
|
||||
/** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */
|
||||
mergeSame?: string[];
|
||||
}
|
||||
@@ -86,8 +95,17 @@ export interface DataTable {
|
||||
setRows: (rows: DataRow[], total?: DataRow | null) => void;
|
||||
/** 그 줄로 스크롤하고 강조 */
|
||||
focusRow: (index: number) => void;
|
||||
/** 그 줄 칸 입력 시작(editOnDouble) — key 없거나 못 고치면 마지막 누른 열 · 첫 고칠 칸 · 못 하면 false */
|
||||
edit: (index: number, key?: string) => boolean;
|
||||
}
|
||||
|
||||
/** 나무 아이콘 — 폴더 · 항목(글꼴색을 따름) · 공용 나무(`ui_template_tree` 기본 줄)와 같은 꼴 */
|
||||
export const TREE_ICON = {
|
||||
folder:
|
||||
'<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M1.5 3.5h4l1.5 1.5h7.5v8h-13z" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
|
||||
leaf: '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M3.5 1.5h6l3 3v10h-9z M9.5 1.5v3h3" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
|
||||
};
|
||||
|
||||
const EMPTY = "-";
|
||||
const ESC: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """ };
|
||||
const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC[c]);
|
||||
@@ -169,14 +187,16 @@ function rowHtml(
|
||||
if (!span) continue; // 위 칸에 합쳐짐
|
||||
const lvc =
|
||||
lv?.key === key
|
||||
? ` ui-data-table__lv is-lv${Math.min(5, lv.n + 1)}${lv.node ? " is-node" : ""}`
|
||||
? ` ui-data-table__lv is-lv${Math.min(5, lv.n + 1)}${lv.node ? " is-node" : ""}${lv.tree === undefined ? "" : " has-tree"}`
|
||||
: "";
|
||||
// 나무(235-1) = 안내선 + 폴더 · 파일 아이콘 — 글 상자 칸은 안내선만
|
||||
const guide = lv?.key === key ? (lv.tree ?? "") : "";
|
||||
const c = spans ? ` data-c="${j}"` : "";
|
||||
if (edit !== null && editable?.(edit, key)) {
|
||||
const v = r[key];
|
||||
const raw = v === null || v === undefined ? "" : String(v);
|
||||
const tip = esc(cols[j].title ?? cols[j].label);
|
||||
tr += `<td class="is-edit${lvc}" data-num=""${c}><input class="ui-data-table__input" data-i="${edit}" data-k="${esc(key)}" value="${esc(raw)}" inputmode="decimal" aria-label="${tip}"></td>`;
|
||||
tr += `<td class="is-edit${lvc}" data-num=""${c}>${guide}<input class="ui-data-table__input" data-i="${edit}" data-k="${esc(key)}" value="${esc(raw)}" inputmode="decimal" aria-label="${tip}"></td>`;
|
||||
continue;
|
||||
}
|
||||
const [txt, empty] = formatCell(r[key], fmts[j], blank);
|
||||
@@ -197,7 +217,11 @@ function rowHtml(
|
||||
(fmts[j] ? ' data-num=""' : "") +
|
||||
(span > 1 ? ` rowspan="${span}"` : "") +
|
||||
c;
|
||||
tr += `<td${attrs}>${esc(txt)}</td>`;
|
||||
const icon =
|
||||
lv?.key === key && lv.tree !== undefined
|
||||
? `<span class="ui-data-table__icon">${lv.node ? TREE_ICON.folder : TREE_ICON.leaf}</span>`
|
||||
: "";
|
||||
tr += `<td${attrs}>${guide}${icon}${esc(txt)}</td>`;
|
||||
}
|
||||
const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra]
|
||||
.filter(Boolean)
|
||||
@@ -209,9 +233,10 @@ function rowHtml(
|
||||
: `<tr>${tr}</tr>`;
|
||||
}
|
||||
|
||||
/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · 묶음 줄인지 · spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */
|
||||
/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · 묶음 줄인지 · 나무 안내선 HTML(나무 아니면 없음) ·
|
||||
* spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */
|
||||
interface RowMore {
|
||||
lv?: { key: string; n: number; node: boolean };
|
||||
lv?: { key: string; n: number; node: boolean; tree?: string };
|
||||
spans?: number[];
|
||||
}
|
||||
|
||||
@@ -248,6 +273,25 @@ export function mergeSpans(o: DataTableOptions, from: number, to: number): numbe
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 나무 안내선(235-1) — 줄마다 칸(한 단 = 들여쓰기 한 칸 · 레벨 2 부터) span HTML.
|
||||
* 자기 칸 = 아래에 같은 레벨 형제가 더 있으면 ├(is-tee) · 끝이면 └(is-end) · 윗대 칸 = 그 윗대 형제가 아래에 더 있으면 │(is-bar) */
|
||||
export function treeGuides(levels: number[]): string[] {
|
||||
const out: string[] = new Array(levels.length);
|
||||
const more: boolean[] = []; // 레벨 d 형제가 아래에 더 있음(더 얕은 줄 전까지)
|
||||
const span = (cls: string, slot: number) =>
|
||||
`<span class="ui-data-table__guide ${cls}" style="--g:${slot}"></span>`;
|
||||
for (let i = levels.length - 1; i >= 0; i--) {
|
||||
const lv = Math.max(1, levels[i]);
|
||||
let html = "";
|
||||
for (let d = 2; d < lv; d++) if (more[d]) html += span("is-bar", d - 2);
|
||||
if (lv >= 2) html += span(more[lv] ? "is-tee" : "is-end", lv - 2);
|
||||
out[i] = html;
|
||||
more[lv] = true;
|
||||
more.length = lv + 1; // 더 깊은 레벨 = 이 줄에서 끊김
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */
|
||||
export interface RowWindow {
|
||||
from: number;
|
||||
@@ -284,6 +328,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
|
||||
const blank = o.empty ?? EMPTY;
|
||||
const pick = o.onRowClick !== undefined || o.onCellClick !== undefined;
|
||||
const spans = o.mergeSame?.length ? mergeSpans(o, from, to) : null;
|
||||
const tree = o.level?.tree ? treeGuides(o.rows.map((_r, i) => o.level!.of(i) ?? 1)) : null;
|
||||
// 더블클릭 입력(235-1) — 글 상자 = 지금 고치는 칸만
|
||||
const inputAt = o.editOnDouble
|
||||
? (i: number, key: string) =>
|
||||
o.editing?.index === i && o.editing.key === key && !!o.editable?.(i, key)
|
||||
: o.editable;
|
||||
for (let i = from; i < to; i++) {
|
||||
const lvN = o.level ? Math.max(0, (o.level.of(i) ?? 1) - 1) : 0;
|
||||
const node = o.level ? isNode(o.level, i, n) : false;
|
||||
@@ -296,12 +346,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
|
||||
blank,
|
||||
pick ? i : null,
|
||||
i,
|
||||
o.editable,
|
||||
inputAt,
|
||||
o.focus === i,
|
||||
o.rowClass?.(i),
|
||||
o.cellClass ? (key) => o.cellClass?.(i, key) : undefined,
|
||||
{
|
||||
...(o.level ? { lv: { key: o.level.key, n: lvN, node } } : {}),
|
||||
...(o.level ? { lv: { key: o.level.key, n: lvN, node, tree: tree?.[i] } } : {}),
|
||||
...(spans ? { spans: spans[i - from] } : {}),
|
||||
},
|
||||
);
|
||||
@@ -419,14 +469,62 @@ export function createDataTable(opts: DataTableOptions): DataTable {
|
||||
const key = input.dataset.k;
|
||||
setTimeout(() => state.onEdit?.(index, key, input.value));
|
||||
});
|
||||
root.addEventListener("click", (event) => {
|
||||
const row = (event.target as Element).closest<HTMLElement>("tr[data-i]");
|
||||
if (!row || !root.contains(row)) return;
|
||||
const cell = (event.target as Element).closest<HTMLTableCellElement>("td");
|
||||
/** 누른 칸 [줄, 열 키] — 본문 줄 밖 = null */
|
||||
const cellAt = (target: EventTarget | null): [number, string] | null => {
|
||||
const row = (target as Element).closest<HTMLElement>("tr[data-i]");
|
||||
if (!row || !root.contains(row)) return null;
|
||||
const cell = (target as Element).closest<HTMLTableCellElement>("td");
|
||||
const column = cell ? state.columns[cellColumn(cell)] : undefined;
|
||||
if (column) state.onCellClick?.(Number(row.dataset.i), column.key);
|
||||
state.onRowClick?.(Number(row.dataset.i));
|
||||
return [Number(row.dataset.i), column?.key ?? ""];
|
||||
};
|
||||
let lastKey = "";
|
||||
root.addEventListener("click", (event) => {
|
||||
const at = cellAt(event.target);
|
||||
if (!at) return;
|
||||
lastKey = at[1];
|
||||
if (at[1]) state.onCellClick?.(at[0], at[1]);
|
||||
state.onRowClick?.(at[0]);
|
||||
});
|
||||
const edit = (index: number, key?: string): boolean => {
|
||||
if (!state.editOnDouble || !state.editable) return false;
|
||||
const can = state.columns.map((c) => c.key).filter((k) => state.editable!(index, k));
|
||||
const k = [key, lastKey, can[0]].find((x) => x !== undefined && can.includes(x));
|
||||
if (k === undefined) return false;
|
||||
state.editing = { index, key: k };
|
||||
draw(true);
|
||||
const input = root.querySelector<HTMLInputElement>(
|
||||
`input[data-i="${index}"][data-k="${CSS.escape(k)}"]`,
|
||||
);
|
||||
input?.focus();
|
||||
input?.select();
|
||||
return true;
|
||||
};
|
||||
if (opts.editOnDouble) {
|
||||
root.addEventListener("dblclick", (event) => {
|
||||
const at = cellAt(event.target);
|
||||
if (at && state.editable?.(at[0], at[1])) edit(at[0], at[1]);
|
||||
});
|
||||
// Enter = 입력 끝(한글 조합 중 Enter 는 조합 끝) — 표에 초점 · 키가 문서까지 가지 않게(다시 입력 시작 방지)
|
||||
root.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" || event.isComposing) return;
|
||||
if (!(event.target instanceof HTMLInputElement)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
root.focus();
|
||||
});
|
||||
// 초점 떠남 = 입력 끝 — 다시 그린 같은 칸으로 초점이 돌아왔으면 그대로
|
||||
root.addEventListener("focusout", (event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!state.editing || !input.dataset?.k) return;
|
||||
setTimeout(() => {
|
||||
const now = document.activeElement as HTMLElement | null;
|
||||
const back = now && root.contains(now) && now.dataset.k === input.dataset.k;
|
||||
if (back && now.dataset.i === input.dataset.i) return;
|
||||
state.editing = null;
|
||||
draw(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
draw(true);
|
||||
if (opts.fillViewport) fillViewport(root, () => draw(false));
|
||||
return {
|
||||
@@ -441,5 +539,6 @@ export function createDataTable(opts: DataTableOptions): DataTable {
|
||||
root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3);
|
||||
draw(true);
|
||||
},
|
||||
edit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import "./ui_template_tree.css";
|
||||
import { enterMenu, leaveMenu } from "./ui_template_context_menu";
|
||||
import { TREE_ICON } from "./ui_template_data_table";
|
||||
import { el } from "./ui_template_elements";
|
||||
|
||||
export interface TreeNode {
|
||||
@@ -28,13 +29,6 @@ export interface TreeNode {
|
||||
folder?: boolean;
|
||||
}
|
||||
|
||||
/** 기본 줄 아이콘 — 폴더 · 항목(글꼴색을 따름) */
|
||||
const ICON = {
|
||||
folder:
|
||||
'<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M1.5 3.5h4l1.5 1.5h7.5v8h-13z" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
|
||||
leaf: '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M3.5 1.5h6l3 3v10h-9z M9.5 1.5v3h3" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
|
||||
};
|
||||
|
||||
export interface TreeMenuItem {
|
||||
label: string;
|
||||
run: () => void;
|
||||
@@ -130,7 +124,7 @@ export function createTreeRow(
|
||||
): HTMLButtonElement {
|
||||
const branch = !!node.children?.length || !!node.folder;
|
||||
const icon = el("span", { className: "ui-tree__icon" });
|
||||
icon.innerHTML = branch ? ICON.folder : ICON.leaf; // 고정 SVG 글만
|
||||
icon.innerHTML = branch ? TREE_ICON.folder : TREE_ICON.leaf; // 고정 SVG 글만
|
||||
const button = el("button", {
|
||||
className: `ui-tree__row ui-sidebar-row${branch ? " is-branch" : ""}`,
|
||||
attrs: { type: "button" },
|
||||
|
||||
Reference in New Issue
Block a user