Files
Aislo/B09_Estimation/B09_Estimation_UI_Link.ts
T

349 lines
14 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_Link.ts
* B09 설계내역서 내역 줄 ↔ 우리 공종 잇기 창(PLAN 707-4) — 불러온 경쟁사 줄(707-1 `hand:x…`)을
* 우리 자동 줄(토공 · 운반 · 구조물)에 이어 B08 수량이 붙게. 저장 안 함(받은 캐시 → [저장] 때 서버 정본).
*
* 길 = `B09_Estimation_Router_Link.py` link/suggest(제안 · 남은 줄 · 빈 자리 · 이은 줄) ·
* link/apply(잇기 · 맞춤 기억 씀) · link/undo(잇기 풀기) · link/forget(이 짝 잊기) — 셈 = 서버 단독
* 창 = 제안 줄(꼬리표 지난번 이음 · 짝 표 · 이름 같음 · 후보 고름 · 수량 갈래 · 묶음 개수 · [잇기]) ·
* 제안 없는 줄([우리 공종으로 잇기] = 단가연결 고르기 창 재사용 · 우리 줄만) · 이은 줄([잇기 풀기] ·
* [이 짝 잊기]) · 머리 [모두 제안대로](짧은 길 = 불러오기 → 모두 제안대로 → [저장])
* 수량 갈래 기본 = 불러올 때 고른 것(가 넘겨받기 · 나 단가만) · 줄에서 바꿈 ·
* 묶음 나 = 개수 칸(빈칸 = B08 수량 · 다르면 수량 칸 붉게)
* 고리 = 불러오기 끝(`…_UI_Import`) · 설계내역서 우클릭(`…_UI_Tabs_Bill_Edit`)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { createListRow } from "@ui/ui_template_list_row";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { createToolbar } from "@ui/ui_template_toolbar";
import {
openLogicPicker,
type PickExtra,
} from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
import { currentProjectId } from "../A00_Common/b_project_switch";
import type { Row } from "./B09_Estimation_Calc_Base";
import type { TabContext } from "./B09_Estimation_UI_Tabs";
/** 받은 원가 자료 · 새 캐시를 화면에 — 불러오기 고리 = 페이지 `done` · 우클릭 = 탭 ctx */
export type LinkDone = (payload: Row, user: Row) => void;
type Mode = "take" | "price";
const MODES = [
{ value: "take", text: "가 넘겨받기" },
{ value: "price", text: "나 단가만" },
];
const HEAD = { earth: "토공", haul: "운반", st: "구조물" } as Record<string, string>;
const TONE: Record<string, BadgeTone> = { "지난번 이음": "success", "짝 표": "info" };
/** 마지막에 고른 수량 갈래(불러올 때 · 창에서) — 우클릭 길 기본 */
let lastMode: Mode = "take";
const flat = (t: unknown): string => String(t ?? "").replace(/\s+/g, "");
const label = (r: Row): string =>
[r.name, r.spec].filter(Boolean).join(" ") + (r.unit ? ` (${r.unit})` : "");
async function post(path: string, body: Row): Promise<Row> {
const response = await fetch(
`${API_BASE_URL}/projects/${currentProjectId()}/estimation/link/${path}`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const got = (await response.json().catch(() => null)) as Row | null;
if (!response.ok || !got) {
const detail = got?.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
return got;
}
const fail = (head: string) => (error: unknown) =>
showToast(`${head} — ${error instanceof Error ? error.message : error}`, "error");
/** 잇기 한 번 — 받은 것 화면에 → 새 캐시 · 알림 글 */
async function linkPairs(user: Row, done: LinkDone, pairs: Row[], counts: Row): Promise<Row> {
const got = await post("apply", { pairs, mode: lastMode, counts, user });
done(got.payload, got.user);
const n = got.알림 || {};
const miss: Row[] = n.못함 || [];
const head = `이음 ${n.이음} · 묶음 ${n.묶음}${n.지움 ? ` · 안 줄 지움 ${n.지움}` : ""}`;
const tail =
(miss.length ? ` · 못함 ${miss.length}(${miss[0].까닭})` : "") +
(n.남은 != null ? ` · 안 이은 불러온 항목 ${n.남은}` : "");
showToast(`${head}${tail} — [저장]해야 남음`, miss.length ? "warning" : "success", 6000);
return got.user;
}
/** 우리 줄 고르기 — 단가연결 고르기 창(덧줄만 · 종류 = 토공 · 운반 · 구조물) */
function pickOurs(spots: Row[], line: Row, take: (key: string) => void): void {
const group = line.kind !== "item";
const extra: PickExtra[] = spots
.filter((s) => (s.head === "st") === group)
.map((s) => ({
kind: String(s.head),
key: String(s.key),
tag: HEAD[s.head] ?? s.head,
name: String(s.name ?? ""),
sub: String(s.spec ?? ""),
unit: String(s.unit ?? ""),
onPick: () => take(String(s.key)),
}));
if (!extra.length)
return void showToast(group ? "빈 구조물 줄 없음" : "빈 토공 · 운반 줄 없음", "warning");
const kinds = [...new Set(extra.map((x) => x.kind))];
openLogicPicker(() => undefined, {
layer: "project",
owner: null,
libOnly: true,
title: `우리 공종으로 잇기 — ${label(line)}`,
extra,
kindFilter: kinds.map((k) => ({ value: k, text: HEAD[k] ?? k })),
pick: () => undefined,
});
}
/** 잇기 창 — user = 지금 캐시 · mode = 불러올 때 고른 갈래(없으면 마지막) ·
* fresh = 불러오기 끝에서 부름 — 지난번 이음은 먼저 바로 잇고 남은 것만 창에(남은 0 = 창 없음) */
export async function openBillLink(
user: Row,
done: LinkDone,
mode?: string,
fresh = false,
): Promise<void> {
if (mode === "take" || mode === "price") lastMode = mode;
let cur = user;
let seen: Row;
try {
seen = await post("suggest", { user: cur });
if (fresh && seen.저절로) {
const got = await post("apply", {
pairs: [],
auto: true,
mode: lastMode,
counts: {},
user: cur,
});
done(got.payload, got.user);
cur = got.user;
showToast(
`지난번 이음 ${got.알림.이음 + got.알림.묶음} 줄 바로 이음 — [저장]해야 남음`,
"success",
);
seen = await post("suggest", { user: cur });
}
} catch (error) {
return fail("제안을 못 받음")(error);
}
if (fresh && !seen.남은.length) return; // 남은 줄 없음 — 창 안 뜸
if (!seen.남은.length && !seen.이은.length) return; // 불러온 줄 없음
let busy = false;
const run = (job: () => Promise<Row | void>): void => {
if (busy) return;
busy = true;
job()
.then((next) => {
if (next) cur = next;
return post("suggest", { user: cur });
})
.then((next) => ((seen = next), draw()), fail("잇기 못 함"))
.finally(() => (busy = false));
};
const all = createButton({ label: "모두 제안대로", variant: "filled" });
const modeSel = createSelectField({
options: MODES,
value: lastMode,
compact: true,
label: "수량 갈래(줄에서 바꿈)",
onChange: (v) => (lastMode = v as Mode),
});
const body = el("div", { className: "b09-import__choices" });
// 줄마다 고른 것(후보 · 갈래 · 개수) — 다시 그려도 남음
const chosen = new Map<string, { target: string; mode: Mode | null; count: string }>();
const pickOf = (s: Row) => {
// 기본 = 앞 줄이 안 고른 첫 후보(암석절취 연암 · 보통암 → 절토(연암) · 암발파)
const taken = new Set([...chosen.values()].map((c) => c.target));
const first = (s.후보 as Row[]).find((c) => !taken.has(c.key)) ?? s.후보[0];
const got = chosen.get(s.key) ?? { target: first.key, mode: null, count: "" };
chosen.set(s.key, got);
return got;
};
const pairOf = (s: Row): Row => {
const p = pickOf(s);
return { line: s.key, target: p.target, ...(p.mode && { mode: p.mode }) };
};
const countsOf = (lines: Row[]): Row =>
Object.fromEntries(
lines
.map((s) => [s.key, Number(pickOf(s).count)])
.filter(([, n]) => Number.isFinite(n) && (n as number) > 0),
);
const forget = (key: string): void =>
run(async () => {
const got = await post("forget", { key, user: cur });
showToast(
got.남음 ? "이 짝 잊음 — 회사 기억이 남음(회사 관리자만 지움)" : "이 짝 잊음",
got.남음 ? "warning" : "success",
);
});
const suggestRow = (s: Row): HTMLElement => {
const p = pickOf(s);
const tags = [createBadge(s.꼬리표, TONE[s.꼬리표] ?? "neutral")];
if (s.후보.some((c: Row) => flat(c.name) === flat(s.name)))
tags.push(createBadge("이름 같음", "accent"));
if (!s.하나) tags.push(createBadge("고름", "warning"));
const target = createSelectField({
options: s.후보.map((c: Row) => ({ value: c.key, text: `→ ${c.name}` })),
value: p.target,
compact: true,
ariaLabel: "이을 우리 줄",
onChange: (v) => (p.target = v),
});
const lineMode = createSelectField({
options: [{ value: "", text: "창 갈래" }, ...MODES],
value: p.mode ?? "",
compact: true,
ariaLabel: "이 줄 수량 갈래",
onChange: (v) => (p.mode = (v || null) as Mode | null),
});
const items: HTMLElement[] = [...tags, target.root, lineMode.root];
if (s.kind !== "item") {
const count = createInputField({
type: "number",
compact: true,
min: 0,
value: p.count,
placeholder: "개수(B08)",
onInput: (v) => (p.count = v),
});
items.push(count.root);
}
items.push(
createButton({
label: "잇기",
variant: "ghost",
onClick: () => run(() => linkPairs(cur, done, [pairOf(s)], countsOf([s]))),
}),
);
if (s.꼬리표 === "지난번 이음")
items.push(
createButton({ label: "이 짝 잊기", variant: "ghost", onClick: () => forget(s.key) }),
);
return createListRow({
label: label(s),
sub: s.까닭 || (s.kind === "item" ? "" : "묶음 → 구조물 줄 통째"),
trailing: createToolbar({ items, size: 24 }),
});
};
const freeRow = (r: Row): HTMLElement =>
createListRow({
label: label(r),
sub: r.kind === "item" ? "" : "묶음",
trailing: createButton({
label: "우리 공종으로 잇기",
variant: "ghost",
size: 24,
onClick: () =>
pickOurs(seen.자리, r, (target) =>
run(() => linkPairs(cur, done, [{ line: r.key, target }], {})),
),
}),
});
const linkedRow = (r: Row): HTMLElement =>
createListRow({
label: `${label(r.from)} → ${r.name}`,
sub: HEAD[String(r.key).split(":")[0]] ?? "",
trailing: createToolbar({
size: 24,
items: [
createButton({
label: "잇기 풀기",
variant: "ghost",
onClick: () => run(() => undoLink(cur, done, r.key)),
}),
createButton({ label: "이 짝 잊기", variant: "ghost", onClick: () => forget(r.key) }),
],
}),
});
const draw = (): void => {
const lines: Row[] = seen.줄;
const has = new Set(lines.map((s) => s.key));
const free = (seen.남은 as Row[]).filter((r) => !has.has(r.key));
const items = (seen.남은 as Row[]).filter((r) => r.kind === "item").length;
const recall = lines.filter((s) => s.꼬리표 === "지난번 이음").length;
all.disabled = !lines.length;
body.replaceChildren(
createText(
`안 이은 불러온 줄 — 항목 ${items} · 묶음 ${seen.남은.length - items} · 제안 ${lines.length}` +
`(지난번 이음 ${recall} · 짝 표 ${lines.length - recall}) · 이은 줄 ${seen.이은.length}`,
"muted",
14,
),
createToolbar({ items: [modeSel.root] }),
...(lines.length ? [createText("제안", "muted", 14), ...lines.map(suggestRow)] : []),
...(free.length ? [createText("제안 없음", "muted", 14), ...free.map(freeRow)] : []),
...(seen.이은.length
? [createText("이은 줄", "muted", 14), ...seen.이은.map(linkedRow)]
: []),
);
};
openModal({
title: mode ? "다음: 우리 공종으로 잇기" : "우리 공종으로 잇기",
closeLabel: "닫기",
width: "960px",
mount: (box, close, actions) => {
all.dataset.b09LinkAll = "";
all.addEventListener("click", () => {
const lines: Row[] = seen.줄;
run(async () => {
const used = new Set<string>(); // 같은 우리 줄 둘째부터 = 남김(고름)
const pairs = lines.map(pairOf).filter((p) => !used.has(p.target) && used.add(p.target));
const next = await linkPairs(cur, done, pairs, countsOf(lines));
close(); // 짧은 길 — 남은 줄은 알림 · 우클릭으로
return next;
});
});
actions.append(all);
draw();
box.append(body);
},
});
}
async function undoLink(user: Row, done: LinkDone, target: string): Promise<Row> {
const got = await post("undo", { target, user });
done(got.payload, got.user);
showToast("잇기 풂 — 불러온 줄로 · [저장]해야 남음", "success");
return got.user;
}
/** 설계내역서 우클릭 — 불러온 줄 = 잇기 · 이은 줄(`was`) = 풀기 · 잊기 */
export function linkMenu(ctx: TabContext, key: string): MapContextMenuItem[] {
const done: LinkDone = (payload, user) => {
Object.assign(ctx.payload, payload);
ctx.setUser(user);
};
const entry = ((ctx.user.bill?.rows as Row[]) || []).find((e) => e.key === key);
const forget = (): void =>
void post("forget", { key, user: ctx.user }).then(
(got) => showToast(got.남음 ? "회사 기억이 남음(회사 관리자만 지움)" : "이 짝 잊음", "info"),
fail("못 잊음"),
);
if (entry?.was)
return [
["잇기 풀기", () => void undoLink(ctx.user, done, key).catch(fail("못 풂"))],
["이 짝 잊기", forget],
];
if (!key.startsWith("hand:x")) return [];
return [["우리 공종으로 잇기", () => void openBillLink(ctx.user, done)]];
}