Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts
T
eomsangdonandClaude Opus 5.5 c5067aa2b6 feat(809-1a): 일위대가 불러오기 = 경쟁사 로직 인정 · 요소만 연결 · [수정] 모달 = 일위대가 컨테이너 부품 · 기초 판정 걷음
- [수정] 모달 = Labor_Table(읽기 · 덧칸 표지 · 엑셀 금액) · 줄 = 로직(단가산출 · 일위대가) / 요소(연결됨 · 후보 · 못 이음) · 단가산출 줄 = 가져온 식 SK 수식 탭 모달(Labor_SkEdit) · 서버 hopyo-view(M02_Hopyo_View)
- 엑셀 보기 목록 · 칠 = 줄 표지(View.listing) · 단가산출 탭 = 블록 목록 + [가져온 식으로 열기] · [내 단가산출로]
- 걷음(요구 703): 판정 체계 785(Judge · Solve · Bind · San_Basis 다섯 · Make.Move · Row 1차 판정 · 화면 Check · CheckRows · Rows · Block · JudgeApi · Picks · Judge.css) · [우리 기초로 바꾸기](to-base) · 근거 기초 제안 · Clause 기초 판정
- 시험 지움: 642_2(Bind · Solve) · 642_3(Row 1차 판정) · 642_5(옮기기 Move) · 642_6(판정 화면) · 786_2(옛 [수정] 모달) · 793_3a(근거 → 기초 제안) — 모두 걷은 기초 판정 길 몫
- 시험 고침: 642_1 · 794_2a2 · 794_2b(근거 제안 부분만 뺌) · 793_2 · 793_3b(기초 바꾸기 기대 → 없음) · 627 · 779_2 · 785_4 · 807_1 · m02_hopyo_excel · grid · stmate(judge 길 → view 길)
- 새 시험 test_809_1_import_labor(기초 길 0 · 표지 1,350 줄 · 가져온 식 SK · 모달 = Labor_Table · 금액 = 엑셀)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:17:58 +09:00

236 lines
11 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Hopyo_View.ts
* 엑셀 불러오기 [수정] 모달(PLAN 809-1 · 요구 702 · 703) — 엑셀 보기 오른쪽 호표 목록 [수정](`Hopyo_Sheet`)이 엶.
* 경쟁사 로직 인정 · 요소만 연결 — 몸 = 일위대가 컨테이너 부품(`Labor_Table` · 806-1 조합과 한 벌 · 읽기만) + 덧칸 둘:
* · 표지 = 로직 줄 「단가산출 N」 · 「일위대가 N」 칩 / 요소 줄 「✓ 연결됨」 · 「후보 n」 · 「못 이음」(엑셀 값 그대로)
* · 엑셀 금액 = 그 줄 엑셀 금액 계
* 줄 = 서버 `GET …/hopyo-view/{키}/{번}`(`M02_Hopyo_View` · 엑셀 숫자 그대로 고정 줄 · 저장 없음 · 금액 = 엑셀).
* · 단가산출 줄 누름 = 그 블록 가져온 식 SK(서버 `…/sangeun/from-hopyo` · 만들 층 · 같은 내용 = 있는 것) →
* 단가산출 「수식」 탭 모달(`Labor_SkEdit` 부르기만 · [엑셀 단가 그대로 | 우리 단가로])
* · 일위대가 줄 누름 = 그 하위 호표 [수정] 모달 · 후보 줄 누름 = 후보 목록(고르기 = [내 일위대가로] 뒤 일위대가 [연결])
* · 머리 = 표지 수 · 엑셀 단가 · [내 일위대가로](807-1 `Hopyo_Mine`) · [구조물 조합으로](만든 SC 있을 때 · `Hopyo_ToItem`) · [엑셀 보기]
* 기초 판정 · 「우리 기초로 바꾸기」 없음(요구 703).
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { currentLanguageIndex, t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { createTable } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { createToolbar } from "@ui/ui_template_toolbar";
import { formatPrice } from "../common_util/common_util_money";
import { call } from "./M02_MasterTemplete_Api_Fetch";
import { at, type HopyoBrief, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import { mineButton } from "./M02_MasterTemplete_Hopyo_Mine";
import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem";
import type { LaborDetailHandle } from "./M02_MasterTemplete_Labor_Detail";
import { openSkEdit } from "./M02_MasterTemplete_Labor_SkEdit";
import { mountLaborTable } from "./M02_MasterTemplete_Labor_Table";
const TEXT = {
Title: ["제{n}호표 — 엑셀 불러오기", "Unit cost #{n} — Excel import"],
Mark: ["표지", "Status"],
Excel: ["엑셀 금액", "Excel amount"],
Linked: ["✓ 연결됨", "✓ Linked"],
Near: ["후보 {n}", "{n} candidates"],
None: ["못 이음", "Not linked"],
Logic: ["로직 {n}", "Logic {n}"],
ExcelPrice: ["엑셀 단가", "Excel price"],
Hint: [
"경쟁사 로직 그대로 · 요소만 연결 — 단가산출 줄을 누르면 수식 탭 · 금액 = 엑셀",
"Competitor logic kept · elements linked — click a build-up line for its formula",
],
Sheet: ["엑셀 보기", "Show in Excel"],
NearTitle: ["이음 후보 — {name}", "Link candidates — {name}"],
NearHint: [
"고르기 = [내 일위대가로] 뒤 내 일위대가의 [연결]",
"Pick after [To my unit costs] with [Link]",
],
Key: ["키", "Key"],
Name: ["이름", "Name"],
Close: ["닫기", "Close"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
const why = (e: unknown): string => (e instanceof Error ? e.message : String(e));
const enc = encodeURIComponent;
type Money = Record<"노무비" | "재료비" | "경비" | "계", number | null>;
/** 줄 표지(서버 `M02_Hopyo_View.mark`) */
export interface ViewMark {
차례: number;
갈래: "로직" | "요소";
표지: "단가산출" | "일위대가" | "연결됨" | "후보" | "못 이음";
번?: number | null;
이음?: Record<string, string>;
후보?: { 키: string; 이름: string }[];
엑셀: Money;
}
export interface ViewReply {
번: number;
이름: string;
규격: string | null;
단위: string | null;
금액: Money | null;
문서: unknown;
줄: ViewMark[];
}
export const readHopyoView = (file: HopyoScope, key: string, no: number): Promise<ViewReply> =>
call(at(file, `/hopyo-view/${enc(key)}/${no}`));
/** 표지 칸 글 */
export function markText(m: ViewMark): string {
if (m.갈래 === "로직") return `${m.표지}${m.번 ? ` ${m.번}` : ""}`;
if (m.표지 === "연결됨") return t("Linked");
if (m.표지 === "후보") return t("Near").replace("{n}", String(m.후보?.length ?? 0));
return t("None");
}
/** 표지 칸 꼴 — 로직 = 칩 · 연결됨 초록 · 후보 주황 · 못 이음 빨강 */
const markTone = (m: ViewMark): string =>
m.갈래 === "로직"
? "ui-hopyo-grid__tag is-pum"
: m.표지 === "연결됨"
? "ui-text--success"
: m.표지 === "후보"
? "ui-text--warning"
: "ui-text--error";
export interface ViewOptions {
/** 실무자료 파일 층 */
file: HopyoScope;
key: string;
/** 가져온 식 SK 를 만들 층 */
target: HopyoScope;
no: number;
/** 목록 한 줄([내 일위대가로] · 엑셀 단가) */
brief?: HopyoBrief;
canWrite: boolean;
/** 모달 닫고 엑셀 보기 그 호표 */
onSheet?: () => void;
}
/** 후보 줄 — 후보 목록 */
function showNear(name: string, m: ViewMark): void {
openModal({
title: t("NearTitle").replace("{name}", name),
closeLabel: t("Close"),
dialogClass: "m02-modal",
mount: (body) => {
const rows = (m.후보 ?? []).map((c) => [c.키, c.이름]);
body.append(createTable([{ label: t("Key") }, { label: t("Name") }], rows, { density: "data" })); // prettier-ignore
body.append(createText(t("NearHint"), "hint"));
},
});
}
/** [구조물 조합으로] — 만든 SC(이 파일 층에서 옮긴 것)로 구조물 조합 만들기 */
const itemButton = (target: HopyoScope, sc: string, name: string, unit: string): HTMLElement =>
createButton({ label: L("M02_CheckToItem"), size: 28, variant: "ghost", onClick: () => toItem({ target, sc, name, unit }) }); // prettier-ignore
/** [수정] 모달 */
export function openHopyoView(o: ViewOptions): void {
const root = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": `${o.key}|${o.no}` } }); // prettier-ignore
const host = el("div", { attrs: { "data-hopyo-view-body": "" } });
let table: LaborDetailHandle | null = null;
let close = (): void => {};
/** 단가산출 줄 — 가져온 식 SK(만들 층) → 수식 탭 모달 */
async function openSk(m: ViewMark, name: string, head: string): Promise<void> {
try {
const made = await call<{ 이름: string }>(at(o.target, "/sangeun/from-hopyo"), {
method: "POST",
body: JSON.stringify({ 층: o.file.layer, ...(o.file.owner ? { 주인: o.file.owner } : {}), 키: o.key, 번: m.번 }), // prettier-ignore
});
const ref = { 층: o.target.layer, 주인: o.target.owner, 키: made.이름 };
void openSkEdit({ ref, title: `${name} — ${made.이름}`, head, readOnly: !o.canWrite, closed: () => {} }); // prettier-ignore
} catch (e) {
showToast(why(e), "error");
}
}
function draw(r: ViewReply): void {
const count = (k: ViewMark["표지"]): number => r.줄.filter((m) => m.표지 === k).length;
const logic = r.줄.filter((m) => m.갈래 === "로직").length;
const name = [r.이름, r.규격].filter(Boolean).join(" ");
const head = [name, r.단위 ? `1 ${r.단위} 당` : ""].filter(Boolean).join(" · ");
const badges = [
createBadge(t("Logic").replace("{n}", String(logic)), "accent"),
createBadge(`${t("Linked")} ${count("연결됨")}`, "success"),
createBadge(t("Near").replace("{n}", String(count("후보"))), "warning"),
createBadge(`${t("None")} ${count("못 이음")}`, "danger"),
];
badges.forEach((b, i) => (b.dataset.viewCount = ["로직", "연결됨", "후보", "못 이음"][i]));
const sheet = createButton({ label: t("Sheet"), size: 28, variant: "ghost", onClick: () => { close(); o.onSheet?.(); } }); // prettier-ignore
const items: (HTMLElement | "divider")[] = [
el("strong", { text: `제${r.번}호표 ${name}` }),
...badges,
"divider",
createText(`${t("ExcelPrice")} ${r.금액?.계 == null ? "—" : formatPrice(r.금액.계)}`, "muted", 14), // prettier-ignore
...(o.brief ? [mineButton(o.file, o.key, o.brief)] : []),
...(o.brief?.만든 && o.canWrite
? [itemButton(o.target, o.brief.만든, name, r.단위 ?? "")]
: []),
...(o.onSheet ? [sheet] : []),
];
root.replaceChildren(createToolbar({ items }), createText(t("Hint"), "hint"), host);
table?.destroy();
table = mountLaborTable(host, r.문서, {
layer: o.target.layer,
owner: o.target.owner,
column: `제${r.번}호표`,
mode: "precombo",
readOnly: true,
onChanged: () => {},
view: {
cols: [
{ key: "x_mark", label: t("Mark"), width: 96 },
{ key: "x_excel", label: t("Excel"), kind: "number", decimals: 1, trim: true, width: 96 },
],
cell: (n, key) => {
const m = r.줄[n];
if (!m) return null;
return key === "x_mark" ? markText(m) : m.엑셀.계;
},
tone: (n, key) => (key === "x_mark" && r.줄[n] ? markTone(r.줄[n]) : null),
onRow: (n) => {
const m = r.줄[n];
const line = (r.문서 as { 담은로직?: { 별칭?: string | null }[] }).담은로직?.[n];
const label = line?.별칭 ?? name;
if (m?.표지 === "단가산출" && m.번) return (void openSk(m, label, head), true);
if (m?.표지 === "일위대가" && m.번) return void openHopyoView({ ...o, no: m.번, brief: undefined }), true; // prettier-ignore
if (m?.표지 === "후보") return (void showNear(label, m), true);
return true; // 요소 줄 = 엑셀 숫자 그대로(모달 없음)
},
},
});
}
async function load(): Promise<void> {
const stop = showLoading(root);
try {
draw(await readHopyoView(o.file, o.key, o.no));
} catch (e) {
root.replaceChildren(loadingError(why(e), () => void load()));
} finally {
stop();
}
}
({ close } = openModal({
title: t("Title").replace("{n}", String(o.no)),
closeLabel: t("Close"),
wide: true,
width: "1100px",
dialogClass: "m02-modal m02-hopyo__view-modal",
beforeClose: () => {
table?.destroy();
return true;
},
mount: (body) => body.append(root),
}));
void load();
}