Files
Aislo/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts
T
eomsangdonandClaude Opus 5.5 190789cdf7 feat(722-1·722-2): M01 단가산출 상세 개편 — 공용 상세 부품(하위 박스 · 계산식 · 박스 옵션) · 줄 모달 그 자리 고침(시험 값)
- 새 공용 단가산출 상세 `M01_MasterData_UI_LogicLab_Ho.ts` — 하위 박스 = 서버 묶음(인력 · 재료 · 경비(장비) · 하위) · 장비 · 하위 로직 = 한 줄 + 펼침(부른 로직 시간당 줄 · 몫 줄 · 다시 셈해도 펼친 채) · 「계산식」 열 = 값 든 식 + 알약 + 그 줄 입력 칩 · 이름 식 = 풍선 · 박스 제목 아래 옵션 고르기 · 어느 박스도 안 쓰는 입력 = 공통 옵션 · 맨 아래 비목 소계 셋 + 계
- 새 `M01_MasterData_UI_LogicLab_Cover.ts` — 줄 모달 「그 자리 고침」(수량 · 단가 · 로직 줄은 노 · 재 · 경) · M01 = 시험 값만(로직 줄 객체 열쇠 메모리 · 시험 계산 줄 사본에 손 단가 · 값묶음 · 수량 덮음 · 정본 · 요소 · 캐시 안 바뀜) · 고침 꼴 = M02 덮개 칸(724 고침 고리 `HoEdit`)
- `LogicLab_Detail.ts` 단가산출 상세 = 공용 부품(호표 묶음 표 코드 옮김 · 551 → 252 줄) · `LogicLab.ts` 시험 계산 줄 = trial.apply · dirty 에 시험 고침 · onTrial(줄 수 627 그대로)
- `calc_sheet.ts` varRow 내보냄 · 값 없으면 빈 첫 칸 · `LogicLab_Formula.ts` inputChip 내보냄 · `LogicLab_Modal.ts` 줄 모달 edit 칸
- 보기 고르기 넷 그대로(지우기는 사용자 확인 뒤)
- 지킴 시험 test_722_ho_detail(박스 묶기 · 고침 덮기 순수 함수 · 캐시 밖 · 부품 상자)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:39:17 +09:00

174 lines
7.2 KiB
TypeScript

/* =============================================================================
* 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<R extends { 호표?: object[] }>(
row: R,
covers: Record<number, LineCover>,
): R {
const ho = (row.호표 ?? []).map((raw, i) => {
const c = covers[i];
if (!c) return raw;
const item = raw as Record<string, unknown>;
const out: Record<string, unknown> = { ...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<object, Record<number, LineCover>>();
export const trial = {
of(row: object): Record<number, LineCover> {
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<R extends object | null>(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<string, readonly [string, string]>;
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<Record<CoverPart, number | null>>;
/** 지금 든 고침(있으면 칸 처음 값 · [되돌림]) */
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,
],
});
}