feat(738-2 · 740-2): 단가산출 · 일위대가 상세 머리 [초기화] [삭제] · 넣은 줄 출처 꼬리표
- 머리 단추 차례 … [새로고침] [초기화] [삭제] [저장] — 새 Main_Actions(쓰기 권한 있는 층만 보임) - [삭제] = 735 경고 창(쓰는 곳 표 · 키) → 나무 지우기 → 목록으로 · 쓰는 곳 없음 = 한 줄 물음 - [초기화] = 서버 -reset 미리 보기(걷힐 고침 수 · 뺀 줄 되살림 수) → 물음 → 그 층에 씀 → 다시 그림 · 저장 안 한 고침은 먼저 물음 · 옵션 · 기본정보 그대로 - 출처 꼬리표(본뜬) — 순수 logic/M02_Born_Tag · 단가산출 = 이름 옆 글 꼬리표(공용 Ho 꼬리표 칸을 이름 줄 안으로 · 첫 줄 보임 · 둘째 줄 풍선) · 일위대가표 격자 = 비고 칸 글 + 풍선 · 줄 표 = 이름 끝 - 시험: 새 test_740_2 · 낡은 시험 4 고침(test_727 · 717_2b · 704_3 · 704_4 — 모델 · 격자 파일을 이어 붙여 도는 시험이라 새 꼬리표 글 파일을 목록에 더함) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -449,17 +449,22 @@ function formulaCell(e: Entry, ctx: HoContext, text = ""): HTMLElement {
|
||||
|
||||
const tagOf = (x: object): string | undefined => (x as { 꼬리표?: string }).꼬리표 || undefined;
|
||||
const sourceTag = (text: string): HTMLElement => {
|
||||
const tag = createBadge(text, "neutral");
|
||||
const [shown, ...tip] = text.split("\n"); // 첫 줄 = 보임 · 나머지 = 풍선(없으면 같은 글) — 740-2
|
||||
const tag = createBadge(shown, "neutral");
|
||||
tag.classList.add("m01lab__src");
|
||||
tag.title = text;
|
||||
tag.title = tip.length ? tip.join(" ") : shown;
|
||||
return tag;
|
||||
};
|
||||
|
||||
function nameCell(e: Entry, label: string, tag: string): HTMLElement {
|
||||
return el("div", {
|
||||
children: [
|
||||
el("div", { text: label }),
|
||||
...(e.source ? [sourceTag(e.source)] : []),
|
||||
e.source
|
||||
? el("div", {
|
||||
className: "m01lab__namerow", // 이름 옆에 꼬리표 — 줄 높이 안 바뀜(740-2)
|
||||
children: [el("span", { text: label }), sourceTag(e.source)],
|
||||
})
|
||||
: el("div", { text: label }),
|
||||
...(e.share && label === e.name ? [createBadge(`${e.share} ${tl("Share")}`, "accent")] : []),
|
||||
...(e.spec ? [el("div", { className: "ui-text ui-text--muted", text: e.spec })] : []),
|
||||
...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []),
|
||||
|
||||
@@ -216,6 +216,17 @@ export const checkLibraryDelete = <T>(
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 대상: picks }),
|
||||
});
|
||||
/** [초기화](740-2) — 미리 보기(`미리보기` true) = 걷힐 고침 수 · 아니면 그 층 문서에 씀(`판` 이 다르면 409) */
|
||||
export const resetLibrary = (
|
||||
kind: LibraryKind,
|
||||
layer: Layer,
|
||||
owner: string | null,
|
||||
body: { 키: string; 미리보기: boolean; 판?: string },
|
||||
): Promise<{ 걷힘: number; 문서: unknown }> =>
|
||||
call(owned(layer, `/layers/${layer}/${kind}-reset`, owner), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
/** 복사해 둔 문서 · 폴더 → 이 층 `dest` 아래 새 키(폴더는 안의 것까지 · 같은 폴더 이름 「(1)」) */
|
||||
export const pasteLibraryTree = (
|
||||
kind: LibraryKind,
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 740-2 넣어 펼친 줄 출처 꼬리표 — 이름 글 끝에 이어 붙음(줄 높이 그대로 · 넘치면 말줄임) */
|
||||
.m02-labrow__born {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-regular, 400);
|
||||
}
|
||||
|
||||
.m02-labrow__key,
|
||||
.m02-labrow__note {
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
@@ -111,6 +111,8 @@ export interface LaborRow {
|
||||
고정?: LaborFixed;
|
||||
/** 그 줄 덮개(715-3 · 717-3 단가산출 모달) — 로직 줄 · SK 참조 줄 · 서버가 검사 */
|
||||
패치?: LinePatch;
|
||||
/** 넣어 펼친 줄 출처(740-1 · 740-2 꼬리표) — {층, 주인, 키, 이름, 때} · 셈에 안 씀 */
|
||||
본뜬?: Record<string, unknown>;
|
||||
}
|
||||
/** 자재 세 칸(148-4 · 서버 `M02_Labor_Doc.MARKS` 와 같음) — 빈 칸 = 읽을 때 기본값 · 금액 무관 */
|
||||
export const MAT_MARKS = {
|
||||
@@ -151,6 +153,8 @@ function cleanShown(v: unknown): { 자재표시?: Record<string, MatMarks> } {
|
||||
return Object.keys(out).length ? { 자재표시: out } : {};
|
||||
}
|
||||
export interface LaborMat extends MatMarks {
|
||||
/** 넣어 펼친 자재 출처(740-2) */
|
||||
본뜬?: Record<string, unknown>;
|
||||
코드: string;
|
||||
이름: string | null;
|
||||
규격: string | null;
|
||||
@@ -192,6 +196,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
||||
...(r.참조 || r.고정 ? {} : cleanShown(r.자재표시)),
|
||||
...(r.고정 ? { 고정: r.고정 } : {}), // 705-1 — 읽을 때 떨구면 [저장]에서 줄이 사라짐
|
||||
...(r.패치 ? { 패치: r.패치 } : {}), // 717-3 — 떨구면 [저장]에서 줄 덮개가 사라짐
|
||||
...(r.본뜬 ? { 본뜬: r.본뜬 } : {}), // 740-2 — 출처 꼬리표
|
||||
})),
|
||||
자재: (d.자재 ?? []).map((m) => ({
|
||||
코드: m.코드,
|
||||
@@ -199,6 +204,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
||||
규격: m.규격 ?? null,
|
||||
단위: m.단위 ?? null,
|
||||
수량: m.수량 ?? null,
|
||||
...(m.본뜬 ? { 본뜬: m.본뜬 } : {}), // 740-2
|
||||
...cleanMarks(m),
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Born.ts
|
||||
* 일위대가 상세 출처 꼬리표(740-2) — 넣어 펼친 줄 · 자재의 `본뜬` 을 짧은 글로(풍선 = 키 · 이름 · 층 · 때).
|
||||
* 일위대가표 격자 = 비고 칸 글 `bornNote` + 풍선 `tipBorn`(칸 폭 · 줄 높이 안 바뀜)
|
||||
* 줄 표(`Labor_Rows` · `Labor_MatTable`) = 이름 글 끝 `bornSpan`(같은 줄 · 넘치면 말줄임)
|
||||
* 색 단추 아님 · 셈에 안 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { bornText } from "./logic/M02_Born_Tag";
|
||||
|
||||
const SPLIT = "\n"; // 글 첫 줄 = 보임 · 나머지 = 풍선
|
||||
|
||||
/** 일위대가표 격자 비고 칸 풍선 — `at` = 격자 줄 → 일위대가 줄 번호(담은 로직 뒤 자재) */
|
||||
export function tipBorn(
|
||||
root: HTMLElement,
|
||||
at: (row: number) => number | null,
|
||||
born: (n: number) => unknown,
|
||||
): void {
|
||||
root.querySelectorAll<HTMLElement>("tr[data-i]").forEach((tr) => {
|
||||
const n = at(Number(tr.dataset.i));
|
||||
const [, ...tip] = n === null ? [] : bornText(born(n)).split(SPLIT);
|
||||
if (tip.length)
|
||||
tr.querySelector<HTMLElement>("td:nth-last-child(2)")?.setAttribute("title", tip.join(" "));
|
||||
});
|
||||
}
|
||||
|
||||
/** 줄 표 이름 끝 꼬리표 — `본뜬` 이 없으면 null */
|
||||
export function bornSpan(born: unknown): HTMLElement | null {
|
||||
const [shown, ...tip] = bornText(born).split(SPLIT);
|
||||
if (!shown) return null;
|
||||
return el("span", {
|
||||
className: "m02-labrow__born",
|
||||
text: ` ${shown}`,
|
||||
attrs: { title: tip.join(" "), "data-born": "" },
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@ui/ui_template_hopyo_grid";
|
||||
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { LaborCalc, LaborCost, LaborDoc } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { bornNote } from "./logic/M02_Born_Tag";
|
||||
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { valueLine } from "./M02_Labor_Values_Model";
|
||||
@@ -79,7 +80,8 @@ export function laborBlock(
|
||||
amounts: money(line?.반영),
|
||||
note:
|
||||
line?.까닭 ??
|
||||
(calc && qty === null ? tc("Grid_NoQty") : row.참조 ? layerWord(row.참조.층) : row.로직),
|
||||
(bornNote(row.본뜬) || // 740-2 넣은 줄 = 출처 글
|
||||
(calc && qty === null ? tc("Grid_NoQty") : row.참조 ? layerWord(row.참조.층) : row.로직)),
|
||||
tag: HOPYO_TAG.pum,
|
||||
};
|
||||
});
|
||||
@@ -101,7 +103,7 @@ export function laborBlock(
|
||||
? tc("Grid_NoQty")
|
||||
: mat.별도계상 !== "직접계산"
|
||||
? tc("Grid_Apart") // 짝 `settle` 의 aside 와 같은 규칙
|
||||
: mat.코드),
|
||||
: bornNote(mat.본뜬) || mat.코드),
|
||||
};
|
||||
});
|
||||
const lines = [...logicLines, ...matLines];
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
LaborCalcMat,
|
||||
LaborMat,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { bornSpan } from "./M02_MasterTemplete_Labor_Born";
|
||||
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
|
||||
import {
|
||||
moneyCell,
|
||||
@@ -66,7 +67,10 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("strong", { text: mat.이름 ?? "" }),
|
||||
el("strong", {
|
||||
text: mat.이름 ?? "",
|
||||
children: [bornSpan(mat.본뜬)].filter((x): x is HTMLElement => !!x),
|
||||
}),
|
||||
...(line?.까닭
|
||||
? [el("small", { className: "ui-text ui-text--error", text: line.까닭 })]
|
||||
: []),
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
LaborRow,
|
||||
MatMarkKey,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { bornSpan } from "./M02_MasterTemplete_Labor_Born";
|
||||
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { attachRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
@@ -414,6 +415,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
}),
|
||||
el("strong", {
|
||||
text: fx?.이름 ?? (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직,
|
||||
children: [bornSpan(row.본뜬)].filter((x): x is HTMLElement => !!x),
|
||||
}),
|
||||
...(values.empty
|
||||
? [
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import type { LaborDetailHandle, LaborDetailOptions } from "./M02_MasterTemplete_Labor_Detail";
|
||||
import { laborBlock } from "./M02_MasterTemplete_Labor_Grid";
|
||||
import { tipBorn } from "./M02_MasterTemplete_Labor_Born";
|
||||
import { unlinkedBar } from "./M02_MasterTemplete_Labor_Unlinked";
|
||||
import { pasteInto } from "./M02_MasterTemplete_Labor_Paste";
|
||||
import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick";
|
||||
@@ -187,6 +188,7 @@ export function mountLaborTable(
|
||||
// 칸 초점이 표를 밀면(scroll) 메뉴가 바로 닫힘 — 밀림이 끝난 다음 그림에 엶
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => openTreeMenu(ev, menuAt(n))));
|
||||
});
|
||||
tipBorn(grid.root, at, (n) => (body.담은로직[n] ?? body.자재[n - body.담은로직.length])?.본뜬); // 740-2
|
||||
const bar = unlinkedBar(cur, edit && ((qtyOnly) => changed(qtyOnly))); // 729-3 안 이은 줄 띠
|
||||
gridHost.replaceChildren(...(bar ? [bar] : []), grid.root);
|
||||
showSource();
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Main_Actions.ts
|
||||
* 단가산출 · 일위대가 상세 머리 단추 [초기화] [삭제](740-2 · 738-2 · 요구 579-3 · 581) — 차례 … [초기화] [삭제] [저장].
|
||||
* [삭제] = 735 경고 창(쓰는 곳 표 · 키) → 나무 지우기 → 목록으로 · 쓰는 곳 없으면 한 줄 물음.
|
||||
* [초기화] = 서버 `-reset` 미리 보기(걷힐 고침 수) → 물음 → 그 층에 씀 → 다시 읽음.
|
||||
* 덮개 · 뺀줄 · 더한줄 단가를 M01 키 값으로 되돌림 · 옵션(입력값) · 기본정보는 그대로(서버 740-1).
|
||||
* 둘 다 쓰기 권한이 있는 층에서만 보임(`update`) · 상세에 저장 안 한 고침이 있으면 먼저 물음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import {
|
||||
editPrecomboTree,
|
||||
fetchPrecomboTree,
|
||||
resetLibrary,
|
||||
type LibraryKind,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { askLibraryDelete } from "./M02_MasterTemplete_Library_Delete";
|
||||
import { precomboList } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import { sangeunList } from "./M02_MasterTemplete_UI_Side_Sangeun";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
const TEXT = {
|
||||
Reset: ["초기화", "Reset"],
|
||||
ResetTip: [
|
||||
"고친 값을 연결된 M01 키 값으로 되돌림 — 옵션 · 기본정보는 그대로",
|
||||
"Revert edited values to their linked M01 key values — options and basic info stay",
|
||||
],
|
||||
Delete: ["삭제", "Delete"],
|
||||
DeleteTip: [
|
||||
"이 항목을 지움 — 쓰는 곳이 있으면 먼저 알림",
|
||||
"Delete this item — warns first if used",
|
||||
],
|
||||
ResetAsk: [
|
||||
"고친 값 {n} 개를 M01 값으로 되돌림{k} — 옵션 · 기본정보는 그대로",
|
||||
"Revert {n} edited value(s) to M01 values{k} — options and basic info stay",
|
||||
],
|
||||
Restored: [" (빼 둔 줄 {k} 개 되살림)", " ({k} removed line(s) restored)"],
|
||||
Nothing: ["되돌릴 고침이 없음", "Nothing to revert"],
|
||||
Done: ["M01 값으로 되돌렸음", "Reverted to M01 values"],
|
||||
DeleteAsk: ["「{name}」 을(를) 지울까?", "Delete 「{name}」?"],
|
||||
Deleted: ["지웠음", "Deleted"],
|
||||
Failed: ["못 했음 — {why}", "Failed — {why}"],
|
||||
} as const;
|
||||
const tx = (key: keyof typeof TEXT): string =>
|
||||
String(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]);
|
||||
const fill = (text: string, vars: Record<string, string | number>): string =>
|
||||
Object.entries(vars).reduce((s, [k, v]) => s.replace(`{${k}}`, String(v)), text);
|
||||
|
||||
export interface MainActionsCtx {
|
||||
sel: () => Selection | null;
|
||||
/** 지금 읽은 문서의 판 */
|
||||
version: () => string;
|
||||
/** 지금 읽은 문서(저장본) — 뺀 줄 수를 셈 */
|
||||
loaded: () => unknown;
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
open: (sel: Selection | null) => Promise<void>;
|
||||
/** 좌측 목록을 다시 읽음 */
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
/** 문서 안 모든 `뺀줄` 길이 합(단가산출 · 일위대가 줄 안 덮개까지) */
|
||||
function removedCount(node: unknown): number {
|
||||
if (Array.isArray(node)) return node.reduce<number>((n, x) => n + removedCount(x), 0);
|
||||
if (!node || typeof node !== "object") return 0;
|
||||
return Object.entries(node as Record<string, unknown>).reduce(
|
||||
(n, [k, v]) => n + (k === "뺀줄" && Array.isArray(v) ? v.length : removedCount(v)),
|
||||
0,
|
||||
);
|
||||
}
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
|
||||
export function createMainActions(ctx: MainActionsCtx): {
|
||||
reset: HTMLButtonElement;
|
||||
del: HTMLButtonElement;
|
||||
update: (writable: boolean) => void;
|
||||
} {
|
||||
const reset = createButton({ label: tx("Reset"), variant: "ghost" });
|
||||
const del = createButton({ label: tx("Delete"), variant: "danger-ghost" });
|
||||
reset.title = tx("ResetTip");
|
||||
del.title = tx("DeleteTip");
|
||||
reset.dataset.mainReset = "";
|
||||
del.dataset.mainDelete = "";
|
||||
|
||||
const target = (): { at: Selection; kind: LibraryKind } | null => {
|
||||
const at = ctx.sel();
|
||||
const kind = at?.kind;
|
||||
return at && at.name && (kind === "sangeun" || kind === "precombo") ? { at, kind } : null;
|
||||
};
|
||||
|
||||
reset.addEventListener("click", async () => {
|
||||
const got = target();
|
||||
if (!got || !(await ctx.confirmLeave())) return;
|
||||
const { at, kind } = got;
|
||||
try {
|
||||
const look = await resetLibrary(kind, at.layer, at.owner ?? null, {
|
||||
키: at.name,
|
||||
미리보기: true,
|
||||
});
|
||||
if (!look.걷힘) return void showToast(tx("Nothing"), "success");
|
||||
const k = removedCount(ctx.loaded());
|
||||
const message = fill(tx("ResetAsk"), {
|
||||
n: look.걷힘,
|
||||
k: k ? fill(tx("Restored"), { k }) : "",
|
||||
});
|
||||
if (!(await askConfirm({ message, okLabel: tx("Reset"), danger: true }))) return;
|
||||
await resetLibrary(kind, at.layer, at.owner ?? null, {
|
||||
키: at.name,
|
||||
미리보기: false,
|
||||
판: ctx.version(),
|
||||
});
|
||||
await ctx.open(at); // 다시 그림
|
||||
ctx.changed();
|
||||
showToast(tx("Done"), "success");
|
||||
} catch (error) {
|
||||
showToast(fill(tx("Failed"), { why: why(error) }), "error");
|
||||
}
|
||||
});
|
||||
|
||||
del.addEventListener("click", async () => {
|
||||
const got = target();
|
||||
if (!got || !(await ctx.confirmLeave())) return;
|
||||
const { at, kind } = got;
|
||||
const owner = at.owner ?? null;
|
||||
try {
|
||||
const tree = await fetchPrecomboTree(at.layer, owner, kind);
|
||||
const pick = { 경로: tree.자리?.[at.name] ?? [], 키: at.name };
|
||||
const ask = fill(tx("DeleteAsk"), { name: at.label ?? at.name });
|
||||
const ans = await askLibraryDelete(kind, at.layer, owner, [pick], ask, tx("Delete"));
|
||||
if (!ans.ok) return;
|
||||
await editPrecomboTree(
|
||||
at.layer,
|
||||
{ op: "delete", 대상: [pick], ...(ans.sure ? { 확인: true } : {}) },
|
||||
tree.판,
|
||||
owner,
|
||||
kind,
|
||||
);
|
||||
const scope = { layer: at.layer, owner, canWrite: true } as Parameters<typeof sangeunList>[0];
|
||||
await ctx.open(kind === "sangeun" ? sangeunList(scope) : precomboList(scope)); // 목록으로
|
||||
ctx.changed();
|
||||
showToast(tx("Deleted"), "success");
|
||||
} catch (error) {
|
||||
showToast(fill(tx("Failed"), { why: why(error) }), "error");
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
reset,
|
||||
del,
|
||||
update: (writable) => {
|
||||
const on = writable && !!target();
|
||||
reset.hidden = del.hidden = !on;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -52,10 +52,26 @@
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 740-2 이름 + 출처 꼬리표 한 줄 — 이름이 말줄임으로 줄고 꼬리표는 그대로 */
|
||||
.m02-sk .m01lab__namerow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-sk .m01lab__namerow > span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 727-2 기초 출처 꼬리표 — 칸 폭 고정 · 넘치면 말줄임(풍선 = 온 글) · 이름 칸 폭을 안 바꿈 */
|
||||
.m02-sk .m01lab__src {
|
||||
display: inline-block;
|
||||
max-width: 6em;
|
||||
flex: none;
|
||||
max-width: 9em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -39,6 +39,7 @@ import { isPrecombo } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { createMainActions } from "./M02_MasterTemplete_Main_Actions";
|
||||
export interface Selection {
|
||||
layer: Layer;
|
||||
projectId: string | null;
|
||||
@@ -141,6 +142,15 @@ export function createMain(
|
||||
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
|
||||
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
|
||||
const save = createButton({ label: L("M02_Save"), variant: "filled" });
|
||||
// 단가산출 · 일위대가 상세 [초기화] [삭제](740-2 · 738-2) — 차례 … [초기화] [삭제] [저장]
|
||||
const actions = createMainActions({
|
||||
sel: () => sel,
|
||||
version: () => version,
|
||||
loaded: () => loaded,
|
||||
confirmLeave: () => confirmLeave(),
|
||||
open: (at) => open(at),
|
||||
changed: () => onSaved?.(),
|
||||
});
|
||||
// 구조물 도면 · 일위대가 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) · 일반 단추(43-5 · 알약 · ghost 아님)
|
||||
const back = createButton({ label: L("M02_Back") });
|
||||
const helpSlot = el("span", { className: "m02-main__help" }); // 상세 [?](49-7) — 제목 바로 옆
|
||||
@@ -171,6 +181,8 @@ export function createMain(
|
||||
helpSlot,
|
||||
slot,
|
||||
reload,
|
||||
actions.reset,
|
||||
actions.del,
|
||||
save,
|
||||
...(extras.newItem ? [extras.newItem] : []),
|
||||
],
|
||||
@@ -256,6 +268,7 @@ export function createMain(
|
||||
const bar = (): void => {
|
||||
reload.hidden = !sel;
|
||||
save.hidden = !sel || !writable(sel);
|
||||
actions.update(!!sel && writable(sel));
|
||||
back.hidden = !sel || !(sel.kind in NUMBER_FIELD);
|
||||
nav.update(back.hidden ? null : sel); // 보임 조건 = 뒤로 단추와 같음
|
||||
const kind = sel && sel.kind in HELP_TITLE ? (sel.kind as keyof typeof HELP_TITLE) : null;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* =============================================================================
|
||||
* M02_Born_Tag.ts
|
||||
* 넣은 줄 출처 꼬리표(740-2 · 요구 581) — 서버 `본뜬 {층, 주인, 키, 이름, 때}`(740-1) → 화면 글.
|
||||
* 순수 계산 — 화면 · fetch 없음. 첫 줄 = 줄 옆에 보이는 짧은 글 · 둘째 줄 = 풍선(키 · 이름 · 층 · 때).
|
||||
* 공용 상세 꼬리표 칸(`LogicLab_Ho` sourceTag)이 「\n」 앞만 보이고 전체를 풍선으로 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
export interface Born {
|
||||
층?: string | null;
|
||||
주인?: string | null;
|
||||
키?: string | null;
|
||||
이름?: string | null;
|
||||
때?: string | null;
|
||||
}
|
||||
|
||||
const LAYER: Record<string, string> = {
|
||||
system: "마스터",
|
||||
company: "회사",
|
||||
personal: "개인",
|
||||
project: "프로젝트",
|
||||
};
|
||||
|
||||
/** 서버 때(UTC) → 이 컴퓨터 시각 「2026-11-02 20:13」 · 읽을 수 없으면 "" */
|
||||
function localTime(at: unknown): string {
|
||||
const d = new Date(String(at ?? ""));
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const z = (n: number): string => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** `본뜬` 한 벌 → 꼬리표 글(없으면 ""). `before` = 이미 있는 꼬리표(기초 원문번호) — 앞에 둠 */
|
||||
export function bornText(born: unknown, before = ""): string {
|
||||
const b = born && typeof born === "object" ? (born as Born) : null;
|
||||
if (!b || (!b.키 && !b.이름)) return before;
|
||||
const shown = `${before ? `${before} · ` : ""}본뜬 ${b.키 ?? b.이름}`;
|
||||
const layer = LAYER[String(b.층 ?? "")] ?? String(b.층 ?? "");
|
||||
const tip = [`${b.키 ?? ""} ${b.이름 ?? ""}`.trim(), layer, localTime(b.때)]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return `${shown}\n${tip}`;
|
||||
}
|
||||
|
||||
/** 단가산출 줄 열쇠(`add:#k` · `add:#k/…`) → 그 더한줄의 꼬리표 글 */
|
||||
export function bornOfCid(cid: string, adds: readonly object[] | undefined, before = ""): string {
|
||||
const hit = /^add:#(\d+)/.exec(cid);
|
||||
return bornText(
|
||||
hit ? (adds?.[Number(hit[1])] as { 본뜬?: unknown } | undefined)?.본뜬 : undefined,
|
||||
before,
|
||||
);
|
||||
}
|
||||
|
||||
/** 격자 비고 칸 글 — 「본뜬 SK-000012」 · 없으면 "" (첫 줄만) */
|
||||
export const bornNote = (born: unknown): string => bornText(born).split("\n")[0];
|
||||
@@ -13,6 +13,7 @@
|
||||
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
|
||||
* ========================================================================== */
|
||||
|
||||
import { bornOfCid } from "./M02_Born_Tag";
|
||||
import type { SheetGroup } from "../../A00_Common/calc_sheet";
|
||||
|
||||
export type CoverPart = "labor" | "material" | "expense";
|
||||
@@ -470,7 +471,8 @@ export function hoView(
|
||||
const l = lineBy.get(cid);
|
||||
const name = l?.이름 ?? cid;
|
||||
const at = subOf(cid);
|
||||
const tag = tags.has(cid) ? { 꼬리표: tags.get(cid) } : {};
|
||||
const born = bornOfCid(cid, doc.더한줄, tags.get(cid) ?? ""); // 넣은 줄 출처(740-2) — 기초 번호 뒤에 붙임
|
||||
const tag = born ? { 꼬리표: born } : {};
|
||||
if ((at?.cid ?? cid).startsWith("pct:")) {
|
||||
extra.push([cid, { ...((known.get(cid) as HoExtra) ?? { 이름: name, 식: "" }), ...tag }]);
|
||||
continue;
|
||||
|
||||
@@ -96,6 +96,7 @@ CALC = {"줄": [{"로직": "GF1", "분모": "㎡", "배": 10, "수량": 2.09,
|
||||
def test_M02_문서_calc_를_격자_줄로():
|
||||
files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
|
||||
"M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts",
|
||||
"M02_MasterTemplete/logic/M02_Born_Tag.ts",
|
||||
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
|
||||
logics = [
|
||||
["GF1", {"이름": "돌쌓기 찰쌓기", "입력": [], "결과단위": "㎡"}],
|
||||
@@ -146,6 +147,7 @@ def test_세_곳_같은_부품():
|
||||
def test_번호는_글에_안_냄_사람_글은_그대로():
|
||||
files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
|
||||
"M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts",
|
||||
"M02_MasterTemplete/logic/M02_Born_Tag.ts",
|
||||
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
|
||||
ref = {"로직": "", "별칭": None, "입력값": {}, "수량": {"손": 1},
|
||||
"참조": {"층": "personal", "주인": "1/3", "키": "SC-000002"}} # fmt: skip
|
||||
|
||||
@@ -176,6 +176,7 @@ def test_격자_규격_열_수량_칸_열마다():
|
||||
|
||||
files = [g.GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
|
||||
"M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts",
|
||||
"M02_MasterTemplete/logic/M02_Born_Tag.ts",
|
||||
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
|
||||
doc = {**g.DOC, "규격": {"이름": "H", "열": ["1.5", "2.0"], "기본": "2.0",
|
||||
"수량": {"GF1": {"1.5": 1.5, "2.0": 2.09}, "MT1": {"1.5": 4}}}} # fmt: skip
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from decimal import Decimal
|
||||
@@ -32,7 +33,9 @@ def _text(p: Path) -> str:
|
||||
|
||||
|
||||
def _node(body: str) -> object:
|
||||
src = _text(MODEL) + "\n" + body
|
||||
born = _text(M02 / "logic" / "M02_Born_Tag.ts") # 740-2 — 모델이 쓰는 꼬리표 글(import 줄은 뺌)
|
||||
model = re.sub(r"^import .*$", "", _text(MODEL), flags=re.M)
|
||||
src = born + "\n" + model + "\n" + body
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", src],
|
||||
|
||||
@@ -206,7 +206,13 @@ def _model(ans, doc, script):
|
||||
f"\nconst ans = {json.dumps(ans, ensure_ascii=False)};"
|
||||
f"\nconst doc = {json.dumps(doc, ensure_ascii=False)};\n{script}"
|
||||
)
|
||||
return _node(["M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts"], body)
|
||||
return _node(
|
||||
[
|
||||
"M02_MasterTemplete/logic/M02_Born_Tag.ts",
|
||||
"M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts",
|
||||
],
|
||||
body,
|
||||
)
|
||||
|
||||
|
||||
def test_727_2_화면_꼴_세부_줄_고침(world): # noqa: F811
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""740-2 · 738-2 상세 머리 [초기화] [삭제] · 출처 꼬리표 지킴 시험(서버 · 화면 없음)."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
MAIN = (M02 / "M02_MasterTemplete_UI_Main.ts").read_text(encoding="utf-8")
|
||||
ACTIONS = (M02 / "M02_MasterTemplete_Main_Actions.ts").read_text(encoding="utf-8")
|
||||
HO = (ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Ho.ts").read_text(encoding="utf-8")
|
||||
COVER = (M02 / "logic" / "M02_Sangeun_Cover_Model.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _born(expr: str):
|
||||
"""순수 TS(`logic/M02_Born_Tag.ts`)를 Node 로 돌려 JSON 답을 받음."""
|
||||
uri = (M02 / "logic" / "M02_Born_Tag.ts").as_uri()
|
||||
code = (
|
||||
f"import {{ bornText, bornOfCid, bornNote }} from '{uri}';"
|
||||
f"console.log(JSON.stringify({expr}));"
|
||||
)
|
||||
out = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=ROOT,
|
||||
)
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
BORN = {
|
||||
"층": "personal",
|
||||
"주인": "u1",
|
||||
"키": "SK-000012",
|
||||
"이름": "강관비계",
|
||||
"때": "2026-11-02T06:30:12Z",
|
||||
}
|
||||
|
||||
|
||||
def test_꼬리표_글_첫줄_짧게_둘째줄_풍선():
|
||||
shown, tip = _born(f"bornText({json.dumps(BORN, ensure_ascii=False)})").split("\n")
|
||||
assert shown == "본뜬 SK-000012"
|
||||
assert "SK-000012 강관비계" in tip and "개인" in tip
|
||||
assert re.search(r"\d{4}-\d\d-\d\d \d\d:\d\d", tip) # 이 컴퓨터 시각 · 시간대 따라 값이 다름
|
||||
|
||||
|
||||
def test_꼬리표_없으면_빈칸_기초번호_뒤에_붙음():
|
||||
assert _born("bornText(null)") == ""
|
||||
assert _born("bornText(undefined, 'A1')") == "A1"
|
||||
got = _born(f"bornText({json.dumps(BORN, ensure_ascii=False)}, 'A1')")
|
||||
assert got.split("\n")[0] == "A1 · 본뜬 SK-000012"
|
||||
|
||||
|
||||
def test_줄_열쇠에서_그_더한줄_출처를_찾음():
|
||||
adds = json.dumps([{}, {"본뜬": BORN}], ensure_ascii=False)
|
||||
assert _born(f"bornOfCid('add:#1', {adds})").startswith("본뜬 SK-000012")
|
||||
assert _born(f"bornOfCid('add:#1/add:#0', {adds})").startswith("본뜬 SK-000012") # 펼침 세부
|
||||
assert _born(f"bornOfCid('add:#0', {adds})") == ""
|
||||
assert _born(f"bornOfCid('hopyo:X#3', {adds})") == ""
|
||||
|
||||
|
||||
def test_머리_단추_차례_초기화_삭제_저장():
|
||||
kids = MAIN[MAIN.index('className: "m02-main__head"') :]
|
||||
kids = kids[: kids.index("]")]
|
||||
order = [kids.index(w) for w in ("reload,", "actions.reset,", "actions.del,", "save,")]
|
||||
assert order == sorted(order)
|
||||
assert "actions.update(" in MAIN # 쓰기 권한 없는 층 = 안 보임
|
||||
|
||||
|
||||
def test_삭제는_735_경고_창을_그대로_씀():
|
||||
assert "askLibraryDelete(" in ACTIONS and '"delete"' in ACTIONS
|
||||
assert "sure ? { 확인: true }" in ACTIONS.replace(
|
||||
"ans.sure", "sure"
|
||||
) # 확인 몸 = 쓰는 곳 줄까지
|
||||
assert "sangeunList(" in ACTIONS and "precomboList(" in ACTIONS # 지운 뒤 목록으로
|
||||
|
||||
|
||||
def test_초기화는_미리보기_뒤_물음_뒤_씀():
|
||||
assert ACTIONS.index("미리보기: true") < ACTIONS.index("미리보기: false")
|
||||
assert "ctx.confirmLeave()" in ACTIONS # 저장 안 한 고침 먼저 물음
|
||||
assert "옵션 · 기본정보는 그대로" in ACTIONS
|
||||
|
||||
|
||||
def test_공용_꼬리표_칸은_첫줄만_보이고_풍선에_나머지():
|
||||
assert 'text.split("\\n")' in HO
|
||||
assert "bornOfCid(" in COVER
|
||||
|
||||
|
||||
def test_격자_비고_칸은_첫줄만():
|
||||
assert _born(f"bornNote({json.dumps(BORN, ensure_ascii=False)})") == "본뜬 SK-000012"
|
||||
assert _born("bornNote(null)") == ""
|
||||
Reference in New Issue
Block a user