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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user