|
|
|
@@ -0,0 +1,325 @@
|
|
|
|
|
/* =============================================================================
|
|
|
|
|
* 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 = 불러올 때 고른 갈래(없으면 마지막) */
|
|
|
|
|
export async function openBillLink(user: Row, done: LinkDone, mode?: string): Promise<void> {
|
|
|
|
|
if (mode === "take" || mode === "price") lastMode = mode;
|
|
|
|
|
let cur = user;
|
|
|
|
|
let seen: Row;
|
|
|
|
|
try {
|
|
|
|
|
seen = await post("suggest", { user: cur });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return fail("제안을 못 받음")(error);
|
|
|
|
|
}
|
|
|
|
|
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)]];
|
|
|
|
|
}
|