feat(804-3): 줄 쌓기 수정 1차 — 표 값 고르기 · 산출근거 들여쓰기 · 찾아 넣기 창 · 짝 강조 · 기계 이름

- 표 값(요구 678-1): 「표」 꼬리표가 글뿐이라 눌러도 안 열림 → [표 · 고른 줄 · 칸 ▾] 단추 = 품셈 표 창(값 칸 누름 = 그 줄 · 칸)
  옵션에 매인 조건(폼타이규격 등) = 그 줄만 고름 · 다른 줄 막힘 + 「위 옵션에서 고름」 안내 · M02 길 표 값 글 수도 고름
- 산출근거 들여쓰기(678-2): 머리 0 · 항목 1 · 가나다 2 · 줄 = 항목 + 1 · 펼친 블록 = 그 줄 + 1(수준 = stackLevels)
- 찾아 넣기(678-3): [+ 줄] 인력 · 재료 · 기계 · 다른 단가산출 = M02 [항목 추가] 창 그대로(그 종류만) · 고르면 줄 생김(닫으면 없음)
  있는 줄 = [찾아 바꾸기](같은 로직 기계 = 지역 · 보정작업 넘김 그대로) · 옛 찾기 칸 + 고름 목록 · 기종 코드 고르개 걷음
  빈 틀 = [찾아 넣기](돈 아닌 단가산출 결과만 — Find_Pick results)
- 짝 강조(679-1): 오른쪽 줄 누름 = 왼쪽 카드 강조 + 보이는 자리로 · 왼쪽 누름 = 오른쪽 같은 줄 강조
- 기계 이름(679-2): 기계 줄 = 기종 이름 · 규격 · machine-hourly 답에 로직 키 · 원문번호 더함(기존 칸 그대로)
- 모델 common_util_line_stack_pick.ts(boundConds · currentRow · pickCell · addPicked · repick · stackLevels)
- 시험 test_804_2 넓힘 3(표 고름 = 값 + 출처 · 서버 셈 같음 · 찾아 넣기 넷 = 요소 키 · 서버 금액 · 다시 고름 · 수준)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 01:27:20 +09:00
co-authored by Claude Opus 5.5
parent 6c14e73a5c
commit 0eb7822772
12 changed files with 690 additions and 160 deletions
+22 -1
View File
@@ -158,7 +158,7 @@
flex-wrap: wrap;
align-items: center;
gap: 0 var(--spacing-4);
padding-inline-start: calc(var(--lstk-depth, 0) * var(--spacing-24));
padding-inline-start: calc(var(--lstk-depth, 0) * var(--spacing-16));
white-space: normal;
}
@@ -195,3 +195,24 @@
.lstk-tex .katex {
font-size: 1.05em;
}
/* 짝 강조(679) — 왼쪽 카드 · 오른쪽 줄 같은 자리 */
.lstk-line.is-pair,
.lstk-item.is-pair {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
.lstk-out tr.is-pair > td {
background: var(--color-accent-soft);
}
.lstk-out tr[data-loc] {
cursor: pointer;
}
.lstk-tpick {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
+45 -132
View File
@@ -4,7 +4,8 @@
* 줄 = 인력 · 재료 · 기계 · 다른 단가산출 · 율 · 식(금액 줄 — 이름 · 단위 · 비목 · 수량 식 · 단가 고르기 또는 식)
* · 공식 묶음(`line_stack_rows.ts`) · 글 줄
* 고침 = 부른 쪽 `change(fn)` — fn 이 문서 · 차림을 고치면 부른 쪽이 `docOf` 로 굳히고 미리 셈 · 다시 그림(입력은 change 때)
* 자원 찾기 = 수식 탭과 같은 길(`/links` 요소 인력 · 재료 · 단가산출) · 기계 = 시간당 기계경비 단가산출 + 기종(그 문서 기계 목록)
* 자원 고르기(804-3) = 찾아 넣기 창(`line_stack_pick.ts` — M02 [항목 추가] 창 그대로) · [+ 줄] 인력 · 재료 · 기계 · 다른 단가산출 =
* 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = [찾아 바꾸기]
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
@@ -13,6 +14,7 @@ import { el } 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,
freshId,
@@ -23,7 +25,8 @@ import {
type StackDoc,
type StackItem,
} from "../common_util/common_util_line_stack";
import type { Pick } from "./formula_doc_defs";
import { addPicked, repick } from "../common_util/common_util_line_stack_pick";
import { openStackPick } from "./line_stack_pick";
import { buildBlock, insertTpl, type Tpl } from "./line_stack_rows";
import { lt, type LsKey } from "./line_stack_text";
@@ -32,12 +35,15 @@ export interface EditHooks {
answer: DocAnswer;
items: StackItem[];
change: (fn: (d: StackDoc, items: StackItem[]) => void) => void;
ask: (kind: string, words: string, group?: string) => Promise<{ 후보: Pick[] }>;
/** 품셈 표 하나(표 고르기 창) */
table: (key: string) => Promise<DocTable | null>;
subDoc: (key: string) => FormulaDoc | null;
name: (key: string) => string;
/** 공식 묶음 펼친 자리(`항목.줄`) — 화면 상태만 */
open: Set<string>;
templates: () => Tpl[];
/** 줄 · 항목 누름 — 오른쪽 짝 강조(679) */
pair: (loc: string) => void;
saveTpl: (tpl: Tpl) => void;
}
@@ -60,47 +66,6 @@ const field = (
const btn = (label: string, onClick: () => void, variant: "ghost" | "danger-ghost" = "ghost") =>
createButton({ label, variant, size: 24, onClick });
/** 자원 찾기 — 찾는 말 → 후보 고르기 */
function finder(
h: EditHooks,
kind: string,
group: string | undefined,
seed: string,
onPick: (p: Pick) => void,
): HTMLElement {
const words = createInputField({
placeholder: lt("Find"),
value: seed,
compact: true,
size: 24,
type: "search",
});
const pick = createSelectField({
options: [{ value: "", text: lt("Pick") }],
compact: true,
size: 24,
ariaLabel: lt("Pick"),
});
let found: Pick[] = [];
const run = (): void =>
void h.ask(kind, words.input.value, group).then((got) => {
found = got.후보 ?? [];
pick.setOptions([
{ value: "", text: found.length ? lt("Pick") : lt("None_Found") },
...found.map((p, k) => ({
value: String(k),
text: [p.이름, p.규격].filter(Boolean).join(" "),
})),
]);
});
words.input.addEventListener("change", run);
pick.select.addEventListener("change", () => {
const p = found[Number(pick.select.value)];
if (pick.select.value !== "" && p) onPick(p);
});
return el("div", { className: "lstk-find", children: [words.root, pick.root] });
}
const KIND_KEY: Record<MoneyKind, LsKey> = {
인력: "K_인력",
재료: "K_재료",
@@ -118,19 +83,6 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
const v = own ? d.변수?.find((x) => varId(x) === own) : undefined;
const put = (fn: (l: MoneyLine & { 규격?: string }) => void): void =>
h.change((doc) => fn(doc.금액![i]));
const setVar = (
fn: (link: NonNullable<typeof v>["연결"] & object, l: MoneyLine & { 규격?: string }) => void,
): void =>
h.change((doc) => {
const x = doc.변수!.find((y) => varId(y) === own)!;
x.연결 ??= {};
fn(x.연결, doc.금액![i]);
});
const named = (p: Pick, l: MoneyLine & { 규격?: string }): void => {
l.이름 = p.이름 ?? l.이름;
if (p.규격) l.규격 = p.규격;
else delete l.규격;
};
const head = el("div", {
className: "lstk-row",
children: [
@@ -159,54 +111,23 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
const price: HTMLElement[] = [];
if (own && v) {
const link = v.연결 ?? {};
const mat = link.재료 as Record<string, unknown> | undefined;
const spec = [line.이름, (line as { 규격?: string }).규격].filter(Boolean).join(" ");
const label = link.키
? link.갈래 === "단가산출"
? (h.subDoc(link.키)?.이름 ?? h.name(link.키))
: h.name(link.키)
: "";
price.push(el("span", { className: "lstk-link", text: label || "—" }));
if (link.갈래 === "요소")
price.push(
finder(h, "요소", kind === "인력" ? "인력" : "재료", "", (p) =>
setVar((k, l) => {
for (const x of Object.keys(k)) delete (k as Record<string, unknown>)[x];
Object.assign(k, { 갈래: "요소", 키: p.키 });
named(p, l);
}),
),
);
else {
const sub = link.키 ? h.subDoc(link.키) : null;
const codes = sub?.변수?.find((x) => x.열쇠 === "기계")?.연결;
if (kind === "기계" && codes?.목록) {
const now = String(link.입력?.기계 ?? `'${codes.값 ?? ""}'`).replace(/^'|'$/g, "");
price.push(
createSelectField({
label: lt("Machine"),
options: codes.목록.map((x) => ({ value: String(x), text: String(x) })),
value: now,
compact: true,
size: 24,
onChange: (x) => setVar((k) => (k.입력 = { ...(k.입력 ?? {}), 기계: `'${x}'` })),
}).root,
);
}
price.push(
finder(
h,
"단가산출",
undefined,
!link.키 && own.startsWith("P_") ? "시간당 기계경비" : "",
(p) =>
setVar((k, l) => {
const keep = k.키 === p.키 ? (k.입력 ?? {}) : {};
for (const x of Object.keys(k)) delete (k as Record<string, unknown>)[x];
Object.assign(k, { 갈래: "단가산출", 키: p.키, 입력: keep });
if (!l.이름) named(p, l);
}),
),
);
}
? kind === "기계" && spec
? spec // 기계 = 기종 이름 · 규격(679 — 코드 번호 아님)
: link.갈래 === "단가산출"
? (h.subDoc(link.키)?.이름 ?? h.name(link.키))
: h.name(link.키)
: 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 why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭;
return [
@@ -219,6 +140,10 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
/** [+ 줄] 고르개 값 → 새 줄 */
function addLine(h: EditHooks, at: number, kind: string): void {
if (["인력", "재료", "기계", "단가산출"].includes(kind))
return openStackPick(kind as MoneyKind, (p) =>
h.change((d, items) => void addPicked(d, items[at], kind as MoneyKind, p)),
);
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)));
@@ -227,32 +152,11 @@ function addLine(h: EditHooks, at: number, kind: string): void {
h.change((d, items) => {
const it = items[at];
const n = (d.금액 ??= []).length;
const money = (line: MoneyLine, id?: string, link?: Record<string, unknown>): void => {
if (id) (d.변수 ??= []).push(newVar(id, link as never));
const money = (line: MoneyLine): void => {
d.금액!.push(line);
it.줄.push({ 꼴: "금액", i: n });
};
if (kind === "인력" || kind === "재료") {
const id = freshId(d, kind === "인력" ? "w" : "P", "새");
money(
{
이름: "",
단위: kind === "인력" ? "인" : "",
비목: kind === "인력" ? "노무비" : "재료비",
수량: "1",
단가: id,
},
id,
{ 갈래: "요소" },
);
} else if (kind === "기계" || kind === "단가산출") {
const id = freshId(d, kind === "기계" ? "P" : "B", "새");
money(
{ 이름: "", 단위: kind === "기계" ? "hr" : "", 비목: "묶음", 수량: "1", 단가: id },
id,
{ 갈래: "단가산출", 입력: {} },
);
} else if (kind === "율")
if (kind === "율")
money({ 이름: "공구손료 및 경장비", 단위: "식", 비목: "경비", 단가: "노무비 × 3 %" });
else if (kind === "공식") {
const id = freshId(d, "x");
@@ -287,8 +191,8 @@ function dropLine(d: StackDoc, items: StackItem[], i: number, j: number): void {
}
export function buildEdit(h: EditHooks): HTMLElement {
const add = (at: number): HTMLElement =>
createSelectField({
const add = (at: number): HTMLElement => {
const pick = createSelectField({
options: [
{ value: "", text: `${lt("Add_Line")} ▾` },
...(["인력", "재료", "기계", "단가산출", "율"] as MoneyKind[]).map((k) => ({
@@ -306,8 +210,13 @@ export function buildEdit(h: EditHooks): HTMLElement {
compact: true,
size: 24,
ariaLabel: lt("Add_Line"),
onChange: (x) => x && addLine(h, at, x),
}).root;
onChange: (x) => {
pick.select.value = ""; // 창을 닫아도 [+ 줄 ▾] 로 돌아옴
if (x) addLine(h, at, x);
},
});
return pick.root;
};
const items = h.items.map((it, i) => {
const head = el("div", {
className: "lstk-ihead",
@@ -355,10 +264,12 @@ export function buildEdit(h: EditHooks): HTMLElement {
: buildBlock(h, i, j, line);
const card = el("div", { className: `lstk-line k-${line.꼴}`, children: [tools, ...body] });
card.dataset.loc = `${i}.${j}`;
card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`));
return card;
});
return el("div", {
const box = el("div", {
className: "lstk-item",
attrs: { "data-loc": String(i) },
children: [
head,
...(lines.length
@@ -366,6 +277,8 @@ export function buildEdit(h: EditHooks): HTMLElement {
: [el("div", { className: "lstk-empty", text: lt("Empty_Item") })]),
],
});
head.addEventListener("pointerdown", () => h.pair(String(i)));
return box;
});
return el("div", {
className: "lstk-edit",
+40 -7
View File
@@ -29,6 +29,7 @@ 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 { lt } from "./line_stack_text";
@@ -39,8 +40,14 @@ interface Row {
cls?: string;
won?: boolean;
depth: number;
/** 들여쓰기 수준(804-3) — 머리 0 · 항목 1 · 가나다 2 · 줄 = 항목 + 1 · 펼친 블록 = 그 줄 + 1 */
lv: number;
/** 왼쪽 짝 자리(679) — 항목 `k` · 줄 `k.j`(펼친 블록 = 그 줄) */
loc?: string;
}
export interface OutHooks {
/** 줄 누름 — 왼쪽 짝 자리(679) */
pair?: (loc: string) => void;
cache: DocCache;
/** 펼친 자리(`부모/기호`) */
open: Set<string>;
@@ -67,14 +74,23 @@ const hasVar = (text: unknown): boolean => {
return !!n && JSON.stringify(n).includes('["var"');
};
function rows(doc: StackDoc, a: DocAnswer, h: OutHooks, depth: number, at: string): Row[] {
function rows(
doc: StackDoc,
a: DocAnswer,
h: OutHooks,
depth: number,
at: string,
base = 0,
): Row[] {
const out: Row[] = [];
let lv = base + 1; // 지금 쌓는 줄 수준
let loc: string | undefined; // 지금 쌓는 왼쪽 자리(맨 위 문서만)
const vals = (a.값 ?? {}) as Values;
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식]));
// 펼친 문서 줄 = 금액 칸 없이(부른 쪽 칸과 섞이지 않게)
const push = (r: Omit<Row, "depth">): void =>
void out.push({ ...r, depth, ...(depth ? { cols: undefined } : {}) });
const push = (r: Omit<Row, "depth" | "lv">): void =>
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)?.단위 ?? "";
@@ -108,10 +124,12 @@ function rows(doc: StackDoc, a: DocAnswer, h: OutHooks, depth: number, at: strin
if (h.cache.takeMissing()) h.miss();
out.push({
depth: depth + 1,
lv: lv + 1,
loc,
h: [`${sub.이름 ?? ""}${sub.결과단위 ? ` [${sub.결과단위}]` : ""}`],
cls: "subhead",
});
out.push(...rows(sub, got, h, depth + 1, path));
out.push(...rows(sub, got, h, depth + 1, path, lv + 1).map((r) => ({ ...r, loc })));
};
/** 공식 묶음 — 값만인 줄은 넷까지 한 줄 */
@@ -240,7 +258,10 @@ function rows(doc: StackDoc, a: DocAnswer, h: OutHooks, depth: number, at: strin
const terms: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] };
let n = 0;
let sub = 0;
for (const it of items) {
const levels = stackLevels(items);
for (const [k, it] of items.entries()) {
lv = base + levels[k].item;
loc = depth ? undefined : String(k);
if (it.제목.trim()) {
const label = it.가나다 ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"} . ` : `${++n}. `;
if (!it.가나다) sub = 0;
@@ -249,7 +270,9 @@ function rows(doc: StackDoc, a: DocAnswer, h: OutHooks, depth: number, at: strin
const sum: Record<string, number> = { 노무비: 0, 재료비: 0, 경비: 0 };
const own: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] };
let hasSub = false;
for (const l of it.줄) {
lv = base + levels[k].line;
for (const [j, l] of it.줄.entries()) {
loc = depth ? undefined : `${k}.${j}`;
if (l.꼴 === "글") push({ h: [l.글] });
else if (l.꼴 === "공식") block(l.기호);
else {
@@ -263,12 +286,15 @@ function rows(doc: StackDoc, a: DocAnswer, h: OutHooks, depth: number, at: strin
}
}
}
loc = depth ? undefined : String(k);
if (hasSub && COSTS.some((c) => sum[c])) {
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]));
}
lv = base + 1;
loc = undefined;
if (doc.결과) {
const r = a.결과;
push({
@@ -282,6 +308,7 @@ function rows(doc: StackDoc, a: DocAnswer, h: OutHooks, depth: number, at: strin
}
if (!money.length) return out;
push({ h: [`${++n}. ${lt("Total")}`], cls: "item" });
lv = base + 2;
for (const c of COSTS) {
const t = terms[c];
const v = a[c];
@@ -326,6 +353,7 @@ export function buildOut(doc: StackDoc, a: DocAnswer, h: OutHooks): HTMLElement
cls: "head",
won: true,
depth: 0,
lv: 0,
};
for (const r of [head, ...rows(doc, a, h, 0, "")]) {
const cell = (k: "sum" | (typeof COSTS)[number]): string => {
@@ -334,9 +362,14 @@ export function buildOut(doc: StackDoc, a: DocAnswer, h: OutHooks): HTMLElement
return r.won ? won(v) : one(v);
};
const first = el("div", { className: "lstk-h", children: r.h });
first.style.setProperty("--lstk-depth", String(r.depth));
first.style.setProperty("--lstk-depth", String(r.lv));
first.dataset.lv = String(r.lv);
const tr = tableRow(columns, [first, cell("sum"), ...COSTS.map(cell)]);
tr.className = `lstk-orow ${r.cls ?? ""} ${r.depth ? "is-sub" : ""}`;
if (r.loc !== undefined) {
tr.dataset.loc = r.loc;
tr.addEventListener("click", () => h.pair?.(r.loc!));
}
tbody.append(tr);
}
root.classList.add("lstk-out");
+128
View File
@@ -0,0 +1,128 @@
/* =============================================================================
* line_stack_pick.ts
* 줄 쌓기 고르기 창(PLAN 804-3 · 요구 678-1 · 678-3)
* 찾아 넣기 = M02 [항목 추가] 창 그대로(`openFindPicker` · 탭 M01 전체 · 찾기 칸 · 표) — 줄 갈래마다 그 종류만
* 인력(노임 LB) · 재료(자재 MT) · 기계(EQ → 시간당 경비 로직 + 기계='원문번호') · 다른 단가산출(M01 단가산출 로직)
* M02 단가산출 · 일위대가 탭은 안 보임 — 수식 문서 연결은 M01 키만 셈
* 표 고르기 = 품셈 표(조건 칸 + 값 칸) · 값 칸 누름 = 그 줄 · 칸 · 지금 닿는 칸 강조 ·
* 옵션에 매인 조건 = 그 줄만 고를 수 있음(다른 줄은 막힘 + 「위 옵션에서 고름」 안내)
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import type { MoneyKind } from "../common_util/common_util_line_stack";
import type { Picked } from "../common_util/common_util_line_stack_pick";
import { openFindPicker, type FindKind } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
import { lt } from "./line_stack_text";
const KINDS: Partial<Record<MoneyKind, FindKind>> = {
인력: "labor",
재료: "material",
기계: "machine",
단가산출: "logic",
};
/** 찾아 넣기 — 고르면 `done(고른 것)` · 닫으면 아무것도 안 함 */
export function openStackPick(kind: MoneyKind, done: (p: Picked) => void): void {
const want = KINDS[kind];
if (!want) return;
const spec = (e: { 규격: string }) => (e.규격 ? { 규격: e.규격 } : {});
openFindPicker({
layer: "system",
owner: null,
title: `${lt("Find_Title")} — ${lt(`K_${kind}` as never)}`,
kinds: [want],
logic: (x) => done({ 키: x.키, 이름: x.이름, 단위: x.결과단위.replace(/^원\/?/, "") }),
element: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위, ...spec(e) }),
machine: (e) =>
e.로직 && e.원문번호
? done({ 키: e.로직, 이름: e.이름, 입력: { 기계: `'${e.원문번호}'` }, ...spec(e) })
: undefined,
});
}
const isNum = (v: unknown): boolean =>
typeof v === "number" || (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v)));
const cellText = (v: unknown): string =>
Array.isArray(v)
? `${v[0] ?? ""} ~ ${v[1] ?? ""}`
: isNum(v)
? eqText(Number(v))
: String(v ?? "");
export interface TablePick {
table: DocTable;
/** 지금 닿는 줄(-1 = 없음) · 칸 */
now: number;
col: string;
/** 옵션에 매인 조건 — {조건 칸: 옵션 열쇠들} */
bound: Record<string, string[]>;
done: (row: Record<string, unknown>, col: string) => void;
}
/** 표 고르기 창 — 값 칸 누름 = 고름 */
export function openTablePick(o: TablePick): void {
const t = o.table;
const conds = Object.keys(t.조건 ?? {});
const valueCols = [
...new Set([...Object.keys(t.값칸 ?? {}), ...t.줄.flatMap((r) => Object.keys(r))]),
].filter((k) => !conds.includes(k));
const locked = Object.keys(o.bound).length > 0;
const columns: Column[] = [
...conds.map((c) => ({ label: c })),
...valueCols.map((c) => {
const unit = (t.값칸 ?? {})[c];
return { label: unit ? `${c} (${String(unit)})` : c, align: "end" as const };
}),
];
openModal({
title: [t.원문번호, (t as { 이름?: string }).이름].filter(Boolean).join(" "),
closeLabel: lt("Close"),
width: "960px",
mount: (body, close) => {
const { root, tbody } = tableShell(columns, { density: "data" });
t.줄.forEach((row, k) => {
const off = locked && k !== o.now;
const tr = tableRow(columns, [
...conds.map((c) => cellText(row[c])),
...valueCols.map((c) =>
isNum(row[c]) // 수만 고름(글 칸 = 단위 등은 보기만 · M02 길은 수를 글로 줌)
? createButton({
label: cellText(row[c]),
variant: k === o.now && c === o.col ? "filled" : "ghost",
size: 24,
disabled: off,
onClick: () => {
close();
o.done(row, c);
},
})
: cellText(row[c]),
),
]);
tr.dataset.row = String(k);
tbody.append(tr);
});
const note = locked
? [
createText(
`${lt("Tbl_Bound")} — ${[...new Set(Object.values(o.bound).flat())].join(" · ")}`,
),
]
: [];
body.append(
el("div", {
className: "lstk-tpick",
attrs: { "data-table-pick": t.키 ?? "" },
children: [...note, createText(lt("Tbl_Hint")), root],
}),
);
},
});
}
+65 -8
View File
@@ -4,7 +4,8 @@
* 접힘 = 값 줄은 값 칸(연결 값을 고쳐 적으면 숫자 = 고정값 · 요구 664) · 식 줄은 「이름 = 식 = 값」 글 · 단가산출 결과 기호 = [식으로 풀기]
* 풀림 = 줄마다 이름 · 식 · 단위 칸 + 지움 · [+ 줄] — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) · 내 틀 = 이 브라우저 저장(localStorage) — 꼴이 바뀌면
* 「내 틀 (틀 이름 에서 변경)」
* 「내 틀 (틀 이름 에서 변경)」 · 빈 틀 = [찾아 넣기](돈 아닌 단가산출 결과 — 작업량 등)
* 표 값(804-3) = [표 · 칸] 단추 → 표 고르기 창(`line_stack_pick.ts`) · 값 칸 누름 = 그 줄 · 칸(`pickCell`)
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
@@ -30,7 +31,10 @@ import {
type StackItem,
type StackLine,
} from "../common_util/common_util_line_stack";
import { boundConds, currentRow, pickCell } 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";
/** 내 틀 — 기호 · 식 조각(이 브라우저) */
@@ -138,6 +142,38 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
});
head.dataset.tpl = changed ? "changed" : line.틀 ? "tpl" : "";
// ── 표 값 — [표 · 칸] → 표 고르기 ──
const tableBtn = (id: string, link: NonNullable<FormulaDoc["변수"]>[number]["연결"] & object) => {
const b = createButton({
label:
[
lt("Tbl"),
...Object.values(link.조건 ?? {}).flatMap((t) =>
/^'.*'$/.test(String(t)) ? [String(t).slice(1, -1)] : [],
),
link.칸,
]
.filter(Boolean)
.join(" · ") + " ▾", // 고른 줄(글 조건) · 칸
variant: "ghost",
size: 24,
onClick: () =>
void h.table(String(link.표 ?? "")).then((t) => {
if (!t) return void showToast(lt("Tbl_Missing"), "warning");
openTablePick({
table: t,
now: currentRow(t, link, vals),
col: String(link.칸 ?? ""),
bound: boundConds(d, link),
done: (row, col) => h.change((doc) => pickCell(doc, id, t, row, col)),
});
}),
});
b.dataset.tablePick = id;
b.title = h.name(String(link.표 ?? "")) || String(link.표 ?? "");
return b;
};
// ── 줄 ──
const renameRow = (old: string, now: string): void => {
const t = now.trim();
@@ -171,12 +207,14 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
const expr = lines.get(id);
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
const tag =
link && LINK_TAG[link.갈래 ?? ""] && !expr
? el("span", {
className: "lstk-tag",
text: `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
})
: null;
link?.갈래 === "표" && !expr
? tableBtn(id, link)
: link && LINK_TAG[link.갈래 ?? ""] && !expr
? el("span", {
className: "lstk-tag",
text: `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
})
: null;
const tools: HTMLElement[] = [];
if (sub?.결과 && link?.키)
tools.push(
@@ -188,7 +226,26 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
}),
);
if (link?.갈래 === "단가산출" && !link.키)
tools.push(el("span", { className: "lstk-hint", text: lt("K_틀") }));
tools.push(
createButton({
label: lt("Find_Title"),
variant: "ghost",
size: 24,
onClick: () =>
openFindPicker({
layer: "system",
owner: null,
title: lt("K_틀"),
kinds: ["logic"],
results: true,
logic: (x) =>
h.change((doc) => {
const y = doc.변수?.find((z) => varId(z) === id);
if (y) y.연결 = { 갈래: "단가산출", 키: x.키, 입력: {} };
}),
}),
}),
);
const name = el("b", { className: "lstk-sym", text: id });
const valueOf =
expr ??
+39 -3
View File
@@ -22,6 +22,7 @@ import {
} from "@ui/ui_template_elements";
import { blankDoc, type FormulaDoc } from "../common_util/common_util_formula_doc";
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
import {
docOf,
@@ -31,7 +32,6 @@ import {
type StackItem,
} from "../common_util/common_util_line_stack";
import { copyBadges, type CopyMark, type CopyOptions } from "./formula_doc_copy";
import type { Pick } from "./formula_doc_defs";
import { docFoot } from "./formula_doc_foot";
import { buildOptions } from "./formula_doc_options";
import { ft } from "./formula_doc_text";
@@ -90,7 +90,9 @@ export function createStackScreen(opt: StackScreenOptions) {
let seq = 0;
let asked = "";
let ctl: AbortController | null = null;
const tables = new Map<string, Promise<DocTable | null>>(); // 표 고르기(문서 열 때 비움)
const opened = new Set<string>(); // 산출근거 펼친 자리
let paired = ""; // 짝 강조 자리(679 — 항목 `k` · 줄 `k.j`)
const blocks = new Set<string>(); // 풀린 공식 묶음
const call = async <T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> => {
@@ -187,6 +189,22 @@ export function createStackScreen(opt: StackScreenOptions) {
}
};
/** 표 하나 — 수식 탭과 같은 길(`/table`) · 받은 표는 계산기 캐시에도 */
const tableOf = (k: string): Promise<DocTable | null> => {
if (!tables.has(k))
tables.set(
k,
call<DocTable & { 끊김?: string }>(`/table?key=${encodeURIComponent(k)}`)
.then((t) => {
if (t.끊김) return null;
cache.putTable(k, t);
return t;
})
.catch(() => null),
);
return tables.get(k)!;
};
/** 고침 — 문서 · 차림을 고친 뒤 문서로 굳힘 */
const change = (fn: (d: StackDoc, items: StackItem[]) => void): void => {
if (!doc) return;
@@ -198,6 +216,20 @@ export function createStackScreen(opt: StackScreenOptions) {
draw();
};
/** 짝 강조 — 왼쪽 카드 · 오른쪽 줄(같은 자리) · from = 누른 쪽(반대쪽을 보이는 자리로) */
const pair = (loc: string, from?: "left" | "right"): void => {
paired = loc;
for (const x of root.querySelectorAll(".is-pair")) x.classList.remove("is-pair");
if (!loc) return;
const sel = `[data-loc="${CSS.escape(loc)}"]`;
const l = left.querySelector<HTMLElement>(sel);
const r = [...right.querySelectorAll<HTMLElement>(sel)];
l?.classList.add("is-pair");
r.forEach((x) => x.classList.add("is-pair"));
if (from === "right") l?.scrollIntoView({ block: "nearest", behavior: "smooth" });
if (from === "left") r[0]?.scrollIntoView({ block: "nearest", behavior: "smooth" });
};
// ── 그림 ──
const edit = (): boolean => opt.admin && (!!key || !cp);
const draw = (): void => {
@@ -269,12 +301,12 @@ export function createStackScreen(opt: StackScreenOptions) {
answer,
items,
change,
ask: (kind, words, group) =>
call<{ 후보: Pick[] }>("/links", { 갈래: kind, 검색: words, 분류: group }),
table: tableOf,
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])),
}),
]
@@ -291,10 +323,12 @@ export function createStackScreen(opt: StackScreenOptions) {
draw();
},
miss: ask,
pair: (loc) => pair(loc, "right"),
})
: el("p", { className: "ui-text ui-text--muted", text: lt("Empty") }),
);
foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)]));
pair(paired);
};
const take = (got: Loaded): void => {
@@ -342,6 +376,7 @@ export function createStackScreen(opt: StackScreenOptions) {
const was = key;
shown = next;
if (next !== key) {
paired = "";
opened.clear();
blocks.clear();
given = {};
@@ -350,6 +385,7 @@ export function createStackScreen(opt: StackScreenOptions) {
doc = answer = null;
asked = "";
cache = createDocCache();
tables.clear();
[cut, mark, version] = [{}, undefined, ""];
draw();
if (!next) {
+10
View File
@@ -57,6 +57,16 @@ const TEXT = {
"Kept fields (edit in Equation tab)",
],
Unsaved_New: ["저장한 단가산출만 — 페이지 [저장] 뒤", "Saved unit prices only"],
Find_Title: ["찾아 넣기", "Find and add"],
Repick: ["찾아 바꾸기", "Find and change"],
Close: ["닫기", "Close"],
Tbl: ["표", "Table"],
Tbl_Missing: ["표를 못 읽음", "Table not found"],
Tbl_Hint: ["값 칸을 누르면 그 값이 들어감", "Click a value cell to use it"],
Tbl_Bound: [
"이 값의 줄은 위 옵션이 고름(다른 줄은 옵션에서 바꿈 · 여기선 칸만)",
"Row follows the option above (change it there; pick a column here)",
],
} as const;
export type LsKey = keyof typeof TEXT;