Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts
eomsangdonandClaude Opus 5.5 ca72718e26 feat(793-3b): 불러오기 단산 줄 = 가져온 식 단가산출 모달 — 블록 → 가져온 식 SK 길 · 검산 · 엑셀/우리 단가 · 이음 고르기 · 근거 세 단 · 우리 기초로 바꾸기 · 입력 필요 · 줄 잇기 · 결과 칩
- 서버: 올림 때 산근 시트 둠(실무자료/산근/<HP>.json · HP 지울 때 같이) · POST …/sangeun/from-hopyo(블록 → 793-2a 내보내기 → 793-2 만들기 · 같은 내용 = 그 SK 다시 씀 · 옛 HP 409) · STmate 끝전 = 엑셀 계와 맞는 쪽
- 화면: [수정] 모달 단산 줄 · 오른쪽 블록 판 [가져온 식으로 열기](새) → 시트 모달 + 머리 판 · [이 줄에 잇기] = 줄 고른 것 SK(입력 필요 남으면 확인 막힘) · 단산 줄 결과 칩
- 시험: test_793_3b_import_screen 6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:09:00 +09:00

332 lines
15 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Hopyo_CheckRows.ts
* [수정] 모달 줄 표(PLAN 786-2 · 요구 644 · 옛 359-3 꼴 되살림) — 구조물 조합 일위대가 상세 꼴
* (`Labor_Rows.moneyCell` · `PRICE_SLOTS` · `Labor_MatMarks` 빌려 씀 · 그 파일은 안 고침)에 새 판정 칸(785)을 얹음.
* · 로직 표 = [확인] · 행 · 구분(엑셀 갈래 + 1차 판정) · 이름(고름 칩 · 단산 [블록 풀이] · 셈 못 함 까닭) ·
* 별칭(엑셀 품명) · 수량산출(위 엑셀 / 아래 우리 = 엑셀 × 배 · 단위 환산 칸) · 단위 ·
* 노무비 · 재료비 · 경비 · 합계(위 엑셀 단가 / 아래 우리 = 서버 미리 셈 ÷ 엑셀 수량) · 품 비교 · 단추
* · 자재 표 = [확인] · 행 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(글만) · 가격 다섯 · 엑셀 / 우리 합계 · 단추
* · 단추 = [고르기] · [원문 보기] · [옵션](기초마다) · 추가 / 로직없음(= 고정 줄) — 옛 「사용안함」(뺌)은 새 옮기기 길에 없어 안 살림
* · 「새」 배지(785 새 기능) = 머리 칸 [확인] · 구분(판정) · 이름(고름 · 블록 풀이) · 수량산출(단위 환산)
* · 단산 줄(793-3b) = 판정 칩 「2차 필요」 대신 결과 칩(가져온 식 있음 · 근거 확정 · 근접 · 사람 고름 · 입력 필요 n ·
* SK 안 이었으면 블록 풀이 판정) · 단추 [가져온 식으로 열기](새 · `Hopyo_Imported` 모달)
* 줄 표 = `tableOf`(고른 것이 모두 재료 · 유가 · 없으면 엑셀 갈래 · 별도계상 줄 = 자재 표).
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { formatPrice } from "../common_util/common_util_money";
import type { JudgeRow } from "./M02_MasterTemplete_Hopyo_JudgeApi";
import { apartRow, bar, show, type RowKit } from "./M02_MasterTemplete_Hopyo_Rows";
import type { CalcMaterial } from "./M02_MasterTemplete_Labor_Calc";
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 { rowChips } from "./logic/M02_Hopyo_Imported_Model";
import type { DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks";
type Money4 = Record<"노무비" | "재료비" | "경비" | "계", number | null>;
/** 서버 미리 셈 한 줄(`/hopyo/{키}/price`) */
export interface PriceLine {
금액: Money4 | null;
자재?: CalcMaterial[];
까닭: string[];
칸: string[];
}
/** 줄 금액 — 셈 중 = wait · 고른 것 없음 = null */
export type LinePrice = { wait: true } | { got: PriceLine } | { fail: string };
export interface CheckCtx {
kit: RowKit;
readOnly: boolean;
price: (row: JudgeRow) => LinePrice | null;
pick: (row: JudgeRow) => void;
source: (row: JudgeRow) => void;
options: (row: JudgeRow, p: DraftPick) => void;
/** 추가(빈 값 = 제안으로 되돌림) · 로직없음(고정 줄) */
out: (row: JudgeRow, value: string) => void;
/** 단산 줄 [가져온 식으로 열기](793-3b) */
imported?: (row: JudgeRow) => void;
}
const COSTS = ["노무비", "재료비", "경비", "계"] as const;
const COST_HEAD = {
노무비: "Prev_Labor",
재료비: "Prev_Material",
경비: "Prev_Expense",
계: "Prev_Sum",
} as const;
const MAT_KINDS = ["재료", "유가"];
export const NO_LOGIC = "로직없음";
const small = (text: string, cls = "m02-labrow__note"): HTMLElement =>
el("small", { className: cls, text, attrs: { title: text } });
/** 「새」 배지(785 새 기능 표지) */
export function newBadge(): HTMLElement {
const b = createBadge(L("M02_CheckNew"), "accent", { size: 18 });
b.dataset.checkNew = "";
b.title = L("M02_CheckNewHint");
return b;
}
/** 줄이 들어갈 표 */
export function tableOf(row: JudgeRow, picks: DraftPick[]): "logic" | "mat" {
if (apartRow(row)) return "mat";
if (picks.length) return picks.every((p) => MAT_KINDS.includes(p.갈래)) ? "mat" : "logic";
return row.갈래.length > 0 && row.갈래.every((k) => MAT_KINDS.includes(k)) ? "mat" : "logic";
}
/** 우리 금액 → 엑셀 1 단위(서버 반영합 ÷ 엑셀 수량 · 수량 없음 = 그대로) */
function perUnit(v: number | null | undefined, row: JudgeRow): number | null {
if (v === null || v === undefined) return null;
const q = row.수량.엑셀;
return q ? v / q : v;
}
const priced = (p: LinePrice | null): PriceLine | null => (p && "got" in p ? p.got : null);
const waiting = (p: LinePrice | null): boolean => !!p && "wait" in p;
/** 이름 칸 덧글 — 셈 못 함 까닭 · 입력 필요 칸 */
function notes(p: LinePrice | null): HTMLElement[] {
const got = priced(p);
const why = [...(p && "fail" in p ? [p.fail] : []), ...(got?.까닭 ?? [])];
const out = why.map((t) => small(`${L("M02_CheckNoCalc")}: ${t}`, "ui-text ui-text--error"));
if (got?.칸.length) out.push(small(`${L("M02_JudgeNeed")}: ${got.칸.join(" · ")}`, "ui-text ui-text--error")); // prettier-ignore
return out;
}
/** 품 비교 — 고른 기초의 줄 대조 판정 수(단산 줄 · 블록 풀이) */
export function compareText(row: JudgeRow, picks: DraftPick[]): string {
const keys = new Set(picks.map((p) => p.키));
const count = new Map<string, number>();
for (const sec of row.풀이?.절 ?? [])
for (const c of sec.후보)
if (keys.has(c.키)) for (const x of c.줄) count.set(x.판정, (count.get(x.판정) ?? 0) + 1);
return [...count].map(([k, n]) => `${k} ${n}`).join("\n");
}
/** 수량산출 — 위 엑셀 수량 · 아래 우리(엑셀 × 배) + 단위 환산 칸 */
function qtyCell(row: JudgeRow, ctx: CheckCtx, picks: DraftPick[]): HTMLElement {
const 배 = ctx.kit.timesOf(row);
const ex = row.수량.엑셀;
const ours = ex === null || 배 === null ? "—" : show(ex * 배);
const unit = ctx.kit.unitInput(row, picks);
return el("td", {
className: "m02-labrow__qty",
attrs: { "data-label": tc("Labor_QtyCol") },
children: [
el("small", { className: "m02-labrow__base", text: ex === null ? "—" : `${show(ex)} ${row.단위 ?? ""}`.trim(), attrs: { title: L("M02_JudgeColExcel") } }), // prettier-ignore
el("span", { className: "m02-labrow__applied", text: ours, attrs: { title: L("M02_JudgeColOurs") } }), // prettier-ignore
...(unit ? [unit] : []),
],
});
}
function actsCell(row: JudgeRow, ctx: CheckCtx, picks: DraftPick[]): HTMLElement {
const ghost = (label: string, run: () => void, at: string): HTMLElement => {
const b = createButton({ label, variant: "ghost", size: "sm", onClick: run });
b.dataset.checkAct = at;
return b;
};
const kids: HTMLElement[] = [];
if (!ctx.readOnly && !apartRow(row)) kids.push(ghost(L("M02_CheckPick"), () => ctx.pick(row), "pick")); // prettier-ignore
if (row.번 !== undefined && ctx.imported) {
const b = ghost(L("M02_CheckImported"), () => ctx.imported!(row), "imported");
(b as HTMLButtonElement).disabled = ctx.readOnly;
kids.push(el("span", { className: "m02-judge__wrap", children: [b, newBadge()] }));
}
kids.push(ghost(L("M02_CheckSource"), () => ctx.source(row), "source"));
for (const p of picks.filter((x) => x.갈래 === "기초")) {
const b = ghost(L("M02_CheckOption"), () => ctx.options(row, p), "option");
b.title = `${p.키} ${L("M02_CheckOption")}`;
kids.push(b);
}
if (!apartRow(row)) {
const fixed = picks.length === 1 && picks[0].갈래 === "고정";
const out = createSelectField({
options: [
{ value: "", text: L("M02_CheckAdd") },
{ value: NO_LOGIC, text: L("M02_CheckNoLogic") },
],
value: fixed ? NO_LOGIC : "",
compact: true,
ariaLabel: L("M02_CheckNoLogic"),
onChange: (v) => ctx.out(row, v),
});
out.select.disabled = ctx.readOnly;
out.select.dataset.checkOut = String(row.차례);
out.root.title = L("M02_CheckNoLogicHint");
kids.push(out.root);
}
return el("td", { className: "m02-hopyo__acts", children: kids });
}
const rowOf = (
row: JudgeRow,
ctx: CheckCtx,
picks: DraftPick[],
cells: HTMLElement[],
): HTMLElement =>
// prettier-ignore
el("tr", {
className: "m02-labrow__row m02-hopyo__line",
attrs: { "data-line": String(row.차례), "data-verdict": row.판정 },
children: [
el("td", { children: [ctx.kit.okChip(row, picks)] }),
el("td", { className: "m02-labrow__no", children: [ctx.kit.rowJump(row)] }),
...cells,
actsCell(row, ctx, picks),
],
});
/** 단산 줄 결과 칩(793-3b) — SK 고른 것 표지 · 없으면 블록 풀이 판정 · 단산 아니면 판정 칩 */
function verdictOf(row: JudgeRow, ctx: CheckCtx): HTMLElement[] {
if (row.번 === undefined) return [ctx.kit.verdict(row)];
const got = rowChips(ctx.kit.picksOf(row));
const list = got ?? (row.풀이?.판정 ? [row.풀이.판정] : []);
if (!list.length) return [ctx.kit.verdict(row)];
return list.map((text) => {
const tone = text.startsWith("입력 필요") ? "danger" : text.includes("확정") || text.startsWith("가져온") ? "success" : text.includes("근접") ? "warning" : "info"; // prettier-ignore
const b = createBadge(text, tone);
b.dataset.judgeResult = text;
return b;
});
}
const kindCell = (row: JudgeRow, ctx: CheckCtx): HTMLElement =>
el("td", {
className: "m02-labrow__kind",
attrs: { title: row.근거 },
children: [bar([...row.갈래.map((k) => createBadge(k, "neutral")), ...verdictOf(row, ctx)])],
});
function logicRow(row: JudgeRow, ctx: CheckCtx): HTMLElement[] {
const picks = ctx.kit.picksOf(row);
const p = ctx.price(row);
const got = priced(p);
const wait = waiting(p);
const unit = picks[0]?.단위 || row.단위 || "";
const tr = rowOf(row, ctx, picks, [
kindCell(row, ctx),
el("td", { className: "m02-labrow__name m02-hopyo__parts", children: [ctx.kit.chips(row, picks), ...notes(p)] }), // prettier-ignore
el("td", {
className: "m02-labrow__alias",
attrs: { title: `${row.이름 ?? ""} ${row.규격 ?? ""}`.trim() },
children: [row.이름 ?? "", ...(row.규격 ? [small(row.규격)] : [])],
}),
qtyCell(row, ctx, picks),
el("td", { className: "m02-labrow__unit", text: unit }),
...COSTS.map(
(c) =>
moneyCell(row.단가?.[c] ?? null, got ? perUnit(got.금액?.[c], row) : null, wait, tc(COST_HEAD[c])), // prettier-ignore
),
el("td", { className: "m02-hopyo__compare", text: compareText(row, picks) }),
]);
const body = ctx.kit.solved(row);
if (!body) return [tr];
const wide = el("td", { children: [body], attrs: { colspan: "15" } });
return [tr, el("tr", { className: "m02-hopyo__solved", children: [wide] })];
}
function matRow(row: JudgeRow, ctx: CheckCtx): HTMLElement {
const picks = ctx.kit.picksOf(row);
const p = ctx.price(row);
const got = priced(p);
const wait = waiting(p);
const mats = got?.자재 ?? [];
const first = mats[0];
const ours = mats.length ? mats.reduce((s, m) => s + (m.반영 ?? 0), 0) : null;
const cell = (v: number | null | undefined): string =>
wait ? "…" : v === null || v === undefined ? "—" : formatPrice(v);
const pk = picks[0];
const apart = apartRow(row) || !!pk?.별도계상;
return rowOf(row, ctx, picks, [
el("td", { className: "m02-labrow__code", text: picks.map((x) => x.키 ?? "").filter(Boolean).join(" · ") || "—" }), // prettier-ignore
el("td", {
className: "m02-labrow__name m02-hopyo__parts",
children: [ctx.kit.chips(row, picks), small(`${L("M02_JudgeColExcel")} ${row.이름 ?? ""}`.trim()), ...notes(p)], // prettier-ignore
}),
el("td", { className: "m02-labrow__spec", text: pk?.규격 || row.규격 || "", attrs: { title: pk?.규격 || row.규격 || "" } }), // prettier-ignore
qtyCell(row, ctx, picks),
el("td", { className: "m02-labrow__unit", text: pk?.단위 || row.단위 || "" }),
...markCells({ 별도계상: apart ? "별도계상" : "직접계산" }),
...PRICE_SLOTS.map((slot) =>
el("td", {
className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`,
text: cell(first?.가격?.[slot]),
attrs: { "data-label": slot, "data-price-slot": slot },
}),
),
moneyCell(row.단가?.계 ?? null, got ? perUnit(ours, row) : null, wait, tc("Labor_AppliedSum")),
]);
}
/** 머리 칸 — 글 · 새 기능이면 「새」 배지 */
const head = (text: string, fresh = false): HTMLElement =>
el("th", { children: fresh ? [text, " ", newBadge()] : [text] });
const grid = (heads: HTMLElement[], rows: HTMLElement[], kind: string): HTMLElement =>
el("div", {
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
children: [
el("table", {
className: "ui-general-block__table m02-labrow m02-hopyo__table m02-hopyo__lines",
attrs: { "data-hopyo-table": kind },
children: [
el("thead", { children: [el("tr", { children: heads })] }),
el("tbody", { children: rows }),
],
}),
],
});
/** 줄 표 둘 — 로직 표 · 자재 표(줄 없는 표는 안 그림) */
export function checkTables(rows: JudgeRow[], ctx: CheckCtx): HTMLElement[] {
const logic = rows.filter((r) => tableOf(r, ctx.kit.picksOf(r)) === "logic");
const mat = rows.filter((r) => !logic.includes(r));
const first = (): HTMLElement[] => [head(L("M02_JudgeColOk"), true), head(L("M02_JudgeColRow"))]; // 표마다 새 노드
const out: HTMLElement[] = [];
if (logic.length)
out.push(
grid(
[
...first(),
head(`${tc("Sub")} · ${L("M02_JudgeColVerdict")}`, true),
head(`${tc("Col_Name")} · ${L("M02_JudgeColPick")}`, true),
head(tc("Col_Alias")),
head(tc("Labor_QtyCol"), true),
head(tc("Col_Unit")),
...COSTS.map((c) => head(tc(COST_HEAD[c]))),
head(L("M02_CheckCompare")),
head(""),
],
logic.flatMap((r) => logicRow(r, ctx)),
"logic",
),
);
if (mat.length)
out.push(
el("h4", { className: "m02-hopyo__subhead", text: L("M02_CheckMat") }),
grid(
[
...first(),
head(tc("Labor_Code")),
head(`${tc("Col_Name")} · ${L("M02_JudgeColPick")}`, true),
head(tc("Labor_Spec")),
head(tc("Labor_QtyCol"), true),
head(tc("Labor_Unit")),
...markHeads().map((t) => head(t)),
...PRICE_SLOTS.map((t) => head(t)),
head(`${L("M02_JudgeColExcel")} / ${L("M02_JudgeColOurs")} ${tc("Labor_AppliedSum")}`),
head(""),
],
mat.map((r) => matRow(r, ctx)),
"mat",
),
);
return out;
}