Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts
T
eomsangdonandClaude Opus 5.5 9d72d80b53 feat(717-4): 일위대가 [로직 추가] 「단가산출」 탭 · B09 재료 창고 단가산출 출처 · 단가산출 더한줄 고르기
- 새 M02_MasterTemplete_Sangeun_Pick — listSangeun = 위 층 SK 목록(마스터 › 회사 › 개인 · 금액 = 층 목록 기준금액) · openSangeunLinePicker = SK 더한줄 고르기(M01 로직 · 일위대가 · 단가산출 · M01 재료 → 715 더한줄 꼴)
- Labor_Pick — lib.sangeun 이면 탭 [기준 목록 | 단가산출] · 단가산출 탭 = 기준 목록 표 꼴(출처 = 단가산출 · 층) · 줄 누름 = 참조 {층, 주인, SK 키}
- withHopyo(일위대가 [로직 추가]) 에 sangeun 켬 · B09 단가연결 창은 그대로(SK 공사 연결 = 719-1 뒤)
- B09 재료 창고 출처 탭 「단가산출」 — SK 목록 · 연결은 알림만
- locale_m2 키 넷 · 시험 test_717_4_sangeun_pick

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:06:31 +09:00

431 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M02_MasterTemplete_Labor_Pick.ts
* [로직 더하기] 창 — 한 목록에 구분(단가산출 · 단가승격 · 일위조합) 칸 · 치는 대로 걸러짐(찾기 단추 없음) ·
* 단가승격 · 일위조합 줄은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) 층의 서고 · 고르면 참조 {층 · 주인 · 키} ·
* 프로젝트 · 카드엔 서고 줄 없음 · 거름 = 로직 품셈 분류 + 사전조합 새 분류(149-2 · 구조물군 › 공통 + 종류)
* 단가연결(251 · B08 거리이정표 · B09 설계내역서) = 종류 고르기(단가산출 · 사전조합 · 덧붙인 줄 종류) +
* 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter` · 늦게 받는 덧줄 `lib.more`
* (324-4 실무 호표 · `withHopyo`)
* 704-1 단가산출 줄 = 기준 목록 표(품명 · 규격 · 단위 · 금액 · 출처 · 대표 규격 펼침 · 공종 거름 ·
* 실무 낱말 = 서버가 별칭에 덧붙임) · 금액 = 서버 M01 엔진(`/logic-lib/standard`) · 오기 전엔 로직 줄만 ·
* 줄 누름 = 계산서 보기(704-2 · `Labor_Sheet`) → [담기] = 지금 담기 동작(`done(로직)`)
* 717-4 `lib.sangeun` = 탭 [기준 목록 | 단가산출] · 단가산출 = 위 층 SK(마스터 › 회사 › 개인 · `Sangeun_Pick`)를
* 같은 표 꼴(출처 = 단가산출 · 층) · 줄 누름 = `lib.pick` 참조(SK 키)
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import { createDataTable } from "@ui/ui_template_data_table";
import { createFindList } from "@ui/ui_template_find_list";
import { aliasNote, matchLogic } from "@ui/ui_template_logic_find";
import { t as L } from "@ui/ui_template_locale";
import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import {
fetchLibLogics as fetchLogics,
fetchLibLogicSubs as fetchLogicSubs,
fetchLibrary,
fetchStandard,
type LibraryRow,
type StandardRow,
} from "./M02_MasterTemplete_Labor_Api";
import { openStandardSheet } from "./M02_MasterTemplete_Labor_Sheet";
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import { ownOwner } from "./M02_MasterTemplete_Layer";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo";
import { fetchStructureDef } from "./StructOptions/M02_SO_Api";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀
import "./M02_MasterTemplete_Labor.css";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { createTabs } from "@ui/ui_template_tabs";
import { listSangeun, type SangeunRow } from "./M02_MasterTemplete_Sangeun_Pick";
export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick";
/** 구분 보이는 글(707 낱말) — 값(저장 글)은 그대로 */
const CLASS_TEXT: Record<string, string> = { 실무자료: "실무 엑셀 일위대가" };
const opts = (values: string[]): { value: string; text: string }[] =>
[L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: CLASS_TEXT[v] ?? v }));
type Scope = Omit<LaborRef, "키">;
/** 덧붙일 줄 하나 — kind = 종류 고르기 값 */
export interface PickExtra {
kind: string;
key: string;
tag: string;
name: string;
sub: string;
unit: string;
onPick: () => void;
}
/** 서고 탭 — 일위대가가 있는 층 · 주인(null = 자기 층) · 고르면 참조 */
export interface LibraryPick {
layer: Layer;
owner: string | null;
/** `alias` = 참조 줄 별칭(324-4 실무 호표 품명 · 없으면 빈칸) */
pick: (ref: LaborRef, name: string, alias?: string | null) => void;
/** 서고 줄만(로직 목록 안 받음 · B09 설계내역서 연결 204-2) · 창 제목 */
libOnly?: boolean;
title?: string;
/** 목록 끝 덧붙일 줄(251 · 구조물 조합 · B09 호표) — kind = 종류 고르기 값 */
extra?: PickExtra[];
/** 창을 연 뒤 받는 덧줄(324-4 실무 호표) — 받으면 `extra` 뒤에 붙음 */
more?: () => Promise<PickExtra[]>;
/** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */
kindFilter?: Array<{ value: string; text: string }>;
/** 717-4 「단가산출」 탭(M02 SK · 위 층 모두) — 고르면 `pick` 참조 */
sangeun?: boolean;
}
/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */
async function upper(layer: Layer, owner: string | null): Promise<Scope[]> {
if (layer === "project") return [];
const own =
layer === "system"
? null
: (owner ?? ownOwner(layer, await fetchSessionUser().catch(() => null)));
const out: Scope[] = [{ 층: "system", 주인: null }];
if (own) out.push({ 층: "company", 주인: own.split("/")[0] });
if (own && layer === "personal") out.push({ 층: "personal", 주인: own });
return out;
}
const LAYER_TEXT = {
system: "Lib_System",
company: "Lib_Company",
personal: "Lib_Personal",
} as const;
/** `pre.query` = 찾기 칸에 미리 넣을 글(338 실무자료 고르기 창 · 인용 조항 번호 · 품명 낱말) */
export function openLogicPicker(
done: (logic: LogicSummary) => void,
lib?: LibraryPick,
pre?: { query?: string },
): void {
const subs: SubBrief[] = [];
let classes: ReturnType<typeof precomboClasses> = []; // 사전조합 새 분류(149-2) — 서고 줄이 있을 때만
let all: LogicSummary[] = [];
let std: StandardRow[] | null = null; // 기준 목록(서버 셈) — 오기 전 = 로직 줄 그대로
let listed: StandardRow[] = [];
const work = createSelectField({ options: opts([]), compact: true, label: tc("Std_Kind") });
const sub = createSelectField({ options: opts([]), compact: true, label: tc("Sub") });
const detail = createSelectField({ options: opts([]), compact: true, label: tc("Detail") });
detail.select.disabled = true;
let close = (): void => {};
let rowPick: Array<() => void> = []; // 표 줄 누름 — 탭마다 다시 셈
let tab: "std" | "sk" = "std";
let skRows: SangeunRow[] | null = null; // 717-4 — 오기 전 = null
/* 한 목록 — 구분(단가산출 · 일위조합) 칸 · 층 단추는 서고 줄(사전조합)의 층 */
const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } });
let scopes: Scope[] = [];
let scope: Scope | null = null;
let libRows: { kind: "Kind_Combo"; row: LibraryRow }[] = [];
// 251 종류 고르기 — 값 "" = 전체 · logic · precombo · 덧붙인 줄 kind
const kindSel = lib?.kindFilter
? createSelectField({
options: [{ value: "", text: L("M01_All") }, ...lib.kindFilter],
compact: true,
label: "종류",
})
: null;
const kindOk = (k: string): boolean => !kindSel?.select.value || kindSel.select.value === k;
let loading = false;
let seq = 0;
let later: PickExtra[] = []; // lib.more 로 받은 덧줄
/* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */
const finder = createFindList({
placeholder: tc("Pick_Search"),
filters: [...(kindSel ? [kindSel.root] : []), work.root, sub.root, detail.root],
extra: [layerBar],
});
const table = createDataTable({
caption: tc("Pick_Title"),
columns: [
{ key: "품명", label: tc("Std_Name"), width: 220 },
{ key: "규격", label: tc("Std_Spec"), width: 220 },
{ key: "단위", label: tc("Std_Unit"), width: 56 },
{ key: "금액", label: tc("Std_Money"), kind: "number", width: 96 },
{ key: "출처", label: tc("Std_Source"), width: 150 },
],
rows: [],
onRowClick: (i) => rowPick[i]?.(),
});
/** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */
const plain = (x: LogicSummary): StandardRow => ({
키: x.키,
이름: x.이름,
규격: "",
단위: x.결과단위.replace(/^원\/?/, ""),
금액: null,
출처: x.출처 ?? "",
공종: "",
별칭: x.별칭 ?? "",
구분: x.구분,
상세구분: x.상세구분,
막힘: x.blocked,
바꿈: {},
});
if (pre?.query) finder.search.value = pre.query;
const find = (): void => {
const q = finder.search.value.trim().toLowerCase();
if (tab === "sk") return findSangeun(q);
const rows = std ?? all.map(plain);
const found = rows.filter(
(x) =>
kindOk("logic") &&
(!work.select.value || x.공종 === work.select.value) &&
(!sub.select.value || x.구분 === sub.select.value) &&
(!detail.select.value || x.상세구분 === detail.select.value) &&
matchLogic(finder.search.value, { ...x, 이름: `${x.이름} ${x.규격}` }).ok, // 공용 · 208-1
);
const libFound = libRows.filter(
(x) =>
kindOk("precombo") &&
(!sub.select.value || x.row.구분 === sub.select.value) &&
(!detail.select.value || x.row.상세구분 === detail.select.value) &&
(!q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)),
);
const extra = [...(lib?.extra ?? []), ...later].filter(
(x) =>
kindOk(x.kind) &&
!sub.select.value &&
(!q || `${x.tag} ${x.name} ${x.sub}`.toLowerCase().includes(q)),
);
const total = rows.length + libRows.length + (lib?.extra?.length ?? 0) + later.length;
const shown = found.length + libFound.length + extra.length;
finder.hint.textContent = loading
? tc("Loading")
: shown
? `${shown} / ${total}`
: tc("Pick_None");
listed = found;
rowPick = listed.map((row) => () => {
const logic = all.find((x) => x.키 === row.키);
if (logic)
openStandardSheet(row, () => {
close();
done(logic);
});
});
table.root.hidden = !!lib?.libOnly;
table.setRows(
found.map((x) => ({
품명: [x.이름, aliasNote(matchLogic(q, x).alias)].filter(Boolean).join(" · "),
규격: x.규격,
단위: `${x.단위}${x.막힘 ? ` · ${tc("Pick_Blocked")}` : ""}`,
금액: x.금액,
출처: x.출처,
})),
);
finder.setRows([
...libFound.map((x) => {
const at = scope as Scope;
return {
key: x.row.이름,
tag: tc(x.kind),
name: x.row.제목 || x.row.이름,
// 707 번호(SC · SP 키) 숨김 — 제목이 없을 때만 이름 자리에 씀
sub: [
x.row.제목 ? "" : x.row.이름,
`${CLASS_TEXT[x.row.구분 ?? ""] ?? (x.row.구분 || tc("Lib_Unsorted"))} ${x.row.상세구분 ?? ""}`.trim(),
]
.filter(Boolean)
.join(" · "),
unit: x.row.단위 ?? "",
onPick: () => {
close();
lib?.pick({ ...at, 키: x.row.이름 }, x.row.제목 || x.row.이름);
},
};
}),
...extra.map((x) => ({
...x,
onPick: () => {
close();
x.onPick();
},
})),
]);
};
/** 717-4 단가산출 탭 — 같은 표 꼴 · 찾기 = 이름 · 제목 · 구분 */
const findSangeun = (q: string): void => {
const rows = (skRows ?? []).filter(
(x) => !q || [x.이름, x.제목, x.구분, x.상세구분].join(" ").toLowerCase().includes(q),
);
finder.hint.textContent = !skRows
? tc("Loading")
: rows.length
? `${rows.length} / ${skRows.length}`
: tc("Pick_None");
rowPick = rows.map((x) => () => {
close();
lib?.pick(x.ref, x.제목 || x.이름);
});
table.root.hidden = false;
table.setRows(
rows.map((x) => ({
품명: x.제목 || x.이름,
규격: [x.구분, x.상세구분].filter(Boolean).join(" "),
단위: x.단위 ?? "",
금액: x.기준금액,
출처: `${L("M02_PickSangeun")} · ${tc(LAYER_TEXT[x.ref.층 as keyof typeof LAYER_TEXT])}`,
})),
);
finder.setRows([]);
};
const loadLib = async (): Promise<void> => {
if (!lib || !scope) return;
const mine = ++seq;
const at = scope;
loading = true;
find();
try {
const combo = await fetchLibrary(at, "precombo");
if (mine !== seq) return;
libRows = combo.map((row) => ({ kind: "Kind_Combo" as const, row }));
} catch (error) {
if (mine !== seq) return;
libRows = [];
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
}
loading = false;
drawSubs();
find();
};
const drawLayers = (): void =>
layerBar.replaceChildren(
...scopes.map((x) =>
createButton({
label: tc(LAYER_TEXT[x.층 as keyof typeof LAYER_TEXT]),
variant: x === scope ? "filled" : "ghost",
onClick: () => {
scope = x;
drawLayers();
void loadLib();
},
}),
),
);
/** 구분 → 상세구분 — 로직 품셈 구분 + 사전조합 새 분류 + 서고 줄 지금 값 · 같은 값은 한 칸 */
const subMap = (): Map<string, Set<string>> => {
const m = new Map([...subs, ...classes].map((s) => [s.name, new Set(s.details)]));
for (const { row } of libRows) {
if (!row.구분) continue;
if (!m.has(row.구분)) m.set(row.구분, new Set());
if (row.상세구분) m.get(row.구분)!.add(row.상세구분);
}
return m;
};
const drawDetails = (keep: string): void => {
const d = [...(subMap().get(sub.select.value) ?? [])];
detail.setOptions(opts(d), d.includes(keep) ? keep : "");
detail.select.disabled = d.length === 0;
};
const drawSubs = (): void => {
const keep = detail.select.value;
sub.setOptions(opts([...subMap().keys()]), sub.select.value);
drawDetails(keep);
};
sub.select.addEventListener("change", () => {
drawDetails("");
find();
});
detail.select.addEventListener("change", find);
work.select.addEventListener("change", find);
kindSel?.select.addEventListener("change", find);
finder.search.addEventListener("input", find);
layerBar.style.display = "none";
const hint = createText(tc("Std_Hint"));
/** 탭 바꿈 — 단가산출 탭은 찾기 칸만(거름 · 층 단추 · 계산서 안내 숨김) */
const tabs = lib?.sangeun
? createTabs({
tabs: [
{ key: "std", label: L("M02_PickStd") },
{ key: "sk", label: L("M02_PickSangeun") },
],
value: tab,
kind: "line",
onChange: (key) => {
tab = key === "sk" ? "sk" : "std";
const std = tab === "std";
for (const f of [kindSel?.root, work.root, sub.root, detail.root])
if (f) f.style.display = std ? "" : "none";
layerBar.style.display = std && scopes.length ? "" : "none";
hint.hidden = !std;
find();
},
})
: null;
if (lib)
void upper(lib.layer, lib.owner).then((got) => {
scopes = got;
if (tabs)
listSangeun(got).then(
(rows) => {
skRows = rows;
tabs.setBadge("sk", rows.length);
find();
},
(error) => {
skRows = [];
find();
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
},
);
scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층
if (!got.length) return;
layerBar.style.display = tab === "std" ? "" : "none";
drawLayers();
void loadLib();
void fetchStructureDef().then(
(def) => {
classes = precomboClasses(def);
drawSubs();
},
() => undefined, // 못 받으면 서고 줄 지금 값만
);
});
// 모달 부품 — 제목줄 [닫기] · 바깥 누르기 · Esc · 위 고정
({ close } = openModal({
title: lib?.title ?? tc("Pick_Title"),
closeLabel: tc("Pick_Close"),
dialogClass: "m01-logic__pick m01-combo",
width: "960px", // 기준 목록 표 다섯 칸(704-1)
backdropClass: "m01-logic__backdrop",
mount: (body) =>
body.append(
el("div", {
className: "m02-std-pick",
children: [...(tabs ? [tabs.root] : []), finder.root, hint, table.root],
}),
),
}));
finder.search.focus();
void lib?.more?.().then(
(got) => ((later = got), find()),
(error) => showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
);
if (!lib?.libOnly)
void Promise.all([fetchLogicSubs(), fetchLogics()])
.then(([s, l]) => {
subs.push(...s);
all = l;
drawSubs();
find();
return fetchStandard().then((got) => {
std = got.줄.length ? got.줄 : null; // 빈 줄 = 서버가 아직 셈 — 로직 줄 그대로
work.setOptions(opts(got.공종), "");
find();
});
})
.catch((error) =>
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
);
}