Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts
T
eomsangdonandClaude Sonnet 5.5 bc479bb3d0 feat(736-2): 빠진 길 둘 — 찾아 넣기 창 [신규 자재](개인 층) · 기계 탭 · 엑셀 연결 줄 갈래대로 탭
- 개인 층 재료 고름 = 창 머리 [신규 자재] — 만들어 바로 자재 목록에 넣음(`openNewMaterial`)
- 찾아 넣기 창 기계(M01 EQ) 탭 — 「M01 전체」 묶음 안 · 부르는 쪽이 `machine` 을 줄 때만 보임
- 엑셀 연결 = 줄 갈래대로 탭(노임 = 인력 · 기계 = 기계 · 자재 = 재료 · 그 밖 = 기초) — 불러오기 고르기 · 남은 줄 · 못옮김 · 안 이은 줄 연결 모두 같은 창 · 따로 [기계] 단추 걷음
- 낡은 시험 고침(721 · 729-3: 기계 고름 길이 찾아 넣기 창으로 옮김) + 736-2 글 시험 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 17:42:05 +09:00

393 lines
16 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Find_Pick.ts
* M02 찾아 넣기 창(PLAN 721-2 · 요구서_내역16 1장 6 · 7 · 2장 1 · 2) — 탭 [M01 전체 | M02 단가산출 | 일위대가] ·
* 찾기 칸 하나 · 표 하나(품명 · 규격 · 단위 · 금액 · 구분 · 출처) · 옛 거름 값(종류 · 공종 · 구분 · 상세구분 · 층) = 「구분」 · 「출처」 열
* · M01 전체 = 단가산출 로직(기준 목록 `/logic-lib/standard` · 줄 누름 = 계산서 보기 → [담기]) +
* 인력(값 있는 노임 직종) · 재료(자재품목) = `/logic-lib/pick` 서버 찾기(친 낱말 · 상한 200) · 기계 뺌 · 실무 호표 뺌
* · M02 단가산출 · 일위대가 = 위 층(마스터 › 회사 › 개인) 서고 목록(`/layers/{층}/templates`) · 고름 = 참조 {층 · 주인 · 키}
* · 인력 · 재료 고름 = `element` · 일위대가 고정 줄 꼴 = `fixedOf`(705-1 이음 노임 LB · 자재 MT · 수량 1 은 부르는 쪽) ·
* `element` 없으면 그 줄 회색 막힘(까닭 = 일위대가 표 개편 뒤 · 723)
* · 부르는 곳 = M02 단가산출 [항목 추가](`Sangeun_Pick`) · 일위대가 [항목 추가]
* (`Labor_Pick` 이 `lib.sangeun` 이면 이 창) — B09 · 불러오기는 옛 창(`Labor_Pick`)
* · (727-1) 기초 = 로직마다 한 줄(`logicHeads` · 옵션은 넣은 뒤 고름 · 옛 창도 같이) · 서버 목록 그대로
* · (736-1) `kinds` = 탭 막기(자리마다 고를 수 있는 종류만 · 불러오기 엑셀 연결 · 별칭 장부) · `material` = 재료 고름을
* 일위대가 「자재 목록」 자리로(`element` 대신) · `query` = 찾기 칸 미리 넣기 · 부르는 곳 = 일위대가 [항목 추가]
* · (736-2) `machine` = M01 기계(EQ · 기계 줄 이음) 고름 · 개인 층에서 재료 고름이면 머리 [신규 자재](개인 층에 만들어 바로 고름) ·
* 기계 줄은 「M01 전체」 탭 묶음 안(인력 · 재료와 같은 탭)
* · (728-2) 돈 아닌 기초(결과단위가 「원」 아님 · ㎥/시간 등 21 개) = 목록에서 뺌
* ========================================================================== */
import { createDataTable } from "@ui/ui_template_data_table";
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { aliasNote, matchLogic } from "@ui/ui_template_logic_find";
import { openNewMaterial } from "./M02_Material_Pick";
import { openModal } from "@ui/ui_template_modal";
import { createTabs } from "@ui/ui_template_tabs";
import { createText } from "@ui/ui_template_text";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import type { LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import { fetchLibLogics, fetchStandard, type StandardRow } from "./M02_MasterTemplete_Labor_Api";
import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import { openStandardSheet } from "./M02_MasterTemplete_Labor_Sheet";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { ownOwner } from "./M02_MasterTemplete_Layer";
import { listLayerDocs, SANGEUN_KIND, type SangeunRow } from "./M02_MasterTemplete_Sangeun_Pick";
import { logicHeads as heads } from "./logic/M02_Find_Heads";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀
import "./M02_MasterTemplete_Labor.css";
type Scope = Omit<LaborRef, "키">;
type Tab = "m01" | "sk" | "pre";
/** M01 인력 · 재료 한 줄(`/logic-lib/pick` · 코드 = LB- · MT- 키) */
export interface M01Element {
종류: "인력" | "재료" | "기계";
코드: string;
이름: string;
규격: string;
단위: string;
값: number | null;
}
/** 고를 수 있는 종류 — logic = M01 단가산출 · 기초 로직 · labor = 인력 · material = 재료 · machine = 기계 · sk = M02 단가산출 · pre = 일위대가 */
export type FindKind = "logic" | "labor" | "material" | "machine" | "sk" | "pre";
export interface FindPickOptions {
layer: Layer;
owner: string | null;
title?: string;
/** 736-1 탭 막기 — 이 종류만 보임(없으면 다 · 탭 하나만 남으면 탭 줄 감춤) */
kinds?: FindKind[];
/** 찾기 칸에 미리 넣을 글 */
query?: string;
/** 재료 고름 — 있으면 재료는 `element` 대신 이쪽(일위대가 「자재 목록」 자리) */
material?: (e: M01Element) => void;
/** M01 단가산출 로직 고름 */
logic: (x: LogicSummary) => void;
/** M02 단가산출 · 일위대가 고름(참조) */
ref?: (ref: LaborRef, name: string) => void;
/** M01 인력 · 재료 고름 — 없으면 그 줄 회색 막힘 */
element?: (e: M01Element) => void;
/** 736-2 M01 기계(EQ) 고름 — 있을 때만 기계 줄이 보임 */
machine?: (e: M01Element) => void;
}
/** 727-1 기초마다 한 줄(옛 창 `Labor_Pick` 도 씀) — 규격 칸 = 「옵션 N」 */
export const logicHeads = (rows: StandardRow[]): StandardRow[] =>
heads(rows, (n) => L("M02_FindOptions").replace("{n}", String(n)));
/** 일위대가 고정 줄(705-1 이음 꼴) — 인력 = 노임 LB · 재료 = 자재 MT · 단가 = 지금 값(셈은 단가 책 값) */
export function fixedOf(e: M01Element): LaborFixed {
const labor = e.종류 === "인력";
return {
이름: e.이름,
규격: e.규격,
단위: e.단위,
단가: { 노무비: labor ? (e.값 ?? 0) : 0, 재료비: labor ? 0 : (e.값 ?? 0), 경비: 0 },
이음: { 노임: labor ? e.코드 : null, 자재: labor ? null : e.코드, 기계: null },
};
}
/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */
export 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: Record<string, "Lib_System" | "Lib_Company" | "Lib_Personal"> = {
system: "Lib_System",
company: "Lib_Company",
personal: "Lib_Personal",
};
/** 노임 「원/일」 → 줄 단위 「인」 · 「원/시」 → 「시」 · 재료 단위 그대로 */
const unitOf = (u: string): string => (u === "원/일" ? "인" : u.replace(/^원\//, ""));
const join = (...xs: Array<string | null | undefined>): string => xs.filter(Boolean).join(" · ");
interface PickItem {
ref: string;
구분?: string | null;
이름: string;
규격?: string | null;
단위?: string | null;
값: unknown;
}
/** 표 한 줄 + 누름 */
interface Line {
품명: string;
규격: string;
단위: string;
금액: number | null;
구분: string;
출처: string;
dim?: boolean;
go: () => void;
}
export function openFindPicker(opt: FindPickOptions): void {
const ok = (k: FindKind): boolean => !opt.kinds || opt.kinds.includes(k);
const allowed = (
[
["m01", ok("logic") || ok("labor") || ok("material") || (!!opt.machine && ok("machine"))],
["sk", ok("sk")],
["pre", ok("pre")],
] as const
)
.filter(([, on]) => on)
.map(([k]) => k as Tab);
let tab: Tab = allowed[0] ?? "m01";
let close = (): void => {};
let logics: LogicSummary[] = [];
let std: StandardRow[] | null = null;
let people: PickItem[] = [];
let goods: PickItem[] = [];
let machines: PickItem[] = [];
const lists: Record<"sk" | "pre", SangeunRow[] | null> = { sk: null, pre: null };
let shown: Line[] = [];
let seq = 0;
let timer = 0;
const search = createInputField({ type: "search", placeholder: L("M02_FindSearch") });
const hint = createText("");
const table = createDataTable({
caption: L("M02_FindTitle"),
columns: [
{ key: "품명", label: tc("Std_Name"), width: 200 },
{ key: "규격", label: tc("Std_Spec"), width: 110 },
{ key: "단위", label: tc("Std_Unit"), width: 44 },
{ key: "금액", label: tc("Std_Money"), kind: "number", width: 84 },
{ key: "구분", label: L("M02_FindClass"), width: 180 },
{ key: "출처", label: tc("Std_Source"), width: 120 },
],
rows: [],
rowClass: (i) => (shown[i]?.dim ? "is-dim" : null),
onRowClick: (i) => shown[i]?.go(),
});
const take = (f: () => void) => (): void => {
close();
f();
};
const logicLines = (q: string): Line[] =>
(std ?? logics.map(plain)).flatMap((x) => {
const hit = matchLogic(q, { ...x, 이름: `${x.이름} ${x.규격}` }); // 공용 · 208-1
if (!hit.ok) return [];
const logic = logics.find((y) => y.키 === x.키);
if (logic && !logic.결과단위.trim().startsWith("원")) return []; // 돈 아닌 기초(작업량 · 728-2) 뺌
return [
{
품명: join(x.이름, aliasNote(hit.alias)),
규격: x.규격,
단위: `${x.단위}${x.막힘 ? ` · ${tc("Pick_Blocked")}` : ""}`,
금액: x.금액,
구분: join(L("M02_FindLogic"), x.공종, x.구분, x.상세구분),
출처: x.출처,
go: () =>
logic &&
openStandardSheet(
x,
take(() => opt.logic(logic)),
),
},
];
});
const elementLines = (kind: M01Element["종류"], items: PickItem[]): Line[] =>
items.map((m) => {
const e: M01Element = {
종류: kind,
코드: m.ref,
이름: m.이름,
규격: m.규격 ?? "",
단위: unitOf(m.단위 ?? ""),
값: Number.isFinite(Number(m.값)) && m.값 !== null ? Number(m.값) : null,
};
const put =
kind === "기계"
? opt.machine
: kind === "재료" && opt.material
? opt.material
: opt.element;
return {
품명: m.이름,
규격: e.규격,
단위: e.단위,
금액: e.값,
구분: join(kind, m.구분, put ? "" : L("M02_FindElementLater")),
출처: `M01 ${m.ref}`,
dim: !put,
go: put ? take(() => put(e)) : () => showToast(L("M02_FindElementLater"), "warning"),
};
});
const docLines = (rows: SangeunRow[], kind: string): Line[] =>
rows.map((x) => ({
품명: x.제목 || x.이름,
규격: "",
단위: x.단위 ?? "",
금액: x.기준금액,
구분: join(kind, x.구분, x.상세구분),
출처: LAYER_TEXT[x.ref.층] ? tc(LAYER_TEXT[x.ref.층]) : x.ref.층,
go: take(() => opt.ref?.(x.ref, x.제목 || x.이름)),
}));
function draw(): void {
const q = search.input.value.trim();
if (tab === "m01") {
shown = [
...(ok("logic") ? logicLines(q) : []),
...(ok("labor") ? elementLines("인력", people) : []),
...(ok("material") ? elementLines("재료", goods) : []),
...(opt.machine && ok("machine") ? elementLines("기계", machines) : []),
];
} else {
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
const kind = tab === "sk" ? L("M02_PickSangeun") : L("M02_PrecomboTitle");
shown = docLines(lists[tab] ?? [], kind).filter((x) => {
const text = join(x.품명, x.구분).toLowerCase();
return words.every((w) => text.includes(w));
});
}
const loading = tab === "m01" ? !logics.length : !lists[tab];
hint.textContent = loading ? tc("Loading") : shown.length ? `${shown.length}` : tc("Pick_None");
table.setRows(shown.map(({ go: _go, dim: _dim, ...row }) => row));
}
/** 인력 · 재료 = 서버 찾기(친 낱말) — 재료는 낱말 있을 때만 · 늦게 온 답은 버림 */
async function findElements(): Promise<void> {
const mine = ++seq;
const q = search.input.value.trim();
const get = (kind: "job" | "price"): Promise<PickItem[]> =>
call<{ items: PickItem[] }>(
`/logic-lib/pick?kind=${kind}&q=${encodeURIComponent(q)}&limit=200`,
).then((d) => d.items);
const getMachine = (): Promise<PickItem[]> =>
call<{ items: PickItem[] }>(
`/logic-lib/elements?group=${encodeURIComponent("기계")}&q=${encodeURIComponent(q)}&limit=200`,
).then((d) => d.items.map((x) => ({ ...x, 단위: "시간", 값: null }))); // 기계 단위 · 값 = 칸별 표(시간당 경비는 서버 셈)
try {
const [p, g, m] = await Promise.all([
ok("labor") ? get("job") : Promise.resolve([]),
q && ok("material") ? get("price") : Promise.resolve([]),
opt.machine && ok("machine") ? getMachine() : Promise.resolve([]),
]);
if (mine !== seq) return;
[people, goods, machines] = [p, g, m];
} catch (error) {
if (mine !== seq) return;
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
}
if (tab === "m01") draw();
}
search.input.addEventListener("input", () => {
draw();
window.clearTimeout(timer);
timer = window.setTimeout(() => void findElements(), 250);
});
const tabs = createTabs({
tabs: [
{ key: "m01", label: L("M02_FindM01") },
{ key: "sk", label: L("M02_PickSangeun") },
{ key: "pre", label: L("M02_PrecomboTitle") },
].filter((x) => allowed.includes(x.key as Tab)),
value: tab,
kind: "line",
onChange: (key) => {
tab = key as Tab;
draw();
},
});
// 736-2 개인 층 + 재료 고름 = 머리 [신규 자재] — 만들어 바로 고름(우리 M01 에 없는 자재)
const newMat = createButton({
label: L("M02_FindNewMaterial"),
variant: "ghost",
onClick: () =>
openNewMaterial(opt.owner, search.input.value.trim(), (row) =>
take(() =>
opt.material?.({
종류: "재료",
코드: row.키,
이름: row.이름,
규격: row.규격,
단위: row.단위,
값: row.값,
}),
)(),
),
});
newMat.dataset.materialNew = "";
const canNew = opt.layer === "personal" && !!opt.material && ok("material");
({ close } = openModal({
title: opt.title ?? L("M02_FindTitle"),
...(canNew ? { actions: [newMat] } : {}),
closeLabel: tc("Pick_Close"),
dialogClass: "m01-logic__pick m01-combo",
width: "960px", // 표 여섯 칸
backdropClass: "m01-logic__backdrop",
mount: (body) =>
body.append(
el("div", {
className: "m02-std-pick",
attrs: { "data-find-pick": "" },
children: [...(allowed.length > 1 ? [tabs.root] : []), search.root, hint, table.root],
}),
),
}));
if (opt.query) search.input.value = opt.query;
search.input.focus();
draw();
const fail = (error: unknown): void =>
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
void Promise.all([fetchLibLogics(), fetchStandard().catch(() => null)])
.then(([l, s]) => {
logics = l;
std = s?.줄.length ? logicHeads(s.줄) : null; // 빈 줄 = 서버가 아직 셈 — 로직 줄 그대로
draw();
})
.catch(fail);
void findElements();
void upper(opt.layer, opt.owner)
.then((scopes) =>
Promise.all(
(["sk", "pre"] as const).filter(ok).map((t) =>
listLayerDocs(scopes, t === "sk" ? SANGEUN_KIND : "precombo").then((rows) => {
lists[t] = rows;
tabs.setBadge(t, rows.length);
}),
),
),
)
.catch((error) => {
lists.sk ??= [];
lists.pre ??= [];
fail(error);
})
.finally(draw);
}
/** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */
const plain = (x: LogicSummary): StandardRow => ({
키: x.키,
이름: x.이름,
규격: "",
단위: x.결과단위.replace(/^원\/?/, ""),
금액: null,
출처: x.출처 ?? "",
공종: "",
별칭: x.별칭 ?? "",
구분: x.구분,
상세구분: x.상세구분,
막힘: x.blocked,
바꿈: {},
});