fix(808-1): 줄 쌓기 수정 3차 — 수량 칸 식 글 칸 + 출처 꼬리표 · 글꼴 M01 = M02 · M02 머리 [단가 갱신][재생성] · 일위대가 줄 모달 일원화

- 수량 칸(요구 701-1) = 늘 식 글 칸(Q_특별인부) + 출처 꼬리표(표 = 표 고르기) · 눌러도 안 바뀜 · 표 값 기호에 수 = 그 값만(직접 입력) · 꼬리표 한 곳(sourceTag)
- 글꼴(701-2) = 줄 쌓기 화면이 정함(14px · M02 가 부모 16px 을 받던 것)
- M02 머리(701-3) = [초기화] 와 [되돌림] 뜻 다름 → [초기화] 이름 「M01 값으로」 · [단가 갱신][재생성] = 보기 고르기 옆 머리 칸(수식 보기만)
- 일위대가 단가산출 줄 모달(701-5) = 페이지 머리와 같음(보기 고르기 · [단가 갱신] · [재생성] · [저장])
- 일위대가 로직 줄 모달(701-4 · 5) = 옛 [옵션] · [단가산출로도 저장] · [항목 추가] · [율 줄 추가] 걷음 · 보기 고르기 없이 상세만 + 안내(온 단가산출) ·
  줄 문서 키 SK-000000 이 수식 문서를 읽어 404 · 「못 읽음」 이던 것 고침
- 시험: test_804_7 낡은 소스 기대(꼬리표 조건 글) 고침 + 808-1 · test_806_1 808-1 모달

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 18:52:03 +09:00
co-authored by Claude Opus 5.5
parent 6f727c9054
commit f5d3347d1e
13 changed files with 225 additions and 166 deletions
+20 -4
View File
@@ -1,5 +1,7 @@
/* 줄 쌓기 탭(804-2) — 위 옵션 · 가운데 왼쪽 편집 | 오른쪽 산출근거 · 맨 아래 합계 상자 · 색은 공통 값만 */
/* 줄 쌓기 탭(804-2) — 위 옵션 · 가운데 왼쪽 편집 | 오른쪽 산출근거 · 맨 아래 합계 상자 · 색은 공통 값만
* 글꼴 = 화면이 정함(M01 · M02 · 일위대가 모달 같음 · 요구 701-2 · 부모 상속 안 받음) */
.lstk {
font-size: var(--text-body-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-12);
@@ -237,9 +239,23 @@
flex-basis: 100%;
}
.lstk-qty.ui-btn {
justify-content: flex-start;
max-width: 100%;
/* 수량 = 식 글 칸 + 출처 꼬리표(요구 701-1) — 꼬리표는 줄어 줄임표 */
.lstk-qty {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.lstk-qty > :first-child {
flex: 1 1 6em;
min-width: 4em;
}
.lstk-qty > .lstk-tag,
.lstk-qty > .ui-btn {
flex: 0 1 auto;
min-width: 0;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+19 -38
View File
@@ -28,6 +28,7 @@ import {
moveLine,
newVar,
ownedOf,
setRow,
stackOf,
type MoneyKind,
type StackDoc,
@@ -45,7 +46,7 @@ import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_U
import { reach } from "./formula_doc_render";
import { attachDrag } from "./line_stack_drag";
import { openStackPick, type StackTake } from "./line_stack_pick";
import { buildBlock } from "./line_stack_rows";
import { buildBlock, sourceTag } from "./line_stack_rows";
import { lt, type LsKey } from "./line_stack_text";
export interface EditHooks {
@@ -194,7 +195,9 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
];
}
/** 수량 — 기호 하나면 값 + 출처 꼬리표(「0.39 ← 표 · 잡석 수량」 · 요구 687-12 · 기호는 마우스 글) · 누르면 그 자리에서 식 고침 */
/** 수량 = 늘 식 글 칸(기호 하나면 그 이름 · 요구 701-1) + 그 기호에 연결이 있으면 옆 출처 꼬리표(표 = 표 고르기) ·
* 칸을 눌러도 안 바뀜 — 글을 실제로 고칠 때만: 표 값 기호 자리에 수를 치면 그 기호 값(별칭 · 「직접 입력」 · 804-9) ·
* 그 밖 = 수량 식 */
function qtyBox(
h: EditHooks,
i: number,
@@ -204,42 +207,20 @@ function qtyBox(
const line = (h.doc.금액 ?? [])[i];
const text = String(line.수량 ?? "");
const v = h.doc.변수?.find((x) => varId(x) === text);
const at = `qty:${i}`;
const edit = (): HTMLElement => {
const f = field(lt("Qty"), shown(text), (t) =>
h.change((doc) => {
h.open.delete(at);
const l = doc.금액![i];
if (t.trim()) l.수량 = real(t);
else delete l.수량;
}),
);
f.classList.add("lstk-qty");
f.dataset.qty = String(i);
return f;
};
if (!v || h.open.has(at)) return edit();
const val = (h.answer.값 ?? {})[text];
const link = v.연결 ?? {};
const kind = link.갈래 ?? "식";
const from =
v.이름 || (link.키 || link.표 ? h.name(String(link.키 ?? link.표)) : "") || shown(text);
const b = createButton({
label: `${isNum(val) ? eqText(val) : "—"}${v.단위 && shownUnit(v.단위) ? ` ${shownUnit(v.단위)}` : ""} ← ${kind} · ${from}`,
variant: "ghost",
size: 24,
onClick: () => {
h.open.add(at);
h.change(() => undefined);
requestAnimationFrame(() =>
document.querySelector<HTMLInputElement>(`[data-qty="${i}"] input`)?.focus(),
);
},
});
b.classList.add("lstk-qty");
b.title = `${lt("Qty")} = ${shown(text)}`;
b.dataset.qty = String(i);
return b;
const f = field(lt("Qty"), shown(text), (t) =>
h.change((doc) => {
const want = real(t);
if (v?.연결?.표 && /^-?[\d.,]+$/.test(want)) return setRow(doc, text, want);
if (want) doc.금액![i].수량 = want;
else delete doc.금액![i].수량;
}),
);
f.dataset.qty = String(i);
const val = v ? (h.answer.값 ?? {})[text] : undefined;
const tag = v ? sourceTag(h, text, v.연결) : null;
if (tag && isNum(val))
tag.title = `${eqText(val)}${shownUnit(v?.단위) ? ` ${shownUnit(v?.단위)}` : ""} — ${tag.title || tag.textContent}`;
return el("span", { className: "lstk-qty", children: tag ? [f, tag] : [f] });
}
/** [+ 줄] 고르개 값 → 새 줄 */
+55 -47
View File
@@ -64,6 +64,60 @@ const LINK_TAG: Record<string, string> = {
const clipTag = (text: string): HTMLElement =>
el("span", { className: "lstk-tag", text, attrs: { title: text } });
type Link = NonNullable<NonNullable<FormulaDoc["변수"]>[number]["연결"]>;
/** 표 값 — [표 · 칸] 단추 → 표 고르기 창(804-3) */
function tableBtn(h: EditHooks, id: string, link: Link): HTMLElement {
const vals = (h.answer.값 ?? {}) as Record<string, unknown>;
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,
clip: true, // 긴 줄 이름 = 칸 안에서 줄임(요구 692-3)
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(h.doc, link),
done: (row, col) => h.change((doc) => pickCell(doc, id, t, row, col)),
});
}),
});
b.dataset.tablePick = id;
b.title = `${b.title} — ${h.name(String(link.표 ?? "")) || String(link.표 ?? "")}`;
return b;
}
/** 값 기호의 출처 꼬리표(식 없는 기호) — 표 = 표 고르기 단추 · 표 값 직접 침 = 「직접 입력」 · 그 밖 연결 = 글 ·
* 공식 묶음 값 줄 · 금액 줄 수량 칸(요구 701-1) 같은 꼴 */
export function sourceTag(h: EditHooks, id: string, link: Link | undefined): HTMLElement | null {
if (!link) return null;
if (link.갈래 === "표") return tableBtn(h, id, link);
if (link.갈래 === "숫자" && link.표)
return el("span", {
className: "lstk-tag",
text: lt("Direct"),
attrs: { title: lt("Direct_Hint") },
});
if (!LINK_TAG[link.갈래 ?? ""]) return null;
const sub = link.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
return clipTag(
`${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
);
}
const input = (
value: string,
placeholder: string,
@@ -141,39 +195,6 @@ export function buildBlock(
});
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,
clip: true, // 긴 줄 이름 = 칸 안에서 줄임(요구 692-3)
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 = `${b.title} — ${h.name(String(link.표 ?? "")) || String(link.표 ?? "")}`;
return b;
};
// ── 줄 ──
const renameRow = (old: string, now: string): void => {
const t = now.trim();
@@ -233,20 +254,7 @@ export function buildBlock(
const was = h.before?.get(id);
const freed = !iffy && raw !== undefined && !!was && hasIf(tryParse(was) ?? ["num", 0, "0"]);
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
const tag =
link?.갈래 === "표" && !expr
? tableBtn(id, link)
: link?.갈래 === "숫자" && link.표 && !expr
? el("span", {
className: "lstk-tag",
text: lt("Direct"),
attrs: { title: lt("Direct_Hint") },
})
: link && LINK_TAG[link.갈래 ?? ""] && !expr
? clipTag(
`${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
)
: null;
const tag = expr ? null : sourceTag(h, id, link);
const tools: HTMLElement[] = [];
if (sub?.결과 && link?.키)
tools.push(
+7 -2
View File
@@ -3,7 +3,8 @@
* 단가산출 「줄 쌓기」 탭(PLAN 804-2 · 요구 670~677) — 공용(M01 단가산출 기초 · M02 단가산출 = 길 앞머리만 바꿈)
* 위 = 옵션 가로(`formula_doc_options.ts`) · M02 사본 [단가 갱신] · [재생성] · 「M01 새 판」 배지(층 쓰기)
* `page` 있음(M01 804-7 · M02 804-8) = 저장 · 되돌림 단추 없음 — 페이지 [저장] · [되돌림]이 `save` · `discard` 를 부름 ·
* 고침이 바뀌면 `page()` · `page` 없음(일위대가 줄 모달 등) = 위에 [고침 버림] · [수식 저장]
* 고침이 바뀌면 `page()` · `page` 없음(일위대가 줄 모달 등) = 위에 [고침 버림] · [수식 저장] ·
* `tools` 있음(808-1) = 화면 단추를 페이지 머리 칸에
* 가운데 = 왼쪽 줄 쌓기 편집(`line_stack_edit.ts`) | 오른쪽 산출근거 출력(`line_stack_out.ts` · 기계 시간당 경비 펼침)
* 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 · 화면 아래 고정 · 요구 687-3)
* 정본 = 수식 문서(탭을 바꾸면 다시 읽음) · 줄 쌓기 차림 = 문서 칸 `쌓기` · 804-6 옛 수식 문서 탭 걷음 = 이 화면이 「수식」 탭
@@ -66,6 +67,8 @@ export interface StackScreenOptions {
scope?: { layer: Layer; owner: string | null };
/** 페이지가 저장 · 되돌림을 맡음(M01 804-7 · M02 804-8) — 고침 있음이 바뀔 때 부름 · 화면 위 [고침 버림] · [수식 저장] 없음 */
page?: () => void;
/** 페이지 머리 칸(808-1 · 요구 701-3) — 있으면 [단가 갱신] · [재생성] 등 화면 단추를 여기에(화면 위 단추 줄 대신) */
tools?: HTMLElement;
}
type Docs = Record<string, FormulaDoc>;
@@ -267,6 +270,7 @@ export function createStackScreen(opt: StackScreenOptions) {
const draw = (): void => {
if (!doc || !answer) {
[head, left, right, foot].forEach((x) => x.replaceChildren());
opt.tools?.replaceChildren();
return;
}
const options = buildOptions(doc, given, !!cp && !opt.admin, (k, v, x) => {
@@ -311,10 +315,11 @@ export function createStackScreen(opt: StackScreenOptions) {
...(cp ? copyBadges(mark, opt.admin) : []),
],
}),
el("div", { className: "lstk__tools", children: tools }),
el("div", { className: "lstk__tools", children: opt.tools ? [] : tools }),
],
}),
);
opt.tools?.replaceChildren(...tools);
const items = stackOf(doc);
root.dataset.edit = edit() ? "1" : "";
left.replaceChildren(