auto: 2026-09-20 02:30 (ESD_LAPTOP)
This commit is contained in:
@@ -83,8 +83,13 @@ export const fetchGroups = async (): Promise<GroupInfo[]> =>
|
||||
export const fetchFiles = async (group: string): Promise<FileInfo[]> =>
|
||||
(await get<{ files: FileInfo[] }>(`/groups/${encodeURIComponent(group)}/files`, {})).files;
|
||||
|
||||
export const fetchRows = (file: string, page: number, size: number, q: string): Promise<RowsPage> =>
|
||||
get<RowsPage>("/rows", { file, page, size, q });
|
||||
export const fetchRows = (
|
||||
file: string,
|
||||
page: number,
|
||||
size: number,
|
||||
q: string,
|
||||
unlinked = false,
|
||||
): Promise<RowsPage> => get<RowsPage>("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0 });
|
||||
|
||||
export const fetchTables = (
|
||||
file: string,
|
||||
@@ -107,3 +112,19 @@ export async function saveFiles(files: FileChanges[]): Promise<SaveResult> {
|
||||
if (status === 422 && typeof detail === "object") return { status, errors: detail.errors ?? [] };
|
||||
return { status, detail: typeof detail === "string" ? detail : JSON.stringify(detail) };
|
||||
}
|
||||
|
||||
export type PickKind = "procure" | "price" | "job";
|
||||
|
||||
export interface PickItem {
|
||||
ref: string;
|
||||
이름: string;
|
||||
규격: string;
|
||||
단위: string;
|
||||
값: unknown;
|
||||
관급: boolean;
|
||||
}
|
||||
|
||||
export const fetchPick = (
|
||||
kind: PickKind,
|
||||
q: string,
|
||||
): Promise<{ total: number; items: PickItem[] }> => get("/pick", { kind, q, limit: 50 });
|
||||
|
||||
@@ -69,8 +69,8 @@ def get_files(group: str) -> dict:
|
||||
|
||||
|
||||
@router.get("/rows")
|
||||
def get_rows(file: str, page: int = 1, size: int = 50, q: str = "") -> dict:
|
||||
return _call(store.rows, file, page, size, q)
|
||||
def get_rows(file: str, page: int = 1, size: int = 50, q: str = "", unlinked: bool = False) -> dict:
|
||||
return _call(store.rows, file, page, size, q, unlinked)
|
||||
|
||||
|
||||
@router.get("/tables")
|
||||
@@ -98,6 +98,11 @@ def get_elements(group: str, q: str = "", limit: int = 50) -> dict:
|
||||
return _call(store.elements, group, q, limit)
|
||||
|
||||
|
||||
@router.get("/pick")
|
||||
def get_pick(kind: str, q: str = "", limit: int = 50) -> dict:
|
||||
return _call(store.pick, kind, q, limit)
|
||||
|
||||
|
||||
@router.post("/calc")
|
||||
def post_calc(body: CalcBody) -> dict:
|
||||
return _call(store.calc, body.book, body.key, body.inputs, body.row, body.file)
|
||||
|
||||
@@ -92,9 +92,11 @@ def files_of(group: str) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def rows(file: str, page: int, size: int, q: str) -> dict:
|
||||
def rows(file: str, page: int, size: int, q: str, unlinked: bool = False) -> dict:
|
||||
data, version = read(file)
|
||||
hits = [r for r in data.get(items_key(data)) or [] if _hit(r, q)]
|
||||
if unlinked: # 품셈재료 — 아직 못 이은 줄
|
||||
hits = [r for r in hits if not r.get("연결")]
|
||||
page, size = max(page, 1), min(max(size, 1), 500)
|
||||
return {
|
||||
"file": file,
|
||||
@@ -192,7 +194,8 @@ def _brief(ref: str, row: dict) -> dict:
|
||||
|
||||
|
||||
def _prices(whole, row: dict) -> dict:
|
||||
"""호표 요소 → 요소 줄 요약(단가 자동 칸) · `{이름}` 낀 열쇠·하위 로직은 계산 때 정해짐."""
|
||||
"""호표 요소 → 요소 줄 요약(단가 자동 칸) · `{이름}` 낀 열쇠·하위 로직은 계산 때 정해짐.
|
||||
품셈재료는 연결을 따라간 낮은 값과 출처 · 자재지역 후보는 계산 때 정해짐(값 None)."""
|
||||
out = {}
|
||||
for item in row.get("호표") or []:
|
||||
ref = str(item.get("요소", ""))
|
||||
@@ -201,6 +204,13 @@ def _prices(whole, row: dict) -> dict:
|
||||
out[ref] = _brief(ref, whole.get(ref))
|
||||
except cm.mf.FormulaError:
|
||||
out[ref] = None
|
||||
continue
|
||||
if ref.startswith(cm.mf.LINKED):
|
||||
try:
|
||||
value, source = cm.mf.element(whole, ref, {})
|
||||
except (cm.mf.FormulaError, KeyError, TypeError):
|
||||
value, source = None, None
|
||||
out[ref].update({"값": value, "출처": source})
|
||||
return out
|
||||
|
||||
|
||||
@@ -219,6 +229,50 @@ def elements(group: str, q: str, limit: int) -> dict:
|
||||
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
|
||||
|
||||
|
||||
_PICK = {
|
||||
"procure": "재료_나라장터자재.json",
|
||||
"price": "재료_시중물가.json",
|
||||
"job": "인력_건설노임.json",
|
||||
}
|
||||
|
||||
|
||||
def _squash(text) -> str:
|
||||
return "".join(str(text or "").lower().split())
|
||||
|
||||
|
||||
def _lowest(value) -> object:
|
||||
"""시중물가 줄 — 시중 다섯 칸 · 조달 값 가운데 낮은 값 · 그 밖은 `값` 그대로."""
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
nums = [v for v in value["시중"].values() if isinstance(v, (int, float))]
|
||||
supply = value["조달"].get("값")
|
||||
return min(nums + ([supply] if supply is not None else []), default=None)
|
||||
|
||||
|
||||
def pick(kind: str, q: str, limit: int) -> dict:
|
||||
"""고르기 창 — `procure` 나라장터자재(ref = 나라장터:<열쇠>) · `price` 시중물가(관급 조달 줄 포함 · ref = 열쇠)
|
||||
· `job` 값 있는 건설노임 직종(ref = 열쇠) · 이름·규격 낱말이 모두 들어간 줄."""
|
||||
if kind not in _PICK:
|
||||
raise StoreError(404, f"없는 고르기 「{kind}」")
|
||||
words = [_squash(w) for w in q.split()]
|
||||
hits = []
|
||||
for row in read(_PICK[kind])[0].get("줄") or []:
|
||||
if kind == "job" and row.get("값") is None:
|
||||
continue
|
||||
text = _squash(f"{row.get('이름')} {row.get('규격')}")
|
||||
if all(w in text for w in words):
|
||||
key = str(row.get("열쇠"))
|
||||
hits.append(
|
||||
{
|
||||
"ref": f"나라장터:{key}" if kind == "procure" else key,
|
||||
**{k: row.get(k) for k in ("이름", "규격", "단위")},
|
||||
"값": _lowest(row.get("값")),
|
||||
"관급": kind == "price" and row["값"]["조달"].get("값") is not None,
|
||||
}
|
||||
)
|
||||
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
|
||||
|
||||
|
||||
# ── 시험 계산 ──────────────────────────────────────────────────────────
|
||||
def _swap(files: dict, book: str, key: str, row: dict, file: str | None) -> None:
|
||||
"""저장 전 시험 — 메모리 사본에서 그 로직을 고친 줄로 바꿈(없으면 `file` 에 더함)."""
|
||||
|
||||
@@ -33,6 +33,16 @@ export function withCell(row: Row, path: string, value: unknown): Row {
|
||||
return { ...row, [head]: { ...(row[head] as Row), [sub]: value } };
|
||||
}
|
||||
|
||||
/** 줄 안 깊은 자리 읽기 · 넣은 새 줄(중간 묶음 없으면 만듦). */
|
||||
export const deep = (row: Row, path: string[]): unknown =>
|
||||
path.reduce<unknown>((o, k) => (o as Row | null | undefined)?.[k], row);
|
||||
|
||||
export function withDeep(row: Row, path: string[], value: unknown): Row {
|
||||
const [head, ...rest] = path;
|
||||
if (!rest.length) return { ...row, [head]: value };
|
||||
return { ...row, [head]: withDeep((row[head] ?? {}) as Row, rest, value) };
|
||||
}
|
||||
|
||||
export function show(v: unknown): string {
|
||||
if (v === null || v === undefined) return "";
|
||||
if (Array.isArray(v)) return v.map((x) => (x === null ? "" : String(x))).join(" ~ ");
|
||||
|
||||
@@ -81,6 +81,8 @@ export interface CalcLine {
|
||||
단가: number;
|
||||
금액: number;
|
||||
비목: Record<string, number>;
|
||||
/** 값 없는 요소를 다른 요소로 셈 — 「준용: <직종>」 */
|
||||
출처?: string;
|
||||
}
|
||||
export type CalcAnswer =
|
||||
| {
|
||||
|
||||
@@ -104,7 +104,7 @@ function answerView(answer: CalcAnswer, draft: boolean): HTMLElement[] {
|
||||
const rows = answer.lines.map((line) =>
|
||||
el("tr", {
|
||||
children: [
|
||||
el("td", { text: line.이름 }),
|
||||
el("td", { text: line.출처 ? `${line.이름} (${line.출처})` : line.이름 }),
|
||||
el("td", { text: `${formatNumber(line.수량)} ${line.단위}` }),
|
||||
el("td", { className: "m01-logic__money", text: formatNumber(line.금액) }),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Pick.ts
|
||||
* 고르기 모달 — 조달 자료(나라장터) · 가격 연결(시중물가) · 준용 직종을 이름·규격으로 찾아 고르면 ref 를 돌려줌
|
||||
* ========================================================================== */
|
||||
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { fetchPick, type PickItem, type PickKind } from "./M01_MasterData_Api_Fetch";
|
||||
import { show } from "./M01_MasterData_UI_Cells";
|
||||
|
||||
/** 지역이 갈리는 재료 — 이름·규격만 적어 두고 지역은 계산 때 입력. */
|
||||
export interface Cond {
|
||||
이름: string;
|
||||
규격: string;
|
||||
지역: "입력";
|
||||
}
|
||||
|
||||
export interface PickOptions {
|
||||
kind: PickKind;
|
||||
title: string;
|
||||
/** 지금 연결 글 — 비면 「연결 끊기」 꺼짐. */
|
||||
current: string;
|
||||
/** 처음 찾을 글 — 그 재료 이름·규격 (없는 결과면 첫 낱말만으로 다시). */
|
||||
seed: string;
|
||||
/** 고르면 ref · 연결 끊기 = null. */
|
||||
onPick: (ref: string | null) => void;
|
||||
/** 있으면 「후보 조건으로 연결」 칸이 뜸. */
|
||||
cond?: { 이름: string; 규격: string; onCond: (c: Cond) => void };
|
||||
}
|
||||
|
||||
const button = (text: string): HTMLButtonElement =>
|
||||
el("button", { className: "m01-master__row-btn", text, attrs: { type: "button" } });
|
||||
|
||||
const input = (value: string, placeholder: string): HTMLInputElement => {
|
||||
const box = el("input", {
|
||||
className: "m01-master__search",
|
||||
attrs: { type: "search", placeholder },
|
||||
});
|
||||
box.value = value;
|
||||
return box;
|
||||
};
|
||||
|
||||
export function openPickModal(opt: PickOptions): void {
|
||||
const close = (): void => back.remove();
|
||||
const search = input(opt.seed, L("M01_ProcureSearch"));
|
||||
const list = el("div", { className: "m01-procure__list" });
|
||||
const cut = button(L("M01_ProcureCut"));
|
||||
cut.disabled = !opt.current;
|
||||
cut.addEventListener("click", () => {
|
||||
opt.onPick(null);
|
||||
close();
|
||||
});
|
||||
const shut = button(L("M01_ProcureClose"));
|
||||
shut.addEventListener("click", close);
|
||||
|
||||
const condRow: HTMLElement[] = [];
|
||||
if (opt.cond) {
|
||||
const { onCond } = opt.cond;
|
||||
const name = input(opt.cond.이름, L("M01_PriceCondName"));
|
||||
const spec = input(opt.cond.규격, L("M01_PriceCondSpec"));
|
||||
const go = button(L("M01_PriceCond"));
|
||||
go.addEventListener("click", () => {
|
||||
if (!name.value.trim()) return;
|
||||
onCond({ 이름: name.value.trim(), 규격: spec.value.trim(), 지역: "입력" });
|
||||
close();
|
||||
});
|
||||
condRow.push(el("div", { className: "m01-procure__cond", children: [name, spec, go] }));
|
||||
}
|
||||
|
||||
const box = el("div", {
|
||||
className: "m01-procure__box",
|
||||
children: [
|
||||
el("h3", { text: opt.title }),
|
||||
el("p", { className: "m01-master__muted", text: opt.current }),
|
||||
search,
|
||||
list,
|
||||
...condRow,
|
||||
el("div", { className: "m01-procure__foot", children: [cut, shut] }),
|
||||
],
|
||||
});
|
||||
const back = el("div", { className: "m01-procure", children: [box] });
|
||||
back.addEventListener("click", (e) => {
|
||||
if (e.target === back) close();
|
||||
});
|
||||
document.body.append(back);
|
||||
|
||||
const paint = (items: PickItem[], total: number): void => {
|
||||
list.replaceChildren(
|
||||
...(items.length
|
||||
? items.map((it) => {
|
||||
const tag = it.관급 ? ` · ${L("M01_Govt")}` : "";
|
||||
const b = el("button", {
|
||||
className: "m01-procure__item",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("strong", { text: it.이름 }),
|
||||
el("span", { text: `${it.규격} · ${it.단위} · ${show(it.값)}${tag}` }),
|
||||
el("span", { className: "m01-master__muted", text: it.ref }),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
opt.onPick(it.ref);
|
||||
close();
|
||||
});
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m01-master__empty", text: L("M01_NoRows") })]),
|
||||
...(total > items.length
|
||||
? [el("p", { className: "m01-master__muted", text: `${items.length} / ${total}` })]
|
||||
: []),
|
||||
);
|
||||
};
|
||||
let timer: number | undefined;
|
||||
const run = async (fallback = false): Promise<void> => {
|
||||
try {
|
||||
const q = search.value.trim();
|
||||
const r = await fetchPick(opt.kind, q);
|
||||
if (!r.total && fallback && q.includes(" ")) {
|
||||
search.value = q.split(/\s+/)[0];
|
||||
return run();
|
||||
}
|
||||
paint(r.items, r.total);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
|
||||
}
|
||||
};
|
||||
search.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void run(), 300);
|
||||
});
|
||||
void run(true);
|
||||
search.focus();
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { openPickModal } from "./M01_MasterData_UI_Pick";
|
||||
import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch";
|
||||
import {
|
||||
addRow,
|
||||
@@ -20,13 +21,22 @@ import {
|
||||
buildCell,
|
||||
buildPager,
|
||||
coerce,
|
||||
deep,
|
||||
flatten,
|
||||
isScalar,
|
||||
SEP,
|
||||
show,
|
||||
withCell,
|
||||
withDeep,
|
||||
} from "./M01_MasterData_UI_Cells";
|
||||
|
||||
const SIZE = 50;
|
||||
const MARKET = "재료_시중물가.json";
|
||||
const LINKED = "재료_품셈재료.json";
|
||||
const WAGE = "인력_건설노임.json";
|
||||
const SUPPLY = ["값", "조달"];
|
||||
const SOURCE = [...SUPPLY, "출처"];
|
||||
const NARA = "나라장터:";
|
||||
const FALLBACK: Row = { 열쇠: "", 이름: "", 규격: "", 단위: "", 값: null, 출처: "" };
|
||||
|
||||
/** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 null. */
|
||||
@@ -39,10 +49,29 @@ function blank(sample: Row): Row {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 요소별 전용 칸으로 그리는 칸 — 일반 칸에서 뺌. */
|
||||
function hidden(file: string, col: string): boolean {
|
||||
if (file === MARKET) return col === `값${SEP}조달`;
|
||||
if (file === LINKED) return col.startsWith("연결");
|
||||
return file === WAGE && col === "준용";
|
||||
}
|
||||
|
||||
const HEADS: Record<string, string[]> = {
|
||||
[MARKET]: ["조달 값", "조달 기준", "M01_ProcureCol"],
|
||||
[LINKED]: ["M01_PriceCol"],
|
||||
[WAGE]: ["M01_JobCol"],
|
||||
};
|
||||
|
||||
const linkText = (link: unknown): string =>
|
||||
link && typeof link === "object"
|
||||
? `${L("M01_PriceCondUsed")}: ${(link as Row)["이름"]} ${(link as Row)["규격"] ?? ""}`.trim()
|
||||
: String(link ?? "");
|
||||
|
||||
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
|
||||
export function renderRows(host: HTMLElement, file: string, q: string): () => void {
|
||||
let page = 1;
|
||||
let data: RowsPage | null = null;
|
||||
let unlinked = false;
|
||||
|
||||
const paint = (): void => {
|
||||
if (!data) return;
|
||||
@@ -51,9 +80,18 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo
|
||||
const adds = draft?.adds ?? [];
|
||||
const cols: string[] = [];
|
||||
for (const row of [...adds, ...d.rows]) {
|
||||
for (const k of Object.keys(flatten(row))) if (!cols.includes(k)) cols.push(k);
|
||||
for (const k of Object.keys(flatten(row)))
|
||||
if (!hidden(file, k) && !cols.includes(k)) cols.push(k);
|
||||
}
|
||||
const head = el("tr", { children: [el("th"), ...cols.map((c) => el("th", { text: c }))] });
|
||||
const head = el("tr", {
|
||||
children: [
|
||||
el("th"),
|
||||
...cols.map((c) => el("th", { text: c })),
|
||||
...(HEADS[file] ?? []).map((h) =>
|
||||
el("th", { text: h.startsWith("M01_") ? L(h as "M01_PriceCol") : h }),
|
||||
),
|
||||
],
|
||||
});
|
||||
const body = el("tbody");
|
||||
|
||||
adds.forEach((row, i) => {
|
||||
@@ -96,9 +134,29 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo
|
||||
}),
|
||||
);
|
||||
}
|
||||
const put = deleted
|
||||
? undefined
|
||||
: (next: Row): void => editRow(file, d.version, key, row, next);
|
||||
if (file === MARKET) tr.append(...marketCells(row, cur, put));
|
||||
if (file === LINKED) tr.append(linkedCell(row, cur, put));
|
||||
if (file === WAGE) tr.append(wageCell(row, cur, put));
|
||||
body.append(tr);
|
||||
}
|
||||
|
||||
const only =
|
||||
file === LINKED
|
||||
? [
|
||||
createButton({
|
||||
label: L("M01_OnlyUnlinked"),
|
||||
variant: unlinked ? "filled" : "ghost",
|
||||
onClick: () => {
|
||||
unlinked = !unlinked;
|
||||
page = 1;
|
||||
void load();
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" });
|
||||
add.addEventListener("click", () => addRow(file, d.version, blank(d.rows[0] ?? FALLBACK)));
|
||||
const empty =
|
||||
@@ -108,7 +166,7 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-master__bar",
|
||||
children: [add, buildPager(d.total, SIZE, d.page, go)],
|
||||
children: [add, ...only, buildPager(d.total, SIZE, d.page, go)],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-master__grid-wrap",
|
||||
@@ -130,7 +188,7 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
data = await fetchRows(file, page, SIZE, q);
|
||||
data = await fetchRows(file, page, SIZE, q, unlinked);
|
||||
paint();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
|
||||
@@ -151,3 +209,78 @@ function actionCell(label: string, onClick: () => void): HTMLTableCellElement {
|
||||
button.addEventListener("click", onClick);
|
||||
return el("td", { children: [button] });
|
||||
}
|
||||
|
||||
type Put = (row: Row) => void;
|
||||
|
||||
const rowBtn = (text: string): HTMLButtonElement =>
|
||||
el("button", { className: "m01-master__row-btn", text, attrs: { type: "button" } });
|
||||
|
||||
/** 시중물가 — 조달 값 · 기준 · 조달 연결(「나라장터:<열쇠>」 · 「가격정보」 줄은 원천이라 그대로). */
|
||||
function marketCells(row: Row, cur: Row, put?: Put): HTMLTableCellElement[] {
|
||||
const link = show(deep(cur, SOURCE));
|
||||
const td = buildCell(link, { changed: link !== show(deep(row, SOURCE)) });
|
||||
if (put && (!link || link.startsWith(NARA))) {
|
||||
const btn = rowBtn(L("M01_ProcureFind"));
|
||||
btn.addEventListener("click", () =>
|
||||
openPickModal({
|
||||
kind: "procure",
|
||||
title: L("M01_ProcureTitle"),
|
||||
current: link,
|
||||
seed: `${cur["이름"] ?? ""} ${cur["규격"] ?? ""}`.trim(),
|
||||
onPick: (ref) => put(withDeep(cur, SOURCE, ref)),
|
||||
}),
|
||||
);
|
||||
td.append(btn);
|
||||
}
|
||||
return [
|
||||
buildCell(show(deep(cur, [...SUPPLY, "값"])), { changed: false }),
|
||||
buildCell(show(deep(cur, [...SUPPLY, "기준"])), { changed: false }),
|
||||
td,
|
||||
];
|
||||
}
|
||||
|
||||
/** 품셈재료 — 가격 연결(시중물가 열쇠 · 후보 조건 · 없음) · 고르기 모달. */
|
||||
function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
|
||||
const link = cur["연결"];
|
||||
const td = buildCell(linkText(link), {
|
||||
changed: JSON.stringify(link ?? null) !== JSON.stringify(row["연결"] ?? null),
|
||||
});
|
||||
if (put) {
|
||||
const cond = link && typeof link === "object" ? (link as Row) : null;
|
||||
const name = String(cond?.["이름"] ?? cur["이름"] ?? "");
|
||||
const spec = String(cond?.["규격"] ?? cur["규격"] ?? "");
|
||||
const btn = rowBtn(L("M01_PriceFind"));
|
||||
btn.addEventListener("click", () =>
|
||||
openPickModal({
|
||||
kind: "price",
|
||||
title: `${L("M01_PriceTitle")} · ${cur["이름"] ?? ""}`,
|
||||
current: linkText(link),
|
||||
seed: `${name} ${spec}`.trim(),
|
||||
onPick: (ref) => put(withCell(cur, "연결", ref)),
|
||||
cond: { 이름: name, 규격: spec, onCond: (c) => put(withCell(cur, "연결", c)) },
|
||||
}),
|
||||
);
|
||||
td.append(btn);
|
||||
}
|
||||
return td;
|
||||
}
|
||||
|
||||
/** 건설노임 — 미공표(값 없음) 줄의 준용 = 공표 직종 고르기. */
|
||||
function wageCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
|
||||
const text = show(cur["준용"]);
|
||||
const td = buildCell(text, { changed: text !== show(row["준용"]) });
|
||||
if (put && row["값"] === null) {
|
||||
const btn = rowBtn(L("M01_JobFind"));
|
||||
btn.addEventListener("click", () =>
|
||||
openPickModal({
|
||||
kind: "job",
|
||||
title: `${L("M01_JobTitle")} · ${cur["이름"] ?? ""}`,
|
||||
current: text,
|
||||
seed: "",
|
||||
onPick: (ref) => put(withCell(cur, "준용", ref)),
|
||||
}),
|
||||
);
|
||||
td.append(btn);
|
||||
}
|
||||
return td;
|
||||
}
|
||||
|
||||
@@ -217,3 +217,53 @@
|
||||
.m01-master__empty {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 조달 찾기 모달 */
|
||||
.m01-procure {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.m01-procure__box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: min(640px, 92vw);
|
||||
max-height: 80vh;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
.m01-procure__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
min-height: 120px;
|
||||
}
|
||||
.m01-procure__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.m01-procure__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.m01-procure__cond {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user