feat(786-2): 불러오기 [수정] → 모달 「확인 — 제N호표」 옛 꼴 되살림 · 새 기능 「새」 배지
- 새 Hopyo_Check(모달 껍데기 · 머리 = 호표 · 엑셀 단가 · 제안 n / 줄 m · 확인 n · 만든 일위대가 ·
[호표 제안 모두 확인](새) · [일위대가 생성] = 옮기기 길 이 호표만 · [구조물 조합으로] · [엑셀 보기])
- 새 Hopyo_CheckRows(로직 표 + 자재 표 = 구조물 조합 상세 꼴 · Labor_Rows moneyCell · PRICE_SLOTS ·
Labor_MatMarks 빌려 씀 · 칸마다 엑셀 / 우리 단가 · 품 비교 · [고르기] · [원문 보기] · [옵션] · 추가 / 로직없음)
- 새 Hopyo_ToItem(구조물 조합 나무 add-item → 그 일위대가 문서에 SC 참조 한 줄 · 옮겨진 뒤만 켬)
- Hopyo_Rows: 시트 아래 줄 판 몸 걷음 → 줄 칸 부품 createRowKit(판정 · 고름 칩 · 블록 풀이 · 단위 환산 · [확인]) ·
옮기기 한 번 runMove(오른쪽 [옮기기] · 모달 [일위대가 생성] 한 벌)
- Router: POST /hopyo/{키}/price 우리 금액 미리 셈(저장 없음 · Make.Move.pick · Lines.picks → Labor_Doc.calc 재사용)
- Sheet: [수정] = openHopyoCheck · 시트 아래 자리는 단가산출 블록 판(SheetSk) 몫으로 남김
- 안 살림: 맞음·별칭 일괄 체크 · 품셈으로 바꾸기 · 모두 고정으로(지운 서버 길 몫) · 사용안함(새 옮기기 길에 뺌 없음)
- 시험: 새 test_786_2_check_modal(우리 금액 예시 1 = 94,227 · 예시 3 입력 필요 → 0 · 쓰기 0 · 모달 · 새 배지 ·
공용 import 만 · 날 button/input 0) · test_642_6 단언 = 줄 판 createJudgeRows → 모달 openHopyoCheck(까닭: 줄 판 몸 걷음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste
|
||||
from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink
|
||||
from M02_MasterTemplete import M02_Hopyo_Row as hopyo_row
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
||||
@@ -173,6 +174,65 @@ def hopyo_move(
|
||||
return _call(folder, hopyo_maker.move, *args)
|
||||
|
||||
|
||||
class PriceBody(BaseModel):
|
||||
"""우리 금액 미리 셈(786-2) — 파일 층 `층` · `주인` · `호표` 번 · `줄 {차례: [고른 것]}`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
층: Layer
|
||||
주인: str | None = None
|
||||
호표: int
|
||||
줄: dict[int, list[Pick]] = Field(default_factory=dict, max_length=300)
|
||||
|
||||
|
||||
def _price(folder: Path, source: Path, key: str, no: int, rows: dict[int, list[dict]]) -> dict:
|
||||
"""줄마다 옮기기와 같은 길(`Make.Move.pick` 재검사 · `Make.Lines.picks` 줄 · 자재)로
|
||||
한 줄 문서 → `Labor_Doc.calc`(정본 셈 · 저장 없음 · 같은 기초 둘째 = 미리 참조) ·
|
||||
막힘 = 그 줄 까닭만."""
|
||||
mv = hopyo_maker.Move(folder, source, key, None)
|
||||
if no not in mv.units:
|
||||
raise store.StoreError(404, f"없는 제{no}호표")
|
||||
lines = mv.units[no].get("줄") or []
|
||||
b = hopyo_build.Builder(folder, source, key, no, None)
|
||||
b.dry, b.strict = True, False
|
||||
where = {"파일": mv.hp.get("파일") or "", "호표": no}
|
||||
out: dict[int, dict[str, Any]] = {}
|
||||
for at, picks in rows.items():
|
||||
if not 0 <= at < len(lines) or not picks:
|
||||
continue
|
||||
try:
|
||||
got = [mv.pick(p, lines[at], f"제{no}호표 {at + 1}째 줄") for p in picks]
|
||||
except hopyo_maker.Stuck as e:
|
||||
out[at] = {"금액": None, "까닭": [e.why], "칸": e.cells}
|
||||
continue
|
||||
body = hopyo_maker.Lines(b, lines, where, "", hopyo_maker.hfix.Linker(False))
|
||||
body.picks(at, lines[at], got)
|
||||
doc = {"종류": "일위대가", "열": "hopyo-price", "단위": None, "담은로직": body.lines}
|
||||
try:
|
||||
calc = labor.calc(labor.clean({**doc, "자재": list(body.mats.values())}), {})
|
||||
except (ValueError, store.StoreError) as e:
|
||||
out[at] = {"금액": None, "까닭": [str(getattr(e, "detail", e))], "칸": []}
|
||||
continue
|
||||
why = [x["까닭"] for x in [*calc.get("줄", []), *calc.get("자재", [])] if x.get("까닭")]
|
||||
out[at] = {"금액": calc.get("반영합"), "자재": calc.get("자재", []), "까닭": why, "칸": []}
|
||||
return {"줄": out}
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo/{key}/price")
|
||||
def hopyo_price(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
body: PriceBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""우리 금액 미리 셈(786-2 [수정] 모달 · 저장 없음) — 경로 층 = 만들 층(참조 읽기) ·
|
||||
몸 층 = 파일 층 → `{줄 {차례: {금액 {노무비 · 재료비 · 경비 · 계}, 자재, 까닭, 칸}}}`."""
|
||||
folder = _root(session, layer, owner)
|
||||
source = _root(session, body.층, body.주인)
|
||||
rows = {at: [p.model_dump(exclude_none=True) for p in ps] for at, ps in body.줄.items()}
|
||||
return _call(folder, _price, folder, source, key, body.호표, rows)
|
||||
|
||||
|
||||
# ── 옛 길(785-7 까지 · 옛 화면) · 그대로 쓰는 길 ─────────────────────────────
|
||||
HOPYO_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
@@ -285,3 +285,51 @@
|
||||
.m02-hopyo-side > .m02-side__row {
|
||||
margin-top: var(--spacing-12);
|
||||
}
|
||||
|
||||
/* [수정] 모달 「확인 — 제N호표」 줄 표(786-2 · 옛 359-3 꼴) — 일위대가 상세 꼴(m02-labrow) · 확인 · 행 · 수량 · 단위 =
|
||||
줄바꿈 없음 · 단추 = 세로로 쌓아 좁게 · 이름 · 별칭 · 품 비교가 남는 폭 */
|
||||
.m02-hopyo__table.m02-hopyo__lines :is(th, td) {
|
||||
padding: var(--spacing-8);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines :is(td:first-child, td:nth-child(2), .m02-labrow__qty, .m02-labrow__unit) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines th > .ui-badge {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines .m02-labrow__name {
|
||||
max-width: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines td.m02-hopyo__compare {
|
||||
min-width: 7em;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines td.m02-hopyo__acts {
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines .m02-hopyo__acts > * {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines .m02-hopyo__acts > * + * {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
}
|
||||
|
||||
.m02-hopyo__solved > td {
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m02-hopyo__subhead {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Check.ts
|
||||
* 불러오기 [수정] 모달 「확인 — 제N호표」(PLAN 786-2 · 요구 644 · 645 · 785 전 옛 확인 창 꼴 되살림) —
|
||||
* 엑셀 보기 오른쪽 호표 목록 [수정](`Hopyo_Sheet`)이 엶.
|
||||
* · 머리 = 호표 · 엑셀 단가 · 제안 n / 줄 m · 확인 n · 만든 사전조합 ·
|
||||
* 단추 [호표 제안 모두 확인](새) · [사전조합 생성] · [구조물 조합으로] · [엑셀 보기]
|
||||
* · 몸 = 로직 표 + 자재 표(구조물 조합 일위대가 상세 꼴 · `Hopyo_CheckRows`) · 줄 칸 부품 = `Hopyo_Rows.createRowKit`
|
||||
* · 우리 금액 = 서버 `/hopyo/{키}/price`(옮기기와 같은 길로 한 줄 문서 → `Labor_Doc.calc` · 저장 없음 ·
|
||||
* 고름이 바뀐 줄만 · 묶어 한 번 · 줄마다 최신 응답만)
|
||||
* · 고름 · 확인 = 판정 캐시(`createJudgeState` · 시트 칠 · 오른쪽 [옮기기 (n)] 과 한 벌) · 자동 확인 · 자동 옮김 없음
|
||||
* · [사전조합 생성] = 옮기기 길(`runMove`) · 이 호표의 확인한 줄만 · 서버가 정본으로 다시 셈
|
||||
* · [구조물 조합으로] = 이 호표가 사전조합으로 옮겨진 뒤만 · 구조물 조합 만들기 길(`Hopyo_ToItem`)
|
||||
* · 행 번호 · [엑셀 보기] = 모달 닫고 시트 그 행(고름은 캐시로 남음 · 다시 [수정] 하면 그대로)
|
||||
* · 옛 [맞음·별칭 일괄 체크] · [품셈으로 바꾸기] · [모두 고정으로] = 지운 서버 길 몫 — [호표 제안 모두 확인]이 갈음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
|
||||
import { formatPrice } from "../common_util/common_util_money";
|
||||
import { call } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { at, HOPYO_BUILT, type HopyoBrief, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import {
|
||||
checkJudge,
|
||||
readJudgeBlock,
|
||||
readJudgeOne,
|
||||
type JudgeOne,
|
||||
type JudgeRow,
|
||||
type Pick,
|
||||
} from "./M02_MasterTemplete_Hopyo_JudgeApi";
|
||||
import {
|
||||
checkTables,
|
||||
newBadge,
|
||||
NO_LOGIC,
|
||||
type LinePrice,
|
||||
type PriceLine,
|
||||
} from "./M02_MasterTemplete_Hopyo_CheckRows";
|
||||
import {
|
||||
createRowKit,
|
||||
runMove,
|
||||
type JudgeState,
|
||||
type SheetRole,
|
||||
} from "./M02_MasterTemplete_Hopyo_Rows";
|
||||
import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem";
|
||||
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import {
|
||||
bare,
|
||||
confirmSuggested,
|
||||
lineOf,
|
||||
moveBody,
|
||||
samePick,
|
||||
setInput,
|
||||
type DraftPick,
|
||||
} from "./logic/M02_MasterTemplete_Hopyo_Picks";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
const why = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
||||
const note = (text: string): HTMLElement => createText(text, "muted");
|
||||
const won = (v: number | null | undefined): string => (v === null || v === undefined ? "—" : formatPrice(v)); // prettier-ignore
|
||||
|
||||
/** 우리 금액 미리 셈 — 경로 층 = 만들 층 · 몸 층 = 파일 층(저장 없음) */
|
||||
const priceJudge = (
|
||||
target: HopyoScope,
|
||||
file: HopyoScope,
|
||||
key: string,
|
||||
no: number,
|
||||
줄: Record<string, Pick[]>,
|
||||
): Promise<{ 줄: Record<string, PriceLine> }> =>
|
||||
call(at(target, `/hopyo/${enc(key)}/price`), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 층: file.layer, ...(file.owner ? { 주인: file.owner } : {}), 호표: no, 줄 }), // prettier-ignore
|
||||
});
|
||||
|
||||
export interface CheckOptions {
|
||||
/** 실무자료 파일 층 */
|
||||
file: HopyoScope;
|
||||
key: string;
|
||||
/** 사전조합 · 구조물 조합을 만들 층 */
|
||||
target: HopyoScope;
|
||||
state: JudgeState;
|
||||
no: number;
|
||||
/** 목록 한 줄(엑셀 단가 · 제안 n / 줄 m) */
|
||||
brief?: HopyoBrief;
|
||||
canWrite: boolean;
|
||||
/** 모달 닫은 뒤 엑셀 보기 그 행 · 그 호표 */
|
||||
onSheet: (행: number, role: SheetRole) => void;
|
||||
onHopyo: () => void;
|
||||
/** 옮긴 뒤(목록 · 칠 다시 읽음) */
|
||||
onMoved: () => void;
|
||||
}
|
||||
|
||||
/** [수정] 모달 */
|
||||
export function openHopyoCheck(opt: CheckOptions): { close: () => void } {
|
||||
const readOnly = !opt.canWrite;
|
||||
const no = opt.no;
|
||||
const root = el("div", { className: "m02-hopyo m02-hopyo-check", attrs: { "data-hopyo-check": `${opt.key}|${no}` } }); // prettier-ignore
|
||||
let one: JudgeOne | null = null;
|
||||
let shut: () => void = () => {};
|
||||
let off: () => void = () => {};
|
||||
let busy = false;
|
||||
const leave = (): void => {
|
||||
off();
|
||||
shut();
|
||||
};
|
||||
|
||||
// ── 우리 금액(줄마다 최신 응답만 · 바뀐 줄만 묶어 한 번) ──
|
||||
const priced = new Map<number, { sig: string; seq: number; val: LinePrice }>();
|
||||
let queued: Record<string, Pick[]> = {};
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
let pending = false;
|
||||
function soon(): void {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
window.setTimeout(() => {
|
||||
pending = false;
|
||||
if (root.isConnected) draw();
|
||||
}, 0);
|
||||
}
|
||||
function flush(): void {
|
||||
const ask = queued;
|
||||
queued = {};
|
||||
const my = ++seq;
|
||||
for (const k of Object.keys(ask)) {
|
||||
const slot = priced.get(Number(k));
|
||||
if (slot) slot.seq = my;
|
||||
}
|
||||
priceJudge(opt.target, opt.file, opt.key, no, ask).then(
|
||||
(got) => {
|
||||
for (const k of Object.keys(ask)) {
|
||||
const slot = priced.get(Number(k));
|
||||
const line = got.줄[k];
|
||||
if (slot?.seq === my) slot.val = line ? { got: line } : { fail: L("M02_CheckNoCalc") };
|
||||
}
|
||||
soon();
|
||||
},
|
||||
(e: unknown) => {
|
||||
for (const k of Object.keys(ask)) {
|
||||
const slot = priced.get(Number(k));
|
||||
if (slot?.seq === my) slot.val = { fail: why(e) };
|
||||
}
|
||||
soon();
|
||||
},
|
||||
);
|
||||
}
|
||||
function price(row: JudgeRow): LinePrice | null {
|
||||
const picks = kit.picksOf(row);
|
||||
if (!picks.length) return null;
|
||||
const 배 = lineOf(opt.state.get(), no, row.차례)?.배;
|
||||
const body = picks.map((p) => bare(p, 배));
|
||||
const sig = JSON.stringify(body);
|
||||
const have = priced.get(row.차례);
|
||||
if (have?.sig === sig) return have.val;
|
||||
priced.set(row.차례, { sig, seq: -1, val: { wait: true } });
|
||||
queued[String(row.차례)] = body;
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(flush, 250);
|
||||
return { wait: true };
|
||||
}
|
||||
|
||||
const kit = createRowKit({
|
||||
file: opt.file,
|
||||
key: opt.key,
|
||||
state: opt.state,
|
||||
no,
|
||||
readOnly,
|
||||
redraw: () => draw(),
|
||||
onSheet: (r, role) => {
|
||||
leave();
|
||||
opt.onSheet(r, role);
|
||||
},
|
||||
});
|
||||
|
||||
/** [원문 보기] — 엑셀 인용 조항(단산 줄 = 블록 근거) · 우리 로직 출처 */
|
||||
async function showSource(row: JudgeRow): Promise<void> {
|
||||
const blk = row.번 !== undefined ? await readJudgeBlock(opt.file, opt.key, row.번, opt.target).catch(() => null) : null; // prettier-ignore
|
||||
const excel = (blk?.근거?.출처 ?? []).map((x) => x.join(" ").trim()).filter(Boolean);
|
||||
const codes = kit.picksOf(row).flatMap((p) => (p.갈래 === "기초" && p.키 ? [p.키] : []));
|
||||
const ours = (await fetchLogicInputs(codes).catch(() => ({ 로직: [] }))).로직.flatMap((x) =>
|
||||
x.출처 ? [`${x.로직} — ${x.출처}`] : [],
|
||||
);
|
||||
openModal({
|
||||
title: `${L("M02_CheckSource")} — ${row.이름 ?? ""}`,
|
||||
closeLabel: L("Common_Btn_Close"),
|
||||
wide: true,
|
||||
dialogClass: "m02-modal m02-hopyo__src",
|
||||
mount: (body) => {
|
||||
if (row.근거) body.append(note(row.근거));
|
||||
const part = (title: string, list: string[], cut: (s: string) => string): void => {
|
||||
body.append(el("h4", { text: title }));
|
||||
if (!list.length) body.append(note(L("M02_CheckNone")));
|
||||
for (const s of list) {
|
||||
const view = createSourceView({ path: "/m02/logic-lib/source", pdf: "/m02/logic-lib/source-pdf" }); // prettier-ignore
|
||||
view.show(cut(s));
|
||||
body.append(note(s), view.root);
|
||||
}
|
||||
};
|
||||
part(L("M02_CheckSourceExcel"), excel, (s) => s);
|
||||
part(L("M02_CheckSourceOurs"), ours, (s) => s.split(" — ").slice(1).join(" — "));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 기초 하나 바꿔 넣음(같은 것 자리) */
|
||||
const swap = (row: JudgeRow, from: DraftPick, to: DraftPick): void =>
|
||||
kit.edit(
|
||||
row,
|
||||
kit.picksOf(row).map((x) => (samePick(x, from) ? to : x)),
|
||||
);
|
||||
|
||||
/** [옵션] — 그 기초 입력값 모달(구조물 조합 상세와 같은 창) · 바뀐 칸 = 출처 「사용자」 */
|
||||
function options(row: JudgeRow, p: DraftPick): void {
|
||||
fetchLogicInputs([String(p.키)]).then(
|
||||
(res) =>
|
||||
openLaborInput({
|
||||
title: `${p.키} ${p.이름}`.trim(),
|
||||
logic: res.로직[0] ?? null,
|
||||
values: p.입력값 as never,
|
||||
memo: null,
|
||||
readOnly,
|
||||
done: (r) => {
|
||||
let next = p;
|
||||
const got = r.입력값 as Record<string, unknown>;
|
||||
for (const k of new Set([...Object.keys(got), ...Object.keys(p.입력값)]))
|
||||
if (got[k] !== p.입력값[k]) next = setInput(next, k, got[k] ?? "");
|
||||
swap(row, p, next);
|
||||
},
|
||||
}),
|
||||
(e: unknown) => showToast(why(e), "error"),
|
||||
);
|
||||
}
|
||||
|
||||
/** [고르기] — 로직 찾기 창(구조물 조합 [로직 추가]와 같은 창) → 검산으로 입력값 · 입력 필요 받아 더함 */
|
||||
function pick(row: JudgeRow): void {
|
||||
openLogicPicker((logic) => {
|
||||
const raw: DraftPick = { 갈래: "기초", 키: logic.키, 이름: logic.이름, 입력값: {}, 출처: {}, 몫: null }; // prettier-ignore
|
||||
checkJudge(opt.file, opt.key, { 호표: no, 차례: row.차례, 고른: [bare(raw)] }).then(
|
||||
(res) => {
|
||||
const c = res.고른[0];
|
||||
const p: DraftPick = { ...raw, 입력값: c?.입력값 ?? {}, 필요: c?.입력필요 ?? [] };
|
||||
kit.edit(row, [...kit.picksOf(row).filter((x) => !samePick(x, p)), p]);
|
||||
},
|
||||
(e: unknown) => showToast(why(e), "error"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 추가(빈 값) = 제안으로 되돌림 · 로직없음 = 고정 줄(엑셀 숫자 그대로) */
|
||||
function out(row: JudgeRow, value: string): void {
|
||||
if (value === NO_LOGIC) {
|
||||
kit.edit(row, [{ 갈래: "고정", 키: null, 이름: row.이름 ?? "", 입력값: {}, 출처: {}, 몫: null }]); // prettier-ignore
|
||||
return;
|
||||
}
|
||||
const d = opt.state.get();
|
||||
const lines = { ...(d.호표[String(no)] ?? {}) };
|
||||
delete lines[String(row.차례)];
|
||||
opt.state.set({ ...d, 호표: { ...d.호표, [String(no)]: lines } });
|
||||
}
|
||||
|
||||
/** [사전조합 생성] — 이 호표의 확인한 줄만 옮김 */
|
||||
async function make(): Promise<void> {
|
||||
const all = moveBody(opt.state.get(), opt.file);
|
||||
const mine = all.body.호표[String(no)] ?? {};
|
||||
const n = Object.keys(mine).length;
|
||||
if (!n) return void showToast(L("M02_JudgeMoveNone"), "info");
|
||||
const ask = L("M02_CheckMakeAsk").replace("{a}", String(n)).replace("{b}", String((one?.줄.length ?? 0) - n)); // prettier-ignore
|
||||
if (!(await showConfirmDialog(ask, L("M02_CheckMake")))) return;
|
||||
busy = true;
|
||||
draw();
|
||||
const stuck = all.막힘.filter((x) => x.호표 === no);
|
||||
const r = await runMove(opt, { ...all.body, 호표: { [String(no)]: mine }, 블록: {} }, stuck);
|
||||
busy = false;
|
||||
if (r) {
|
||||
document.dispatchEvent(new Event(HOPYO_BUILT));
|
||||
opt.onMoved();
|
||||
await load(true);
|
||||
} else draw();
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (!one) return;
|
||||
const h = one;
|
||||
const done = h.줄.filter((r) => lineOf(opt.state.get(), no, r.차례)?.확인).length;
|
||||
const [a, m] = opt.brief?.맞춤 ?? [h.줄.filter((r) => r.판정 === "제안 있음").length, h.줄.length]; // prettier-ignore
|
||||
const all = createButton({ label: L("M02_JudgeConfirmAll"), size: "sm", variant: "ghost", disabled: readOnly, onClick: () => opt.state.set(confirmSuggested(opt.state.get(), no, h.줄)) }); // prettier-ignore
|
||||
all.dataset.judgeAll = String(no);
|
||||
const go = createButton({ label: L("M02_CheckMake"), size: "sm", variant: "filled", disabled: readOnly || busy || !done, onClick: () => void make() }); // prettier-ignore
|
||||
go.dataset.hopyoMake = "";
|
||||
const item = createButton({ label: L("M02_CheckToItem"), size: "sm", disabled: readOnly || !h.만든, onClick: () => void toItem({ target: opt.target, sc: h.만든!, name: [h.이름, h.규격].filter(Boolean).join(" "), unit: h.단위 }) }); // prettier-ignore
|
||||
item.dataset.hopyoToItem = "";
|
||||
item.title = h.만든 ? "" : L("M02_CheckToItemHint");
|
||||
const sheet = createButton({ label: L("M02_CheckSheet"), size: "sm", onClick: () => { leave(); opt.onHopyo(); } }); // prettier-ignore
|
||||
sheet.dataset.hopyoSheet = "";
|
||||
const head = el("div", {
|
||||
className: "ui-toolbar m02-hopyo__bar m02-judge__wrap",
|
||||
children: [
|
||||
el("strong", { text: `${L("M02_JudgeHopyo").replace("{n}", String(no))} ${h.이름}` }),
|
||||
note(h.규격),
|
||||
note(h.단위),
|
||||
note(`${L("M02_CheckExcelPrice")} ${won(opt.brief?.금액.계)}`),
|
||||
note(L("M02_CheckMoneyHint")),
|
||||
note(L("M02_CheckCount").replace("{a}", String(a)).replace("{m}", String(m)).replace("{c}", String(done))), // prettier-ignore
|
||||
...(h.만든 ? [note(`${L("M02_JudgeMade")} ${L("M02_PrecomboTitle")} ${h.만든}`)] : []),
|
||||
newBadge(),
|
||||
all,
|
||||
go,
|
||||
item,
|
||||
sheet,
|
||||
],
|
||||
});
|
||||
const legend = el("div", { className: "ui-toolbar m02-judge__wrap", children: [newBadge(), note(L("M02_CheckNewHint"))] }); // prettier-ignore
|
||||
const ctx = { kit, readOnly, price, pick, source: (r: JudgeRow) => void showSource(r), options, out }; // prettier-ignore
|
||||
root.replaceChildren(head, legend, ...(h.줄.length ? checkTables(h.줄, ctx) : [note(L("M02_CheckNoLines"))])); // prettier-ignore
|
||||
}
|
||||
|
||||
async function load(quiet = false): Promise<void> {
|
||||
const stop = quiet ? () => {} : showLoading(root);
|
||||
try {
|
||||
one = await readJudgeOne(opt.file, opt.key, no, opt.target);
|
||||
draw();
|
||||
} catch (e) {
|
||||
if (!quiet) root.replaceChildren(loadingError(why(e), () => void load()));
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
openModal({
|
||||
title: L("M02_CheckTitle").replace("{n}", String(no)),
|
||||
closeLabel: L("Common_Btn_Close"),
|
||||
wide: true,
|
||||
dialogClass: "m02-modal m02-hopyo__check-modal",
|
||||
beforeClose: () => {
|
||||
off();
|
||||
return true;
|
||||
},
|
||||
mount: (body, close) => {
|
||||
shut = close;
|
||||
body.append(root);
|
||||
},
|
||||
});
|
||||
off = opt.state.on(() => draw());
|
||||
void load();
|
||||
return { close: leave };
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_CheckRows.ts
|
||||
* [수정] 모달 줄 표(PLAN 786-2 · 요구 644 · 옛 359-3 꼴 되살림) — 구조물 조합 일위대가 상세 꼴
|
||||
* (`Labor_Rows.moneyCell` · `PRICE_SLOTS` · `Labor_MatMarks` 빌려 씀 · 그 파일은 안 고침)에 새 판정 칸(785)을 얹음.
|
||||
* · 로직 표 = [확인] · 행 · 구분(엑셀 갈래 + 1차 판정) · 이름(고름 칩 · 단산 [블록 풀이] · 셈 못 함 까닭) ·
|
||||
* 별칭(엑셀 품명) · 수량산출(위 엑셀 / 아래 우리 = 엑셀 × 배 · 단위 환산 칸) · 단위 ·
|
||||
* 노무비 · 재료비 · 경비 · 합계(위 엑셀 단가 / 아래 우리 = 서버 미리 셈 ÷ 엑셀 수량) · 품 비교 · 단추
|
||||
* · 자재 표 = [확인] · 행 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(글만) · 가격 다섯 · 엑셀 / 우리 합계 · 단추
|
||||
* · 단추 = [고르기] · [원문 보기] · [옵션](기초마다) · 추가 / 로직없음(= 고정 줄) — 옛 「사용안함」(뺌)은 새 옮기기 길에 없어 안 살림
|
||||
* · 「새」 배지(785 새 기능) = 머리 칸 [확인] · 구분(판정) · 이름(고름 · 블록 풀이) · 수량산출(단위 환산)
|
||||
* 줄 표 = `tableOf`(고른 것이 모두 재료 · 유가 · 없으면 엑셀 갈래 · 별도계상 줄 = 자재 표).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { formatPrice } from "../common_util/common_util_money";
|
||||
import type { JudgeRow } from "./M02_MasterTemplete_Hopyo_JudgeApi";
|
||||
import { apartRow, bar, show, type RowKit } from "./M02_MasterTemplete_Hopyo_Rows";
|
||||
import type { CalcMaterial } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
|
||||
import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import type { DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks";
|
||||
|
||||
type Money4 = Record<"노무비" | "재료비" | "경비" | "계", number | null>;
|
||||
|
||||
/** 서버 미리 셈 한 줄(`/hopyo/{키}/price`) */
|
||||
export interface PriceLine {
|
||||
금액: Money4 | null;
|
||||
자재?: CalcMaterial[];
|
||||
까닭: string[];
|
||||
칸: string[];
|
||||
}
|
||||
/** 줄 금액 — 셈 중 = wait · 고른 것 없음 = null */
|
||||
export type LinePrice = { wait: true } | { got: PriceLine } | { fail: string };
|
||||
|
||||
export interface CheckCtx {
|
||||
kit: RowKit;
|
||||
readOnly: boolean;
|
||||
price: (row: JudgeRow) => LinePrice | null;
|
||||
pick: (row: JudgeRow) => void;
|
||||
source: (row: JudgeRow) => void;
|
||||
options: (row: JudgeRow, p: DraftPick) => void;
|
||||
/** 추가(빈 값 = 제안으로 되돌림) · 로직없음(고정 줄) */
|
||||
out: (row: JudgeRow, value: string) => void;
|
||||
}
|
||||
|
||||
const COSTS = ["노무비", "재료비", "경비", "계"] as const;
|
||||
const COST_HEAD = {
|
||||
노무비: "Prev_Labor",
|
||||
재료비: "Prev_Material",
|
||||
경비: "Prev_Expense",
|
||||
계: "Prev_Sum",
|
||||
} as const;
|
||||
const MAT_KINDS = ["재료", "유가"];
|
||||
export const NO_LOGIC = "로직없음";
|
||||
|
||||
const small = (text: string, cls = "m02-labrow__note"): HTMLElement =>
|
||||
el("small", { className: cls, text, attrs: { title: text } });
|
||||
|
||||
/** 「새」 배지(785 새 기능 표지) */
|
||||
export function newBadge(): HTMLElement {
|
||||
const b = createBadge(L("M02_CheckNew"), "accent", { size: 18 });
|
||||
b.dataset.checkNew = "";
|
||||
b.title = L("M02_CheckNewHint");
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 줄이 들어갈 표 */
|
||||
export function tableOf(row: JudgeRow, picks: DraftPick[]): "logic" | "mat" {
|
||||
if (apartRow(row)) return "mat";
|
||||
if (picks.length) return picks.every((p) => MAT_KINDS.includes(p.갈래)) ? "mat" : "logic";
|
||||
return row.갈래.length > 0 && row.갈래.every((k) => MAT_KINDS.includes(k)) ? "mat" : "logic";
|
||||
}
|
||||
|
||||
/** 우리 금액 → 엑셀 1 단위(서버 반영합 ÷ 엑셀 수량 · 수량 없음 = 그대로) */
|
||||
function perUnit(v: number | null | undefined, row: JudgeRow): number | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
const q = row.수량.엑셀;
|
||||
return q ? v / q : v;
|
||||
}
|
||||
|
||||
const priced = (p: LinePrice | null): PriceLine | null => (p && "got" in p ? p.got : null);
|
||||
const waiting = (p: LinePrice | null): boolean => !!p && "wait" in p;
|
||||
|
||||
/** 이름 칸 덧글 — 셈 못 함 까닭 · 입력 필요 칸 */
|
||||
function notes(p: LinePrice | null): HTMLElement[] {
|
||||
const got = priced(p);
|
||||
const why = [...(p && "fail" in p ? [p.fail] : []), ...(got?.까닭 ?? [])];
|
||||
const out = why.map((t) => small(`${L("M02_CheckNoCalc")}: ${t}`, "ui-text ui-text--error"));
|
||||
if (got?.칸.length) out.push(small(`${L("M02_JudgeNeed")}: ${got.칸.join(" · ")}`, "ui-text ui-text--error")); // prettier-ignore
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 품 비교 — 고른 기초의 줄 대조 판정 수(단산 줄 · 블록 풀이) */
|
||||
export function compareText(row: JudgeRow, picks: DraftPick[]): string {
|
||||
const keys = new Set(picks.map((p) => p.키));
|
||||
const count = new Map<string, number>();
|
||||
for (const sec of row.풀이?.절 ?? [])
|
||||
for (const c of sec.후보)
|
||||
if (keys.has(c.키)) for (const x of c.줄) count.set(x.판정, (count.get(x.판정) ?? 0) + 1);
|
||||
return [...count].map(([k, n]) => `${k} ${n}`).join("\n");
|
||||
}
|
||||
|
||||
/** 수량산출 — 위 엑셀 수량 · 아래 우리(엑셀 × 배) + 단위 환산 칸 */
|
||||
function qtyCell(row: JudgeRow, ctx: CheckCtx, picks: DraftPick[]): HTMLElement {
|
||||
const 배 = ctx.kit.timesOf(row);
|
||||
const ex = row.수량.엑셀;
|
||||
const ours = ex === null || 배 === null ? "—" : show(ex * 배);
|
||||
const unit = ctx.kit.unitInput(row, picks);
|
||||
return el("td", {
|
||||
className: "m02-labrow__qty",
|
||||
attrs: { "data-label": tc("Labor_QtyCol") },
|
||||
children: [
|
||||
el("small", { className: "m02-labrow__base", text: ex === null ? "—" : `${show(ex)} ${row.단위 ?? ""}`.trim(), attrs: { title: L("M02_JudgeColExcel") } }), // prettier-ignore
|
||||
el("span", { className: "m02-labrow__applied", text: ours, attrs: { title: L("M02_JudgeColOurs") } }), // prettier-ignore
|
||||
...(unit ? [unit] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function actsCell(row: JudgeRow, ctx: CheckCtx, picks: DraftPick[]): HTMLElement {
|
||||
const ghost = (label: string, run: () => void, at: string): HTMLElement => {
|
||||
const b = createButton({ label, variant: "ghost", size: "sm", onClick: run });
|
||||
b.dataset.checkAct = at;
|
||||
return b;
|
||||
};
|
||||
const kids: HTMLElement[] = [];
|
||||
if (!ctx.readOnly && !apartRow(row)) kids.push(ghost(L("M02_CheckPick"), () => ctx.pick(row), "pick")); // prettier-ignore
|
||||
kids.push(ghost(L("M02_CheckSource"), () => ctx.source(row), "source"));
|
||||
for (const p of picks.filter((x) => x.갈래 === "기초")) {
|
||||
const b = ghost(L("M02_CheckOption"), () => ctx.options(row, p), "option");
|
||||
b.title = `${p.키} ${L("M02_CheckOption")}`;
|
||||
kids.push(b);
|
||||
}
|
||||
if (!apartRow(row)) {
|
||||
const fixed = picks.length === 1 && picks[0].갈래 === "고정";
|
||||
const out = createSelectField({
|
||||
options: [
|
||||
{ value: "", text: L("M02_CheckAdd") },
|
||||
{ value: NO_LOGIC, text: L("M02_CheckNoLogic") },
|
||||
],
|
||||
value: fixed ? NO_LOGIC : "",
|
||||
compact: true,
|
||||
ariaLabel: L("M02_CheckNoLogic"),
|
||||
onChange: (v) => ctx.out(row, v),
|
||||
});
|
||||
out.select.disabled = ctx.readOnly;
|
||||
out.select.dataset.checkOut = String(row.차례);
|
||||
out.root.title = L("M02_CheckNoLogicHint");
|
||||
kids.push(out.root);
|
||||
}
|
||||
return el("td", { className: "m02-hopyo__acts", children: kids });
|
||||
}
|
||||
|
||||
const rowOf = (
|
||||
row: JudgeRow,
|
||||
ctx: CheckCtx,
|
||||
picks: DraftPick[],
|
||||
cells: HTMLElement[],
|
||||
): HTMLElement =>
|
||||
// prettier-ignore
|
||||
el("tr", {
|
||||
className: "m02-labrow__row m02-hopyo__line",
|
||||
attrs: { "data-line": String(row.차례), "data-verdict": row.판정 },
|
||||
children: [
|
||||
el("td", { children: [ctx.kit.okChip(row, picks)] }),
|
||||
el("td", { className: "m02-labrow__no", children: [ctx.kit.rowJump(row)] }),
|
||||
...cells,
|
||||
actsCell(row, ctx, picks),
|
||||
],
|
||||
});
|
||||
|
||||
const kindCell = (row: JudgeRow, ctx: CheckCtx): HTMLElement =>
|
||||
el("td", {
|
||||
className: "m02-labrow__kind",
|
||||
attrs: { title: row.근거 },
|
||||
children: [bar([...row.갈래.map((k) => createBadge(k, "neutral")), ctx.kit.verdict(row)])],
|
||||
});
|
||||
|
||||
function logicRow(row: JudgeRow, ctx: CheckCtx): HTMLElement[] {
|
||||
const picks = ctx.kit.picksOf(row);
|
||||
const p = ctx.price(row);
|
||||
const got = priced(p);
|
||||
const wait = waiting(p);
|
||||
const unit = picks[0]?.단위 || row.단위 || "";
|
||||
const tr = rowOf(row, ctx, picks, [
|
||||
kindCell(row, ctx),
|
||||
el("td", { className: "m02-labrow__name m02-hopyo__parts", children: [ctx.kit.chips(row, picks), ...notes(p)] }), // prettier-ignore
|
||||
el("td", {
|
||||
className: "m02-labrow__alias",
|
||||
attrs: { title: `${row.이름 ?? ""} ${row.규격 ?? ""}`.trim() },
|
||||
children: [row.이름 ?? "", ...(row.규격 ? [small(row.규격)] : [])],
|
||||
}),
|
||||
qtyCell(row, ctx, picks),
|
||||
el("td", { className: "m02-labrow__unit", text: unit }),
|
||||
...COSTS.map(
|
||||
(c) =>
|
||||
moneyCell(row.단가?.[c] ?? null, got ? perUnit(got.금액?.[c], row) : null, wait, tc(COST_HEAD[c])), // prettier-ignore
|
||||
),
|
||||
el("td", { className: "m02-hopyo__compare", text: compareText(row, picks) }),
|
||||
]);
|
||||
const body = ctx.kit.solved(row);
|
||||
if (!body) return [tr];
|
||||
const wide = el("td", { children: [body], attrs: { colspan: "15" } });
|
||||
return [tr, el("tr", { className: "m02-hopyo__solved", children: [wide] })];
|
||||
}
|
||||
|
||||
function matRow(row: JudgeRow, ctx: CheckCtx): HTMLElement {
|
||||
const picks = ctx.kit.picksOf(row);
|
||||
const p = ctx.price(row);
|
||||
const got = priced(p);
|
||||
const wait = waiting(p);
|
||||
const mats = got?.자재 ?? [];
|
||||
const first = mats[0];
|
||||
const ours = mats.length ? mats.reduce((s, m) => s + (m.반영 ?? 0), 0) : null;
|
||||
const cell = (v: number | null | undefined): string =>
|
||||
wait ? "…" : v === null || v === undefined ? "—" : formatPrice(v);
|
||||
const pk = picks[0];
|
||||
const apart = apartRow(row) || !!pk?.별도계상;
|
||||
return rowOf(row, ctx, picks, [
|
||||
el("td", { className: "m02-labrow__code", text: picks.map((x) => x.키 ?? "").filter(Boolean).join(" · ") || "—" }), // prettier-ignore
|
||||
el("td", {
|
||||
className: "m02-labrow__name m02-hopyo__parts",
|
||||
children: [ctx.kit.chips(row, picks), small(`${L("M02_JudgeColExcel")} ${row.이름 ?? ""}`.trim()), ...notes(p)], // prettier-ignore
|
||||
}),
|
||||
el("td", { className: "m02-labrow__spec", text: pk?.규격 || row.규격 || "", attrs: { title: pk?.규격 || row.규격 || "" } }), // prettier-ignore
|
||||
qtyCell(row, ctx, picks),
|
||||
el("td", { className: "m02-labrow__unit", text: pk?.단위 || row.단위 || "" }),
|
||||
...markCells({ 별도계상: apart ? "별도계상" : "직접계산" }),
|
||||
...PRICE_SLOTS.map((slot) =>
|
||||
el("td", {
|
||||
className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`,
|
||||
text: cell(first?.가격?.[slot]),
|
||||
attrs: { "data-label": slot, "data-price-slot": slot },
|
||||
}),
|
||||
),
|
||||
moneyCell(row.단가?.계 ?? null, got ? perUnit(ours, row) : null, wait, tc("Labor_AppliedSum")),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 머리 칸 — 글 · 새 기능이면 「새」 배지 */
|
||||
const head = (text: string, fresh = false): HTMLElement =>
|
||||
el("th", { children: fresh ? [text, " ", newBadge()] : [text] });
|
||||
|
||||
const grid = (heads: HTMLElement[], rows: HTMLElement[], kind: string): HTMLElement =>
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-labrow m02-hopyo__table m02-hopyo__lines",
|
||||
attrs: { "data-hopyo-table": kind },
|
||||
children: [
|
||||
el("thead", { children: [el("tr", { children: heads })] }),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
/** 줄 표 둘 — 로직 표 · 자재 표(줄 없는 표는 안 그림) */
|
||||
export function checkTables(rows: JudgeRow[], ctx: CheckCtx): HTMLElement[] {
|
||||
const logic = rows.filter((r) => tableOf(r, ctx.kit.picksOf(r)) === "logic");
|
||||
const mat = rows.filter((r) => !logic.includes(r));
|
||||
const first = [head(L("M02_JudgeColOk"), true), head(L("M02_JudgeColRow"))];
|
||||
const out: HTMLElement[] = [];
|
||||
if (logic.length)
|
||||
out.push(
|
||||
grid(
|
||||
[
|
||||
...first,
|
||||
head(`${tc("Sub")} · ${L("M02_JudgeColVerdict")}`, true),
|
||||
head(`${tc("Col_Name")} · ${L("M02_JudgeColPick")}`, true),
|
||||
head(tc("Col_Alias")),
|
||||
head(tc("Labor_QtyCol"), true),
|
||||
head(tc("Col_Unit")),
|
||||
...COSTS.map((c) => head(tc(COST_HEAD[c]))),
|
||||
head(L("M02_CheckCompare")),
|
||||
head(""),
|
||||
],
|
||||
logic.flatMap((r) => logicRow(r, ctx)),
|
||||
"logic",
|
||||
),
|
||||
);
|
||||
if (mat.length)
|
||||
out.push(
|
||||
el("h4", { className: "m02-hopyo__subhead", text: L("M02_CheckMat") }),
|
||||
grid(
|
||||
[
|
||||
...first,
|
||||
head(tc("Labor_Code")),
|
||||
head(`${tc("Col_Name")} · ${L("M02_JudgeColPick")}`, true),
|
||||
head(tc("Labor_Spec")),
|
||||
head(tc("Labor_QtyCol"), true),
|
||||
head(tc("Labor_Unit")),
|
||||
...markHeads().map((t) => head(t)),
|
||||
...PRICE_SLOTS.map((t) => head(t)),
|
||||
head(`${L("M02_JudgeColExcel")} / ${L("M02_JudgeColOurs")} ${tc("Labor_AppliedSum")}`),
|
||||
head(""),
|
||||
],
|
||||
mat.map((r) => matRow(r, ctx)),
|
||||
"mat",
|
||||
),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Rows.ts
|
||||
* 엑셀 불러오기 줄 판(PLAN 785-6 · 요구 641 · 642 · 설계 642-A 2-3 · 2-4 · 2-6 · 분석 642-D 2-3 · 4) — 시트 아래 `[data-hopyo-lines]`.
|
||||
* · 호표 하나 = 줄마다 1차 판정 칩(제안 있음 · 2차 필요 · 후보 여럿 · 후보 없음) · 갈래 칩 · 근거 ·
|
||||
* 후보 복수 고름(기초 · 단가산출 · 일위대가 · 인력 · 재료 · 유가 · 기계 · 율 · 고정 — 한 줄에 여럿) ·
|
||||
* 수량 · 단위 환산 칸(`logic/Hopyo_Unit` · 같은 단위 = 숨김) · [보기] = 엑셀 그 행 · [확인]
|
||||
* · 단산 줄 = [블록 풀이] 펼침(`Hopyo_Block.solvedBody` · 절마다 기초 · 입력값 칸) + 「단가산출 통째」 칩(단산N → SK)
|
||||
* · 머리 [호표 제안 모두 확인](사용자 누름 · 다 풀린 제안만) · 아래 [옮기기](`createJudgeMove` · 오른쪽 판 아래)
|
||||
* 엑셀 불러오기 줄 칸 부품 · 옮기기(PLAN 785-6 · 786-2 · 요구 641 · 642 · 644 · 설계 642-A 2-3 · 2-4 · 2-6).
|
||||
* · `createRowKit` = 호표 하나의 줄 칸 부품 — [수정] 모달 「확인 — 제N호표」(`Hopyo_Check` · `Hopyo_CheckRows`)가
|
||||
* 옛 확인 창 표 칸 안에 담음: 1차 판정 칩 · 후보 복수 고름 칩(기초 · 단가산출 · 일위대가 · 인력 · 재료 · 유가 ·
|
||||
* 기계 · 율 · 고정) · 단산 줄 [블록 풀이] 펼침(`Hopyo_Block.solvedBody` · 절마다 기초 · 입력값 칸 · 출처 꼬리표) +
|
||||
* 「단가산출 통째」 칩(단산N → SK) · 단위 환산 칸(`logic/Hopyo_Unit` · 같은 단위 = 숨김) · [확인]
|
||||
* · `createJudgeMove` = 오른쪽 판 아래 [옮기기 (n)] · `runMove` = 옮기기 한 번(모달 [사전조합 생성]도 이 길 · 이 호표만)
|
||||
* · 이미 옮긴 것(409) = 물음 한 번 뒤 덮음
|
||||
* · 고름 · 확인 = 캐시(`createJudgeState`) · 자동 확인 · 자동 옮김 없음 · 옮김 = 서버가 정본으로 다시 셈
|
||||
* (785-6 시트 아래 줄 판 몸은 786-2 에서 모달로 옮김)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
|
||||
@@ -19,8 +20,6 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createPanelHead } from "@ui/ui_template_panel_head";
|
||||
import { tableRow, tableShell, type Cell, type Column } from "@ui/ui_template_table";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createToggle } from "@ui/ui_template_toggle";
|
||||
import { formatNumber } from "../common_util/common_util_money";
|
||||
@@ -30,14 +29,13 @@ import { createJudgeState, solvedBody, type JudgeState } from "./M02_MasterTempl
|
||||
import {
|
||||
checkJudge,
|
||||
moveJudge,
|
||||
readJudgeOne,
|
||||
type JudgeOne,
|
||||
type JudgeRow,
|
||||
type MoveBody,
|
||||
type MoveBlocked,
|
||||
type MoveResult,
|
||||
} from "./M02_MasterTemplete_Hopyo_JudgeApi";
|
||||
import {
|
||||
bare,
|
||||
confirmSuggested,
|
||||
confirmable,
|
||||
editLine,
|
||||
lineOf,
|
||||
@@ -59,38 +57,26 @@ const VERDICT: Record<string, BadgeTone> = {
|
||||
"후보 여럿": "warning",
|
||||
"후보 없음": "danger",
|
||||
};
|
||||
const show = (v: unknown): string =>
|
||||
export const show = (v: unknown): string =>
|
||||
v === null || v === undefined ? "" : typeof v === "number" ? formatNumber(v) : String(v);
|
||||
const bar = (kids: (HTMLElement | string)[]): HTMLElement =>
|
||||
export const bar = (kids: (HTMLElement | string)[]): HTMLElement =>
|
||||
el("div", { className: "ui-toolbar m02-judge__wrap", children: kids });
|
||||
|
||||
export type SheetRole = "일위대가표" | "단가산출상세";
|
||||
|
||||
export interface JudgeRowsOptions {
|
||||
host: HTMLElement;
|
||||
export interface RowKitOptions {
|
||||
/** 파일 층 */
|
||||
file: HopyoScope;
|
||||
key: string;
|
||||
/** 만들 층 */
|
||||
target: HopyoScope;
|
||||
state: JudgeState;
|
||||
no: number;
|
||||
/** 엑셀 보기 그 행(일위대가표 · 단가산출 상세) */
|
||||
onSheet?: (행: number, role: SheetRole) => void;
|
||||
onClose?: () => void;
|
||||
readOnly?: boolean;
|
||||
/** [블록 풀이] 펼침 · 접음 — 부르는 쪽이 다시 그림 */
|
||||
redraw: () => void;
|
||||
}
|
||||
|
||||
const COLS: Column[] = [
|
||||
{ label: L("M02_JudgeColRow"), width: "52px" },
|
||||
{ label: L("M02_JudgeColName") },
|
||||
{ label: L("M02_JudgeColVerdict"), width: "84px" },
|
||||
{ label: L("M02_JudgeColKind") },
|
||||
{ label: L("M02_JudgeColPick") },
|
||||
{ label: L("M02_JudgeColQty"), align: "end" },
|
||||
{ label: L("M02_JudgeColOk"), width: "72px" },
|
||||
];
|
||||
|
||||
/** 2차 줄 「단가산출 통째」 고른 것 — 단산N(같은 옮기기 블록 먼저 SK) */
|
||||
const wholeSk = (row: JudgeRow): DraftPick => ({
|
||||
갈래: "단가산출",
|
||||
@@ -103,25 +89,29 @@ const wholeSk = (row: JudgeRow): DraftPick => ({
|
||||
몫: null,
|
||||
});
|
||||
|
||||
/** 시트 아래 줄 판 — 호표 하나 */
|
||||
export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; destroy: () => void } {
|
||||
const root = el("section", { className: "m02-judge", attrs: { "data-judge-rows": String(opt.no) } }); // prettier-ignore
|
||||
root.append(createText(L("M02_JudgeLoading"), "hint"));
|
||||
opt.host.replaceChildren(root);
|
||||
let one: JudgeOne | null = null;
|
||||
/** 별도계상 줄(786-1 표지) — 고름 칩 없이 글만 */
|
||||
export const apartRow = (row: JudgeRow): boolean => !!(row as { 별도계상?: boolean }).별도계상;
|
||||
|
||||
/** 호표 하나 줄 칸 부품 — 판정 칩 · 고름 칩 · 수량(단위 환산) · [확인] · 블록 풀이 몸 */
|
||||
export function createRowKit(opt: RowKitOptions) {
|
||||
const no = opt.no;
|
||||
const open = new Set<number>();
|
||||
const bodies = new Map<number, ReturnType<typeof solvedBody>>();
|
||||
let alive = true;
|
||||
const no = opt.no;
|
||||
|
||||
const picksOf = (row: JudgeRow): DraftPick[] => linePicks(opt.state.get(), no, row);
|
||||
const edit = (row: JudgeRow, list: DraftPick[]): void =>
|
||||
opt.state.set(editLine(opt.state.get(), no, row, list));
|
||||
|
||||
function verdict(row: JudgeRow): HTMLElement {
|
||||
const v = createBadge(row.판정, VERDICT[row.판정] ?? "neutral");
|
||||
v.dataset.judgeVerdict = row.판정;
|
||||
return v;
|
||||
}
|
||||
|
||||
function chips(row: JudgeRow, picks: DraftPick[]): HTMLElement {
|
||||
const kids: HTMLElement[] = [];
|
||||
if (apartRow(row)) return bar([createText(L("M02_CheckApart"), "muted")]);
|
||||
if (row.번 !== undefined && row.풀이) {
|
||||
const solve = createToggle({ kind: "chip", label: L("M02_JudgeSolve"), checked: open.has(row.차례), onChange: (on) => { if (on) open.add(row.차례); else open.delete(row.차례); draw(); } }); // prettier-ignore
|
||||
const solve = createToggle({ kind: "chip", label: L("M02_JudgeSolve"), checked: open.has(row.차례), onChange: (on) => { if (on) open.add(row.차례); else open.delete(row.차례); opt.redraw(); } }); // prettier-ignore
|
||||
solve.root.dataset.judgeSolve = String(row.번);
|
||||
const sk = wholeSk(row);
|
||||
const whole = createToggle({ kind: "chip", label: `${L("M02_JudgeWholeSk")} ${sk.키}`, checked: picks.some((p) => samePick(p, sk)), disabled: opt.readOnly, onChange: () => edit(row, togglePick(picks, sk)) }); // prettier-ignore
|
||||
@@ -130,6 +120,7 @@ export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; des
|
||||
for (const p of picks.filter((x) => x.갈래 === "기초")) {
|
||||
const b = createBadge(`${p.키}`, missing(p).length ? "danger" : "accent");
|
||||
b.dataset.judgePicked = String(p.키);
|
||||
if (missing(p).length) b.title = `${L("M02_JudgeNeed")} ${missing(p).join(" · ")}`;
|
||||
kids.push(b);
|
||||
}
|
||||
} else {
|
||||
@@ -142,18 +133,24 @@ export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; des
|
||||
chip.root.dataset.judgeCand = `${c.갈래}:${c.키 ?? c.이름}`;
|
||||
chip.root.title = c.키 ?? "";
|
||||
kids.push(chip.root);
|
||||
const need = picks.find((p) => samePick(p, c));
|
||||
if (need && missing(need).length) {
|
||||
const b = createBadge(`${L("M02_JudgeNeed")} ${missing(need).length}`, "danger");
|
||||
b.dataset.judgeNeed = missing(need).join(",");
|
||||
kids.push(b);
|
||||
}
|
||||
}
|
||||
if (!seen.length) kids.push(createText(L("M02_JudgeNoCand"), "warning"));
|
||||
}
|
||||
return bar(kids);
|
||||
}
|
||||
|
||||
function qtyCell(row: JudgeRow, picks: DraftPick[]): HTMLElement {
|
||||
/** 단위 환산 칸 — 같은 단위면 null */
|
||||
function unitInput(row: JudgeRow, picks: DraftPick[]): HTMLElement | null {
|
||||
const line = lineOf(opt.state.get(), no, row.차례);
|
||||
const 배 = line?.배 ?? row.수량.배;
|
||||
const view = unitView({ 배, 환산: { 배: row.수량.배, 갈래: row.수량.갈래 } }, row.단위, picks[0]?.단위); // prettier-ignore
|
||||
const text = `${show(row.수량.엑셀)}${배 !== null && 배 !== 1 ? ` × ${show(배)}` : ""}`;
|
||||
if (view.mode !== "input" || opt.readOnly) return bar([text]);
|
||||
if (view.mode !== "input" || opt.readOnly) return null;
|
||||
const f = createInputField({ compact: true, size: 24, value: show(배), unit: view.to, placeholder: L("M02_JudgeUnitHint").replace("{from}", view.from) }); // prettier-ignore
|
||||
f.input.dataset.judgeTimes = String(row.차례);
|
||||
f.input.addEventListener("change", () => {
|
||||
@@ -162,10 +159,14 @@ export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; des
|
||||
opt.state.set(putLine(opt.state.get(), no, row.차례, { 고른: picks, 확인: false, 배: next }));
|
||||
});
|
||||
if (배 === null) f.setError(L("M02_JudgeNeed"));
|
||||
return bar([text, L("M02_JudgeUnitHint").replace("{from}", view.from), f.root]);
|
||||
return bar([L("M02_JudgeUnitHint").replace("{from}", view.from), f.root]);
|
||||
}
|
||||
|
||||
function okCell(row: JudgeRow, picks: DraftPick[]): HTMLElement {
|
||||
/** 지금 배(사용자 단위 환산 › 서버) */
|
||||
const timesOf = (row: JudgeRow): number | null =>
|
||||
lineOf(opt.state.get(), no, row.차례)?.배 ?? row.수량.배;
|
||||
|
||||
function okChip(row: JudgeRow, picks: DraftPick[]): HTMLElement {
|
||||
const line = lineOf(opt.state.get(), no, row.차례);
|
||||
const ok = createToggle({
|
||||
kind: "chip",
|
||||
@@ -186,41 +187,25 @@ export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; des
|
||||
return ok.root;
|
||||
}
|
||||
|
||||
function rowCells(row: JudgeRow): Cell[] {
|
||||
const picks = picksOf(row);
|
||||
const jump =
|
||||
row.행 !== null && opt.onSheet
|
||||
? createButton({ label: String(row.행), size: 24, variant: "link", onClick: () => opt.onSheet!(row.행!, "일위대가표") }) // prettier-ignore
|
||||
: show(row.행);
|
||||
if (jump instanceof HTMLElement) jump.dataset.judgeRow = String(row.행);
|
||||
const v = createBadge(row.판정, VERDICT[row.판정] ?? "neutral");
|
||||
v.dataset.judgeVerdict = row.판정;
|
||||
const kinds = row.갈래.map((k) => {
|
||||
const b = createBadge(k, "neutral");
|
||||
b.dataset.judgeKind = k;
|
||||
return b;
|
||||
});
|
||||
const name = [row.이름, row.규격, row.단위 ? `(${row.단위})` : ""].filter(Boolean).join(" ");
|
||||
return [
|
||||
jump,
|
||||
name,
|
||||
v,
|
||||
bar([...kinds, row.근거]),
|
||||
chips(row, picks),
|
||||
qtyCell(row, picks),
|
||||
okCell(row, picks),
|
||||
];
|
||||
/** 행 번호 단추 — 엑셀 보기 그 행 */
|
||||
function rowJump(row: JudgeRow): HTMLElement | string {
|
||||
if (row.행 === null || !opt.onSheet) return show(row.행);
|
||||
const b = createButton({ label: String(row.행), size: 24, variant: "link", onClick: () => opt.onSheet!(row.행!, "일위대가표") }); // prettier-ignore
|
||||
b.dataset.judgeRow = String(row.행);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 블록 풀이 몸이 쥐는 고른 것 = 단가산출 통째 칩 빼고(기초 · 덧 줄) */
|
||||
const isSk = (p: DraftPick): boolean => p.갈래 === "단가산출";
|
||||
const inner = (row: JudgeRow): DraftPick[] => picksOf(row).filter((p) => !isSk(p));
|
||||
|
||||
function solved(row: JudgeRow): HTMLElement {
|
||||
/** 펼친 단산 줄의 블록 풀이 몸(접힘 = null) */
|
||||
function solved(row: JudgeRow): HTMLElement | null {
|
||||
if (!row.풀이 || !open.has(row.차례)) return null;
|
||||
let body = bodies.get(row.차례);
|
||||
if (!body) {
|
||||
body = solvedBody({
|
||||
solved: row.풀이!,
|
||||
solved: row.풀이,
|
||||
picks: inner(row),
|
||||
readOnly: opt.readOnly,
|
||||
excel: row.단가,
|
||||
@@ -234,51 +219,9 @@ export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; des
|
||||
return body.root;
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (!one) return;
|
||||
const all = createButton({
|
||||
label: L("M02_JudgeConfirmAll"),
|
||||
size: 24,
|
||||
variant: "ghost",
|
||||
disabled: opt.readOnly,
|
||||
onClick: () => opt.state.set(confirmSuggested(opt.state.get(), no, one!.줄)),
|
||||
});
|
||||
all.dataset.judgeAll = String(no);
|
||||
const actions: HTMLElement[] = [all];
|
||||
if (opt.onClose) actions.push(createButton({ label: L("Common_Btn_Close"), size: 24, variant: "ghost", onClick: opt.onClose })); // prettier-ignore
|
||||
const title = `${L("M02_JudgeHopyo").replace("{n}", String(no))} ${one.이름}`;
|
||||
const sub = [one.규격, one.단위, one.만든 ? `${L("M02_JudgeMade")} ${one.만든}` : ""].filter(Boolean).join(" · "); // prettier-ignore
|
||||
const { root: table, tbody } = tableShell(COLS, { density: "data" });
|
||||
for (const row of one.줄) {
|
||||
const tr = tableRow(COLS, rowCells(row));
|
||||
tr.dataset.judgeLine = String(row.차례);
|
||||
tbody.append(tr);
|
||||
if (row.풀이 && open.has(row.차례))
|
||||
tbody.append(tableRow(COLS, [{ content: solved(row), colSpan: COLS.length }]));
|
||||
}
|
||||
root.replaceChildren(createPanelHead({ title, sub, actions, divider: true }).root, table);
|
||||
}
|
||||
|
||||
const off = opt.state.on(draw);
|
||||
void readJudgeOne(opt.file, opt.key, no, opt.target)
|
||||
.then((got) => {
|
||||
if (!alive) return;
|
||||
one = got;
|
||||
draw();
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (alive) root.replaceChildren(createText(e.message, "error"));
|
||||
});
|
||||
|
||||
return {
|
||||
root,
|
||||
destroy(): void {
|
||||
alive = false;
|
||||
off();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
return { picksOf, edit, verdict, chips, unitInput, timesOf, okChip, rowJump, solved };
|
||||
}
|
||||
export type RowKit = ReturnType<typeof createRowKit>;
|
||||
|
||||
export interface JudgeMoveOptions {
|
||||
file: HopyoScope;
|
||||
@@ -289,6 +232,44 @@ export interface JudgeMoveOptions {
|
||||
onMoved?: (r: MoveResult) => void;
|
||||
}
|
||||
|
||||
/** 옮기기 한 번 — 409 = 물음 뒤 덮음 · 옮긴 것은 캐시에서 걷음 · 알림 · 실패 = null */
|
||||
export async function runMove(
|
||||
opt: Pick<JudgeMoveOptions, "target" | "key" | "state">,
|
||||
body: MoveBody,
|
||||
막힘: MoveBlocked[],
|
||||
): Promise<MoveResult | null> {
|
||||
try {
|
||||
const r = await moveJudge(opt.target, opt.key, body).catch(async (e: unknown) => {
|
||||
// 이미 옮긴 호표 · 블록 — 물음 한 번 뒤 덮음(아니면 그만)
|
||||
if (!(e instanceof StaleError) || !(await askConfirm({ message: e.message }))) throw e;
|
||||
return moveJudge(opt.target, opt.key, { ...body, 덮음: true });
|
||||
});
|
||||
const stuck = new Set(r.막힘.map((x) => `${x.호표 ?? "b" + x.블록}:${x.차례 ?? ""}`));
|
||||
let d = opt.state.get();
|
||||
for (const [h, lines] of Object.entries(body.호표))
|
||||
for (const at of Object.keys(lines))
|
||||
if (!stuck.has(`${h}:${at}`)) {
|
||||
const { [at]: _gone, ...rest } = d.호표[h] ?? {};
|
||||
d = { ...d, 호표: { ...d.호표, [h]: rest } };
|
||||
}
|
||||
for (const b of Object.keys(body.블록))
|
||||
if (!stuck.has(`b${b}:`)) {
|
||||
const { [b]: _gone, ...rest } = d.블록;
|
||||
d = { ...d, 블록: rest };
|
||||
}
|
||||
opt.state.set(d);
|
||||
const msg = L("M02_JudgeMoved")
|
||||
.replace("{a}", String(r.만듦.length))
|
||||
.replace("{b}", String(r.단가산출.length))
|
||||
.replace("{c}", String(r.막힘.length + 막힘.length));
|
||||
showToast(msg, r.막힘.length + 막힘.length ? "warning" : "success");
|
||||
return r;
|
||||
} catch (e) {
|
||||
showToast((e as Error).message, "error");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 오른쪽 판 아래 [옮기기 (n)] — 확인한 줄 · 블록만 · 서버가 정본으로 다시 셈 · 옮긴 것은 캐시에서 걷음 */
|
||||
export function createJudgeMove(opt: JudgeMoveOptions): { root: HTMLElement; destroy: () => void } {
|
||||
const btn = createButton({ label: L("M02_JudgeMove"), size: 28, variant: "filled", onClick: () => void go() }); // prettier-ignore
|
||||
@@ -307,37 +288,9 @@ export function createJudgeMove(opt: JudgeMoveOptions): { root: HTMLElement; des
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await moveJudge(opt.target, opt.key, body).catch(async (e: unknown) => {
|
||||
// 이미 옮긴 호표 · 블록 — 물음 한 번 뒤 덮음(아니면 그만)
|
||||
if (!(e instanceof StaleError) || !(await askConfirm({ message: e.message }))) throw e;
|
||||
return moveJudge(opt.target, opt.key, { ...body, 덮음: true });
|
||||
});
|
||||
const stuck = new Set(r.막힘.map((x) => `${x.호표 ?? "b" + x.블록}:${x.차례 ?? ""}`));
|
||||
let d = opt.state.get();
|
||||
for (const [h, lines] of Object.entries(body.호표))
|
||||
for (const at of Object.keys(lines))
|
||||
if (!stuck.has(`${h}:${at}`)) {
|
||||
const { [at]: _gone, ...rest } = d.호표[h] ?? {};
|
||||
d = { ...d, 호표: { ...d.호표, [h]: rest } };
|
||||
}
|
||||
for (const b of Object.keys(body.블록))
|
||||
if (!stuck.has(`b${b}:`)) {
|
||||
const { [b]: _gone, ...rest } = d.블록;
|
||||
d = { ...d, 블록: rest };
|
||||
}
|
||||
opt.state.set(d);
|
||||
const msg = L("M02_JudgeMoved")
|
||||
.replace("{a}", String(r.만듦.length))
|
||||
.replace("{b}", String(r.단가산출.length))
|
||||
.replace("{c}", String(r.막힘.length + 막힘.length));
|
||||
showToast(msg, r.막힘.length + 막힘.length ? "warning" : "success");
|
||||
opt.onMoved?.(r);
|
||||
} catch (e) {
|
||||
showToast((e as Error).message, "error");
|
||||
} finally {
|
||||
count();
|
||||
}
|
||||
const r = await runMove(opt, body, 막힘);
|
||||
count();
|
||||
if (r) opt.onMoved?.(r);
|
||||
}
|
||||
|
||||
const off = opt.state.on(count);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 오른쪽 = 호표 목록(제N호표 [수정] · 찾기 · 제안 n/m — 다 제안이면 녹색 · 뒤 셈 중이면 3 초마다 다시) ·
|
||||
* 단가산출표 · 상세 탭이면 단가산출 목록으로 바뀜(780-2 · `Hopyo_SheetSk`).
|
||||
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 머리 행이면 영역 고름
|
||||
* · [수정] = 시트 아래 줄 판(785-6 `Hopyo_Rows` · 줄 누름과 따로) · 단가산출 탭 블록 = 같은 자리 블록 판 ·
|
||||
* · [수정] = 모달 「확인 — 제N호표」(786-2 `Hopyo_Check` · 줄 누름과 따로) · 단가산출 탭 블록 = 시트 아래 블록 판 ·
|
||||
* 오른쪽 판 아래 [옮기기](확인한 줄 · 블록 · 고름 캐시 `createJudgeState` 한 벌)
|
||||
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록은 됨
|
||||
* · `buildHopyoSheet` = 몸(머리 · 띠 · 시트 + 목록) · 옛 [저장] · [모두 고정으로 들여오기] · 모달 껍데기는 785-7 에서 지움
|
||||
@@ -28,11 +28,8 @@ import {
|
||||
type HopyoView,
|
||||
type HopyoScope,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import {
|
||||
createJudgeMove,
|
||||
createJudgeRows,
|
||||
createJudgeState,
|
||||
} from "./M02_MasterTemplete_Hopyo_Rows";
|
||||
import { openHopyoCheck } from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import { createJudgeMove, createJudgeState } from "./M02_MasterTemplete_Hopyo_Rows";
|
||||
import { lineOf } from "./logic/M02_MasterTemplete_Hopyo_Picks";
|
||||
import * as Nav from "./M02_MasterTemplete_Hopyo_SheetNav";
|
||||
import { createSheetSk, type SheetSk } from "./M02_MasterTemplete_Hopyo_SheetSk";
|
||||
@@ -72,7 +69,7 @@ export interface HopyoSheetBody {
|
||||
destroy: () => void;
|
||||
/** 안 옮긴 단가산출 블록 수(판정 전 null) */
|
||||
skCount: () => { 블록: number; 밖: number; 줄: number } | null;
|
||||
/** 그 단가산출 블록으로 뜀 + 줄 판 엶 */
|
||||
/** 그 단가산출 블록으로 뜀 + 블록 판 엶 */
|
||||
showSk: (번: number) => void;
|
||||
}
|
||||
|
||||
@@ -133,7 +130,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
const items = new Map<number, HTMLElement>();
|
||||
const target = opt.target ?? opt.scope;
|
||||
const state = createJudgeState(opt.scope, opt.key); // 판정 고름 캐시(785-6)
|
||||
let panel: { destroy: () => void } | null = null; // 시트 아래 줄 판 몸
|
||||
let panel: { destroy: () => void } | null = null; // 시트 아래 블록 판 몸
|
||||
const offPaint = state.on(() => nav?.repaint()); // 확인 바뀜 = 다시 칠
|
||||
|
||||
const head = el("div", {
|
||||
@@ -152,7 +149,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
const lines = el("div", {
|
||||
className: "m02-hopyo-sheet__lines",
|
||||
attrs: { "data-hopyo-lines": "" },
|
||||
}); // 시트 아래 줄 판 자리
|
||||
}); // 시트 아래 블록 판 자리(단가산출 탭 · SheetSk)
|
||||
lines.hidden = true;
|
||||
const closeLines = (): void => {
|
||||
panel?.destroy();
|
||||
@@ -242,7 +239,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi);
|
||||
}
|
||||
|
||||
/** [수정] — 그 호표 자리 + 시트 아래 줄 판(줄 누름과 따로) */
|
||||
/** [수정] — 그 호표 자리 + 모달 「확인 — 제N호표」(786-2 · 줄 누름과 따로) */
|
||||
const editButton = (h: HopyoBrief): HTMLElement => {
|
||||
const b = createButton({
|
||||
label: "수정",
|
||||
@@ -251,19 +248,18 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
focus(h.번);
|
||||
openLines((host) =>
|
||||
createJudgeRows({
|
||||
host,
|
||||
file: opt.scope,
|
||||
key: opt.key,
|
||||
target,
|
||||
state,
|
||||
no: h.번,
|
||||
readOnly: !opt.canWrite,
|
||||
onClose: closeLines,
|
||||
onSheet: (r, role) => nav?.jumpTo(role === "일위대가표" ? ilwi : detail, r),
|
||||
}),
|
||||
);
|
||||
openHopyoCheck({
|
||||
file: opt.scope,
|
||||
key: opt.key,
|
||||
target,
|
||||
state,
|
||||
no: h.번,
|
||||
brief: h,
|
||||
canWrite: opt.canWrite,
|
||||
onSheet: (r, role) => nav?.jumpTo(role === "일위대가표" ? ilwi : detail, r),
|
||||
onHopyo: () => focus(h.번),
|
||||
onMoved: () => void refresh(),
|
||||
});
|
||||
},
|
||||
});
|
||||
b.setAttribute("data-hopyo-edit", String(h.번));
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_ToItem.ts
|
||||
* [구조물 조합으로](PLAN 786-2 · 옛 704-4 꼴 되살림) — [수정] 모달에서 이 호표가 옮겨진 일위대가(SC)를 담은
|
||||
* 구조물 조합 항목을 만듦. 창 = 항목 이름(기본 = 호표 이름 · 규격) · [만들기].
|
||||
* 길 = 구조물 조합 나무 항목 더하기(`editTree` add-item · 「실무자료」 폴더) → 그 항목 일위대가 문서에 SC 참조 한 줄
|
||||
* (`readTemplate` · `saveTemplate` — 서버가 정리 · 셈) · 구조물 조합 공용 파일 · 길은 부르기만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { editTree, fetchTree, readTemplate, saveTemplate } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
|
||||
/** 만든 항목이 놓이는 폴더 */
|
||||
export const ITEM_PATH = ["실무자료"];
|
||||
|
||||
export interface ToItemOptions {
|
||||
/** 만들 층(일위대가 SC 가 있는 층) */
|
||||
target: HopyoScope;
|
||||
/** 이 호표가 옮겨진 일위대가 키(SC-…) */
|
||||
sc: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
/** 항목 더하기 → 일위대가 문서에 SC 참조 한 줄 → 새 열 id */
|
||||
export async function makeItem(o: ToItemOptions, label: string): Promise<string> {
|
||||
const { layer, owner } = o.target;
|
||||
const tree = await fetchTree(layer, owner);
|
||||
const made = await editTree(layer, { op: "add-item", 경로: ITEM_PATH, 이름: label, 단위: o.unit }, tree.판, owner); // prettier-ignore
|
||||
const col = [made.알림?.열].flat()[0];
|
||||
if (!col) throw new Error(L("M02_CheckToItemFail"));
|
||||
const now = await readTemplate(layer, "labor", col, null, owner);
|
||||
const doc = (now.문서 ?? {}) as Record<string, unknown>;
|
||||
const line = {
|
||||
참조: { 층: layer, 주인: owner, 키: o.sc },
|
||||
별칭: null,
|
||||
메모: null,
|
||||
수량: { 손: 1 },
|
||||
};
|
||||
await saveTemplate(layer, "labor", col, null, now.판 ?? "", { ...doc, 담은로직: [line], 단위: o.unit || doc.단위 || null }, owner); // prettier-ignore
|
||||
return col;
|
||||
}
|
||||
|
||||
export function toItem(o: ToItemOptions): void {
|
||||
const name = createInputField({ label: L("M02_CheckItemName"), value: o.name });
|
||||
openModal({
|
||||
title: `${L("M02_CheckToItem")} — ${o.sc}`,
|
||||
closeLabel: L("Common_Btn_Cancel"),
|
||||
width: "40rem",
|
||||
mount: (body, close, actions) => {
|
||||
const go = createButton({
|
||||
label: L("M02_CheckItemMake"),
|
||||
onClick: async () => {
|
||||
const label = name.input.value.trim();
|
||||
if (!label) return name.setError(L("M02_CheckItemEmpty"));
|
||||
go.disabled = true;
|
||||
try {
|
||||
await makeItem(o, label);
|
||||
close();
|
||||
showToast(L("M02_CheckItemMade").replace("{name}", label).replace("{path}", ITEM_PATH.join(" › ")), "success", 6000); // prettier-ignore
|
||||
} catch (e) {
|
||||
showToast(`${L("M02_CheckToItemFail")} — ${e instanceof Error ? e.message : String(e)}`, "error"); // prettier-ignore
|
||||
} finally {
|
||||
go.disabled = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
go.dataset.hopyoItemMake = "";
|
||||
actions.append(go);
|
||||
body.append(createText(L("M02_CheckItemHint"), "muted"), name.root);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -262,10 +262,10 @@ def test_블록칠_새판정(monkeypatch):
|
||||
|
||||
|
||||
def test_엑셀보기_이음_가운데페이지없음():
|
||||
"""줄 판 = 시트 아래 `[data-hopyo-lines]` · [옮기기] = 오른쪽 판 아래 · 단가산출 목록 = 새 블록 판정."""
|
||||
"""[수정] = 모달 「확인 — 제N호표」(786-2) · [옮기기] = 오른쪽 판 아래 · 단가산출 목록 = 새 블록 판정."""
|
||||
assert not (M02 / "M02_MasterTemplete_Hopyo_Wizard.ts").exists()
|
||||
sheet = (M02 / "M02_MasterTemplete_Hopyo_Sheet.ts").read_text(encoding="utf-8")
|
||||
assert "createJudgeRows({" in sheet and "createJudgeMove({" in sheet
|
||||
assert "openHopyoCheck({" in sheet and "createJudgeMove({" in sheet
|
||||
right = sheet.split('className: "m02-hopyo-sheet__side"')[1].split("});")[0]
|
||||
assert "move.root" in right
|
||||
sk = (M02 / "M02_MasterTemplete_Hopyo_SheetSk.ts").read_text(encoding="utf-8")
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""786-2 불러오기 [수정] 모달 지킴 — 「확인 — 제N호표」 옛 꼴 되살림 + 새 기능 「새」 표지
|
||||
(요구 644 · 645).
|
||||
|
||||
- 우리 금액 길(`/hopyo/{키}/price` · 저장 없음) — 옮기기와 같은 길로 한 줄 문서 →
|
||||
`Labor_Doc.calc`: 예시 1 야면석소운반(단산10 · GC001140 · 입력 필요 0 · 엑셀 1 단위 94,227) ·
|
||||
예시 3 합판거푸집(GC000211 · 높이감률 · 구멍땜 입력 필요 → 0 넣으면 노무 38,291) ·
|
||||
쓰기 0(SC · SK 수 그대로)
|
||||
- 화면 소스 — [수정] = 모달(`openHopyoCheck` · 부품 상자 모달) · 시트 아래 호표 줄 판 몸 없음 ·
|
||||
「새」 배지 · 구조물 조합 공용 부품(`Labor_Rows` · `Labor_MatMarks` · `Labor_Input` ·
|
||||
`Labor_Pick`)은 import 만 · 날 button · input · select 0 · 700 줄
|
||||
임시 storage(`client` 마당) · M01 원본 그대로.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Judge as judge
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
# ruff: noqa: F811
|
||||
from resources.tester.test_m02_hopyo_excel import _post, client, master_hash # noqa: F401
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
API = "/api/m02/layers/personal"
|
||||
KEY = "HP-000001"
|
||||
MINE = ("Check", "CheckRows", "ToItem", "Rows")
|
||||
SHARED = ("Labor_Rows", "Labor_MatMarks", "Labor_Input", "Labor_Pick", "Labor_Detail", "Structure")
|
||||
|
||||
|
||||
def _src(name: str) -> str:
|
||||
return (M02 / f"M02_MasterTemplete_{name}.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _at(unit, no):
|
||||
return next(r for r in unit["줄"] if r.get("번") == no and "단가산출" in r["갈래"])
|
||||
|
||||
|
||||
def _base(row):
|
||||
part = row["풀이"]["절"][0]
|
||||
cand = next(c for c in part["후보"] if c["키"] == part["고름"])
|
||||
return {"갈래": "기초", "키": cand["키"], "입력값": cand["바인딩"]["입력값"]}
|
||||
|
||||
|
||||
def _count(kind):
|
||||
folder = layers.personal_dir(7, 42) / kind
|
||||
return len(list(folder.glob("*.json"))) if folder.exists() else 0
|
||||
|
||||
|
||||
def test_우리금액_옮기기와_같은_길_저장없음(client):
|
||||
before = master_hash()
|
||||
assert _post(client).status_code == 200
|
||||
judge.solve_all(layers.personal_dir(7, 42), KEY)
|
||||
made = (_count("precombo"), _count("sangeun"))
|
||||
url = f"{API}/hopyo/{KEY}/price"
|
||||
|
||||
two = client.get(f"{API}/hopyo-judge/{KEY}/2").json()
|
||||
ten = _at(two, 10) # 예시 1 야면석소운반
|
||||
got = client.post(
|
||||
url, json={"층": "personal", "호표": 2, "줄": {str(ten["차례"]): [_base(ten)]}}
|
||||
)
|
||||
assert got.status_code == 200, got.text
|
||||
line = got.json()["줄"][str(ten["차례"])]
|
||||
assert line["까닭"] == [] and line["칸"] == []
|
||||
assert int(line["금액"]["계"] / ten["수량"]["엑셀"]) == 94227 == ten["단가"]["계"]
|
||||
|
||||
one = client.get(f"{API}/hopyo-judge/{KEY}/1").json()
|
||||
form = _at(one, 24) # 예시 3 합판거푸집
|
||||
ex3 = _base(form)
|
||||
need = client.post(url, json={"층": "personal", "호표": 1, "줄": {str(form["차례"]): [ex3]}})
|
||||
stuck = need.json()["줄"][str(form["차례"])]
|
||||
assert stuck["금액"] is None and stuck["칸"] == ["높이감률", "구멍땜"]
|
||||
ex3["입력값"] = {**ex3["입력값"], "높이감률": 0, "구멍땜": 0}
|
||||
ok = client.post(url, json={"층": "personal", "호표": 1, "줄": {str(form["차례"]): [ex3]}})
|
||||
money = ok.json()["줄"][str(form["차례"])]["금액"]
|
||||
assert abs(money["노무비"] / form["수량"]["엑셀"] - 38291) <= 2 # 엑셀 노무 38,291(줄 0.1 원 끝수)
|
||||
|
||||
bad = client.post(
|
||||
url, json={"층": "personal", "호표": 2, "줄": {"0": [{**_base(ten), "금액": 1}]}}
|
||||
)
|
||||
assert bad.status_code == 422 # 금액 칸 = 서버가 셈
|
||||
assert (_count("precombo"), _count("sangeun")) == made # 쓰기 0
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
def test_수정은_모달_줄판몸_없음():
|
||||
sheet = _src("Hopyo_Sheet")
|
||||
edit = sheet.split("const editButton")[1].split("function paintList")[0]
|
||||
assert "openHopyoCheck({" in edit and "openLines" not in edit
|
||||
assert "createJudgeRows" not in sheet + _src("Hopyo_Rows")
|
||||
check = _src("Hopyo_Check")
|
||||
assert 'from "@ui/ui_template_modal"' in check and "openModal({" in check
|
||||
assert 'L("M02_CheckTitle")' in check and "confirmSuggested(" in check # [호표 제안 모두 확인]
|
||||
assert "runMove(" in check and "[String(no)]: mine" in check # [사전조합 생성] = 이 호표만
|
||||
assert "h.만든" in check.split("const item = ")[1].split(";")[0] # 옮겨진 뒤만
|
||||
locale = (ROOT / "ui_template/ui_template_locale_m2_judge.ts").read_text(encoding="utf-8")
|
||||
assert 'M02_CheckTitle: ["확인 — 제{n}호표"' in locale and 'M02_CheckNew: ["새"' in locale
|
||||
|
||||
|
||||
def test_새_배지_옛_표_꼴():
|
||||
rows = _src("Hopyo_CheckRows")
|
||||
assert "createBadge(" in rows and "newBadge()" in rows and "dataset.checkNew" in rows
|
||||
assert "newBadge()" in _src("Hopyo_Check")
|
||||
for part in ("moneyCell(", "PRICE_SLOTS", "markCells(", "markHeads()", '"logic"', '"mat"'):
|
||||
assert part in rows, part
|
||||
assert "M02_CheckApart" in _src("Hopyo_Rows") # 별도계상 줄 = 고름 칩 없이 글
|
||||
|
||||
|
||||
def test_공용파일_import_만_부품상자만_700줄():
|
||||
texts = {n: _src(f"Hopyo_{n}") for n in MINE}
|
||||
joined = "\n".join(texts.values())
|
||||
for name in ("Labor_Rows", "Labor_MatMarks", "Labor_Input", "Labor_Pick"):
|
||||
assert f'from "./M02_MasterTemplete_{name}"' in joined, name
|
||||
for name in SHARED: # 공용 쪽은 새 모달을 모름(거꾸로 이음 없음)
|
||||
assert "Hopyo_Check" not in _src(name) and "Hopyo_ToItem" not in _src(name), name
|
||||
for name, text in texts.items():
|
||||
assert len(text.splitlines()) <= 700, name
|
||||
assert not re.search(r"""\bel\(\s*["'](button|input|select)["']""", text), name
|
||||
assert not re.search(r"""createElement\(\s*["'](button|input|select)["']""", text), name
|
||||
assert "<button" not in text and "<input" not in text, name
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,6}\b|rgba?\(", text), name
|
||||
assert len(_src("Hopyo_Sheet").splitlines()) <= 402 # 늘리지 않음
|
||||
@@ -55,4 +55,50 @@ export const ui_locales_m2_judge = {
|
||||
"Amounts are reference — verdict is by quantity",
|
||||
],
|
||||
M02_JudgeOutside: ["일위대가 밖", "Not called by items"],
|
||||
M02_CheckTitle: ["확인 — 제{n}호표", "Check — item {n}"],
|
||||
M02_CheckNew: ["새", "New"],
|
||||
M02_CheckNewHint: ["「새」 = 785 에서 더한 기능", "「New」 = added in 785"],
|
||||
M02_CheckApart: ["별도계상 · 품목은 프로젝트에서", "Separate · item chosen in project"],
|
||||
M02_CheckExcelPrice: ["엑셀 단가", "Excel unit price"],
|
||||
M02_CheckMoneyHint: [
|
||||
"금액 칸 위 = 엑셀 · 아래 = 우리(미리 셈 · 엑셀 1 단위)",
|
||||
"Amount top = Excel · bottom = ours (preview · per Excel unit)",
|
||||
],
|
||||
M02_CheckCount: ["제안 {a} / 줄 {m} · 확인 {c}", "Suggested {a} / lines {m} · confirmed {c}"],
|
||||
M02_CheckMake: ["일위대가 생성", "Create unit-cost item"],
|
||||
M02_CheckMakeAsk: [
|
||||
"확인한 줄 {a} · 고정 줄 {b}(엑셀 숫자 그대로) — 일위대가 생성",
|
||||
"Confirmed {a} · fixed {b} (Excel values) — create item",
|
||||
],
|
||||
M02_CheckToItem: ["구조물 조합으로", "To structure combo"],
|
||||
M02_CheckToItemHint: ["일위대가 생성 뒤 켜짐", "Enabled after the item is created"],
|
||||
M02_CheckToItemFail: ["구조물 조합 못 만듦", "Could not create structure item"],
|
||||
M02_CheckItemName: ["구조물 조합 이름", "Structure item name"],
|
||||
M02_CheckItemMake: ["만들기", "Create"],
|
||||
M02_CheckItemEmpty: ["이름이 빔", "Name is empty"],
|
||||
M02_CheckItemHint: [
|
||||
"옮긴 일위대가를 담은 구조물 조합 항목을 「실무자료」 폴더에 만듦",
|
||||
"Creates a structure item holding the moved unit-cost item in 「실무자료」",
|
||||
],
|
||||
M02_CheckItemMade: [
|
||||
"구조물 조합 「{name}」 만듦 — {path}",
|
||||
"Structure item 「{name}」 created — {path}",
|
||||
],
|
||||
M02_CheckSheet: ["엑셀 보기", "View Excel"],
|
||||
M02_CheckPick: ["고르기", "Pick"],
|
||||
M02_CheckSource: ["원문 보기", "Source"],
|
||||
M02_CheckSourceExcel: ["엑셀 인용 조항", "Excel cited clauses"],
|
||||
M02_CheckSourceOurs: ["우리 로직 출처", "Our logic source"],
|
||||
M02_CheckNone: ["없음", "None"],
|
||||
M02_CheckOption: ["옵션", "Options"],
|
||||
M02_CheckAdd: ["추가", "Add"],
|
||||
M02_CheckNoLogic: ["로직없음", "No logic"],
|
||||
M02_CheckNoLogicHint: [
|
||||
"로직없음 = 고정 줄(엑셀 숫자 그대로) · 추가 = 제안으로",
|
||||
"No logic = fixed line (Excel values) · Add = back to suggestion",
|
||||
],
|
||||
M02_CheckCompare: ["품 비교", "Qty compare"],
|
||||
M02_CheckMat: ["자재", "Materials"],
|
||||
M02_CheckNoCalc: ["셈 못 함", "Not computed"],
|
||||
M02_CheckNoLines: ["구성 줄 없음", "No lines"],
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user