Merge remote-tracking branch 'origin/dev' into sub_laptop_3

This commit is contained in:
2026-11-03 06:16:18 +09:00
35 changed files with 979 additions and 198 deletions
@@ -26,7 +26,7 @@ import type { LaborCalc } from "./M02_MasterTemplete_Labor_Api";
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { formatPrice } from "../common_util/common_util_money";
import { formatNumber, formatPrice } from "../common_util/common_util_money";
import { tableOf } from "./logic/M02_MasterTemplete_Hopyo_Draft";
/** 드롭다운 — 저장 값(서버 뺌 까닭) → 화면 글 */
@@ -62,8 +62,7 @@ export interface RowsContext {
options: (line: HopyoLine, at: number) => void;
}
const num = (v: number): string =>
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
const num = formatNumber;
const small = (text: string, cls = "m02-labrow__note"): HTMLElement =>
el("small", { className: cls, text, attrs: { title: text } });
@@ -12,6 +12,7 @@ import { t as L, type LocaleKey } from "@ui/ui_template_locale";
import { createSegment } from "@ui/ui_template_segment";
import { createTable } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { formatNumber, formatPrice } from "../common_util/common_util_money";
import type {
CompareLook,
ElemCand,
@@ -26,7 +27,7 @@ const LOOK: Record<CompareLook, { tone: BadgeTone; key: LocaleKey }> = {
기초만: { tone: "neutral", key: "M02_CmpBase" },
};
const LOOKS = Object.keys(LOOK) as CompareLook[];
const num = (n: number | null): string => (n === null ? "—" : String(Number(n.toPrecision(4))));
const num = (n: number | null): string => (n === null ? "—" : formatNumber(n));
/** 선택 칸 글 뒤 요약 — 「같음 3 · 품 다름 1」 · 견줌 못함 = "" */
export const compareBrief = (c: SangeunCompare | null | undefined): string =>
@@ -112,7 +113,7 @@ export function elemChoices(cands: ElemCand[], pick: (c: ElemCand) => void): HTM
const box = el("div", { className: "ui-toolbar", attrs: { "data-elem": "" } });
box.title = L("M02_ElemTitle");
for (const c of cands.slice(0, 3)) {
const price = c.단가 ? ` · ${c.단가.toLocaleString()}` : "";
const price = c.단가 ? ` · ${formatPrice(c.단가)}` : "";
const b = createButton({
label: `${c.이름}${c.규격 ? ` ${c.규격}` : ""} [${c.단위 ?? ""}]${price}`,
size: 24,
@@ -38,6 +38,7 @@ import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead";
import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side";
import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit";
import {
dropBase,
@@ -63,6 +64,8 @@ export interface SangeunDetailOptions {
owner: string | null;
readOnly: boolean;
onChanged: (dirty: boolean) => void;
/** 752-1 오른쪽 원문 패널 — 단가산출 화면만 켬(일위대가 줄 모달 안 상세는 끔) */
source?: boolean;
}
const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" };
@@ -90,6 +93,9 @@ export function mountSangeunDetail(
let fail = "";
let extra: LogicInput[] = []; // 더한줄 로직 입력 중 바탕에 없는 것 = 옵션 칸(725-6 · 727-3)
const inputsOf = new Map<string, LogicInput[]>();
const infoOf = new Map<string, SourceTab>(); // 더한줄 로직 → 원문 탭(752-1)
let extraKeys: string[] = [];
const side = opt.source ? createSangeunSide() : null;
let seq = 0;
let alive = true;
@@ -141,19 +147,17 @@ export function mountSangeunDetail(
touch(false);
void loadBase(true);
}
const baseHint = createText(L("M02_SangeunBaseViews"));
host.replaceChildren(
el("div", {
className: "m02-sk",
attrs: { "data-sangeun-detail": doc.키 ?? "" },
children: [
head,
notes,
el("div", { className: "m02-sk__head", children: [views.root, addButton, baseHint] }),
editor,
],
}),
);
const main = el("div", {
className: "m02-sk",
attrs: { "data-sangeun-detail": doc.키 ?? "" },
children: [
head,
notes,
el("div", { className: "m02-sk__head", children: [views.root, addButton] }),
editor,
],
});
host.replaceChildren(side ? side.wrap(main) : main);
const opened = (): boolean => (doc.열린입력 ?? []).length > 0;
// 바탕 없는 SK 의 지문 = 이름 · 단위 따라 바뀜 — 바탕이 있을 때만 M01 개정으로 봄(725-3) · 둘째 기초 지문(727-2)
@@ -185,11 +189,15 @@ export function mountSangeunDetail(
.filter((k) => !inputsOf.has(k))
.map((k) =>
fetchLogic(k).then(
(r) => void inputsOf.set(k, r.logic.입력 ?? []),
(r) => {
inputsOf.set(k, r.logic.입력 ?? []);
infoOf.set(k, tabOf(r.logic));
},
() => void inputsOf.set(k, []),
),
),
);
extraKeys = keys;
const seen = new Set<string>((base?.logic.입력 ?? []).map((i) => i.이름));
extra = keys
.flatMap((k) => inputsOf.get(k) ?? [])
@@ -306,7 +314,6 @@ export function mountSangeunDetail(
if (!alive) return;
drawHead();
drawNotes();
baseHint.hidden = !base;
const rows = ans ? rowsOf(ans, bare, doc) : [];
const names = new Map((bare ? rowsOf(bare, null, {}) : []).map((r) => [r.cid, r.이름]));
const removed = [
@@ -382,6 +389,10 @@ export function mountSangeunDetail(
cover: { ho, est, open: lineOpener(hoCtx) }, // 748-2 텍스트 수식 변수 · 값 누름 = 같은 줄 모달
});
drawCalc();
side?.set([
...(base ? [tabOf(base.logic)] : []),
...extraKeys.flatMap((k) => (k === baseKey() ? [] : infoOf.has(k) ? [infoOf.get(k)!] : [])),
]);
}
/** 재료 고름(줄 모달 · 시험 계산 재료 칸) = SK 문서 `고름`(cid → MT) — 둘째 기초 세부 줄은 보기만 */
@@ -0,0 +1,83 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Side.ts
* 단가산출(SK) 상세 오른쪽 원문 패널(PLAN 752-1 · 요구 588-3) — 일위대가 상세(47-4 · 85-4)와 같은 부품:
* 오른쪽 서랍(`createSidePanels`) + 원문 보기(`createSourceView` · M02 로그인 길 `/m02/logic-lib/source`)
* 기초마다 탭 — 바탕 로직 · 둘째 기초(더한줄 로직) 각각의 출처 · 기초가 하나면 탭 없음 · 없으면 안내
* 처음 = 넓은 화면만 열림(좁으면 세로 띠로 접힘) · 열림 · 폭 기억 = `m02-sangeun-side`
* 일위대가 줄 모달 안 상세는 안 만듦(부르는 쪽이 `source` 를 안 켬).
* ========================================================================== */
import { createSegment } from "@ui/ui_template_segment";
import { el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { createText } from "@ui/ui_template_text";
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import { tc } from "./M02_MasterTemplete_Labor_Text";
export interface SourceTab {
label: string;
src: string;
}
/** 기초 로직 → 원문 탭(「원문번호 이름」 · 출처 글) */
export const tabOf = (logic: {
키: string;
원문번호?: string;
이름: string;
출처?: string;
}): SourceTab => ({
label: `${logic.원문번호 || logic.키} ${logic.이름}`,
src: logic.출처 ?? "",
});
/** 이 폭 이상일 때만 처음부터 열림 — 상세 표 여섯 칸이 좁아지지 않게 */
const OPEN_FROM = 1500;
export function createSangeunSide(): {
wrap: (main: HTMLElement) => HTMLElement;
set: (tabs: SourceTab[]) => void;
} {
const view = createSourceView({
path: "/m02/logic-lib/source",
pdf: "/m02/logic-lib/source-pdf",
});
const host = el("div", { attrs: { "data-sangeun-source": "" } });
let tabs: SourceTab[] = [];
let at = 0;
const side = createSidePanels({
prefKey: "m02-sangeun-side",
follow: false, // 일위대가와 같음 — 상세 고정 높이 칸을 채움 · 본문과 따로 스크롤
defaultOpen: window.innerWidth >= OPEN_FROM ? "source" : null,
panels: [{ id: "source", title: tc("Labor_Source"), body: host }],
onChange: () => paint(),
});
function paint(): void {
if (side.current() !== "source") return; // 접힘 = 찾지 않음
if (!tabs.length)
return void host.replaceChildren(createText(L("M02_SangeunSourceNone"), "muted"));
at = Math.min(at, tabs.length - 1);
view.show(tabs[at].src);
const bar =
tabs.length > 1
? [
createSegment({
options: tabs.map((x, i) => ({ value: String(i), label: x.label })),
value: String(at),
size: 28,
ariaLabel: tc("Labor_Source"),
onChange: (v) => ((at = Number(v)), paint()),
}).root,
]
: [];
host.replaceChildren(...bar, view.root);
}
return {
wrap: (main) => el("div", { className: "ui-side-panels-row", children: [main, side.root] }),
set: (next) => {
if (JSON.stringify(next) === JSON.stringify(tabs)) return;
tabs = next;
paint();
},
};
}
@@ -18,5 +18,5 @@ export function mountSangeunView(
doc: unknown,
opt: SangeunViewOptions,
): { getDoc: () => unknown; destroy: () => void } {
return mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null });
return mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null, source: true }); // 752-1
}
@@ -91,6 +91,8 @@ export interface SkLine {
원문?: { 수량: number | null; 금액: number | null; 비목?: Record<string, number | null> };
/** 재료 줄이 잡은 품목(시험 계산 재료 칸 · 줄 모달) */
품목?: CalcLine["품목"];
/** (753-1) 옵션으로 빠진 줄 — 화면은 안 그림 */
옵션꺼짐?: boolean;
}
export interface SkMat {
@@ -153,6 +155,8 @@ export interface CoverRow {
sub: { k: number; cid: string; scale: number } | null;
/** 화면배(728-2) — 문서 품 = 화면 품 × t · `was` = 문서 몫 */
t: number;
/** (753-2) 옵션으로 빠진 줄 — 계산서에서 안 그림 */
off: boolean;
}
/* ── 순수 셈 ── */
@@ -239,7 +243,13 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR
금액: number | null;
},
parts: Record<string, number>,
extra: { 묶음?: string | null; 율?: number; apart?: boolean; 바탕값?: SkLine["바탕값"] },
extra: {
묶음?: string | null;
율?: number;
apart?: boolean;
바탕값?: SkLine["바탕값"];
옵션꺼짐?: boolean;
},
): CoverRow => {
const hit = ADD_RE.exec(cid);
const added = hit ? Number(hit[1]) : null;
@@ -283,6 +293,7 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR
apartPick: one?.종류 === "재료",
sub: at ? { ...at, scale: Number(owner?.수량 ?? 1) } : null,
t: pct || at ? 1 : t, // 세부 줄 품 = M01 원문 그대로(744-1) — 화면배 안 곱함
off: !!extra.옵션꺼짐,
};
};
for (const l of flatLines(ans)) rows.push(row(l.cid, l, partsOf(l), l));
@@ -422,6 +433,7 @@ export function sheetGroups(rows: CoverRow[]): SheetGroup[] {
금액: amount,
묶음: r.묶음,
장비: entries.length > 1 && (r.sub?.cid ?? r.cid).startsWith("logic:"),
...(r.off ? { 옵션꺼짐: true } : {}),
});
}
}