Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_QtyLinks.ts
T
eomsangdonandClaude Sonnet 5.5 e171dda4e3 feat(797-2): 엑셀 불러오기 소요량 · 계수 이음 — 값 역색인 후보 + 사람 확인 · 고름 = 표 칸 참조 기록
- 새 `M02_Sangeun_QtyLinks` — 가져온 식 줄(호표 수량 · 덧줄 식)의 수를 M01 소요량(QC/QF) · 계수(CC/CF) 칸과 값 역색인(`San_Basis_Index.lookup`)으로 맞춤 · 같은 값 + 같은 단위 성격 + 공종 문맥(인력 = 직종 · 재료 = 칸 이름 추가) · 일반 수 · 비교 글자 · 중간 변수는 자리 아님 · 유일해도 자동 확정 없음
- 고름 = `가져온.연결` 기록(층 M01 · 표 · 줄 · 칸 · 값 · 꼴) · 엑셀 수 = 칸 값일 때만(다르면 400 까닭) · 버림 · 되돌림 · 기록만(셈 · 금액 · 단가 갱신 안 건드림) · 저장 거름(`clean`) = 자리 · 수 맞는 기록만
- 길 `…/sangeun/imported/{qty|qty-pick|qty-drop}` · 가져온 식 만들 때 표지 「참고 · 소요량 후보 N · 사람 확인」 · 「표 칸 없음」(못 옮김 표지 아님)
- 화면 — 가져온 식 모달 머리 판 한 줄(집계 · [이음 보기]) + 모달(줄마다 후보 · [고르기] · [풀기] · [버리기] · [되돌리기] · 부품 상자만)
- 시험 test_797_2_qty_coef_links 9 · 낡은 시험 없음

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 15:23:12 +09:00

205 lines
7.9 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Hopyo_QtyLinks.ts
* 가져온 식 소요량 · 계수 이음 화면(PLAN 797-2 · 요구 655-3) — `Hopyo_ImportedPanel` 한 줄의 [이음 보기] 가 엶.
* · 줄마다 「소요량 후보 N · 사람 확인」(0 = 「표 칸 없음」) + 후보 칸(표 · 칸 · 값 · 단위 · 맥락) [고르기] · [버리기]
* · 고른 것 = 그 줄에 M01 표 칸 참조 기록(층 · 표 · 줄 · 칸) — 기록만(금액 그대로) · 값이 다르면 서버가 거절(까닭 토스트)
* · M01 칸 값이 그새 바뀌었으면 「M01 값 바뀜」 표시 · 유일해도 자동 고름 없음
* 부품 = 모달 · 표 · 배지 · 단추 · 글 · 띠(날 단추 · 입력 없음) · 서버 길은 부르는 쪽(`Hopyo_Imported`).
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { el } from "@ui/ui_template_elements";
import { currentLanguageIndex } 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";
const TEXT = {
Title: ["소요량 · 계수 이음", "Quantity / coefficient links"],
Head: [
"엑셀 수와 같은 값의 M01 표 칸만 후보 — 고르면 기록만 남고 금액은 그대로",
"Only M01 cells with the same value are candidates — picking only records the link",
],
Line: ["줄", "Line"],
Num: ["수", "Value"],
State: ["상태", "State"],
Cells: ["후보 칸", "Candidate cells"],
Cand: ["소요량 후보 {n} · 사람 확인", "{n} candidates · confirm"],
Empty: ["표 칸 없음", "No table cell"],
Unique: ["후보 유일", "One candidate"],
Many: ["후보 여럿", "Several candidates"],
Picked: ["고름", "Picked"],
Dropped: ["버림", "Dropped"],
Pick: ["고르기", "Pick"],
Drop: ["버리기", "Drop"],
Undo: ["되돌리기", "Undo"],
Free: ["풀기", "Release"],
Changed: ["M01 값 바뀜 {v}", "M01 value changed {v}"],
More: ["외 {n}", "+{n} more"],
Close: ["닫기", "Close"],
None: ["가져온 식에 소요량 · 계수 수가 없음", "No quantity / coefficient values"],
Ctx: ["공종", "Work"],
} as const satisfies Record<string, readonly [string, string]>;
const tx = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
export interface QtyCell {
표: string;
줄: number;
칸: string;
값: number;
단위: string;
꼴: "eq" | "pct" | "kgt";
맥락: string;
이름?: string;
번호?: string;
갈래: "소요량" | "계수";
}
export interface QtyPicked {
표: string;
줄: number;
칸: string;
값: number;
지금?: number | null;
같음?: boolean;
}
export interface QtyRow {
자리: string;
줄: string;
단위: string;
수: number;
후보수: number;
후보: QtyCell[];
고른: QtyPicked | null;
상태: "후보" | "없음" | "고름" | "버림";
}
export interface QtyTally {
자리: number;
줄: number;
유일: number;
여럿: number;
없음: number;
고름: number;
버림: number;
}
export interface QtyView {
줄: QtyRow[];
집계: QtyTally;
}
export interface QtyApi {
view: () => Promise<QtyView>;
pick: (spot: string, cell: QtyCell) => Promise<void>;
drop: (spot: string, undo: boolean) => Promise<void>;
}
const muted = (s: string): HTMLElement => createText(s, "muted", 14);
/** 집계 배지 띠(패널 줄 · 모달 머리 공용) */
export function tallyBadges(t: QtyTally): HTMLElement[] {
return [
createBadge(`${tx("Unique")} ${t.유일}`, t.유일 ? "warning" : "neutral"),
createBadge(`${tx("Many")} ${t.여럿}`, t.여럿 ? "warning" : "neutral"),
createBadge(`${tx("Empty")} ${t.없음}`, "neutral"),
createBadge(`${tx("Picked")} ${t.고름}`, t.고름 ? "success" : "neutral"),
];
}
function cellRow(c: QtyCell, spot: string, locked: boolean, go: () => void): HTMLElement {
const pick = createButton({ label: tx("Pick"), size: 24, variant: "ghost", disabled: locked, onClick: go }); // prettier-ignore
pick.dataset.qtyPick = `${spot}|${c.표}|${c.줄}|${c.칸}`;
return el("div", {
className: "ui-toolbar m02-judge__wrap",
attrs: { "data-qty-cand": c.표 },
children: [
createBadge(`${c.표} ${c.이름 ?? ""}`.trim(), c.갈래 === "소요량" ? "accent" : "info"),
muted([c.번호, `${c.칸} ${c.값}${c.단위 ? ` ${c.단위}` : ""}`].filter(Boolean).join(" · ")),
createBadge(c.맥락 === "공종" ? tx("Ctx") : c.맥락, "neutral"),
pick,
],
});
}
/** 모달 — 자리마다 후보 · 고름 · 버림 */
export function openQtyLinks(api: QtyApi, readOnly: boolean): void {
const root = el("div", { attrs: { "data-qty-links": "" } });
let busy = false;
const act = (job: () => Promise<void>): void => {
if (busy) return;
busy = true;
void job()
.catch(() => {})
.finally(() => {
busy = false;
void draw();
});
};
function state(r: QtyRow): HTMLElement {
if (r.상태 === "고름" && r.고른) {
const bad = r.고른.같음 === false;
return el("div", {
className: "ui-toolbar m02-judge__wrap",
children: [
createBadge(`${tx("Picked")} ${r.고른.표} ${r.고른.칸} ${r.고른.값}`, "success"),
...(bad ? [createBadge(tx("Changed").replace("{v}", String(r.고른.지금 ?? "—")), "warning")] : []), // prettier-ignore
],
});
}
if (r.상태 === "버림") return createBadge(tx("Dropped"), "neutral");
if (r.상태 === "없음") return createBadge(tx("Empty"), "neutral");
return createBadge(tx("Cand").replace("{n}", String(r.후보수)), "warning");
}
function cells(r: QtyRow): HTMLElement {
const locked = readOnly || busy;
const list = r.상태 === "후보" || r.상태 === "고름" ? r.후보 : [];
const more = r.후보수 - r.후보.length;
return el("div", {
children: [
...list.map((c) => cellRow(c, r.자리, locked, () => act(() => api.pick(r.자리, c)))),
...(more > 0 && list.length ? [muted(tx("More").replace("{n}", String(more)))] : []),
],
});
}
function side(r: QtyRow): HTMLElement | string {
if (r.상태 === "없음") return "";
const undo = r.상태 !== "후보"; // 버림 → 되돌리기 · 고름 → 풀기(후보로) · 후보 → 버리기
const label = r.상태 === "버림" ? tx("Undo") : r.상태 === "고름" ? tx("Free") : tx("Drop");
const b = createButton({ label, size: 24, variant: "ghost", disabled: readOnly || busy, onClick: () => act(() => api.drop(r.자리, undo)) }); // prettier-ignore
b.dataset.qtyDrop = r.자리;
return b;
}
async function draw(): Promise<void> {
const got = await api.view().catch(() => null);
if (!got) return;
const rank = { 고름: 0, 후보: 1, 버림: 2, 없음: 3 };
const sorted = [...got.줄].sort((a, b) => rank[a.상태] - rank[b.상태]); // 사람이 볼 것 먼저(안정 정렬)
const rows = sorted.map((r) => [`${r.줄}`, `${r.수}${r.단위 ? ` ${r.단위}` : ""}`, state(r), cells(r), side(r)]); // prettier-ignore
const heads = [tx("Line"), tx("Num"), tx("State"), tx("Cells"), ""];
const table = createTable(heads.map((h, i) => ({ label: h, ...(i === 1 ? { align: "end" as const } : {}), ...(i === 4 ? { width: "84px" } : {}) })), rows, { density: "data" }); // prettier-ignore
table.dataset.qtyTable = String(got.줄.length);
root.replaceChildren(
createToolbar({ items: [muted(tx("Head")), ...tallyBadges(got.집계)] }),
got.줄.length ? table : createText(tx("None"), "muted", 14),
);
}
openModal({
title: tx("Title"),
closeLabel: tx("Close"),
wide: true,
dialogClass: "m02-modal m02-hopyo__qty",
mount: (body) => body.append(root),
});
void draw();
}