diff --git a/A00_Common/calc_sheet.ts b/A00_Common/calc_sheet.ts index d3edd2d2e..10e0fad4c 100644 --- a/A00_Common/calc_sheet.ts +++ b/A00_Common/calc_sheet.ts @@ -154,12 +154,16 @@ const out = (v: number | null, key = false, digits = 0): HTMLElement => const lineRow = (line: SheetLine, label = line.이름): HTMLElement => createCalcFormulaRow(label, createCalcFormula(sheetFormula(line.글)), out(line.금액, false, 1)); -/** 변수 칸 하나 — 고르기 있으면 고르기 · 아니면 글 칸(Enter · 떠남 때 넘김) */ -function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement { +/** 변수 칸 하나 — 고르기 있으면 고르기(값 없으면 빈 첫 칸) · 아니면 글 칸(Enter · 떠남 때 넘김) · + * 722-1 공용 단가산출 상세 박스 옵션도 씀 */ +export function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement { let control: HTMLElement; if (spec.고르기?.length) { control = createSelectField({ - options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })), + options: [...(value ? [] : [""]), ...spec.고르기.map(String)].map((o) => ({ + value: o, + text: o, + })), value, compact: true, ariaLabel: spec.이름, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index e32601f29..aee6cc173 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -34,7 +34,7 @@ import { buildCalc } from "./M01_MasterData_UI_Logic_Calc"; import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List"; import type { SideHandle } from "./M01_MasterData_UI_Side"; import { tx } from "./M01_MasterData_UI_Logic_Text"; -import { buildLabDetail, isOwnLogic } from "./M01_MasterData_UI_LogicLab_Detail"; +import { buildLabDetail, isOwnLogic, trial } from "./M01_MasterData_UI_LogicLab_Detail"; import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal"; import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; @@ -91,10 +91,10 @@ interface Opened extends Draft { /** 줄의 흐름(있으면 흐름도로 셈) */ const flowOf = (row: LogicRow | null): CfLogic | null => (row?.흐름 as CfLogic | undefined) ?? null; const OWN_FILE = "로직_자체.json"; -/** 시험 계산에 넘길 줄 — 흐름 로직은 입력 칸을 흐름의 「설계 때 정할 것」으로(옛 식 칸은 서버가 지움) */ +/** 시험 계산에 넘길 줄 — 흐름 로직은 입력 칸을 흐름의 「설계 때 정할 것」으로(옛 식 칸은 서버가 지움) · 그 밖은 시험 고침(722-2) 덮은 사본 */ const calcRow = (row: LogicRow | null): LogicRow | null => { const f = flowOf(row); - return f && row ? { ...row, 입력: designFields(f) } : row; + return f && row ? { ...row, 입력: designFields(f) } : trial.apply(row); }; const cacheKey = "m01_logic_drafts_lab"; @@ -218,7 +218,7 @@ export async function mountM01LogicLab( file: current.file, row: calcRow(current.row) as LogicRow, prices: current.prices, - dirty: () => current.id in drafts, + dirty: () => current.id in drafts || trial.has(current.row), values: current.values, onText: (text) => { current.text = text; @@ -233,8 +233,7 @@ export async function mountM01LogicLab( }; const drawEditor = (): void => { - // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산 - const listing = !opened; + const listing = !opened; // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산 list.root.hidden = !listing || !loaded; waiting.hidden = loaded; panes.root.hidden = !opened; @@ -257,6 +256,7 @@ export async function mountM01LogicLab( ownHost: current.ownHost, onChange: touch, onInput: (name, v) => syncLeft(current, { [name]: v }, true), + onTrial: drawCalc, calcHost: calc, flowHost: flowOf(current.row) ? current.flowHost : undefined, admin, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts new file mode 100644 index 000000000..b1841a53f --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts @@ -0,0 +1,173 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Cover.ts + * 단가산출 상세 줄 「그 자리 고침」(PLAN 722-2 · 내역 16 · 17-2) — 줄 모달 안 수량 · 단가 칸. + * 고침 꼴 = M02 단가산출 덮개 칸과 같음(`qty` · `labor` · `material` · `expense` — `M02_Sangeun_Doc._covered` 규칙 중 품 · 단가) + * M01 = 시험 값만 — 고친 값은 화면 메모리(로직 줄 객체 열쇠)에만 · 셈 = 줄 사본에 덮어 서버로(`/calc` · `/text` row) + * · 정본 · 요소(노임 · 자재) · 캐시(초안) 안 바뀜 · 로직을 다시 열면 사라짐 + * M02(724) = 같은 고침 칸 · 같은 꼴을 고침 고리(`HoEdit.set`)로 받아 덮개로 보관 + * 서버 셈 = 줄 `단가`(손 단가) · 로직 줄 `값묶음`(노 · 재 · 경) · `수량` 글 — `master_pick._priced` · `bundled` 가 읽음 + * ========================================================================== */ + +import { createButton, createInputField, el } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; + +/* ── 순수 셈 ── */ +/** 줄 하나 고침 — 품(수량) · 비목 단가(로직 줄은 셋 · 그 밖은 그 줄 비목 하나) */ +export interface LineCover { + qty?: number; + labor?: number; + material?: number; + expense?: number; +} +export const COVER_PARTS = { labor: "노무비", material: "재료비", expense: "경비" } as const; +export type CoverPart = keyof typeof COVER_PARTS; + +/** 지수 꼴 없는 수 글(엔진 식 칸) — 1e-7 → 0.0000001 */ +const plainNum = (v: number): string => + v.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 20 }); + +/** 호표 줄(차례 열쇠)에 고침을 덮은 줄 사본 — 원본은 안 바뀜 · 덧줄은 고침 대상 밖 */ +export function applyCovers( + row: R, + covers: Record, +): R { + const ho = (row.호표 ?? []).map((raw, i) => { + const c = covers[i]; + if (!c) return raw; + const item = raw as Record; + const out: Record = { ...item }; + if (c.qty !== undefined) out.수량 = plainNum(c.qty); + const parts = (Object.keys(COVER_PARTS) as CoverPart[]).filter((k) => c[k] !== undefined); + if (parts.length && item.종류 === "로직") { + const given = parts.map((k) => [COVER_PARTS[k], c[k]]); + out.값묶음 = { ...((item.값묶음 as object) ?? {}), ...Object.fromEntries(given) }; + } else if (parts.length) out.단가 = c[parts[0]]; + return out; + }); + return { ...row, 호표: ho }; +} +/* ── 순수 셈 끝 ── */ + +/** 시험 고침 — 로직 줄 객체마다(다시 열면 새 객체 = 사라짐) */ +const trials = new WeakMap>(); +export const trial = { + of(row: object): Record { + let got = trials.get(row); + if (!got) trials.set(row, (got = {})); + return got; + }, + has: (row: object | null): boolean => !!row && Object.keys(trials.get(row) ?? {}).length > 0, + /** 시험 계산에 넘길 줄 — 고침 없으면 원본 그대로(같은 객체) */ + apply(row: R): R { + return row && trial.has(row) ? (applyCovers(row, trials.get(row)!) as R) : row; + }, +}; + +const TEXT = { + Head: ["그 자리 고침 (시험 값)", "Edit here (trial value)"], + Note: [ + "정본 · 마스터 값(노임 · 자재)은 안 바뀜 · 로직을 다시 열면 사라짐", + "Source and master prices stay unchanged · cleared when reopened", + ], + NoteM02: [ + "고친 값은 이 단가산출의 변경 항목으로 저장 — M01 은 안 바뀜", + "Saved as this item's override — M01 stays unchanged", + ], + Qty: ["수량(품)", "Quantity"], + Price: ["단가", "Unit price"], + Apply: ["적용", "Apply"], + Undo: ["되돌림", "Undo"], + Bad: ["0 이상 수만", "Numbers ≥ 0 only"], +} as const satisfies Record; +export const tc = (k: keyof typeof TEXT): string => + TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +export interface CoverFormOptions { + /** 지금 값(덮기 전 셈 값) — 칸 처음 값 */ + qty: number | null; + /** 단가 칸 — 로직 줄 = 셋(1 단위 비목 값) · 그 밖 = 그 줄 비목 하나 */ + prices: Partial>; + /** 지금 든 고침(있으면 칸 처음 값 · [되돌림]) */ + cover?: LineCover; + /** 「m02」 = 덮개 보관 안내 */ + scope?: "m01" | "m02"; + onApply: (cover: LineCover | null) => void; +} + +const num = (raw: string): number | null => { + const v = Number(raw.replaceAll(",", "").trim()); + return raw.trim() !== "" && Number.isFinite(v) && v >= 0 ? v : null; +}; + +/** 고침 칸 — 수량 · 단가(들) · [적용] [되돌림] · 바뀐 칸만 고침으로 넘김 */ +export function coverForm(o: CoverFormOptions): HTMLElement { + const parts = Object.keys(o.prices) as CoverPart[]; + const field = (label: string, raw: number | null, mine?: number) => { + const now = raw === null ? null : Number(raw.toPrecision(10)); // 칸 글 = 유효 10 자리 · 손대지 않으면 고침 아님 + const box = createInputField({ + label, + value: String(mine ?? now ?? ""), + compact: true, + type: "text", + }); + box.input.inputMode = "decimal"; + return { box, now }; + }; + const qty = field(tc("Qty"), o.qty, o.cover?.qty); + const prices = parts.map((k) => ({ + k, + ...field(parts.length > 1 ? COVER_PARTS[k] : tc("Price"), o.prices[k] ?? null, o.cover?.[k]), + })); + const error = createText("", "error"); + error.hidden = true; + const apply = (): void => { + const next: LineCover = {}; + let bad = false; + const take = (f: { box: { input: HTMLInputElement }; now: number | null }, was?: number) => { + const v = num(f.box.input.value); + if (v === null) + return f.box.input.value.trim() === "" ? undefined : ((bad = true), undefined); + return v !== f.now || was !== undefined ? v : undefined; // 셈 값 그대로면 고침 아님 + }; + const q = take(qty, o.cover?.qty); + if (q !== undefined) next.qty = q; + for (const p of prices) { + const v = take(p, o.cover?.[p.k]); + if (v !== undefined) next[p.k] = v; + } + error.textContent = tc("Bad"); + error.hidden = !bad; + if (!bad) o.onApply(Object.keys(next).length ? next : null); + }; + const actions = el("div", { + className: "m01lab-cover__actions", + children: [ + createButton({ label: tc("Apply"), variant: "filled", size: 32, onClick: apply }), + ...(o.cover + ? [ + createButton({ + label: tc("Undo"), + variant: "ghost", + size: 32, + onClick: () => o.onApply(null), + }), + ] + : []), + ], + }); + return el("section", { + className: "m01lab-cover", + attrs: { "data-cover-form": "" }, + children: [ + el("strong", { text: tc("Head") }), + createText(tc(o.scope === "m02" ? "NoteM02" : "Note"), "hint"), + el("div", { + className: "m01lab-cover__fields", + children: [qty.box.root, ...prices.map((p) => p.box.root)], + }), + error, + actions, + ], + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index ba75ddea0..e6cc7b59e 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -2,76 +2,29 @@ * M01_MasterData_UI_LogicLab_Detail.ts * 「단가산출 로직」 상세 화면 — 컨테이너 넷을 위에서 아래로 * ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로) - * ③ 단가산출 상세(인력·자재·경비·그 밖 묶음 + 소계) ④ 텍스트 수식(자리 — `_LogicLab_Formula.ts`) - * 호표 줄을 누르면 그 줄이 쓰는 자료 모달(`_LogicLab_Modal.ts`) + * ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침) ④ 텍스트 수식(`_LogicLab_Formula.ts`) + * 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜) * ========================================================================== */ import { el } from "@ui/ui_template_elements"; -import type { - CalcLine, - ElementBrief, - HoLine, - LogicRow, - NamedFormula, - TextAnswer, - TextLine, - TextPiece, -} from "./M01_MasterData_UI_Logic_Api"; -import { pctView } from "./M01_MasterData_UI_Logic_Api"; -import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit"; -import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; +import type { LogicRow } from "./M01_MasterData_UI_Logic_Api"; +import { buildEditor } from "./M01_MasterData_UI_Logic_Edit"; import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; -import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick"; import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula"; import { buildEst } from "./M01_MasterData_UI_LogicLab_Est"; -import { - condText, - openMaterialModal, - openNoteModal, - openRefModal, - openLogic, - openTableModal, - type PickCond, -} from "./M01_MasterData_UI_LogicLab_Modal"; -import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill"; -import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text"; +import { buildHo, type HoContext, type HoEdit } from "./M01_MasterData_UI_LogicLab_Ho"; +import { trial } from "./M01_MasterData_UI_LogicLab_Cover"; +import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; import "./M01_MasterData_UI_LogicLab_Pill.css"; -import { createBadge } from "@ui/ui_template_badge"; import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View"; -/** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */ -function toFragments(pieces: TextPiece[], ctx: DetailContext): Fragment[] { - return pieces.map((piece): Fragment => { - const dim = piece.깊이 ? { dim: true } : {}; - if (piece.kind !== "요소" && piece.kind !== "표" && piece.kind !== "로직") - return { text: piece.글, ...dim }; - const ref = piece.참조; - const open = (): void => { - if (!ref) return openNoteModal(piece.글, tl("Pill_NoPick")); - if (piece.kind === "표") - openTableModal({ title: piece.글, table: ref.키, row: ref.줄 ?? ref.행, col: ref.열 }); - else if (piece.kind === "요소") - openRefModal({ title: piece.글, ref: ref.키, prices: ctx.prices }); - else openLogic(ref.키); - }; - return { pill: { label: piece.글, onClick: open }, ...dim }; - }); -} +export { trial }; -export interface DetailContext { - row: LogicRow; +export interface DetailContext extends HoContext { file: string; reasons: string[]; - prices: Record; - /** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */ - lines: CalcLine[] | null; - /** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */ - text: TextAnswer | null; - /** 마지막으로 잘 받은 읽는 식 — `text` 가 멈춤이면 조각을 이걸로 */ - lastText?: TextAnswer; - values: Record; /** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */ ownHost: HTMLElement; onChange: () => void; @@ -82,58 +35,12 @@ export interface DetailContext { flowHost?: HTMLElement; /** 시스템 관리자 — 기본 로직 안내 글이 다름(흐름도에서 고침 / 보기만) */ admin?: boolean; - /** 714-3 텍스트 수식 입력 단추 — 시험 계산 입력 고침 + 다시 셈 */ - onInput?: (name: string, value: string) => void; + /** 722-2 시험 고침 뒤 — 시험 계산 칸을 고친 줄로 다시 그림(있으면 그 자리 고침 켜짐 · 셈은 여기서 누름) */ + onTrial?: () => void; /** 717-2b M02 단가산출 — 단가산출 상세 · 인력·재료·경비 덩이를 이 칸으로(SK 셈 답 · 고침 표) */ cover?: { ho: HTMLElement; est: HTMLElement }; } -type Group = "labor" | "material" | "cost" | "other"; -const GROUPS: Group[] = ["labor", "material", "cost", "other"]; -const OF_KIND: Record = { 인력: "labor", 재료: "material", 기계: "cost" }; -const OF_COST: Record = { 노무비: "labor", 재료비: "material", 경비: "cost" }; - -/** 줄 종류가 셋 중 어디에도 안 들어가면 「그 밖」 — 다른 로직을 부르는 줄은 비목으로 */ -const groupOfHo = (item: HoLine): Group => - OF_KIND[item.종류] ?? (item.종류 === "로직" ? OF_COST[item.비목 ?? ""] : undefined) ?? "other"; - -interface Entry { - /** 호표 줄 차례(덧줄은 호표 수 + 덧줄 차례) — 서버 조각을 찾는 열쇠 */ - idx: number; - group: Group; - name: string; - spec: string; - unit: string; - qty: string; - /** 고른 수량 값(계산 답 `CalcLine.수량` · % 덧줄은 율) — 계산 전 「—」 */ - qtyValue: string; - qtyTag: string; - qtyFrag: Fragment[]; - /** 한 줄이 비목 몫으로 갈렸을 때 이 묶음의 몫 이름(예 「노무비」) */ - share?: string; - price: string; - amount: number | null; - /** 덧줄이면 true */ - extra: boolean; - open: (self: Entry) => void; - /** 서버 조각으로 그린 단가 자리 · 수량 풀이 — 줄 모달이 씀(붙기 전엔 없음) */ - unitFrag?: Fragment[]; - piecesFrag?: Fragment[]; - /** 서버 요소조각의 첫 요소 — 자리 표시 든 줄이 입력으로 풀린 키 · 글 */ - unitRef?: string; - unitText?: string; - /** 자재 단위가 줄 단위와 성격이 다름 — 「줄 m · 자재 Ton」 */ - warn?: string; -} - -/** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */ -const condOf = (item: HoLine): PickCond | null => - typeof item.요소 === "object" && item.요소 !== null ? (item.요소 as unknown as PickCond) : null; -const condLabel = (cond: PickCond): string => - [cond.구분, cond.상세구분, cond.규격].filter(Boolean).join(" · "); - -const groupName = (group: Group): string => tl(`G_${group}` as LabTextKey); - const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement => el("section", { className: "m01-logic__section m01lab__box", @@ -238,220 +145,6 @@ function infoBox(ctx: DetailContext): HTMLElement[] { return [fold, ...reasons]; } -/** 수량 칸 = 계산 답의 고른 수량(% 덧줄은 율) · 계산 전 「—」 */ -const qtyOf = (line?: CalcLine): string => (line ? formatNumber(pctView(line).qty) : "—"); - -/** 계산된 줄은 서버가 준 비목별 몫으로 묶음에 나눠 넣음 — 로직을 부르는 줄도 노무비·재료비·경비로 갈림 */ -const spread = (e: Entry, line?: CalcLine): Entry[] => { - const parts = Object.entries(line?.비목 ?? {}).filter(([, v]) => v !== 0); - if (!parts.length) return [e]; - return parts.map(([k, v]) => ({ - ...e, - group: OF_COST[k] ?? "other", - amount: v, - ...(parts.length > 1 - ? { - share: k, - // 몫 줄 단가 = 단가 × (몫 금액 ÷ 줄 금액) — 곱하기 수량이 몫 금액과 맞게 - ...(line && line.금액 && line.단가 != null - ? { price: formatPrice((line.단가 * v) / line.금액) } - : {}), - } - : {}), - })); -}; - -function entries(ctx: DetailContext): Entry[] { - const middles: NamedFormula[] = ctx.row.중간 ?? []; - const ho = ctx.row.호표 ?? []; - const out: Entry[] = ho.flatMap((item, i) => { - const line = ctx.lines?.[i]; - const cond = condOf(item); - const brief = cond ? ctx.prices[condText(cond)] : ctx.prices[item.요소]; - const price = line ? line.단가 : brief?.값; - const entry: Entry = { - idx: i, - group: groupOfHo(item), - name: item.이름 ?? (cond ? condLabel(cond) : item.요소), - spec: item.규격 ?? brief?.규격 ?? "", - unit: item.단위 ?? "", - qty: item.수량, - qtyValue: qtyOf(line), - qtyTag: qtyKind(item.수량) === tx("Ho_QtyTable") ? tl("Tag_Table") : qtyKind(item.수량), - qtyFrag: [{ text: item.수량 }], - price: price === undefined || price === null ? "" : formatPrice(price), - amount: line ? line.금액 : null, - warn: line?.단위경고, - extra: false, - open: (self) => - openMaterialModal({ - unitFrag: self.unitFrag, - unitRef: line?.품목?.키 ?? self.unitRef, - unitText: self.unitText, - qtyFrag: self.piecesFrag, - title: item.이름 ?? (cond ? condLabel(cond) : item.요소), - expr: item.수량, - ...(cond ? { cond, unit: item.단위 } : { element: item.요소 }), - prices: ctx.prices, - middles, - values: ctx.values, - // 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜) - kind: item.종류, - usedSlot: line?.품목?.값칸, - ...(item.종류 === "재료" - ? { onPick: (it) => pickMaterial(ctx.calcHost, ctx.values, i, it) } - : {}), - }), - }; - return spread(entry, line); - }); - (ctx.row.덧줄 ?? []).forEach((extra, i) => { - const line = ctx.lines?.[ho.length + i]; - const entry: Entry = { - idx: ho.length + i, - group: OF_COST[extra.비목 ?? ""] ?? "other", - name: extra.이름, - spec: "", - unit: line ? pctView(line).unit : "", - qty: extra.식, - qtyValue: qtyOf(line), - qtyTag: tl("Extra"), - qtyFrag: [{ text: extra.식 }], - price: "", - amount: line ? line.금액 : null, - extra: true, - open: (self) => - openMaterialModal({ - unitFrag: self.unitFrag, - qtyFrag: self.piecesFrag, - title: extra.이름, - expr: extra.식, - prices: ctx.prices, - middles, - values: ctx.values, - }), - }; - out.push(...spread(entry, line)); - }); - attachPieces(out, ctx); - return out; -} - -/** 서버 `/text` 줄의 조각을 호표 줄 차례(덧줄은 덧줄 차례)로 이음 — 서버는 묶음마다 호표 차례대로 줄을 주므로 - * 이름을 차례로 따라가며 줄 차례에 맞춤 · 금액 0 로직 부름 줄처럼 묶음이 달라도 찾음 · - * 끝내 못 찾으면 날식 대신 빈칸(계산 전 · 서버 실패면 식 글자 그대로) */ -function attachPieces(all: Entry[], ctx: DetailContext): void { - const source = ctx.text?.ok ? ctx.text : ctx.lastText; - if (!source?.ok) { - if (ctx.text) for (const e of all) e.qtyFrag = e.piecesFrag = []; // 멈춤 · 받은 조각 없음 — 날식 대신 빈칸 - return; - } - const ho = ctx.row.호표 ?? []; - const names = [ - ...ho.map((item) => item.이름 ?? (condOf(item) ? condLabel(condOf(item)!) : item.요소)), - ...(ctx.row.덧줄 ?? []).map((extra) => extra.이름), - ]; - const found = new Map(); - for (const g of source.groups) { - let from = 0; - for (const line of g.줄) { - const at = names.findIndex((n, i) => i >= from && n === line.이름); - if (at < 0) continue; - found.set(`${g.비목}|${at}`, line); - from = at + 1; - } - } - const costs = Object.keys(OF_COST); - for (const e of all) { - const own = costs.find((c) => OF_COST[c] === e.group); - const line = [own, ...costs].map((c) => found.get(`${c}|${e.idx}`)).find(Boolean); - const pieces = line?.조각; - e.qtyFrag = e.piecesFrag = pieces?.length ? toFragments(pieces, ctx) : []; - if (line?.요소조각?.length) { - e.unitFrag = toFragments(line.요소조각, ctx); - const first = line.요소조각.find((piece) => piece.kind === "요소"); - e.unitRef = first?.참조?.키; - e.unitText = first?.글; - } - } -} - -const cellOf = (text: string, className = ""): HTMLElement => el("td", { className, text }); - -function lineRow(e: Entry): HTMLElement { - const tr = el("tr", { - className: `m01lab__line${e.extra ? " m01lab__line--extra" : ""}`, - attrs: { tabindex: "0", "data-line": e.name }, - children: [ - el("td", { - children: [ - el("div", { text: e.name }), - ...(e.share ? [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")] : []), - ], - }), - cellOf(e.qtyValue, "m01-logic__money"), - cellOf(e.unit), - el("td", { - className: "m01lab__qty-cell", - children: [createBadge(e.qtyTag, "accent"), fragmentRow(e.qtyFrag)], - }), - cellOf(e.price, "m01-logic__money"), - cellOf(e.amount === null ? "" : formatMoney(e.amount), "m01-logic__money"), - ], - }); - tr.addEventListener("click", () => e.open(e)); - tr.addEventListener("keydown", (ev) => ev.key === "Enter" && e.open(e)); - return tr; -} - -function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLElement[] { - const sub = mine.reduce((s, e) => s + (e.amount ?? 0), 0); - const empty = el("tr", { - children: [ - el("td", { - attrs: { colspan: "6" }, - className: "ui-text ui-text--muted", - text: tl("Empty_Group"), - }), - ], - }); - const subtotal = el("tr", { - className: "m01lab__subtotal", - attrs: { "data-group": group, "data-subtotal": calculated ? String(sub) : "" }, - children: [ - el("td", { attrs: { colspan: "5" }, text: `${groupName(group)} ${tl("Subtotal")}` }), - cellOf(calculated ? formatMoney(sub) : "—", "m01-logic__money"), - ], - }); - const headers = [ - tl("Col_Name"), - tl("Col_Qty"), - tl("Col_Unit"), - tl("Col_Logic"), - tl("Col_Price"), - tl("Col_Amount"), - ]; - return [ - el("h4", { className: "m01lab__group", text: groupName(group) }), - el("div", { - className: "m01-logic__scroll", - children: [ - el("table", { - className: "ui-table ui-table--data m01-logic__grid m01lab__ho", - children: [ - el("thead", { - children: [el("tr", { children: headers.map((h) => el("th", { text: h })) })], - }), - el("tbody", { children: [...(mine.length ? mine.map(lineRow) : [empty]), subtotal] }), - ], - }), - ], - }), - ]; -} - /** 자체 로직(키 GX…) — 정본은 아님 */ export const isOwnLogic = (row: LogicRow): boolean => row.키.startsWith("GX"); @@ -477,6 +170,23 @@ function ownEditor(ctx: DetailContext): HTMLElement { }); } +/** M01 그 자리 고침 고리 — 시험 값(줄 객체 열쇠) · 고치면 시험 계산을 다시 그리고 셈 */ +function trialEdit(ctx: DetailContext): HoEdit | undefined { + const redraw = ctx.onTrial; + if (!redraw) return undefined; + const covers = trial.of(ctx.row); + return { + covers, + scope: "m01", + set: (idx, cover) => { + if (cover) covers[idx] = cover; + else delete covers[idx]; + redraw(); + ctx.calcHost.querySelector("[data-calc-run]")?.click(); + }, + }; +} + function hoBox(ctx: DetailContext): HTMLElement { const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원"); if (!money) { @@ -488,26 +198,17 @@ function hoBox(ctx: DetailContext): HTMLElement { "ho", ); } - const all = entries(ctx); - const calculated = ctx.lines !== null; - const tables = GROUPS.flatMap((group) => { - const mine = all.filter((e) => e.group === group); - return group === "other" && !mine.length ? [] : groupTable(group, mine, calculated); // 그 밖은 있을 때만 - }); - const total = all.reduce((s, e) => s + (e.amount ?? 0), 0); - const totalLine = el("p", { - className: "m01lab__total", - attrs: { "data-sum": calculated ? String(total) : "" }, - text: `${tl("Total")} ${calculated ? formatMoney(total) : "—"}`, - }); - // 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 묶음 표는 접어 둠(펼치면 소계가 보임) + const body = buildHo({ ...ctx, edit: trialEdit(ctx) }); + const totalLine = body.pop()!; // 계 줄 — 자체 로직은 접힌 표 밖에 둠 + // 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 박스는 접어 둠(펼치면 소계가 보임) const fold = el("details", { className: "m01lab__fold", attrs: ctx.ownHost.dataset.fold ? { open: "" } : {}, - children: [el("summary", { text: tl("Ho_Fold") }), ...tables], + children: [el("summary", { text: tl("Ho_Fold") }), ...body], }); fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : "")); - const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...tables, totalLine]; + const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...body, totalLine]; + const calculated = ctx.lines !== null; const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`; return block(section(tl("Ho_Title"), [nowInputs(ctx.values), ...blocks], hint), "ho"); } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts index 4cf179ae1..478eb30cb 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts @@ -45,8 +45,12 @@ export function usedInputs( } /* ── 순수 셈 끝 ── */ -/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` */ -function inputChip(spec: LogicInput, shown: string, onInput: (v: string) => void): HTMLElement { +/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` · 722-1 계산식 칸도 씀 */ +export function inputChip( + spec: LogicInput, + shown: string, + onInput: (v: string) => void, +): HTMLElement { const wrap = el("span", { className: "m01lab-fx__var", attrs: { "data-fx-var": spec.이름 } }); const chip = createButton({ label: `${spec.이름} ${shown}${spec.단위 ? ` ${spec.단위}` : ""}`, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts new file mode 100644 index 000000000..d7ac3df33 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -0,0 +1,649 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Ho.ts + * 공용 「단가산출 상세」(PLAN 722-1 · 요구서 내역16 2장 6 · 7) — M01 단가산출 로직 상세가 씀 · (724) M02 단가산출 · 일위대가 모달도. + * 하위 박스 = 서버 `묶음`(인력 · 재료 · 경비(장비) · 하위 — `/text` 줄 덧칸 714-1) · 계산 전 = 호표 종류로 + * · 장비 · 하위 로직 줄 = 박스 안 한 줄(노 · 재 · 경 몫 합) + 누르면 펼침(부른 로직 시간당 줄 · 몫 식 줄) + * 박스 제목 바로 아래 = 그 박스 줄들이 쓰는 입력 고르기(`calc_sheet` 변수 칸 꼴) · 어느 박스도 안 쓰는 입력 = 맨 위 「공통 옵션」 + * 「계산식」 열 = 값 든 식(「268,908 × 0.12」 · 서버 `글`) + 표 · 요소 · 로직 알약 + 그 줄 입력 칩 · 변수 이름 식(`이름글`)은 마우스 올리면 + * 맨 아래 = 비목 소계 셋(노무비 · 재료비 · 경비 — 장비 노무는 여기서 인력과 더해짐) + 계 + * 줄 누름 = 그 줄 자료 모달(`_LogicLab_Modal.ts`) · 고침 고리(`edit`) 있으면 모달 안 「그 자리 고침」(`_LogicLab_Cover.ts`) + * 값 = 서버 셈 그대로(박스 소계 · 비목 소계만 줄 금액 합) · 화면에서 다시 셈 안 함 + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createCalcFormulaRow } from "@ui/ui_template_calc_row"; +import { createCard } from "@ui/ui_template_card"; +import { el } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { emptyRow, tableRow, tableShell, type Column } from "@ui/ui_template_table"; +import { createText } from "@ui/ui_template_text"; +import { sheetFormula, varRow, type SheetUnfold } from "../A00_Common/calc_sheet"; +import type { + CalcLine, + ElementBrief, + HoLine, + LogicRow, + NamedFormula, + TextAnswer, + TextLine, + TextPiece, +} from "./M01_MasterData_UI_Logic_Api"; +import { pctView } from "./M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; +import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; +import { tx } from "./M01_MasterData_UI_Logic_Text"; +import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick"; +import { coverForm, type CoverPart, type LineCover } from "./M01_MasterData_UI_LogicLab_Cover"; +import { inputChip } from "./M01_MasterData_UI_LogicLab_Formula"; +import { + condText, + openMaterialModal, + openNoteModal, + openRefModal, + openLogic, + openTableModal, + type PickCond, +} from "./M01_MasterData_UI_LogicLab_Modal"; +import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill"; +import { tl } from "./M01_MasterData_UI_LogicLab_Text"; + +/* ── 순수 셈 ── */ +/** 박스로 묶을 줄 — 호표 차례 · 박스 이름 · 한 줄로 합칠지(장비 · 하위) · 금액 */ +export interface HoRowLike { + idx: number; + bundle: string; + merge: boolean; + amount: number | null; +} +export interface HoMerged { + idx: number; + 몫: T[]; + 금액: number | null; +} +export interface HoBox { + 묶음: string; + 줄: (T | HoMerged)[]; + 소계: number; +} +const BOX_ORDER = ["인력", "재료", "경비", "하위"]; + +/** 줄 → 박스(인력 · 재료 · 경비 · 하위 차례) — 합칠 줄은 같은 박스 · 같은 호표 차례끼리 한 줄(몫 차례 그대로) · + * `always` 박스는 줄이 없어도 빈 박스로 */ +export function boxesOf(list: T[], always: string[] = []): HoBox[] { + const boxes = new Map>(); + const take = (name: string): HoBox => { + let b = boxes.get(name); + if (!b) boxes.set(name, (b = { 묶음: name, 줄: [], 소계: 0 })); + return b; + }; + always.forEach(take); + const merged = new Map>(); + for (const e of list) { + const b = take(e.bundle); + b.소계 += e.amount ?? 0; + if (!e.merge) { + b.줄.push(e); + continue; + } + const id = `${e.bundle}\n${e.idx}`; + let m = merged.get(id); + if (!m) { + merged.set(id, (m = { idx: e.idx, 몫: [], 금액: null })); + b.줄.push(m); + } + m.몫.push(e); + m.금액 = m.금액 === null && e.amount === null ? null : (m.금액 ?? 0) + (e.amount ?? 0); + } + const rank = (x: string): number => (BOX_ORDER.includes(x) ? BOX_ORDER.indexOf(x) : 9); + return [...boxes.values()].sort((a, b) => rank(a.묶음) - rank(b.묶음)); +} +/* ── 순수 셈 끝 ── */ + +const TEXT = { + 인력: ["인력", "Labor"], + 재료: ["재료", "Material"], + 경비: ["경비(장비)", "Expense (equipment)"], + 하위: ["하위 로직", "Sub logic"], + Common: ["공통 옵션", "Common options"], + Basis: ["기준", "Basis"], + Formula: ["계산식", "Formula"], + Short: ["노 {a} · 재 {b} · 경 {c}", "L {a} · M {b} · E {c}"], + Fold: ["누르면 펼침", "Click to expand"], + Per: ["{n} — {v} 원/{u}", "{n} — {v} KRW/{u}"], + Edited: ["시험 고침", "Trial edit"], +} as const satisfies Record; +const th = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; +const boxName = (b: string): string => (b in TEXT ? th(b as keyof typeof TEXT) : b); + +/** 고침 고리 — M01 = 시험 값(줄 사본) · M02 = 덮개 보관 · 열쇠 = 호표 줄 차례 */ +export interface HoEdit { + covers: Record; + set: (idx: number, cover: LineCover | null) => void; + scope?: "m01" | "m02"; +} + +export interface HoContext { + row: LogicRow; + prices: Record; + /** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */ + lines: CalcLine[] | null; + /** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */ + text: TextAnswer | null; + /** 마지막으로 잘 받은 읽는 식 — `text` 가 멈춤이면 조각을 이걸로 */ + lastText?: TextAnswer; + values: Record; + /** 박스 옵션 · 입력 칩 고침 — 시험 계산 입력 고침 + 다시 셈(없으면 고르기 · 칩 없음) */ + onInput?: (name: string, value: string) => void; + /** 시험 계산 칸 — 재료 후보 고르기가 씀(없으면 후보만 보임) */ + calcHost?: HTMLElement; + /** 그 자리 고침 고리(없으면 모달은 보기만) */ + edit?: HoEdit; +} + +type Group = "labor" | "material" | "cost" | "other"; +const OF_KIND: Record = { 인력: "labor", 재료: "material", 기계: "cost" }; +const OF_COST: Record = { 노무비: "labor", 재료비: "material", 경비: "cost" }; +const BOX_OF: Record = { + labor: "인력", + material: "재료", + cost: "경비", + other: "하위", +}; +const COST_OF: Record = { labor: "노무비", material: "재료비", cost: "경비" }; +const PART_OF: Record = { + labor: "labor", + material: "material", + cost: "expense", + other: "expense", +}; + +/** 줄 종류가 셋 중 어디에도 안 들어가면 「그 밖」 — 다른 로직을 부르는 줄은 비목으로 */ +const groupOfHo = (item: HoLine): Group => + OF_KIND[item.종류] ?? (item.종류 === "로직" ? OF_COST[item.비목 ?? ""] : undefined) ?? "other"; + +interface Entry extends HoRowLike { + group: Group; + name: string; + spec: string; + unit: string; + qty: string; + /** 고른 수량 값(계산 답 `CalcLine.수량` · % 덧줄은 율) — 계산 전 「—」 */ + qtyValue: string; + qtyFrag: Fragment[]; + /** 한 줄이 비목 몫으로 갈렸을 때 이 몫 이름(예 「노무비」) */ + share?: string; + price: string; + /** 갈리기 전 줄 단가(장비 한 줄 머리) */ + linePrice: string; + extra: boolean; + /** 계산 답 줄 · 서버 읽는 식 줄(붙기 전엔 없음) */ + line?: CalcLine; + textLine?: TextLine; + /** 고침 칸 — 로직 줄 = 노 · 재 · 경 · 그 밖 = 그 줄 비목 */ + logic: boolean; + part: CoverPart; + open: (self: Entry) => void; + unitFrag?: Fragment[]; + piecesFrag?: Fragment[]; + unitRef?: string; + unitText?: string; + warn?: string; +} + +/** 서버 조각(`/text` 줄의 `조각`) → 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */ +function toFragments(pieces: TextPiece[], ctx: HoContext): Fragment[] { + return pieces.map((piece): Fragment => { + const dim = piece.깊이 ? { dim: true } : {}; + if (piece.kind !== "요소" && piece.kind !== "표" && piece.kind !== "로직") + return { text: piece.글, ...dim }; + const ref = piece.참조; + const open = (): void => { + if (!ref) return openNoteModal(piece.글, tl("Pill_NoPick")); + if (piece.kind === "표") + openTableModal({ title: piece.글, table: ref.키, row: ref.줄 ?? ref.행, col: ref.열 }); + else if (piece.kind === "요소") + openRefModal({ title: piece.글, ref: ref.키, prices: ctx.prices }); + else openLogic(ref.키); + }; + return { pill: { label: piece.글, onClick: open }, ...dim }; + }); +} + +/** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */ +const condOf = (item: HoLine): PickCond | null => + typeof item.요소 === "object" && item.요소 !== null ? (item.요소 as unknown as PickCond) : null; +const condLabel = (cond: PickCond): string => + [cond.구분, cond.상세구분, cond.규격].filter(Boolean).join(" · "); +const nameOf = (item: HoLine): string => + item.이름 ?? (condOf(item) ? condLabel(condOf(item)!) : item.요소); + +/** 수량 칸 = 계산 답의 고른 수량(% 덧줄은 율) · 계산 전 「—」 */ +const qtyOf = (line?: CalcLine): string => (line ? formatNumber(pctView(line).qty) : "—"); + +/** 계산된 줄은 서버가 준 비목별 몫으로 나눔 — 로직을 부르는 줄 · 장비 줄도 노무비·재료비·경비로 갈림 */ +const spread = (e: Entry, line?: CalcLine): Entry[] => { + const parts = Object.entries(line?.비목 ?? {}).filter(([, v]) => v !== 0); + if (!parts.length) return [e]; + return parts.map(([k, v]) => ({ + ...e, + group: OF_COST[k] ?? "other", + bundle: BOX_OF[OF_COST[k] ?? "other"], + amount: v, + ...(parts.length > 1 + ? { + share: k, + // 몫 줄 단가 = 단가 × (몫 금액 ÷ 줄 금액) — 곱하기 수량이 몫 금액과 맞게 + ...(line && line.금액 && line.단가 != null + ? { price: formatPrice((line.단가 * v) / line.금액) } + : {}), + } + : {}), + })); +}; + +/** 줄 모달 — 자료 보기 + (고리 있으면) 그 자리 고침 */ +function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLine }): void { + const middles: NamedFormula[] = ctx.row.중간 ?? []; + const { cond, item } = extra; + const line = e.line; + const edit = ctx.edit; + const perUnit = (k: string): number | null => + line && line.수량 ? (line.비목?.[k] ?? 0) / line.수량 : null; + openMaterialModal({ + unitFrag: e.unitFrag, + unitRef: line?.품목?.키 ?? e.unitRef, + unitText: e.unitText, + qtyFrag: e.piecesFrag, + title: e.name, + expr: e.qty, + ...(item ? (cond ? { cond, unit: item.단위 } : { element: item.요소 }) : {}), + prices: ctx.prices, + middles, + values: ctx.values, + // 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜) + kind: item?.종류, + usedSlot: line?.품목?.값칸, + ...(item?.종류 === "재료" && ctx.calcHost + ? { onPick: (it) => pickMaterial(ctx.calcHost!, ctx.values, e.idx, it) } + : {}), + ...(edit && item && !e.extra + ? { + edit: (close: () => void) => + coverForm({ + qty: line ? line.수량 : null, + prices: e.logic + ? { + labor: perUnit("노무비"), + material: perUnit("재료비"), + expense: perUnit("경비"), + } + : { [e.part]: line ? line.단가 : null }, + cover: edit.covers[e.idx], + scope: edit.scope, + onApply: (c) => (close(), edit.set(e.idx, c)), + }), + } + : {}), + }); +} + +function entries(ctx: HoContext): Entry[] { + const ho = ctx.row.호표 ?? []; + const out: Entry[] = ho.flatMap((item, i) => { + const line = ctx.lines?.[i]; + const cond = condOf(item); + const brief = cond ? ctx.prices[condText(cond)] : ctx.prices[item.요소]; + const price = line ? line.단가 : brief?.값; + const group = groupOfHo(item); + const shown = price === undefined || price === null ? "" : formatPrice(price); + const entry: Entry = { + idx: i, + group, + bundle: BOX_OF[group], + merge: false, + name: nameOf(item), + spec: item.규격 ?? brief?.규격 ?? "", + unit: item.단위 ?? "", + qty: item.수량, + qtyValue: qtyOf(line), + qtyFrag: [{ text: item.수량 }], + price: shown, + linePrice: shown, + amount: line ? line.금액 : null, + warn: line?.단위경고, + extra: false, + line, + logic: item.종류 === "로직", + part: OF_COST[item.비목 ?? ""] ? PART_OF[OF_COST[item.비목 ?? ""]] : PART_OF[group], + open: (self) => modalOf(self, ctx, { cond: cond ?? undefined, item }), + }; + return spread(entry, line); + }); + (ctx.row.덧줄 ?? []).forEach((extra, i) => { + const line = ctx.lines?.[ho.length + i]; + const group = OF_COST[extra.비목 ?? ""] ?? "other"; + const entry: Entry = { + idx: ho.length + i, + group, + bundle: BOX_OF[group], + merge: false, + name: extra.이름, + spec: "", + unit: line ? pctView(line).unit : "", + qty: extra.식, + qtyValue: qtyOf(line), + qtyFrag: [{ text: extra.식 }], + price: "", + linePrice: "", + amount: line ? line.금액 : null, + extra: true, + line, + logic: false, + part: PART_OF[group], + open: (self) => modalOf(self, ctx, {}), + }; + out.push(...spread(entry, line)); + }); + attachPieces(out, ctx); + return out; +} + +/** 서버 `/text` 줄을 호표 줄 차례(덧줄은 덧줄 차례)로 이음 — 서버는 비목마다 호표 차례대로 줄을 주므로 + * 이름을 차례로 따라가며 줄 차례에 맞춤 · 붙은 줄의 `묶음` · `장비` 로 박스를 정함 · + * 끝내 못 찾으면 날식 대신 빈칸(계산 전 · 서버 실패면 식 글자 그대로) */ +function attachPieces(all: Entry[], ctx: HoContext): void { + const source = ctx.text?.ok ? ctx.text : ctx.lastText; + if (!source?.ok) { + if (ctx.text) for (const e of all) e.qtyFrag = e.piecesFrag = []; // 멈춤 · 받은 조각 없음 — 날식 대신 빈칸 + return; + } + const names = [...(ctx.row.호표 ?? []).map(nameOf), ...(ctx.row.덧줄 ?? []).map((x) => x.이름)]; + const found = new Map(); + for (const g of source.groups) { + let from = 0; + for (const line of g.줄) { + const at = names.findIndex((n, i) => i >= from && n === line.이름); + if (at < 0) continue; + found.set(`${g.비목}|${at}`, line); + from = at + 1; + } + } + const costs = Object.keys(OF_COST); + const fresh = ctx.lines !== null; // 묶음은 지금 셈 답에서만(옛 답의 묶음으로 박스를 안 옮김) + for (const e of all) { + const own = costs.find((c) => OF_COST[c] === e.group); + const line = [own, ...costs].map((c) => found.get(`${c}|${e.idx}`)).find(Boolean); + const pieces = line?.조각; + e.qtyFrag = e.piecesFrag = pieces?.length ? toFragments(pieces, ctx) : []; + if (line?.요소조각?.length) { + e.unitFrag = toFragments(line.요소조각, ctx); + const first = line.요소조각.find((piece) => piece.kind === "요소"); + e.unitRef = first?.참조?.키; + e.unitText = first?.글; + } + if (!line || !fresh) continue; + e.textLine = line; + if (line.묶음) e.bundle = line.묶음; + e.merge = !!line.장비 || line.묶음 === "하위"; + } +} + +const COLS: Column[] = [ + { label: tl("Col_Name"), width: "20%" }, + { label: tl("Col_Qty"), width: "9%", align: "end" }, + { label: tl("Col_Unit"), width: "7%" }, + { label: th("Formula"), width: "38%" }, + { label: tl("Col_Price"), width: "13%", align: "end" }, + { label: tl("Col_Amount"), width: "13%", align: "end" }, +]; + +const money = (v: number | null): string => (v === null ? "" : formatMoney(v)); + +/** 이 줄이 쓰는 입력 칩 — 서버 `쓰는입력` ∩ 로직 입력 */ +function chips(e: Entry, ctx: HoContext): HTMLElement[] { + const onInput = ctx.onInput; + const uses = e.textLine?.쓰는입력 ?? []; + const specs = (ctx.row.입력 ?? []).filter((s) => uses.includes(s.이름)); + if (!onInput || !specs.length) return []; + return [ + el("div", { + className: "m01lab-fx__vars", + children: specs.map((s) => inputChip(s, ctx.values[s.이름] ?? "", (v) => onInput(s.이름, v))), + }), + ]; +} + +/** 계산식 칸 — 값 든 식(계산 뒤) · 계산 전 = 수량 식 · 알약 · 입력 칩 · 이름 식은 풍선 */ +function formulaCell(e: Entry, ctx: HoContext, text = ""): HTMLElement { + const tline = e.textLine; + const main = text || (tline ? sheetFormula(tline.글) : e.qty); + const pills = e.qtyFrag.some((f) => "pill" in f) ? [fragmentRow(e.qtyFrag)] : []; + const cell = el("div", { + className: "m01lab__qty-cell", + children: [ + el("div", { className: "m01lab__qty-text", text: main }), + ...(text ? [] : [...pills, ...chips(e, ctx)]), + ], + }); + cell.title = tline?.이름글 ?? e.qty; + return cell; +} + +function nameCell(e: Entry, label: string, covered: boolean): HTMLElement { + return el("div", { + children: [ + 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")] : []), + ...(covered ? [createBadge(th("Edited"), "accent")] : []), + ], + }); +} + +/** 보통 줄 · 몫 줄 — 누르면 그 줄 모달 */ +function lineRow(e: Entry, ctx: HoContext, label = e.name): HTMLTableRowElement { + const covered = !!ctx.edit?.covers[e.idx]; + const tr = tableRow(COLS, [ + nameCell(e, label, covered && !e.share), + e.qtyValue, + e.unit, + formulaCell(e, ctx), + e.price, + money(e.amount), + ]); + tr.classList.add("m01lab__line"); + if (e.extra) tr.classList.add("m01lab__line--extra"); + if (covered) tr.classList.add("is-changed"); + tr.tabIndex = 0; + tr.dataset.line = e.name; + tr.addEventListener("click", () => e.open(e)); + tr.addEventListener("keydown", (ev) => ev.key === "Enter" && e.open(e)); + return tr; +} + +/** 펼친 장비 줄 — 로직 줄 객체마다 기억(다시 셈 · 다시 그려도 펼친 채) */ +const opened = new WeakMap>(); +const openOf = (row: object): Set => { + let got = opened.get(row); + if (!got) opened.set(row, (got = new Set())); + return got; +}; + +/** 서버 줄 덧칸 — 장비 줄 펼침(714-1b · 부른 기계경비 로직 시간당 줄) */ +type Unfoldable = TextLine & { 펼침?: SheetUnfold | null }; + +/** 장비 · 하위 한 줄 — 머리(노 · 재 · 경 몫 + 합) · 누르면 펼침(부른 로직 시간당 줄 · 몫 줄) */ +function mergedRows(m: HoMerged, ctx: HoContext): HTMLTableRowElement[] { + const first = m.몫[0]; + const share = (cost: string): string => + formatMoney( + m.몫 + .filter((x) => (x.share ?? COST_OF[x.group]) === cost) + .reduce((s, x) => s + (x.amount ?? 0), 0), + ); + const short = th("Short") + .replace("{a}", share("노무비")) + .replace("{b}", share("재료비")) + .replace("{c}", share("경비")); + const covered = !!ctx.edit?.covers[m.idx]; + const head = tableRow(COLS, [ + nameCell(first, `▸ ${first.name}`, covered), + first.qtyValue, + first.unit, + formulaCell(first, ctx, short), + first.linePrice, + money(m.금액), + ]); + head.classList.add("m01lab__line"); + if (covered) head.classList.add("is-changed"); + head.dataset.equip = first.name; + head.title = th("Fold"); + head.tabIndex = 0; + head.setAttribute("aria-expanded", "false"); + const unfold = m.몫.map((x) => (x.textLine as Unfoldable | undefined)?.펼침).find(Boolean); + const subs: HTMLTableRowElement[] = [ + ...(unfold + ? [ + tableRow(COLS, [ + { + content: createText( + th("Per") + .replace("{n}", unfold.이름) + .replace("{v}", formatPrice(unfold.계 ?? 0)) + .replace("{u}", unfold.단위 || "-"), + "hint", + ), + colSpan: COLS.length, + }, + ]), + ...unfold.줄.map((x) => + tableRow(COLS, [ + `${x.이름} (${x.비목})`, + "", + "", + sheetFormula(x.글), + "", + money(x.금액), + ]), + ), + ] + : []), + ...m.몫.map((x) => lineRow(x, ctx, `${x.share ?? first.name}`)), + ]; + for (const s of subs) { + s.classList.add("is-sub"); + s.hidden = true; + s.dataset.part = first.name; + } + const label = head.querySelector("td > div > div") as HTMLElement; + const keep = openOf(ctx.row); + const show = (open: boolean): void => { + head.setAttribute("aria-expanded", String(open)); + label.textContent = `${open ? "▾" : "▸"} ${first.name}`; + subs.forEach((s) => (s.hidden = !open)); + if (open) keep.add(m.idx); + else keep.delete(m.idx); + }; + const toggle = (): void => show(head.getAttribute("aria-expanded") !== "true"); + if (keep.has(m.idx)) show(true); + head.addEventListener("click", toggle); + head.addEventListener("keydown", (ev) => { + if (ev.key !== "Enter" && ev.key !== " ") return; + ev.preventDefault(); + toggle(); + }); + return [head, ...subs]; +} + +/** 옵션 고르기 줄들 — 이름 차례 · 고르기 있으면 고르기 · 아니면 글 칸 */ +function optionRows(names: string[], ctx: HoContext): HTMLElement[] { + const onInput = ctx.onInput; + if (!onInput) return []; + return (ctx.row.입력 ?? []) + .filter((s) => names.includes(s.이름)) + .map((s) => varRow(s, ctx.values[s.이름] ?? "", (v) => onInput(s.이름, v))); +} + +const usesOf = (b: HoBox): string[] => [ + ...new Set( + b.줄.flatMap((r) => ("몫" in r ? r.몫 : [r]).flatMap((e) => e.textLine?.쓰는입력 ?? [])), + ), +]; + +function boxCard(b: HoBox, ctx: HoContext, calculated: boolean): HTMLElement { + const name = boxName(b.묶음); + const lines = b.줄.flatMap((r) => ("몫" in r ? r.몫 : [r])); + const basis = [...new Set(lines.map((e) => e.textLine?.기준단위).filter(Boolean))]; + const notes = [...new Set(lines.map((e) => e.textLine?.환산근거).filter(Boolean))]; + const { root, tbody } = tableShell(COLS, { density: "data" }); + root.classList.add("m01lab__ho"); + tbody.append( + ...(b.줄.length + ? b.줄.flatMap((r) => ("몫" in r ? mergedRows(r, ctx) : [lineRow(r, ctx)])) + : [emptyRow(COLS, tl("Empty_Group"))]), + ); + const sub = tableRow(COLS, [ + { content: `${name} ${tl("Subtotal")}`, colSpan: 5 }, + calculated ? formatMoney(b.소계) : "—", + ]); + sub.classList.add("m01lab__subtotal"); + sub.dataset.box = b.묶음; + sub.dataset.subtotal = calculated ? String(b.소계) : ""; + tbody.append(sub); + const card = createCard({ + title: name, + pad: 12, + body: [ + ...(basis.length ? [createText(`${th("Basis")} ${basis.join(" · ")}`, "muted")] : []), + ...(notes.length ? [createText(notes.join(" · "), "hint")] : []), + ...optionRows(usesOf(b), ctx), + root, + ], + }); + card.classList.add("m01lab__ho-box"); + card.dataset.hoBox = b.묶음; + return card; +} + +/** 어느 박스 줄도 안 쓰는 입력 — 맨 위 한 카드 */ +function commonCard(rows: HTMLElement[]): HTMLElement { + const card = createCard({ title: th("Common"), pad: 12, body: rows }); + card.classList.add("m01lab__ho-box"); + card.dataset.hoBox = "common"; + return card; +} + +/** 공용 단가산출 상세 몸 — 공통 옵션 · 박스들 · 비목 소계 셋 · 계 */ +export function buildHo(ctx: HoContext): HTMLElement[] { + const all = entries(ctx); + const calculated = ctx.lines !== null; + const boxes = boxesOf(all, ["인력", "재료", "경비"]); + const used = new Set(boxes.flatMap(usesOf)); + const common = optionRows( + (ctx.row.입력 ?? []).map((s) => s.이름).filter((n) => !used.has(n)), + ctx, + ); + const sums = (["labor", "material", "cost"] as Group[]).map((g) => { + const v = all.filter((e) => e.group === g).reduce((s, e) => s + (e.amount ?? 0), 0); + const out = el("span", { + className: "ui-calc-row__out is-key", + text: calculated ? formatMoney(v) : "—", + }); + const row = createCalcFormulaRow(COST_OF[g], null, out); + row.dataset.group = g; + row.dataset.subtotal = calculated ? String(v) : ""; + return row; + }); + const total = all.reduce((s, e) => s + (e.amount ?? 0), 0); + return [ + ...(common.length ? [commonCard(common)] : []), + ...boxes.map((b) => boxCard(b, ctx, calculated)), + el("div", { className: "m01lab__sums", children: sums }), + el("p", { + className: "m01lab__total", + attrs: { "data-sum": calculated ? String(total) : "" }, + text: `${tl("Total")} ${calculated ? formatMoney(total) : "—"}`, + }), + ]; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts index bb3270f83..619b2a157 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts @@ -92,6 +92,8 @@ export interface ModalTarget { usedSlot?: string; /** 있으면 후보 줄을 눌러 고름(시험 입력 · 저장 안 함) — 고르면 모달 닫힘 */ onPick?: (item: { ref: string; label: string }) => void; + /** 722-2 그 자리 고침 칸(맨 위) — 받는 것 = 모달 닫기 */ + edit?: (close: () => void) => HTMLElement; } /** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */ @@ -503,6 +505,7 @@ export function openMaterialModal(target: ModalTarget): void { rate.append(muted(`${element} — ${tl("Pill_NoValue")}`)); } body.replaceChildren( + ...(target.edit ? [target.edit(close)] : []), rate, ...(cond || target.kind === "재료" ? [candidateView(target, priceRef, close)] : []), muted(tl("Modal_Sum")), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index a1fbc0bd6..947469e4c 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -233,3 +233,42 @@ gap: var(--spacing-4); min-width: 0; } + +/* 722-1 공용 단가산출 상세 — 하위 박스(카드) 안 · 옵션 줄 · 표 사이 간격 · 맨 아래 비목 소계 */ +.m01lab__ho-box > .ui-card__body { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.m01lab__sums { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +/* 722-2 줄 모달 「그 자리 고침」 */ +.m01lab-cover { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.m01lab-cover__fields { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--spacing-8); +} + +.m01lab-cover__actions { + display: flex; + gap: var(--spacing-8); +} + +.m01lab__ho-box .ui-card__title { + font-size: var(--text-body); + font-weight: 600; +} diff --git a/resources/tester/test_722_ho_detail.py b/resources/tester/test_722_ho_detail.py new file mode 100644 index 000000000..b13834ea6 --- /dev/null +++ b/resources/tester/test_722_ho_detail.py @@ -0,0 +1,113 @@ +"""722 M01 단가산출 상세 개편 지킴 시험 — 글 · 순수 함수(화면 · 서버 없음). + +- 722-1 공용 단가산출 상세(`M01_MasterData_UI_LogicLab_Ho.ts`) — 하위 박스(서버 묶음) · 장비 한 줄 + 펼침 · 계산식 열 · 박스 옵션 +- 722-2 그 자리 고침(`M01_MasterData_UI_LogicLab_Cover.ts`) — M01 = 시험 값(줄 사본) · 정본 · 캐시 안 바뀜 +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +M01 = ROOT / "M01_MasterData" +HO = M01 / "M01_MasterData_UI_LogicLab_Ho.ts" +COVER = M01 / "M01_MasterData_UI_LogicLab_Cover.ts" + + +def _text(p: Path) -> str: + return p.read_text(encoding="utf-8") + + +def _pure(p: Path) -> str: + s = _text(p) + return s[s.index("/* ── 순수 셈 ── */") : s.index("/* ── 순수 셈 끝 ── */")] + + +def _node(code: str): + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", code], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def test_상세가_공용_부품을_씀(): + detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts") + assert "buildHo({ ...ctx, edit: trialEdit(ctx) })" in detail + ho = _text(HO) + # 부품 상자만 — 표 · 카드 · 계산 줄 · 변수 칸(calc_sheet) · 입력 칩(텍스트 수식) + for part in ( + "tableShell(", + "tableRow(", + "createCard(", + "varRow(", + "inputChip(", + "sheetFormula(", + ): + assert part in ho, part + for s in (ho, _text(COVER)): + assert 'el("table"' not in s and 'el("button"' not in s and 'el("input"' not in s + assert "document.createElement" not in s and "sessionStorage" not in s + assert "export function varRow(" in _text(ROOT / "A00_Common" / "calc_sheet.ts") + for p in (HO, COVER, M01 / "M01_MasterData_UI_LogicLab.ts"): + assert len(_text(p).splitlines()) <= ( + 627 if p.name == "M01_MasterData_UI_LogicLab.ts" else 700 + ) + + +def test_시험_고침은_캐시_밖(): + lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts") + # 시험 계산에만 덮은 사본 · 고침이 있으면 줄을 보냄 · drafts(캐시)에는 원본 줄 + assert ": trial.apply(row);" in lab + assert "dirty: () => current.id in drafts || trial.has(current.row)," in lab + assert "onTrial: drawCalc," in lab + cover = _text(COVER) + assert "drafts" not in cover and "touch" not in cover and "/save" not in cover + modal = _text(M01 / "M01_MasterData_UI_LogicLab_Modal.ts") + assert "...(target.edit ? [target.edit(close)] : [])" in modal + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_박스_묶기(): + body = """ +const e = (idx, bundle, merge, amount) => ({ idx, bundle, merge, amount }); +const got = boxesOf([ + e(0, "인력", false, 10), e(1, "경비", true, 100), e(1, "경비", true, 20), e(1, "경비", true, 30), + e(2, "하위", true, 5), e(2, "하위", true, null), e(3, "재료", false, null), +], ["인력", "재료", "경비"]).map((b) => ({ 묶음: b.묶음, 소계: b.소계, + 줄: b.줄.map((r) => "몫" in r ? [r.idx, r.금액, r.몫.length] : r.idx) })); +console.log(JSON.stringify(got)); +""" + got = _node(_pure(HO) + "\n" + body) + assert [b["묶음"] for b in got] == ["인력", "재료", "경비", "하위"] + assert got[0] == {"묶음": "인력", "소계": 10, "줄": [0]} + assert got[1] == {"묶음": "재료", "소계": 0, "줄": [3]} + assert got[2] == {"묶음": "경비", "소계": 150, "줄": [[1, 150, 3]]} # 장비 한 줄 = 몫 셋 합 + assert got[3]["줄"] == [[2, 5, 2]] + empty = _node(_pure(HO) + '\nconsole.log(JSON.stringify(boxesOf([], ["인력"])))') + assert empty == [{"묶음": "인력", "줄": [], "소계": 0}] + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_고침_덮기(): + body = """ +const row = { 키: "GF1", 호표: [ + { 종류: "인력", 요소: "LB1", 수량: "찾기(Q).수량" }, + { 종류: "로직", 요소: "로직(GC1)", 수량: "1 / 작업량", 값묶음: { 경비: 3 } }, + { 종류: "재료", 요소: "MT1", 수량: "2" } ], 덧줄: [{ 이름: "공구", 식: "노무비 * 3 / 100" }] }; +const out = applyCovers(row, { 0: { qty: 0.0000001, labor: 100000 }, 1: { labor: 50000 }, 9: { qty: 1 } }); +console.log(JSON.stringify({ out, same: row.호표[0].수량, keep: out.호표[2] === row.호표[2] })); +""" + got = _node(_pure(COVER) + "\n" + body) + out = got["out"] + assert ( + out["호표"][0]["수량"] == "0.0000001" and out["호표"][0]["단가"] == 100000 + ) # 지수 꼴 없음 · 손 단가 + assert out["호표"][1]["값묶음"] == {"경비": 3, "노무비": 50000} and "단가" not in out["호표"][1] + assert got["keep"] and got["same"] == "찾기(Q).수량" # 원본 안 바뀜 · 안 고친 줄 그대로 + assert out["덧줄"] == [{"이름": "공구", "식": "노무비 * 3 / 100"}]