Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1

This commit is contained in:
2026-11-08 08:43:18 +09:00
14 changed files with 686 additions and 176 deletions
+103 -20
View File
@@ -38,27 +38,74 @@
display: none;
}
/* 탭 폭이 좁으면(1366 화면 등) 편집 위 · 산출근거 아래 */
@container (max-width: 1240px) {
/* 산출근거(요구 683) — 왼쪽을 내려도 화면에 고정 · 길면 자기 스크롤(높이 = 스크롤 영역 · fit()) */
.lstk[data-edit="1"] .lstk__right {
position: sticky;
inset-block-start: 0;
max-height: var(--lstk-view, 100vh);
overflow: auto;
}
/* 탭 폭이 좁으면(1366 화면 등) 편집 위 · 산출근거 아래(고정 없음) */
@container (max-width: 1040px) {
.lstk__areas {
grid-template-columns: minmax(0, 1fr);
}
.lstk[data-edit="1"] .lstk__right {
position: static;
max-height: none;
overflow: visible;
}
}
/* ── 왼쪽 편집 ── */
.lstk-edit {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
gap: var(--spacing-16);
}
/* 항목(804-5) — 머리 띠(바탕 · 왼쪽 굵은 띠 · 번호) · 줄 = 들여쓰기 + 왼쪽 세로 선 · 가나다 = 한 단 더 들여씀 */
.lstk-item {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8);
border: 1px solid var(--color-border);
gap: var(--spacing-8);
}
.lstk-item.is-sub {
margin-inline-start: var(--spacing-24);
}
.lstk-item + .lstk-item.is-sub {
margin-block-start: calc(-1 * var(--spacing-8));
}
.lstk-ihead {
align-items: center;
padding: var(--spacing-4) var(--spacing-8);
border-inline-start: 4px solid var(--color-accent);
border-radius: var(--radius-sm);
background: var(--color-surface-sunken);
}
.lstk-item.is-sub > .lstk-ihead {
border-inline-start-color: var(--color-border-strong);
}
.lstk-no {
min-width: 1.5em;
font-weight: var(--font-weight-bold);
color: var(--color-text);
}
.lstk-lines {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-inline-start: var(--spacing-12);
padding-inline-start: var(--spacing-12);
border-inline-start: 2px solid var(--color-border-strong);
}
.lstk-ihead,
@@ -84,9 +131,15 @@
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8);
padding-inline-end: 120px;
padding-inline: var(--spacing-24) var(--spacing-32);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-sunken);
background: var(--color-surface-raised);
}
/* 글 · 근거 칸 = 한 줄 높이(세로 묶음에서 늘지 않게) */
.lstk-line > .lstk-f-l {
flex: none;
}
.lstk-ltools {
@@ -96,6 +149,45 @@
gap: 0;
}
/* 끌기(804-5) — 손잡이 · 끄는 것 반투명 · 놓을 자리 = 위 · 아래 선 · 빈 항목 = 점선 */
.lstk-grip {
touch-action: none;
}
.lstk-line > .lstk-grip {
position: absolute;
inset-block-start: var(--spacing-12);
inset-inline-start: var(--spacing-8);
}
.lstk-edit[data-dragging] {
cursor: grabbing;
user-select: none;
}
.is-dragging {
opacity: 0.45;
}
.lstk-line[data-drop="before"],
.lstk-item[data-drop="before"] {
box-shadow: 0 -3px 0 0 var(--color-accent);
}
.lstk-line[data-drop="after"],
.lstk-item[data-drop="after"] {
box-shadow: 0 3px 0 0 var(--color-accent);
}
.lstk-item[data-drop="in"] .lstk-empty {
outline: 2px dashed var(--color-accent);
outline-offset: 2px;
}
.lstk-res {
max-width: 100%;
}
.lstk-kind {
font-size: var(--text-caption);
font-weight: var(--font-weight-medium);
@@ -126,16 +218,11 @@
.lstk-val,
.lstk-unit,
.lstk-tag,
.lstk-hint,
.lstk-link {
.lstk-hint {
font-size: var(--text-body-sm);
color: var(--color-text-secondary);
}
.lstk-link {
color: var(--color-text-body);
}
.lstk-tag {
padding: 0 var(--spacing-4);
border: 1px solid var(--color-border);
@@ -196,13 +283,9 @@
font-size: 1.05em;
}
/* 짝 강조(679) — 왼쪽 카드 · 오른쪽 줄 같은 자리 */
/* 짝 강조(679 · 684) — 왼쪽 카드 · 그룹 머리 · 오른쪽 줄 = 같은 바탕 채우기 */
.lstk-line.is-pair,
.lstk-item.is-pair {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
.lstk-item.is-pair > .lstk-ihead,
.lstk-out tr.is-pair > td {
background: var(--color-accent-soft);
}
+80
View File
@@ -0,0 +1,80 @@
/* =============================================================================
* line_stack_drag.ts
* 줄 쌓기 왼쪽 끌어 옮기기(PLAN 804-5 · 요구 682-4) — 손잡이(⋮⋮)를 누르고 끌면 놓을 자리에 선 표시 · 놓으면 `drop`
* 줄 = 항목 안 · 항목 사이 어디든(빈 항목 = 그 안) → `k.p`(항목 k 의 p 자리) · 항목 = 항목 사이 → `p`
* 포인터 끌기(마우스 · 터치 같음) · Esc = 그만 · 자리 표시 = 클래스(`data-drop`)만 — 끄는 동안 배치 안 바뀜
* ========================================================================== */
export interface DragOpts {
/** 끄는 것 — 줄 카드 · 항목 상자 */
what: "line" | "item";
/** 끌기 영역(`.lstk-edit`) */
root: HTMLElement;
/** 끄는 요소(반투명) */
self: HTMLElement;
/** 놓음 — line `k.p` · item `p` */
drop: (to: string) => void;
}
const mid = (r: DOMRect): number => r.top + r.height / 2;
/** 포인터 y 의 놓을 자리 + 표시할 요소 · 꼴 */
function spotOf(o: DragOpts, y: number): { to: string; at: HTMLElement; side: string } | null {
const boxes = [...o.root.querySelectorAll<HTMLElement>(":scope > .lstk-item")];
if (!boxes.length) return null;
if (o.what === "item") {
const k = boxes.findIndex((b) => y < mid(b.getBoundingClientRect()));
return k < 0
? { to: String(boxes.length), at: boxes[boxes.length - 1], side: "after" }
: { to: String(k), at: boxes[k], side: "before" };
}
// 줄 — 포인터가 든 항목(위 · 아래 밖 = 처음 · 끝 항목)
let k = boxes.findIndex((b) => y < b.getBoundingClientRect().bottom);
if (k < 0) k = boxes.length - 1;
const cards = [...boxes[k].querySelectorAll<HTMLElement>(":scope .lstk-lines > .lstk-line")];
if (!cards.length) return { to: `${k}.0`, at: boxes[k], side: "in" };
const p = cards.findIndex((c) => y < mid(c.getBoundingClientRect()));
return p < 0
? { to: `${k}.${cards.length}`, at: cards[cards.length - 1], side: "after" }
: { to: `${k}.${p}`, at: cards[p], side: "before" };
}
/** 손잡이에 끌기를 붙임 */
export function attachDrag(grip: HTMLElement, o: DragOpts): void {
grip.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
grip.setPointerCapture(e.pointerId);
let spot: ReturnType<typeof spotOf> = null;
const clear = (): void => {
for (const x of o.root.querySelectorAll("[data-drop]")) x.removeAttribute("data-drop");
};
o.self.classList.add("is-dragging");
o.root.dataset.dragging = o.what;
const move = (ev: PointerEvent): void => {
clear();
spot = spotOf(o, ev.clientY);
if (spot) spot.at.dataset.drop = spot.side;
};
const end = (ok: boolean): void => {
grip.removeEventListener("pointermove", move);
grip.removeEventListener("pointerup", up);
grip.removeEventListener("pointercancel", cancel);
window.removeEventListener("keydown", esc);
clear();
o.self.classList.remove("is-dragging");
delete o.root.dataset.dragging;
if (ok && spot) o.drop(spot.to);
};
const up = (): void => end(true);
const cancel = (): void => end(false);
const esc = (ev: KeyboardEvent): void => {
if (ev.key === "Escape") end(false);
};
grip.addEventListener("pointermove", move);
grip.addEventListener("pointerup", up);
grip.addEventListener("pointercancel", cancel);
window.addEventListener("keydown", esc);
});
}
+171 -60
View File
@@ -1,33 +1,46 @@
/* =============================================================================
* line_stack_edit.ts
* 줄 쌓기 왼쪽 편집(PLAN 804-2 · 요구 670~677) — 항목(제목 · 가나다 · [+ 줄] · 지움) 안 줄 카드(위 · 아래 · 지움)
* 줄 쌓기 왼쪽 편집(PLAN 804-2 · 요구 670~677) — 항목(띠 · 번호 · 제목 · 가나다 · [+ 줄] · 접기 · X) 안 줄 카드(⋮⋮ · X)
* 차림(804-5 · 요구 682) = 항목 머리 띠 · 줄 = 들여쓰기 + 왼쪽 세로 선 · 순서 = 손잡이 끌기(`line_stack_drag.ts`) ·
* X = 확인 없이 지우고 알림 눌러 되돌리기 한 번 · 자원 이름 = 단추(누르면 찾아 넣기 창 → 바꿈)
* 줄 = 인력 · 재료 · 기계 · 다른 단가산출 · 율 · 식(금액 줄 — 이름 · 단위 · 비목 · 수량 식 · 단가 고르기 또는 식)
* · 공식 묶음(`line_stack_rows.ts`) · 글 줄
* 고침 = 부른 쪽 `change(fn)` — fn 이 문서 · 차림을 고치면 부른 쪽이 `docOf` 로 굳히고 미리 셈 · 다시 그림(입력은 change 때)
* 자원 고르기(804-3) = 찾아 넣기 창(`line_stack_pick.ts` — M02 [항목 추가] 창 그대로) · [+ 줄] 인력 · 재료 · 기계 · 다른 단가산출 =
* 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = [찾아 바꾸기]
* 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = 자원 이름 단추
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { createButton, createIconButton } from "@ui/ui_template_button";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { el } from "@ui/ui_template_elements";
import { el, showToast } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_util_formula_doc";
import type { DocAnswer } from "../common_util/common_util_formula_doc_calc";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import {
dropSyms,
docOf,
freshId,
kindOf,
moveItem,
moveLine,
newVar,
ownedOf,
stackOf,
type MoneyKind,
type StackDoc,
type StackItem,
} from "../common_util/common_util_line_stack";
import { addPicked, repick } from "../common_util/common_util_line_stack_pick";
import { placeNames } from "../common_util/common_util_line_stack_copy";
import {
addPicked,
aliasText,
repick,
tableAlias,
} from "../common_util/common_util_line_stack_pick";
import { attachDrag } from "./line_stack_drag";
import { openStackPick, type StackTake } from "./line_stack_pick";
import { buildBlock, insertTpl, type Tpl } from "./line_stack_rows";
import { buildBlock } from "./line_stack_rows";
import { lt, type LsKey } from "./line_stack_text";
export interface EditHooks {
@@ -41,10 +54,8 @@ export interface EditHooks {
name: (key: string) => string;
/** 공식 묶음 펼친 자리(`항목.줄`) — 화면 상태만 */
open: Set<string>;
templates: () => Tpl[];
/** 줄 · 항목 누름 — 오른쪽 짝 강조(679) */
pair: (loc: string) => void;
saveTpl: (tpl: Tpl) => void;
/** M02 단가산출(804-4) — 다른 단가산출 · 일위대가 = 복사해 펼침 · M01 = 없음(M01 전체만) */
take?: StackTake;
}
@@ -65,8 +76,8 @@ const field = (
f.input.addEventListener("change", () => set(f.input.value));
return f.root;
};
const btn = (label: string, onClick: () => void, variant: "ghost" | "danger-ghost" = "ghost") =>
createButton({ label, variant, size: 24, onClick });
const btn = (label: string, onClick: () => void) =>
createButton({ label, variant: "ghost", size: 24, onClick });
const KIND_KEY: Record<MoneyKind, LsKey> = {
인력: "K_인력",
@@ -104,10 +115,14 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
]),
],
});
// 표 값 기호 = 화면 이름(요구 683) — 칸 글은 화면 이름 ↔ 기호로 오감
const alias = tableAlias(d);
const shown = (t: unknown): string => aliasText(String(t ?? ""), alias);
const real = (t: string): string => aliasText(t.trim(), alias, true);
const qty = field(
lt("Qty"),
line.수량 ?? "",
(t) => put((l) => (t.trim() ? (l.수량 = t.trim()) : delete l.수량)),
shown(line.수량),
(t) => put((l) => (t.trim() ? (l.수량 = real(t)) : delete l.수량)),
"l",
);
const price: HTMLElement[] = [];
@@ -124,18 +139,43 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
: mat
? String(mat.검색어 ?? mat.이름 ?? mat.상세구분 ?? "")
: "";
price.push(
el("span", { className: "lstk-link", text: label || "—" }),
btn(lt("Repick"), () =>
openStackPick(kind, (p) => h.change((doc) => repick(doc, i, own, kind, p))),
),
);
} else price.push(field(lt("Price"), line.단가, (t) => put((l) => (l.단가 = t.trim())), "l"));
const size = (line as { 규격?: string }).규격;
const pick = createButton({
label: (size && !label.includes(size) ? `${label} ${size}` : label) || "—",
variant: "ghost",
tone: "link",
size: 24,
iconStart: "search",
onClick: () => openStackPick(kind, (p) => h.change((doc) => repick(doc, i, own, kind, p))),
});
pick.classList.add("lstk-res");
pick.title = lt("Repick");
pick.dataset.repick = String(i);
price.push(pick);
} else
price.push(field(lt("Price"), shown(line.단가), (t) => put((l) => (l.단가 = real(t))), "l"));
const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭;
// 율 밑 번지(복사해 온 조각) — 칸 아래 읽는 이름(804-5)
const places = /번지\./.test(`${line.단가} ${line.수량 ?? ""}`) ? placeNames(d, h.items) : null;
const read = (t: unknown): string =>
shown(t).replace(/번지\.([^.\s()]+)\./g, (all, p: string) =>
places?.[p] ? `${places[p]} ` : all,
);
return [
el("span", { className: "lstk-kind", text: lt(KIND_KEY[kind]) }),
head,
el("div", { className: "lstk-row", children: [qty, el("span", { text: "×" }), ...price] }),
el("div", {
className: "lstk-row",
children: [qty, el("span", { className: "lstk-unit", text: "×" }), ...price],
}),
...(places
? [
el("div", {
className: "lstk-hint",
text: `= ${[line.수량, line.단가].filter(Boolean).map(read).join(" × ")}`,
}),
]
: []),
...(why ? [el("div", { className: "lstk-miss", text: why })] : []),
];
}
@@ -148,11 +188,6 @@ function addLine(h: EditHooks, at: number, kind: string): void {
(p) => h.change((d, items) => void addPicked(d, items[at], kind as MoneyKind, p)),
h.take,
);
if (kind.startsWith("tpl:")) {
const t = h.templates().find((x) => x.id === kind.slice(4));
if (t) h.change((d, items) => items[at].줄.push(insertTpl(d, t)));
return;
}
h.change((d, items) => {
const it = items[at];
const n = (d.금액 ??= []).length;
@@ -175,15 +210,6 @@ function addLine(h: EditHooks, at: number, kind: string): void {
});
}
/** 줄 옮김 — 항목 끝을 넘으면 옆 항목으로 */
function move(items: StackItem[], i: number, j: number, by: -1 | 1): void {
const [line] = items[i].줄.splice(j, 1);
const to = j + by;
if (to < 0 && i > 0) items[i - 1].줄.push(line);
else if (to > items[i].줄.length && i < items.length - 1) items[i + 1].줄.unshift(line);
else items[i].줄.splice(Math.max(0, Math.min(to, items[i].줄.length)), 0, line);
}
/** 줄 지움 — 금액 줄은 그 줄이 가진 연결 기호까지 · 공식 묶음은 기호까지 */
function dropLine(d: StackDoc, items: StackItem[], i: number, j: number): void {
const line = items[i].줄[j];
@@ -205,9 +231,6 @@ export function buildEdit(h: EditHooks): HTMLElement {
})),
{ value: "공식", text: lt("K_새공식") },
{ value: "틀", text: lt("K_틀") },
...h
.templates()
.map((t) => ({ value: `tpl:${t.id}`, text: `${lt("K_공식")} — ${t.name}` })),
{ value: "글", text: lt("K_글") },
],
value: "",
@@ -221,10 +244,67 @@ export function buildEdit(h: EditHooks): HTMLElement {
});
return pick.root;
};
// X 지움 — 확인 없이 · 알림 누르면 되돌림(그 뒤 다른 고침이 없을 때만)
const drop = (what: string, fn: (d: StackDoc, items: StackItem[]) => void): void => {
const snap = JSON.stringify(h.doc);
let after = "";
h.change((d, items) => {
fn(d, items);
after = JSON.stringify(docOf(d, items));
});
showToast(`${what || lt("K_줄")} ${lt("Dropped")}`, "info", 6000, () =>
h.change((d, items) => {
if (JSON.stringify(docOf(d, items)) !== after)
return void showToast(lt("Undo_Late"), "warning");
const back = JSON.parse(snap) as StackDoc;
for (const k of Object.keys(d)) delete (d as Record<string, unknown>)[k];
Object.assign(d, back);
items.splice(0, items.length, ...stackOf(back));
}),
);
};
const x = (title: string, onClick: () => void): HTMLElement => {
const b = createIconButton({ icon: "x", title, size: 24, onClick });
b.classList.add("lstk-x");
return b;
};
const grip = (): HTMLElement =>
el("span", { className: "ui-list-row__grip lstk-grip", attrs: { title: lt("Drag") } });
const root = el("div", { className: "lstk-edit" });
/** 항목 접힘 표시(화면 상태)를 항목 차례 바뀜에 맞춤 */
const refold = (fn: (flags: boolean[]) => void): void => {
const flags = h.items.map((_, k) => h.open.has(`fold:${k}`));
fn(flags);
h.items.forEach((_, k) => h.open.delete(`fold:${k}`));
flags.forEach((f, k) => f && h.open.add(`fold:${k}`));
};
// 항목 번호 — 산출근거와 같은 셈(제목 있는 항목 1. 2. · 가나다 = 가. 나.)
let n = 0;
let sub = 0;
const nos = h.items.map((it) => {
if (!it.제목.trim()) return "";
const no = it.가나다 ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"}.` : `${++n}.`;
if (!it.가나다) sub = 0;
return no;
});
const items = h.items.map((it, i) => {
const folded = h.open.has(`fold:${i}`);
const itemGrip = grip();
const head = el("div", {
className: "lstk-ihead",
children: [
itemGrip,
createIconButton({
icon: folded ? "chevronRight" : "chevronDown",
title: folded ? lt("Unfold_Item") : lt("Fold_Item"),
size: 24,
onClick: () => {
if (folded) h.open.delete(`fold:${i}`);
else h.open.add(`fold:${i}`);
h.change(() => undefined);
},
}),
el("b", { className: "lstk-no", text: nos[i] }),
field(lt("Title"), it.제목, (t) => h.change((_, items) => (items[i].제목 = t.trim())), "l"),
createCheckbox(lt("Gana"), {
checked: !!it.가나다,
@@ -232,25 +312,29 @@ export function buildEdit(h: EditHooks): HTMLElement {
onChange: (c) =>
h.change((_, items) => (c ? (items[i].가나다 = true) : delete items[i].가나다)),
}).root,
...(folded
? [el("span", { className: "lstk-hint", text: `${lt("K_줄")} ${it.줄.length}` })]
: []),
add(i),
btn(
lt("Del_Item"),
() =>
h.change((d, items) => {
for (let j = items[i].줄.length - 1; j >= 0; j--) dropLine(d, items, i, j);
items.splice(i, 1);
}),
"danger-ghost",
x(lt("Del_Item"), () =>
drop(it.제목.trim() || lt("Item"), (d, items) => {
refold((f) => f.splice(i, 1));
for (let j = items[i].줄.length - 1; j >= 0; j--) dropLine(d, items, i, j);
items.splice(i, 1);
}),
),
],
});
const lines = it.줄.map((line, j) => {
const lineGrip = grip();
const tools = el("div", {
className: "lstk-ltools",
children: [
btn("↑", () => h.change((_, items) => move(items, i, j, -1))),
btn("↓", () => h.change((_, items) => move(items, i, j, 1))),
btn(lt("Del"), () => h.change((d, items) => dropLine(d, items, i, j)), "danger-ghost"),
x(lt("Del"), () =>
drop(line.꼴 === "금액" ? String(h.doc.금액?.[line.i]?.이름 ?? "") : "", (d, items) =>
dropLine(d, items, i, j),
),
),
],
});
const body =
@@ -266,29 +350,56 @@ export function buildEdit(h: EditHooks): HTMLElement {
),
]
: buildBlock(h, i, j, line);
const card = el("div", { className: `lstk-line k-${line.꼴}`, children: [tools, ...body] });
const card = el("div", {
className: `lstk-line k-${line.꼴}`,
children: [lineGrip, tools, ...body],
});
card.dataset.loc = `${i}.${j}`;
card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`));
attachDrag(lineGrip, {
what: "line",
root,
self: card,
drop: (to) => {
const [k, p] = to.split(".").map(Number);
h.change((_, items) => moveLine(items, i, j, k, p));
},
});
return card;
});
const box = el("div", {
className: "lstk-item",
className: `lstk-item${it.가나다 ? " is-sub" : ""}${folded ? " is-folded" : ""}`,
attrs: { "data-loc": String(i) },
children: [
head,
...(lines.length
? lines
: [el("div", { className: "lstk-empty", text: lt("Empty_Item") })]),
...(folded
? []
: [
el("div", {
className: "lstk-lines",
children: lines.length
? lines
: [el("div", { className: "lstk-empty", text: lt("Empty_Item") })],
}),
]),
],
});
head.addEventListener("pointerdown", () => h.pair(String(i)));
attachDrag(itemGrip, {
what: "item",
root,
self: box,
drop: (to) => {
const p = Number(to);
refold((f) => moveItem(f, i, p));
h.change((_, items) => moveItem(items, i, p));
},
});
return box;
});
return el("div", {
className: "lstk-edit",
children: [
...items,
btn(lt("Add_Item"), () => h.change((_, items) => items.push({ 제목: "", 줄: [] }))),
],
});
root.append(
...items,
btn(lt("Add_Item"), () => h.change((_, items) => items.push({ 제목: "", 줄: [] }))),
);
return root;
}
+50 -15
View File
@@ -29,8 +29,9 @@ import {
subInputs,
type StackDoc,
} from "../common_util/common_util_line_stack";
import { stackLevels } from "../common_util/common_util_line_stack_pick";
import { katexNode, symTex, tex, valTex } from "./line_stack_tex";
import { placeNames } from "../common_util/common_util_line_stack_copy";
import { shownUnit, stackLevels, tableAlias } from "../common_util/common_util_line_stack_pick";
import { katexNode, symTex, tex, valTex, type TexCtx } from "./line_stack_tex";
import { lt } from "./line_stack_text";
type Cols = Partial<Record<"sum" | (typeof COSTS)[number], number>>;
@@ -61,12 +62,20 @@ const won = (n: number): string => eqText(Math.round(n));
const one = (n: number): string =>
n.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const MAX_DEPTH = 3;
const LONG_SUM = 4; // 합계 덧셈 항이 이보다 많으면 항목 소계끼리(경쟁사 「노무비 : 2,523.3 + 919.5 = 3,442」)
/** 식 글 → 조판(못 풀면 글) */
function exprNode(text: unknown, side: "sym" | "val", vals: Values, inline = false, cost?: string) {
function exprNode(
text: unknown,
side: "sym" | "val",
vals: Values,
inline = false,
cost?: string,
more: Partial<TexCtx> = {},
) {
const n = nodeOf(text);
return n
? katexNode(tex(n, { side, vals, inline, cost }))
? katexNode(tex(n, { side, vals, inline, cost, ...more }))
: el("span", { text: String(text ?? "") });
}
const hasVar = (text: unknown): boolean => {
@@ -93,7 +102,9 @@ function rows(
void out.push({ ...r, depth, lv, loc, ...(depth ? { cols: undefined } : {}) });
const shown = new Set(reach(doc, vals)); // 지금 탄 갈래로 닿는 기호만
const steps = lineMap(doc);
const unit = (id: string): string => vars.get(id)?.단위 ?? "";
const place = placeNames(doc, stackOf(doc)); // 번지 읽는 이름(804-5)
const unit = (id: string): string => shownUnit(vars.get(id)?.단위);
const alias = tableAlias(doc); // 표 값 기호 화면 이름(요구 683)
const subOf = (id: string): FormulaDoc | null => {
const k = vars.get(id)?.연결;
if (k?.갈래 !== "단가산출" || !k.키 || k.키.includes("{")) return null;
@@ -143,7 +154,7 @@ function rows(
for (const id of ids.filter((x) => shown.has(x))) {
const v = vals[id];
const t = lines.get(id);
const head = katexNode(symTex(id));
const head = katexNode(symTex(alias[id] ?? id));
if (t !== undefined && hasVar(t)) {
flush();
const res = isNum(v)
@@ -151,8 +162,8 @@ function rows(
: missing(a, id);
// 기호식 = 값식 = 결과 — 같은 꼴이 이어지면 한 번만(만약이 수 하나를 탄 줄)
const n = nodeOf(t)!;
const sym = tex(n, { side: "sym", vals });
const val = tex(n, { side: "val", vals });
const sym = tex(n, { side: "sym", vals, alias });
const val = tex(n, { side: "val", vals, alias });
const same = isNum(v) && val === valTex(v);
push({
h: [
@@ -180,7 +191,10 @@ function rows(
expand(id);
continue;
}
const expr = t !== undefined && !isNum(Number(t)) ? [" = ", exprNode(t, "sym", vals)] : [];
const expr =
t !== undefined && !isNum(Number(t))
? [" = ", exprNode(t, "sym", vals, false, undefined, { alias })]
: [];
const cell = el("span", {
className: "lstk-pack",
children: [
@@ -206,16 +220,29 @@ function rows(
단가?: unknown;
};
const scope: Values = { ...vals, ...moneyScope(a.줄, i) };
const at: Record<string, Values> = {}; // 앞 줄 번지 합(계산기와 같음 — 번지 값을 값 식에 · 804-5)
money.slice(0, i).forEach((m, k) => {
if (m.기호) scope[m.기호] = (a.줄?.[k] as { 금액?: unknown } | undefined)?.금액; // 앞 금액 줄 기호 = 그 줄 금액
const got = a.줄?.[k] as { 금액?: unknown; 비목?: Values } | undefined;
if (m.기호) scope[m.기호] = got?.금액; // 앞 금액 줄 기호 = 그 줄 금액
if (m.번지)
for (const c of COSTS) {
const box = (at[String(m.번지)] ??= { 노무비: 0, 재료비: 0, 경비: 0 });
if (isNum(got?.비목?.[c]))
box[c] = Math.round(((box[c] as number) + (got!.비목![c] as number)) * 1e6) / 1e6;
}
});
if (Object.keys(at).length) scope.번지 = at;
const name = [line.이름, (line as { 규격?: string }).규격].filter(Boolean).join(" ");
const qty =
line.수량 !== undefined && line.수량 !== null && line.수량 !== "" ? line.수량 : null;
const qtyNode = (cost?: string): (string | HTMLElement)[] =>
qty === null
? []
: [" × ", exprNode(qty, "val", scope, true, cost), line.단위 ? ` ${line.단위}` : ""];
: [
" × ",
exprNode(qty, "val", scope, true, cost, { alias }),
line.단위 ? ` ${line.단위}` : "",
];
if (r.까닭 && r.금액 === null) {
push({ h: [`${name} : — `, el("span", { className: "lstk-miss", text: r.까닭 })] });
return { sub: false, cols: {} };
@@ -244,8 +271,12 @@ function rows(
const amt = r.금액 ?? 0;
const price =
hasVar(line.단가) && kindOf(doc, i) === "율"
? [exprNode(line.단가, "sym", scope, true), " = ", exprNode(line.단가, "val", scope, true)]
: [exprNode(line.단가, "val", scope, true)];
? [
exprNode(line.단가, "sym", scope, true, undefined, { place, alias }),
" = ",
exprNode(line.단가, "val", scope, true, undefined, { alias }),
]
: [exprNode(line.단가, "val", scope, true, undefined, { alias })];
const c = String(line.비목);
push({
h: [`${name} : `, ...price, ...qtyNode(), ` = ${one(amt)}`],
@@ -256,6 +287,7 @@ function rows(
const items = stackOf(doc);
const terms: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] };
const perItem: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] }; // 덧셈이 길면 항목 소계끼리(804-5)
let n = 0;
let sub = 0;
const levels = stackLevels(items);
@@ -291,7 +323,10 @@ function rows(
const s = Math.round((sum.노무비 + sum.재료비 + sum.경비) * 10) / 10;
push({ h: [`${lt("Sub_Sum")} : ${one(s)}`], cols: { sum: s, ...sum }, cls: "subsum" });
}
for (const c of COSTS) terms[c].push(...(hasSub ? (sum[c] ? [sum[c]] : []) : own[c]));
for (const c of COSTS) {
terms[c].push(...(hasSub ? (sum[c] ? [sum[c]] : []) : own[c]));
if (sum[c]) perItem[c].push(sum[c]);
}
}
lv = base + 1;
loc = undefined;
@@ -310,7 +345,7 @@ function rows(
push({ h: [`${++n}. ${lt("Total")}`], cls: "item" });
lv = base + 2;
for (const c of COSTS) {
const t = terms[c];
const t = terms[c].length > LONG_SUM ? perItem[c] : terms[c];
const v = a[c];
const body = !isNum(v)
? "—"
+35 -51
View File
@@ -3,14 +3,14 @@
* 줄 쌓기 공식 묶음(PLAN 804-2 · 요구 675) — 「이름 = 값」 · 「이름 = 식」 줄 묶음 · 틀 이름표 · [식으로 풀기] · [틀로 저장]
* 접힘 = 값 줄은 값 칸(연결 값을 고쳐 적으면 숫자 = 고정값 · 요구 664) · 식 줄은 「이름 = 식 = 값」 글 · 단가산출 결과 기호 = [식으로 풀기]
* 풀림 = 줄마다 이름 · 식 · 단위 칸 + 지움 · [+ 줄] — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) · 내 틀 = 이 브라우저 저장(localStorage) — 꼴이 바뀌면
* 「내 틀 (틀 이름 에서 변경)」 · 빈 틀 = [찾아 넣기](돈 아닌 단가산출 결과 — 작업량 등)
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 ·
* 빈 틀 = [찾아 넣기](M01 단가산출 결과 — 작업량 등) · [틀로 저장] · 내 틀 목록 걷음(요구 685) · 표 값만 = 머리 「표 값」
* 표 값(804-3) = [표 · 칸] 단추 → 표 고르기 창(`line_stack_pick.ts`) · 값 칸 누름 = 그 줄 · 칸(`pickCell`)
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { askText, el, showToast } from "@ui/ui_template_elements";
import { el, showToast } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import {
DocError,
@@ -27,32 +27,25 @@ import {
setRow,
shapeOf,
unfold,
type StackDoc,
type StackItem,
type StackLine,
} from "../common_util/common_util_line_stack";
import { boundConds, currentRow, pickCell } from "../common_util/common_util_line_stack_pick";
import {
aliasText,
boundConds,
currentRow,
pickCell,
shownUnit,
tableAlias,
} from "../common_util/common_util_line_stack_pick";
import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
import type { EditHooks } from "./line_stack_edit";
import { openTablePick } from "./line_stack_pick";
import { lt } from "./line_stack_text";
/** 내 틀 — 기호 · 식 조각(이 브라우저) */
export interface Tpl {
id: string;
name: string;
문서: FormulaDoc;
}
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
type Block = Extract<StackLine, { 꼴: "공식" }>;
/** 틀 넣기 — 새 공식 묶음 줄 */
export function insertTpl(d: StackDoc, t: Tpl): Block {
const moved = unfold(d, t.문서);
return { 꼴: "공식", 기호: moved, 틀: `my:${t.id}`, 틀꼴: shapeOf(d, moved) };
}
const LINK_TAG: Record<string, string> = {
표: "표",
요소: "요소",
@@ -81,9 +74,15 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
const vars = new Map((d.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Map((d.식 ?? []).map((l) => [String(l.기호 ?? ""), String(l.식 ?? "")]));
const blockOf = (items: StackItem[]): Block => items[i].줄[j] as Block;
// 표 값 기호 = 화면 이름(요구 683 · 문서 기호 그대로) — 칸 글은 화면 이름 ↔ 기호로 오감
const alias = tableAlias(d);
const show = (id: string): string => alias[id] ?? id;
const shownText = (t: string): string => aliasText(t, alias);
const realText = (t: string): string => aliasText(t, alias, true);
const valText = (id: string): string => {
const v = vals[id];
if (isNum(v) || typeof v === "string") return `${eqText(v)} ${vars.get(id)?.단위 ?? ""}`.trim();
if (isNum(v) || typeof v === "string")
return `${eqText(v)} ${shownUnit(vars.get(id)?.단위)}`.trim();
if (v && typeof v === "object")
return ["노무비", "재료비", "경비"]
.map((c) => `${c} ${eqText((v as Record<string, unknown>)[c])}`)
@@ -93,12 +92,11 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
// ── 이름표 ──
const tplName = (k: string): string =>
k.startsWith("my:")
? (h.templates().find((t) => `my:${t.id}` === k)?.name ?? lt("My_Tpl"))
: (h.subDoc(k)?.이름 ?? (h.name(k) || k));
k.startsWith("my:") ? lt("My_Tpl") : (h.subDoc(k)?.이름 ?? (h.name(k) || k)); // my: = 옛 내 틀(걷음 · 요구 685)
const changed = !!line.틀 && !!line.틀꼴 && shapeOf(d, line.기호) !== line.틀꼴;
const tableOnly = line.기호.every((id) => vars.get(id)?.연결?.갈래 === "표" && !lines.has(id));
const label = !line.틀
? lt("K_공식")
? lt(tableOnly && line.기호.length ? "K_표값" : "K_공식")
: changed
? `${lt("My_Tpl")} (${tplName(line.틀)} ${lt("Changed_From")})`
: tplName(line.틀);
@@ -116,28 +114,6 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
h.change(() => undefined);
},
}),
createButton({
label: lt("Save_Tpl"),
variant: "ghost",
size: 24,
onClick: () =>
void askText({ message: lt("Tpl_Name"), value: label }).then((name) => {
if (!name?.trim()) return;
const ids = new Set(line.기호);
const id = `t${Date.now()}`;
h.saveTpl({
id,
name: name.trim(),
문서: {
변수: clone((d.변수 ?? []).filter((v) => ids.has(varId(v)))),
식: clone((d.식 ?? []).filter((l) => ids.has(String(l.기호 ?? "")))),
},
});
h.change((doc, items) =>
Object.assign(blockOf(items), { 틀: `my:${id}`, 틀꼴: shapeOf(doc, line.기호) }),
);
}),
}),
],
});
head.dataset.tpl = changed ? "changed" : line.틀 ? "tpl" : "";
@@ -246,16 +222,20 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
}),
}),
);
const name = el("b", { className: "lstk-sym", text: id });
const name = el("b", { className: "lstk-sym", text: show(id) });
const valueOf =
expr ??
(link?.갈래 === "숫자" ? String(link.값 ?? "") : isNum(vals[id]) ? String(vals[id]) : "");
let body: (HTMLElement | string)[];
if (open)
body = [
input(id, lt("Row_Name"), "lstk-f-s", (t) => renameRow(id, t)),
input(show(id), lt("Row_Name"), "lstk-f-s", (t) =>
renameRow(id, t.trim() === show(id) ? id : t),
),
"=",
input(valueOf, lt("Row_Expr"), "lstk-f-l", (t) => h.change((doc) => setRow(doc, id, t))),
input(shownText(valueOf), lt("Row_Expr"), "lstk-f-l", (t) =>
h.change((doc) => setRow(doc, id, realText(t))),
),
input(v?.단위 ?? "", lt("Unit"), "lstk-f-s", (t) =>
h.change((doc) => {
let x = doc.변수?.find((y) => varId(y) === id);
@@ -274,15 +254,19 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
else if (expr !== undefined || sub || link?.갈래 === "단가산출")
body = [
name,
`= ${expr ?? ""}${expr ? " =" : ""}`,
`= ${shownText(expr ?? "")}${expr ? " =" : ""}`,
el("span", { className: "lstk-val", text: valText(id) }),
];
else
body = [
name,
"=",
input(valueOf, valText(id), "lstk-f-m", (t) => h.change((doc) => setRow(doc, id, t))),
el("span", { className: "lstk-unit", text: v?.단위 ?? "" }),
input(shownText(valueOf), valText(id), "lstk-f-m", (t) =>
h.change((doc) => setRow(doc, id, realText(t))),
),
...(shownUnit(v?.단위)
? [el("span", { className: "lstk-unit", text: shownUnit(v?.단위) })]
: []),
];
const row = el("div", {
className: `lstk-frow ${open ? "is-open" : ""}`,
+8 -11
View File
@@ -39,7 +39,6 @@ import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy
import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { buildEdit } from "./line_stack_edit";
import { buildOut } from "./line_stack_out";
import type { Tpl } from "./line_stack_rows";
import { lt } from "./line_stack_text";
import "./formula_doc.css";
import "./line_stack.css";
@@ -66,7 +65,6 @@ interface Loaded {
판?: string;
}
const TPL_KEY = "lineStack.myTemplates";
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
export function createStackScreen(opt: StackScreenOptions) {
@@ -80,6 +78,13 @@ export function createStackScreen(opt: StackScreenOptions) {
attrs: { "data-line-stack": "" },
children: [head, notes, el("div", { className: "lstk__areas", children: [left, right] }), foot],
});
// 산출근거 고정 높이(요구 683) — 스크롤 조상(없으면 창) 높이 · 넓을 때만 고정(line_stack.css)
const fit = (): void => {
let p = root.parentElement;
while (p && !/(auto|scroll)/.test(getComputedStyle(p).overflowY)) p = p.parentElement;
root.style.setProperty("--lstk-view", `${p?.clientHeight ?? innerHeight}px`);
};
window.addEventListener("resize", fit);
const cp = opt.copy;
let key = "";
let shown: string | null = null; // 연 키(같은 키 다시 그림 = 안 읽음 · 탭 바꿈 = force)
@@ -136,13 +141,6 @@ export function createStackScreen(opt: StackScreenOptions) {
/* 캐시가 막혀도 화면 안 고침은 남음 */
}
};
const templates = (): Tpl[] => {
try {
return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]") as Tpl[];
} catch {
return [];
}
};
// ── 셈 ──
const usable = (): Record<string, unknown> => {
@@ -323,9 +321,7 @@ export function createStackScreen(opt: StackScreenOptions) {
subDoc,
name: (k) => cache.names.get(k) ?? "",
open: blocks,
templates,
pair: (loc) => pair(loc, "left"),
saveTpl: (t) => localStorage.setItem(TPL_KEY, JSON.stringify([...templates(), t])),
take: takeCtx(),
}),
]
@@ -348,6 +344,7 @@ export function createStackScreen(opt: StackScreenOptions) {
);
foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)]));
pair(paired);
fit();
};
const take = (got: Loaded): void => {
+20 -7
View File
@@ -19,6 +19,10 @@ export interface TexCtx {
inline?: boolean;
/** 묶음 값의 비목(기계 · 단가산출 줄 노 · 재 · 경) */
cost?: string;
/** 번지 읽는 이름(804-5) — 「번지.복사N.노무비」 → 「항목 이름 노무비」 */
place?: Record<string, string>;
/** 표 값 기호 화면 이름(요구 683 — 「Q_수량2^표」 → 「Q_작업반장」) */
alias?: Record<string, string>;
}
const HIDE = new Set(["반올림", "올림", "버림"]);
@@ -69,6 +73,14 @@ export const valTex = (v: unknown): string => {
return isNum(v) ? t.replace(/,/g, "{,}") : txt(t);
};
/** 칸 값 — `번지.복사1.노무비` 처럼 겹친 칸까지 */
const look = (n: Node, vals: Values): unknown => {
if (n[0] === "var") return vals[n[1]];
if (n[0] !== "field") return undefined;
const v = look(n[1], vals);
return v && typeof v === "object" ? (v as Values)[n[2]] : undefined;
};
const paren = (t: string): string => `\\left(${t}\\right)`;
const miss = (t: string): string => `\\htmlClass{lstk-miss}{${t}}`;
@@ -80,19 +92,20 @@ export function tex(n: Node, c: TexCtx): string {
case "str":
return txt(`'${n[1]}'`);
case "var": {
if (c.side === "sym") return symTex(n[1]);
const id = c.alias?.[n[1]] ?? n[1];
if (c.side === "sym") return symTex(id);
let v = c.vals[n[1]];
if (v && typeof v === "object" && c.cost) v = (v as Values)[c.cost];
return v === null || v === undefined || typeof v === "object"
? miss(symTex(n[1]))
: valTex(v);
return v === null || v === undefined || typeof v === "object" ? miss(symTex(id)) : valTex(v);
}
case "field": {
if (c.side === "val" && n[1][0] === "var") {
const v = c.vals[n[1][1]];
const got = v && typeof v === "object" ? (v as Values)[n[2]] : undefined;
const [, of, key] = n;
if (c.side === "val") {
const got = look(n, c.vals);
if (isNum(got)) return valTex(got);
}
if (of[0] === "field" && of[1][0] === "var" && of[1][1] === "번지" && c.place?.[of[2]])
return txt(`${c.place[of[2]]} ${key}`);
return `${n[1][0] === "var" ? symTex(n[1][1]) : go(n[1])}.${txt(n[2])}`;
}
case "neg":
+13 -9
View File
@@ -11,13 +11,18 @@ const TEXT = {
Total: ["합 계", "Total"],
Sum: ["계", "Sum"],
Sub_Sum: ["소 계", "Subtotal"],
Add_Item: ["+ 항목", "+ Item"],
Add_Item: ["+ 그룹", "+ Group"],
Add_Line: ["+ 줄", "+ Line"],
Del_Item: ["항목 지움", "Delete item"],
Del_Item: ["그룹 지움", "Delete group"],
Del: ["지움", "Delete"],
Up: ["위로", "Up"],
Down: ["아래로", "Down"],
Title: ["항목 이름(비우면 번호 없음)", "Item title"],
Item: ["그룹", "Group"],
K_줄: ["줄", "Line"],
Drag: ["끌어 옮기기", "Drag to move"],
Fold_Item: ["그룹 접기", "Collapse group"],
Unfold_Item: ["그룹 펼치기", "Expand group"],
Dropped: ["지움 — 눌러 되돌리기", "deleted — click to undo"],
Undo_Late: ["그 뒤 고친 것이 있어 못 되돌림", "Changed since — cannot undo"],
Title: ["그룹 이름(비우면 번호 없음)", "Group title"],
Gana: ["가나다", "Sub"],
K_인력: ["인력", "Labor"],
K_재료: ["재료", "Material"],
@@ -28,6 +33,7 @@ const TEXT = {
Take_Failed: ["복사 못 함", "Copy failed"],
K_율: ["율 · 식", "Rate"],
K_공식: ["공식", "Formula"],
K_표값: ["표 값", "Table values"],
K_새공식: ["공식 — 새 공식(빈 묶음)", "Formula — new"],
K_틀: ["공식 — 틀 찾기(단가산출 결과)", "Formula — template"],
K_글: ["글 · 근거", "Text"],
@@ -40,10 +46,8 @@ const TEXT = {
Find: ["찾기", "Find"],
Pick: ["고름 ▾", "Pick ▾"],
None_Found: ["찾은 것 없음", "Nothing found"],
Unfold: ["식으로 풀기", "Unfold"],
Unfold: ["식 고치기", "Edit formula"],
Fold: ["접기", "Fold"],
Save_Tpl: ["틀로 저장", "Save as template"],
Tpl_Name: ["틀 이름", "Template name"],
New_Formula: ["새 공식", "New formula"],
My_Formula: ["내 공식", "My formula"],
My_Tpl: ["내 틀", "My template"],
@@ -53,7 +57,7 @@ const TEXT = {
Row_Expr: ["값 또는 식", "Value or expr"],
Bad_Name: ["못 쓰는 이름 · 같은 이름 있음", "Bad or duplicate name"],
Empty_Item: ["줄 없음 — [+ 줄]", "No lines — [+ Line]"],
Empty: ["빈 단가산출 — [+ 항목] 으로 시작", "Empty — start with [+ Item]"],
Empty: ["빈 단가산출 — [+ 그룹] 으로 시작", "Empty — start with [+ Group]"],
Text_Ph: ["< 품셈 근거 > · ※ 메모 · 별산", "Note"],
Notes: [
"줄 쌓기에서 못 고치는 칸(보존됨 · 수식 탭에서 고침)",
+13
View File
@@ -318,6 +318,19 @@ export function dropSyms(doc: FormulaDoc, ids: string[]): void {
doc.식 = (doc.식 ?? []).filter((l) => !gone.has(String(l.기호 ?? "")));
}
/** 끌어 옮김(804-5) — 줄 `[i, j]` → 항목 k 의 p 자리(빼기 전 차례 · 같은 항목 아래로면 하나 당김) */
export function moveLine(items: StackItem[], i: number, j: number, k: number, p: number): void {
const [line] = items[i].줄.splice(j, 1);
if (!line || !items[k]) return void (line && items[i].줄.splice(j, 0, line));
items[k].줄.splice(Math.min(i === k && p > j ? p - 1 : p, items[k].줄.length), 0, line);
}
/** 끌어 옮김(804-5) — 항목 i → p 자리(빼기 전 차례) */
export function moveItem<T>(items: T[], i: number, p: number): void {
const [it] = items.splice(i, 1);
if (it !== undefined) items.splice(Math.min(p > i ? p - 1 : p, items.length), 0, it);
}
/** 공식 줄 값 · 식 고침 — 수 = 숫자 연결 · 식 = 식 줄 */
export function setRow(doc: FormulaDoc, id: string, text: string): void {
const t = text.trim().replace(/,/g, "");
@@ -195,3 +195,29 @@ export function addTaken(doc: StackDoc, items: StackItem[], t: Taken): void {
for (const r of rates) pushMoney({ ...r, 단가: rescope(r.단가, places) as string });
}
}
/** 번지 읽는 이름(804-5) — 산출근거 「번지.복사N.노무비」 대신 · 그 번지 줄만 든 항목 제목 · 없으면 줄 이름(둘까지 · 「외 n」) */
export function placeNames(doc: StackDoc, items: StackItem[]): Record<string, string> {
const money = doc.금액 ?? [];
const out: Record<string, string> = {};
for (const p of new Set(money.map((l) => String(l.번지 ?? "")).filter(Boolean))) {
const mine = (l: StackLine): boolean => l.꼴 === "금액" && money[l.i]?.번지 === p;
const holds = items.filter((it) => it.줄.some(mine));
const only = holds.every((it) =>
it.줄.every((l) => l.꼴 !== "금액" || mine(l) || !money[l.i]?.번지),
);
let title = only ? (holds.find((it) => it.제목.trim())?.제목.trim() ?? "") : "";
// 여러 항목에 걸친 조각 = 첫 항목 「조각 이름 · 항목 제목」의 조각 이름(addTaken 꼴)
// ponytail: 항목 제목에 「 · 」가 있으면 덜 자름 — 조각 이름을 문서에 따로 적게 되면 그것으로
if (title && holds.length > 1 && title.lastIndexOf(" · ") > 0)
title = title.slice(0, title.lastIndexOf(" · "));
const lines = money.filter((l) => l.번지 === p).map((l) => String(l.이름 ?? "").trim());
out[p] =
title ||
(lines.length > 2
? `${lines[0]} 외 ${lines.length - 1}`
: lines.filter(Boolean).join(" · ")) ||
p;
}
return out;
}
+55 -1
View File
@@ -6,7 +6,15 @@
* 산출근거 수준 = 항목(1. 2.) 1 · 가나다 2 · 줄 = 항목 + 1 · 제목 없는 항목 줄 = 1
* ========================================================================== */
import { names, varId, type FormulaDoc, type Link } from "./common_util_formula_doc";
import {
names,
renameText,
splitId,
symId,
varId,
type FormulaDoc,
type Link,
} from "./common_util_formula_doc";
import { findRow, type DocTable } from "./common_util_formula_doc_table";
import { evalNode } from "./common_util_formula_doc_view";
import {
@@ -14,6 +22,7 @@ import {
isOption,
newVar,
nodeOf,
symbols,
type MoneyKind,
type StackItem,
} from "./common_util_line_stack";
@@ -144,3 +153,48 @@ export function stackLevels(items: StackItem[]): { item: number; line: number }[
return { item, line: item + 1 };
});
}
/** 표 값 기호 화면 이름(804-5 · 요구 683) — 「Q_수량2^표」 → 「Q_작업반장」(고른 줄 글 조건 첫 낱말) · 글 조건 없으면 「^표」만 뗌
* · 「^표」 기호만 · 겹치면 2 · 3 · 문서 기호는 그대로(화면만 · 칸 글은 `aliasText` 로 오감) */
export function tableAlias(doc: FormulaDoc): Record<string, string> {
const taken = new Set([
...symbols(doc),
...(doc.금액 ?? []).map((l) => String(l.기호 ?? "")),
"노무비",
"재료비",
"경비",
]);
const out: Record<string, string> = {};
for (const v of doc.변수 ?? []) {
const link = v.연결;
if (link?.갈래 !== "표") continue;
const id = varId(v);
const [s, lo, up] = splitId(id);
const said = Object.values(link.조건 ?? {})
.map(String)
.find((t) => /^'.+'$/.test(t));
const label = said?.slice(1, -1).split(/[\s(,·/]/)[0] ?? "";
if (up !== "표") continue; // 「^표」 꼬리 기호만(r_인력 등 손 이름은 그대로)
const base = label || lo.replace(/\d+$/, "") || lo; // 번호 꼬리 뗌(겹치면 다시 2 · 3)
for (let n = 1; n < 100; n++) {
const now = symId(s, n > 1 ? `${base}${n}` : base);
const node = nodeOf(now);
if (taken.has(now) || node?.[0] !== "var" || node[1] !== now) continue;
taken.add(now);
out[id] = now;
break;
}
}
return out;
}
/** 식 글의 기호를 화면 이름으로(또는 `back` = 화면 이름을 기호로) */
export function aliasText(text: string, alias: Record<string, string>, back = false): string {
let t = text;
for (const [id, now] of Object.entries(alias))
t = String(back ? renameText(t, now, id) : renameText(t, id, now));
return t;
}
/** 표 칸 단위 「줄 따름」(줄마다 다른 단위) = 화면에 안 보임(요구 683) */
export const shownUnit = (u: unknown): string => (u === "줄 따름" ? "" : String(u ?? ""));
@@ -190,6 +190,55 @@ function takeIn({ 문서, 받음 }) {
};
}
/* 804-5 끌어 옮김 · 번지 읽는 이름 — 옮김 = [[from, to] …](줄 "i.j" → "k.p" · 항목 "i" → "p") */
function moveAll({ 문서, 옮김 }) {
let doc = JSON.parse(JSON.stringify(문서));
for (const [a, b] of 옮김) {
const items = ls.stackOf(doc);
if (a.includes("."))
ls.moveLine(items, ...a.split(".").map(Number), ...b.split(".").map(Number));
else ls.moveItem(items, Number(a), Number(b));
doc = ls.docOf(doc, items);
}
const items = ls.stackOf(doc);
return {
문서: doc,
항목: items.map((it) => ({
제목: it.제목,
줄: it.줄.map((l) =>
l.꼴 === "금액" ? doc.금액[l.i].이름 : l.꼴 === "글" ? l.글 : l.기호.join(","),
),
})),
};
}
const placeOf = ({ 문서 }) => cp.placeNames(문서, ls.stackOf(문서));
/* 683 표 값 기호 화면 이름 — 별칭 · 글 왕복(화면 이름 → 기호 = 처음 식과 같은 나무) */
const pk2 = util("common_util_line_stack_pick");
const fd = util("common_util_formula_doc");
function aliasOf({ 문서 }) {
const alias = pk2.tableAlias(문서);
const texts = [
...(문서.식 || []).map((l) => l.식),
...(문서.금액 || []).flatMap((l) => [l.수량, l.단가]),
].filter((t) => typeof t === "string" && t);
const bad = [];
for (const t of texts) {
let want;
try {
want = fd.textOf(fd.parse(t));
} catch {
continue;
}
const shown = pk2.aliasText(t, alias);
const back = pk2.aliasText(shown, alias, true);
if (fd.textOf(fd.parse(back)) !== want) bad.push(t);
if (Object.keys(alias).some((id) => fd.names(fd.parse(shown)).includes(id)))
bad.push(`남음:${t}`);
}
return { 별칭: alias, 틀림: bad };
}
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const job = {
왕복: roundTrip,
@@ -199,5 +248,8 @@ const job = {
넣기: putPicked,
바꿈: repickOne,
복사: takeIn,
옮김: moveAll,
번지: placeOf,
별칭: aliasOf,
}[input.할일];
process.stdout.write(JSON.stringify(input.문서들.map(job)));
@@ -200,3 +200,58 @@ def test_권한_저장_단가갱신(world, monkeypatch):
got["후"]["계"] > again["셈"]["계"] + 1000 * 0.3
) # 보통인부 줄들(복사한 0.3 인 포함) 따라감
assert got["문서"]["금액"] == again["문서"]["금액"]
def test_끌어옮김_번지이름(world):
"""804-5 — 줄 끌기(항목 안 · 사이) · 항목 끌기 = 되돌려 끌면 같은 문서 · 율 줄 번지 = 읽는 이름."""
gc = dc.folder()[DIG] # 항목 둘(인력 · 굴착기)
one = _ts("옮김", [{"문서": gc, "옮김": []}])[0]
first = one["항목"][0]["줄"][0]
gone = _ts("옮김", [{"문서": gc, "옮김": [["0.0", "1.0"]]}])[0]
assert (
gone["항목"][1]["줄"][0] == first
and len(gone["항목"][0]["줄"]) == len(one["항목"][0]["줄"]) - 1
)
back = _ts("옮김", [{"문서": gc, "옮김": [["0.0", "1.0"], ["1.0", "0.0"]]}])[0]
assert back["문서"] == one["문서"]
# 같은 항목 안 아래로(빼기 전 차례) — 첫 줄을 끝 자리 = 둘째로
inner = _ts("옮김", [{"문서": gc, "옮김": [["0.0", "0.2"]]}])[0]
assert inner["항목"][0]["줄"] == one["항목"][0]["줄"][::-1]
n = len(one["항목"])
rot = _ts("옮김", [{"문서": gc, "옮김": [["0", str(n)]]}])[0]
assert rot["항목"][-1] == one["항목"][0] and rot["항목"][0] == one["항목"][1]
assert (
_money(rot["문서"])["계"] == _money(gc)["계"]
) # 항목만 옮김 = 금액 같음(율 줄은 그 항목 안)
# 율 줄 번지 — 일위대가 복사 = 번지마다 읽는 이름(「복사N」 아님)
me, sk, sc = _combo(world)
g = _into(dc.md.blank("SK-000009", "빈"), _take(me, _rid(sc)))
names = _ts("번지", [{"문서": g["문서"]}])[0]
places = {m["번지"] for m in g["문서"]["금액"] if m.get("번지")}
assert places and set(names) == places
assert all(v and not v.startswith("복사") for v in names.values()), names
def test_표값_화면이름_전수():
"""요구 683 — 표 값 기호(「Q_수량2^표」) = 화면 이름(「Q_작업반장」) · 겹침 없음 · 식 글 왕복 = 같은 식(1,518 전수)."""
docs = dc.folder()
keys = sorted(docs)
got = _ts("별칭", [{"문서": docs[k]} for k in keys])
named = 0
for k, g in zip(keys, got):
assert not g["틀림"], (k, g["틀림"][:3])
alias = g["별칭"]
assert len(set(alias.values())) == len(alias), k # 겹치면 2 · 3
assert all("^표" not in v and k.endswith("^표") for k, v in alias.items()), k
named += len(alias)
assert named > 500
gc = dict(zip(keys, got))[FORM]["별칭"]
assert gc and "Q_보통인부22" not in gc.values() # 번호 꼬리 뗀 뒤 2 · 3
# 문서 기호는 그대로(화면만) — 원본 문서 변수에 별칭 이름 없음
names = {
v["기호"]
+ (f"_{v['아래']}" if v.get("아래") else "")
+ (f"^{v['위']}" if v.get("위") else "")
for v in docs[FORM]["변수"]
}
assert not set(gc.values()) & names
+5 -2
View File
@@ -90,8 +90,11 @@ export function showToast(
): void {
const key = `${kind}::${message}`;
const now = Date.now();
if (lastToast && lastToast.key === key && now < lastToast.until) return;
lastToast = { key, until: now + durationMs };
// 누름 동작 알림(되돌리기 등)은 조작마다 따로 — 같은 문구여도 버리지 않음(804-5)
if (!onClick) {
if (lastToast && lastToast.key === key && now < lastToast.until) return;
lastToast = { key, until: now + durationMs };
}
const toast = el("div", { className: `ui-toast ui-toast--${kind}`, text: message });
if (onClick) {
toast.style.cursor = "pointer";