- 표 값 기호 화면 이름 = 고른 줄 이름(Q_수량2^표 → Q_특별인부 · 겹치면 2 · 3) — 왼쪽 · 산출근거 같음 · 칸 글은 화면 이름 ↔ 기호로 오감(문서 기호 그대로) - 단위 「줄 따름」 안 보임 · 표 값만 든 묶음 머리 = 「표 값」 · [식으로 풀기] → 「식 고치기」 - 산출근거 = 넓을 때 화면 고정 + 자기 스크롤(높이 = 스크롤 영역) · 나란히 놓는 탭 폭 1240 → 1040 - 글 「항목」 → 「그룹」(그룹 이름 · + 그룹 · 그룹 지움) - 짝 강조 = 왼쪽 카드 · 그룹 머리 · 오른쪽 줄 모두 같은 바탕 채우기(테두리 걷음) - [틀로 저장] · 내 틀 목록(브라우저 저장) 걷음 · 공식 틀 고르기 = M01 단가산출 결과만 - 시험 test_804_4 표 값 화면 이름 1,518 전수(겹침 없음 · 식 글 왕복 같음) 더함 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
464 lines
16 KiB
TypeScript
464 lines
16 KiB
TypeScript
/* =============================================================================
|
|
* line_stack_screen.ts
|
|
* 단가산출 「줄 쌓기」 탭(PLAN 804-2 · 요구 670~677) — 공용(M01 단가산출 기초 · M02 단가산출 = 길 앞머리만 바꿈)
|
|
* 위 = 옵션 가로(수식 탭과 같은 부품 `formula_doc_options.ts`) · [고침 버림] · [수식 저장](관리자 · 층 쓰기)
|
|
* 가운데 = 왼쪽 줄 쌓기 편집(`line_stack_edit.ts`) | 오른쪽 산출근거 출력(`line_stack_out.ts` · 기계 시간당 경비 펼침)
|
|
* 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 — 수식 탭과 같은 부품)
|
|
* 정본 = 수식 문서(수식 탭과 같은 문서 · 같은 길 · 같은 캐시 열쇠 — 탭을 바꾸면 다시 읽음) · 줄 쌓기 차림 = 문서 칸 `쌓기`
|
|
* 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음)
|
|
* 고침 = 캐시(sessionStorage)에 쌓고 [수식 저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음
|
|
* M02 사본(`copy`) — 저장 몸 {문서, 끊음(수식 탭이 끊은 문서 그대로), 판} · [단가 갱신] · [재생성]은 수식 탭
|
|
* ========================================================================== */
|
|
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import { createBadge } from "@ui/ui_template_badge";
|
|
import { createButton } from "@ui/ui_template_button";
|
|
import {
|
|
el,
|
|
hideLoadingOverlay,
|
|
showConfirmDialog,
|
|
showLoadingOverlay,
|
|
showToast,
|
|
} 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,
|
|
notesOf,
|
|
stackOf,
|
|
type StackDoc,
|
|
type StackItem,
|
|
} from "../common_util/common_util_line_stack";
|
|
import { copyBadges, type CopyMark, type CopyOptions } from "./formula_doc_copy";
|
|
import { docFoot } from "./formula_doc_foot";
|
|
import { buildOptions } from "./formula_doc_options";
|
|
import { ft } from "./formula_doc_text";
|
|
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 { lt } from "./line_stack_text";
|
|
import "./formula_doc.css";
|
|
import "./line_stack.css";
|
|
|
|
export interface StackScreenOptions {
|
|
/** 길 앞머리(`/m01/logic/doc`) — GET ?key · POST /preview · /save · /links */
|
|
base: string;
|
|
admin: boolean;
|
|
/** sessionStorage 열쇠 — 수식 탭과 같게(같은 문서 고침을 이어 받음) */
|
|
cache: string;
|
|
copy?: CopyOptions;
|
|
/** M02 SK 층(804-4) — 줄 쌓기 다른 단가산출 · 일위대가 복사 길 · 없으면(M01) 복사 없음 */
|
|
scope?: { layer: Layer; owner: string | null };
|
|
}
|
|
|
|
type Docs = Record<string, FormulaDoc>;
|
|
interface Loaded {
|
|
문서: StackDoc;
|
|
셈: DocAnswer;
|
|
연결: DocRecord;
|
|
새?: boolean;
|
|
끊음?: Docs;
|
|
사본?: CopyMark;
|
|
판?: string;
|
|
}
|
|
|
|
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
|
|
|
export function createStackScreen(opt: StackScreenOptions) {
|
|
const head = el("div", { className: "lstk__head" });
|
|
const notes = el("div", { className: "lstk__notes" });
|
|
const left = el("div", { className: "lstk__left" });
|
|
const right = el("div", { className: "lstk__right" });
|
|
const foot = el("div", { className: "lstk__foot" });
|
|
const root = el("div", {
|
|
className: "lstk",
|
|
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)
|
|
let saved = "";
|
|
let doc: StackDoc | null = null;
|
|
let answer: DocAnswer | null = null;
|
|
let cache = createDocCache();
|
|
let given: Record<string, unknown> = {};
|
|
let cut: Docs = {};
|
|
let mark: CopyMark | undefined;
|
|
let version = "";
|
|
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> => {
|
|
const r = await fetch(`${API_BASE_URL}${cp ? cp.route(key, path) : opt.base + path}`, {
|
|
method: body === undefined ? "GET" : "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
signal,
|
|
});
|
|
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
|
|
if (!r.ok) {
|
|
const why = data.detail;
|
|
throw Object.assign(new Error(typeof why === "string" ? why : r.statusText), {
|
|
status: r.status,
|
|
});
|
|
}
|
|
return data;
|
|
};
|
|
|
|
// ── 캐시(수식 탭과 같은 열쇠) ──
|
|
const drafts = (): Record<string, FormulaDoc> => {
|
|
try {
|
|
return JSON.parse(sessionStorage.getItem(opt.cache) ?? "{}") as Record<string, FormulaDoc>;
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
const dirty = (): boolean => !!doc && JSON.stringify(doc) !== saved;
|
|
const keep = (): void => {
|
|
const all = drafts();
|
|
if (dirty()) all[key] = doc!;
|
|
else delete all[key];
|
|
try {
|
|
sessionStorage.setItem(opt.cache, JSON.stringify(all));
|
|
} catch {
|
|
/* 캐시가 막혀도 화면 안 고침은 남음 */
|
|
}
|
|
};
|
|
|
|
// ── 셈 ──
|
|
const usable = (): Record<string, unknown> => {
|
|
const out: Record<string, unknown> = {};
|
|
for (const v of doc?.변수 ?? []) {
|
|
const k = v.열쇠;
|
|
if (!k || !(k in given)) continue;
|
|
const kind = v.연결?.갈래;
|
|
if (kind === "입력") out[k] = given[k];
|
|
if (kind === "선택" && (v.연결?.목록 ?? []).some((x) => String(x) === String(given[k])))
|
|
out[k] = (v.연결!.목록 ?? []).find((x) => String(x) === String(given[k]));
|
|
}
|
|
return out;
|
|
};
|
|
const recalc = (): void => {
|
|
if (!doc) return;
|
|
answer = calcDoc(doc, cache.links, usable());
|
|
if (cache.takeMissing()) ask();
|
|
};
|
|
/** 캐시에 없는 연결 — 뒤에서 서버 기록을 받아 더함 */
|
|
const ask = (): void => {
|
|
const snap = JSON.stringify([doc, usable()]);
|
|
if (snap === asked) return;
|
|
asked = snap;
|
|
ctl?.abort();
|
|
ctl = new AbortController();
|
|
const mine = ++seq;
|
|
void call<{ 연결: DocRecord }>(
|
|
"/preview",
|
|
cp ? { 문서: doc, 끊음: cut } : { 문서: key ? doc : { ...doc, 키: "_새" }, 입력: usable() },
|
|
ctl.signal,
|
|
)
|
|
.then((got) => {
|
|
if (mine !== seq) return;
|
|
cache.merge(got.연결);
|
|
cache.merge({ 문서: cut });
|
|
recalc();
|
|
draw();
|
|
})
|
|
.catch(() => undefined);
|
|
};
|
|
const subDoc = (k: string): FormulaDoc | null => {
|
|
try {
|
|
return cache.links.doc(k) ?? null;
|
|
} catch {
|
|
if (cache.takeMissing()) ask();
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/** 표 하나 — 수식 탭과 같은 길(`/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;
|
|
const items = stackOf(doc);
|
|
fn(doc, items);
|
|
doc = docOf(doc, items);
|
|
keep();
|
|
recalc();
|
|
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" });
|
|
};
|
|
|
|
/** 복사해 펼침(804-4) — 서버 조각 → 새 항목(고침 = 캐시 · [수식 저장] 때 서버가 다시 셈) */
|
|
const takeIn = async (src: string): Promise<void> => {
|
|
try {
|
|
const got = await call<Taken>(`/take?src=${encodeURIComponent(src)}`);
|
|
change((d, items) => addTaken(d, items, got));
|
|
} catch (e) {
|
|
showToast(`${lt("Take_Failed")} — ${e instanceof Error ? e.message : ""}`, "error");
|
|
}
|
|
};
|
|
const takeCtx = () =>
|
|
cp && opt.scope && key
|
|
? { ...opt.scope, me: key, take: (src: string) => void takeIn(src) }
|
|
: undefined;
|
|
|
|
// ── 그림 ──
|
|
const edit = (): boolean => opt.admin && (!!key || !cp);
|
|
const draw = (): void => {
|
|
if (!doc || !answer) {
|
|
[head, notes, left, right, foot].forEach((x) => x.replaceChildren());
|
|
return;
|
|
}
|
|
const options = buildOptions(doc, given, !!cp && !opt.admin, (k, v, x) => {
|
|
const at = doc?.변수?.indexOf(v) ?? -1;
|
|
if (cp) return change((d) => at >= 0 && (d.변수![at].연결 = { ...v.연결!, 값: x }));
|
|
given[k] = x;
|
|
recalc();
|
|
draw();
|
|
});
|
|
const busy = dirty();
|
|
const tools =
|
|
edit() && key
|
|
? [
|
|
createButton({
|
|
label: ft("Discard"),
|
|
variant: "ghost",
|
|
size: 28,
|
|
disabled: !busy,
|
|
onClick: () => void discard(),
|
|
}),
|
|
createButton({
|
|
label: ft("Save"),
|
|
variant: "filled",
|
|
size: 28,
|
|
disabled: !busy,
|
|
onClick: () => void save(),
|
|
}),
|
|
]
|
|
: [];
|
|
head.replaceChildren(
|
|
...(options.length ? [el("div", { className: "fdoc__options", children: options })] : []),
|
|
el("div", {
|
|
className: "lstk__bar",
|
|
children: [
|
|
el("div", {
|
|
className: "lstk__badges",
|
|
children: [
|
|
...(busy ? [createBadge(ft("Dirty"), "warning")] : []),
|
|
...(cp ? copyBadges(mark, opt.admin) : []),
|
|
],
|
|
}),
|
|
el("div", { className: "lstk__tools", children: tools }),
|
|
],
|
|
}),
|
|
);
|
|
const kept = notesOf(doc);
|
|
notes.replaceChildren(
|
|
...(kept.length
|
|
? [
|
|
el("p", {
|
|
className: "ui-text ui-text--muted",
|
|
text: `${lt("Notes")} — ${kept.join(" · ")}`,
|
|
}),
|
|
]
|
|
: []),
|
|
);
|
|
const items = stackOf(doc);
|
|
root.dataset.edit = edit() ? "1" : "";
|
|
left.replaceChildren(
|
|
...(edit()
|
|
? [
|
|
buildEdit({
|
|
doc,
|
|
answer,
|
|
items,
|
|
change,
|
|
table: tableOf,
|
|
subDoc,
|
|
name: (k) => cache.names.get(k) ?? "",
|
|
open: blocks,
|
|
pair: (loc) => pair(loc, "left"),
|
|
take: takeCtx(),
|
|
}),
|
|
]
|
|
: []),
|
|
);
|
|
right.replaceChildren(
|
|
items.length || doc.결과
|
|
? buildOut(doc, answer, {
|
|
cache,
|
|
open: opened,
|
|
toggle: (at) => {
|
|
if (opened.has(at)) opened.delete(at);
|
|
else opened.add(at);
|
|
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);
|
|
fit();
|
|
};
|
|
|
|
const take = (got: Loaded): void => {
|
|
cache.merge(got.연결);
|
|
saved = JSON.stringify(got.문서);
|
|
doc = clone(got.문서);
|
|
cut = clone(got.끊음 ?? {});
|
|
mark = got.사본;
|
|
version = got.판 ?? "";
|
|
};
|
|
|
|
const save = async (): Promise<void> => {
|
|
if (!doc || !dirty()) return;
|
|
const kept = drafts()[`${key}#끊음`] as unknown as Docs | undefined; // 수식 탭이 끊은 문서 고침
|
|
const send = cp ? { 문서: doc, 끊음: kept ?? cut, 판: version } : { 문서: doc };
|
|
showLoadingOverlay();
|
|
try {
|
|
const got = await call<Loaded>("/save", send);
|
|
take(got);
|
|
const all = drafts();
|
|
delete all[`${key}#끊음`];
|
|
sessionStorage.setItem(opt.cache, JSON.stringify(all));
|
|
keep();
|
|
recalc();
|
|
draw();
|
|
if (cp && got.판) cp.onSaved?.(got.판);
|
|
showToast(ft("Saved"), "success");
|
|
} catch (e) {
|
|
showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error");
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
};
|
|
|
|
const discard = async (): Promise<void> => {
|
|
if (!doc || !dirty()) return void draw();
|
|
doc = JSON.parse(saved) as StackDoc;
|
|
keep();
|
|
recalc();
|
|
draw();
|
|
};
|
|
|
|
/** 문서 열기 — 서버 · 캐시에서 다시 읽음(수식 탭 고침 · 저장을 이어 받음) */
|
|
const open = async (next: string): Promise<void> => {
|
|
const was = key;
|
|
shown = next;
|
|
if (next !== key) {
|
|
paired = "";
|
|
opened.clear();
|
|
blocks.clear();
|
|
given = {};
|
|
}
|
|
key = next;
|
|
doc = answer = null;
|
|
asked = "";
|
|
cache = createDocCache();
|
|
tables.clear();
|
|
[cut, mark, version] = [{}, undefined, ""];
|
|
draw();
|
|
if (!next) {
|
|
if (cp)
|
|
return void right.replaceChildren(
|
|
el("p", { className: "ui-text ui-text--muted", text: lt("Unsaved_New") }),
|
|
);
|
|
doc = (drafts()[""] as StackDoc | undefined) ?? blankDoc();
|
|
saved = JSON.stringify(blankDoc());
|
|
recalc();
|
|
return draw();
|
|
}
|
|
const mine = ++seq;
|
|
try {
|
|
const got = await call<Loaded>(cp ? "" : `?key=${encodeURIComponent(next)}`);
|
|
if (mine !== seq || key !== next) return;
|
|
take(got);
|
|
const all = drafts();
|
|
if (!was && got.새 && all[""] && !all[next]) {
|
|
all[next] = { ...all[""], 키: next, 이름: got.문서.이름, 결과단위: got.문서.결과단위 };
|
|
delete all[""];
|
|
sessionStorage.setItem(opt.cache, JSON.stringify(all));
|
|
}
|
|
if (all[next]) doc = clone(all[next] as StackDoc);
|
|
if (cp && all[`${next}#끊음`])
|
|
cache.merge({ 문서: (cut = clone(all[`${next}#끊음`] as unknown as Docs)) });
|
|
recalc();
|
|
draw();
|
|
} catch (e) {
|
|
if (mine === seq)
|
|
head.replaceChildren(
|
|
el("p", {
|
|
className: "ui-text ui-text--muted",
|
|
text: `${ft("Load_Failed")} — ${e instanceof Error ? e.message : ""}`,
|
|
}),
|
|
);
|
|
}
|
|
};
|
|
|
|
const canLeave = async (): Promise<boolean> => {
|
|
if (!dirty()) return true;
|
|
if (!(key in drafts())) return true; // 수식 탭에서 이미 버림
|
|
if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false;
|
|
doc = JSON.parse(saved) as StackDoc;
|
|
keep();
|
|
return true;
|
|
};
|
|
|
|
return {
|
|
root,
|
|
/** 로직 · SK 키로 그림(빈 키 = 저장 전 새 것) — 같은 키는 그대로 · `force` = 다시 읽음(탭 바꿈) */
|
|
draw: (next: string, force = false): void => {
|
|
if (force || next !== shown) void open(next);
|
|
},
|
|
canLeave,
|
|
/** 시험 · 확인용 */
|
|
state: () => ({ doc, answer, dirty: dirty(), items: doc ? stackOf(doc) : [] }),
|
|
};
|
|
}
|