Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-10 00:15:09 +09:00
10 changed files with 395 additions and 56 deletions
+57 -11
View File
@@ -37,43 +37,89 @@
color: var(--color-danger);
}
/* 카드 셋(도면 · 일위대가 · 면적산출) = 한 줄 세 칸 · 도면을 조금 넓게 · 높이 = 큰 창에 맞춤(156-4) */
/* 카드 셋(도면 · 일위대가 · 면적산출) = 한 줄 세 칸 · 일위대가 · 면적산출 넓게(왼쪽) · 도면 맨 오른쪽(163-2) ·
높이 = 큰 창에 맞춤(156-4) */
.b08-sheets .m02-struct {
--m02-struct-card-h: max(480px, calc(94vh - 14rem));
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr) minmax(0, 2fr);
grid-template-columns: minmax(0, 3fr) minmax(0, 3fr) minmax(0, 2fr);
gap: var(--spacing-16);
}
.b08-sheets .m02-struct > :first-child {
order: 3;
}
@media (max-width: 960px) {
.b08-sheets .m02-struct {
grid-template-columns: minmax(0, 1fr);
}
}
/* 「조합 연결」 칸(154-2) — 시설 이름 · 칸 줄(이름 · 연결 여부 · [선택]) */
/* 카드 [수정] → 사본 편집 화면(163-3) — 모달 몸통을 채움 · 넣을 값이면 안내 한 줄 위 */
.b08-sheets .ui-item-picker__edit-host {
flex-direction: column;
gap: var(--spacing-8);
}
/* 「조합 연결」 칸(154-2 · 163-1) — 시설마다 한 줄(제목 + 칸 칩 · 넘치면 다음 줄) */
.b08-link__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.b08-link__facility {
margin: 0;
font-weight: var(--font-weight-semibold);
color: var(--color-text);
}
.b08-link__slot {
display: grid;
grid-template-columns: minmax(120px, 200px) 1fr auto;
.b08-link__chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
padding-left: var(--spacing-16);
max-width: 100%;
padding: 2px var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
}
.b08-link__buttons {
display: flex;
gap: var(--spacing-8);
.b08-link__chip.is-missing {
border-color: var(--color-danger);
}
.b08-link__state {
overflow: hidden;
max-width: 18rem;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--color-text-secondary);
}
.b08-link__state.is-missing {
color: var(--color-danger);
}
.b08-link__badges,
.b08-link__buttons {
display: inline-flex;
gap: var(--spacing-4);
}
.b08-link__badges:empty {
display: none;
}
/* 배지(163-4) — 고침 있음 · 마스터가 바뀜(경고) */
.b08-link__badge {
padding: 0 var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-caption);
color: var(--color-text);
}
.b08-link__badge.is-warn {
border-color: var(--color-warning);
font-weight: var(--font-weight-semibold);
}
+73 -8
View File
@@ -4,7 +4,10 @@
* 조합 한 개 = M02 항목 카드(`M02_MasterTemplete_ItemCards`) 그대로 — 도면(도각 포함 · 보기 전용 CAD ·
* 여러 장이면 장 탭) · 일위대가 · 면적산출 = 공용 카드 셋을 한 줄 세 칸(156-4 · 창 = 공용 wide).
*
* 원천 = 그 구조물의 프로젝트 사본만(연결된 사본 · PLAN 143-1) — 없으면 「사본 없음」 · 읽기만
* 원천 = 그 구조물의 프로젝트 사본만(연결된 사본 · PLAN 143-1) — 없으면 「사본 없음」
* 카드 [수정](163-3) = 모달 몸통이 사본 전용 편집 화면(고르기 모달과 같은 `createEditView`)으로 바뀜 ·
* 도면 · 면적산출 = 그 부분 · 일위대가 카드 = 넣을 값만(담은로직 · 자재는 원본 층에서 · 서버 400 · 142-1) ·
* [뒤로] = 카드 다시 읽음 · 저장했으면 닫을 때 집계표 다시 셈 · 판 다르면 서버 409 그대로
* 조합 목록 = 집계표와 같음(`B08_Quantity_Summary_Data` `combosAt`)
* 시설이 있으면 맨 위 「조합 연결」 칸(`B08_Quantity_UI_Link` · PLAN 154-2 · 156-1) — 저장 · 끊기면 닫고 다시 셈
* 카드 · CAD · 스프레드시트는 모달을 열 때만 `import()` · 닫을 때 카드를 모두 정리
@@ -13,10 +16,12 @@
import "./B08_Quantity_UI_Drawing.css";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { showToast } from "@ui/ui_template_elements";
import { PART_OF, projectItems, withCopyValues } from "@ui/ui_template_item_picker_project";
import type { EditView } from "@ui/ui_template_item_picker_edit";
import type { LaborDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model";
import type { ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards";
import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards";
import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource";
import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item";
import type {
@@ -79,12 +84,21 @@ function sourceOf(projectId: string, folder: string): ItemSource {
};
}
/** 카드 [수정] — 사본 · 고칠 부분(일위대가 카드 = 넣을 값) · 카드 다시 읽기 */
type OpenEdit = (
copy: LinkedSnapshot,
name: string,
kind: EditKind | "inputs",
reload: () => void,
) => void;
/** 조합 한 칸 — 제목줄(이름 · 원천) + 안내 + 카드. */
function comboSection(
request: SheetsRequest,
combo: StationCombo,
createItemCards: typeof import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards").createItemCards,
handles: ItemCardsHandle[],
openEdit: OpenEdit,
): HTMLElement {
const label = request.describe(combo.item);
const name = label
@@ -109,16 +123,18 @@ function comboSection(
from.textContent = L("B08_Quantity_Sheets_FromCopy");
title.append(from);
if (!copy.부분.includes("도면")) section.append(note(L("B08_Quantity_Drawing_NoDrawing")));
const load = (): void => void cards.load({ id: copy.폴더, label: name });
// 카드 안 [저장]은 없음(자료 자리에 write 없음) — [수정]만 사본 편집 화면으로(163-3)
const cards = createItemCards({
source: () => source,
editable: () => false,
onEdit: () => undefined,
editable: () => true,
onEdit: (kind) => openEdit(copy, name, kind, load),
warnOnFail: () => false, // 못 읽으면 카드에 「없음」
labor: () => undefined, // 일위대가 카드(161-1) — 보기 전용이라 [수정] 숨김(editable false)
labor: () => openEdit(copy, name, "inputs", load), // 일위대가 카드(161-1) [수정] = 넣을 값만
});
handles.push(cards);
section.append(cards.root);
void cards.load({ id: copy.폴더, label: name });
load();
return section;
}
@@ -126,17 +142,66 @@ function comboSection(
export function openStructureSheets(request: SheetsRequest): void {
const handles: ItemCardsHandle[] = [];
let closed = false;
let edited = false; // 사본을 고쳐 저장함 — 닫을 때 집계표 다시 셈(163-3)
let editView: EditView | null = null;
let modalBody: HTMLElement | null = null;
/** 카드 [수정] — 몸통을 사본 편집 화면으로 · [뒤로] = 몸통 되돌리고 그 카드 다시 읽음 */
const openEdit: OpenEdit = (copy, name, kind, reload) => {
const body = modalBody;
if (!body || editView) return;
void (async () => {
try {
const [{ createEditView }] = await Promise.all([
import("@ui/ui_template_item_picker_edit"),
import("@ui/ui_template_item_picker.css"),
]);
if (closed) return;
const kept = [...body.children];
const host = document.createElement("div");
host.className = "ui-item-picker__edit-host";
const view = createEditView(
{
kind,
row: { key: copy.항목.key, 열: copy.항목.key, 경로: [name] },
info: { 폴더: copy.폴더, 원본: copy.항목 },
items: projectItems(request.projectId),
projectId: request.projectId,
},
(saved) => {
view.destroy();
editView = null;
body.replaceChildren(...kept);
edited ||= saved;
if (saved) reload();
},
);
editView = view;
host.append(
...(kind === "inputs" ? [note(L("B08_Quantity_Sheets_InputsOnly"))] : []),
view.root,
);
body.replaceChildren(host);
view.start();
} catch (error) {
showToast(`${L("B08_Quantity_Drawing_Failed")} — ${String(error)}`, "error");
}
})();
};
openModal({
title: `${request.station} ${L("B08_Quantity_Sheets_Title")}`,
closeLabel: L("Common_Btn_Close"),
wide: true, // 공용 큰 창(96vw × 94vh · 156-4)
dialogClass: "b08-sheets",
beforeClose: () => {
beforeClose: async () => {
if (editView && !(await editView.leave())) return false; // 저장 안 한 고침 — 버릴지 물음
editView?.destroy();
closed = true; // 아직 불러오는 중이면 뒤늦게 카드를 만들지 않음
handles.forEach((cards) => cards.destroy());
if (edited) request.onLinked(); // 사본을 고쳤으면 집계표 다시 셈(연결 저장과 같은 꼴)
return true;
},
mount: (body, close) => {
modalBody = body;
const linking = request.facilities.length
? [
linkSection(request.projectId, request.facilities, () => {
@@ -161,7 +226,7 @@ export function openStructureSheets(request: SheetsRequest): void {
body.replaceChildren(
...linking,
...request.combos.map((combo) =>
comboSection(request, combo, createItemCards, handles),
comboSection(request, combo, createItemCards, handles, openEdit),
),
);
} catch (error) {
+109 -24
View File
@@ -1,17 +1,25 @@
/* =============================================================================
* B08_Quantity_UI_Link.ts
* 구조물도 모달 맨 위 「조합 연결」 칸(PLAN 154-2 · 156) — 그 측점 시설의 칸 전부 + 연결 여부.
* 구조물도 모달 맨 위 「조합 연결」 칸(PLAN 154-2 · 156 · 163) — 그 측점 시설의 칸 전부 + 연결 여부.
*
* 꼴(163-1) = 시설마다 한 줄(제목 + 칸 칩 · flex-wrap) · 공용 접기(모두 연결이면 접힘 · 미연결 있으면 펼침)
* [선택] → 공용 고르기 모달(`openItemPicker` · 누를 때 `import()`) → 바로 저장
* = PUT …/items/{사본 id}/link(그 칸 하나만 정본에 · 서버가 사본 맞춤) → 집계표 다시 셈
* [연결 끊기](연결된 칸만) → 확인 창 → 같은 길에 null(그 칸만 비움 · 사본 폴더 그대로 · 156-1)
* 단추 = 모달 머리 [닫기]와 같은 양식(ghost · 기본 크기 · 156-3)
* [업데이트](연결된 칸만 · 163-4) = 칩 그린 뒤 `list(사본 폴더)` 로 배지(마스터가 바뀜 · 고침 있음 ·
* 원본이 없어짐) · 마스터가 바뀜일 때만 켬 → 확인 창 → 고르기 모달 [재적용]과 같은 길(`reapply`)
* 단추 = 작은 ghost(칩 안 · 163-1)
* B05/B06 세션 초안(구조물 · 관)이 있으면 막고 알림 — 그쪽 [저장]이 목록 통째로 덮어 연결이 사라짐
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { projectItems } from "@ui/ui_template_item_picker_project";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
projectItems,
structureFolder,
type ProjectItemInfo,
} from "@ui/ui_template_item_picker_project";
import { readPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import type { SummaryItem } from "../common_util/common_util_summary_item";
@@ -74,57 +82,134 @@ async function unlink(projectId: string, facility: UnlinkedFacility, slot: Unlin
}
}
/** 「조합 연결」 칸 — 시설마다 이름 + 칸 줄(이름 · 연결 여부 · [선택]). `onLinked` = 저장 뒤(모달 닫고 다시 셈). */
/** [업데이트] — 사본을 원본으로 덮음(입력값 · 고른자재는 같은 키면 서버가 살림) · 했으면 true. */
async function update(projectId: string, info: ProjectItemInfo): Promise<boolean> {
const ask =
L("B08_Quantity_Link_UpdateAsk") + (info.고침 ? ` ${L("B08_Quantity_Link_UpdateEdited")}` : "");
if (!(await showConfirmDialog(ask, L("B08_Quantity_Link_Update")))) return false;
try {
const got = await projectItems(projectId).reapply(info);
showToast(
got.covered.length
? `${L("B08_Quantity_Link_UpdateDone")} — ${got.covered.join(" · ")}`
: L("B08_Quantity_Link_UpdateNone"),
"success",
);
return true;
} catch (error) {
showToast(`${L("B08_Quantity_Link_UpdateFailed")} — ${String(error)}`, "error");
return false;
}
}
/** 칩 배지 · [업데이트] — 사본 목록(원본판 대조)을 뒤에서 받아 칠함 · 못 받으면 단추 꺼진 채. */
async function paintCopyState(
projectId: string,
slot: UnlinkedSlot,
badges: HTMLElement,
button: HTMLButtonElement,
): Promise<ProjectItemInfo | null> {
const folder = structureFolder(slot.copy);
const info = await projectItems(projectId)
.list(folder)
.then((rows) => rows.find((row) => row.폴더 === folder) ?? null)
.catch(() => null);
if (!info) return null;
const badge = (key: keyof typeof ui_locales, warn: boolean): HTMLElement => {
const node = document.createElement("span");
node.className = warn ? "b08-link__badge is-warn" : "b08-link__badge";
node.textContent = L(key);
return node;
};
if (info.고침) badges.append(badge("B08_Quantity_Link_Edited", false));
if (info.원본없음) badges.append(badge("B08_Quantity_Link_Gone", true));
else if (info.마스터바뀜) badges.append(badge("B08_Quantity_Link_Changed", true));
button.disabled = !info.마스터바뀜 || !!info.원본없음;
button.title = button.disabled
? L("B08_Quantity_Link_UpdateSame")
: L("B08_Quantity_Link_UpdateTip");
return info;
}
/** 「조합 연결」 칸 — 시설마다 한 줄(제목 + 칸 칩) · 공용 접기. `onLinked` = 저장 뒤(모달 닫고 다시 셈). */
export function linkSection(
projectId: string,
facilities: readonly UnlinkedFacility[],
onLinked: () => void,
): HTMLElement {
const slots = facilities.flatMap((f) => f.slots);
const missing = slots.filter((slot) => !slot.linked).length;
const section = document.createElement("section");
section.className = "b08-sheets__combo b08-link";
// 공용 접기(163-1) — 모두 연결이면 접힘 · 미연결 있으면 펼침
section.className = `b08-sheets__combo b08-link ui-collapsible${missing ? "" : " is-collapsed"}`;
section.dataset.b08Link = "section";
const title = document.createElement("h4");
title.className = "b08-sheets__title";
title.textContent = L("B08_Quantity_Link_Title");
title.className = "b08-sheets__title ui-collapsible__title";
const count = document.createElement("span");
count.className = "b08-sheets__from";
count.textContent = `${L("B08_Quantity_Link_Linked")} ${slots.length - missing} / ${slots.length}`;
const label = document.createElement("span");
label.textContent = L("B08_Quantity_Link_Title");
label.append(" ", count);
title.append(label);
section.append(title);
attachCollapsible(section);
for (const facility of facilities) {
const head = document.createElement("p");
const line = document.createElement("div");
line.className = "b08-link__row";
const head = document.createElement("span");
head.className = "b08-link__facility";
head.textContent = facility.name;
section.append(head);
line.append(head);
for (const slot of facility.slots) {
const row = document.createElement("div");
row.className = "b08-link__slot";
row.dataset.b08LinkSlot = slot.copy;
const chip = document.createElement("span");
chip.className = slot.linked ? "b08-link__chip" : "b08-link__chip is-missing";
chip.dataset.b08LinkSlot = slot.copy;
const name = document.createElement("span");
name.className = "b08-link__name";
name.textContent = slot.group ?? L("B08_Quantity_Link_Body");
const state = document.createElement("span");
state.className = slot.linked ? "b08-link__state" : "b08-link__state is-missing";
state.textContent = slot.linked
? `${L("B08_Quantity_Link_Linked")} · ${slot.item?.name ?? ""}`
? (slot.item?.name ?? L("B08_Quantity_Link_Linked"))
: L("B08_Quantity_Summary_Unlinked");
state.title = state.textContent; // 칩이 좁으면 긴 조합 이름이 잘림
const badges = document.createElement("span");
badges.className = "b08-link__badges";
const buttons = document.createElement("span");
buttons.className = "b08-link__buttons";
const act = (label: string, run: typeof pickAndLink): HTMLButtonElement => {
const button = createButton({
const act = (label: string, run: () => Promise<boolean>): HTMLButtonElement =>
createButton({
label,
variant: "ghost", // 모달 [닫기]와 같은 양식(156-3)
variant: "ghost",
size: "sm", // 칩 안 작은 단추(163-1)
onClick: () => {
const was = [...buttons.querySelectorAll("button")].map((b) => b.disabled);
buttons.querySelectorAll("button").forEach((b) => (b.disabled = true));
void run(projectId, facility, slot).then((saved) =>
void run().then((saved) =>
saved
? onLinked()
: buttons.querySelectorAll("button").forEach((b) => (b.disabled = false)),
: buttons.querySelectorAll("button").forEach((b, i) => (b.disabled = was[i])),
);
},
});
return button;
};
buttons.append(act(L("B08_Quantity_Link_Pick"), pickAndLink));
if (slot.linked) buttons.append(act(L("B08_Quantity_Link_Unlink"), unlink));
row.append(name, state, buttons);
section.append(row);
buttons.append(
act(L("B08_Quantity_Link_Pick"), () => pickAndLink(projectId, facility, slot)),
);
if (slot.linked) {
buttons.append(act(L("B08_Quantity_Link_Unlink"), () => unlink(projectId, facility, slot)));
let info: ProjectItemInfo | null = null;
const updateButton = act(L("B08_Quantity_Link_Update"), () =>
info ? update(projectId, info) : Promise.resolve(false),
);
updateButton.disabled = true; // 원본판 대조를 받기 전엔 꺼 둠
buttons.append(updateButton);
void paintCopyState(projectId, slot, badges, updateButton).then((got) => (info = got));
}
chip.append(name, state, badges, buttons);
line.append(chip);
}
section.append(line);
}
return section;
}
+35 -3
View File
@@ -7,7 +7,8 @@
* 고칠 칸 = 단위수량 · ㎥당 배합 · 할증 % — 조작 = 캐시(`b08-materials`) + 짝 계산(`Material_Calc.ts`)으로 바로
* 총괄 모래 · 자갈 · 시멘트 할증 = 좌측 패널 값(PLAN 124 · 표에서 안 고침 · 패널 조작에 바로 다시 셈)
* [저장] = PUT …/quantity/materials → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음
* 값 없는 줄은 숨김(화면만 · 셈 · 열 · 단위수량은 그대로) · 표는 공용 카드 안(PLAN 143-3)
* 값 없는 줄은 숨김(화면만 · 셈 · 열 · 단위수량은 그대로) · 표는 공용 카드 안(PLAN 143-3) ·
* 콘크리트소요는 5줄 늘 보임(수량 빈칸 · 배합 늘 고침) + 콘크리트 전용 빈 까닭 한 줄(163-5)
* 콘크리트(기계) · 모르터 1:3 열 = 늘 보임 · 손 단위수량 → 콘크리트소요(160-4) · 엑셀 손값과 다른
* 배합 · 할증은 표 아래 알림 한 줄만 · 맞춤 없는 사본 자재 · 채집석 공제 = 총괄 끝 줄(160-3 · 5)
* ========================================================================== */
@@ -26,6 +27,7 @@ import {
blankUnitRows,
computeMaterialSheets,
DEDUCT_KEY,
matched,
MIX_FIELDS,
type Cells,
type MaterialOverrides,
@@ -189,9 +191,31 @@ function excelDiffs(s: MaterialSheets): string {
return out.length ? `2공구 엑셀 손값과 다름 — ${out.join(" · ")} · 지금 값 = 품셈 근거` : "";
}
/** 콘크리트소요 원천 = 콘크리트(기계) · 모르터 1:3 열 + 그 둘에 맞는 사본 자재 열(짝 계산 `matched` 그대로) */
const CONCRETE_SOURCES = ["concrete_machine", "mortar_1_3"];
/** 콘크리트소요가 다 빈칸일 때 까닭 한 줄(163-5) — 원천 단위수량이 빈 집계표 줄을 사본 자재 유무로 가름 */
function concreteWhy(s: MaterialSheets, source: SummarySource): string {
if (s.concrete.rows.some((row) => row.qty != null)) return "";
const keys = new Set(
s.quantity.materials
.filter(
(m) =>
CONCRETE_SOURCES.includes(m.key) ||
CONCRETE_SOURCES.includes(matched(m.name, m.spec, m.unit) ?? ""),
)
.map((m) => m.key),
);
const blank = s.quantity.rows.filter((row) => [...keys].every((k) => row.units[k] == null));
const copied = blank.filter((row) => source.snapshot?.units?.[row.id]).length;
return blank.length
? `콘크리트소요 빈 까닭 — 콘크리트(기계) · 모르터 1:3 단위수량 비었음 ${blank.length}줄(사본 자재에 콘크리트 · 모르터 없음 ${copied} · 사본 자재 없음 · 미연결 ${blank.length - copied}) — 자재수량 탭 그 열에 넣음`
: "";
}
function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
const { rows, total } = s.concrete;
const keep = shown(rows, (row) => row.qty != null || anyValue(row.amounts));
const keep = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침
const rowsOut: DataRow[] = keep.map((at, i) => {
const row = rows[at];
const out: DataRow = {
@@ -310,7 +334,10 @@ export async function renderMaterialSheet(
const toolbar = document.createElement("div");
toolbar.className = "b08-material__toolbar";
const notes = document.createElement("div");
notes.append(note(BASIS), note(`${HINTS[sheet]} · ${HIDDEN}`));
notes.append(
note(BASIS),
note(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`),
);
if (sheet !== "quantity") {
// 빈 까닭 한 줄(131) — 단위수량 빈 줄 · 서버 알림 · 빈 칸은 빈칸 그대로
const blank = blankUnitRows(sheets);
@@ -324,6 +351,9 @@ export async function renderMaterialSheet(
notes.append(reason);
}
}
const concreteNote = note(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시
concreteNote.dataset.b08MaterialConcreteWhy = "";
if (sheet === "concrete") notes.append(concreteNote);
toolbar.append(notes, save);
let built = BUILD[sheet](sheets, overrides);
@@ -358,6 +388,8 @@ export async function renderMaterialSheet(
built = BUILD[sheet](sheets, overrides);
table.setRows(built.rows, built.total);
excel.textContent = sheet === "quantity" ? "" : excelDiffs(sheets);
concreteNote.textContent = concreteWhy(sheets, payload.source);
concreteNote.hidden = !concreteNote.textContent;
};
redraw();
host.replaceChildren(createCard({ body: [toolbar, table.root, excel] }));
@@ -12,9 +12,10 @@ SUMMARY = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding=
def test_자재_셋_값_없는_줄_숨김():
assert MATERIAL.count("const keep = shown(rows, ") == 3 # 총괄 · 콘크리트 · 자재수량
assert (
MATERIAL.count("const keep = shown(rows, ") == 2
) # 총괄 · 자재수량(콘크리트 = 늘 · 163-5)
assert "row.qty != null || anyValue(row.units)" in MATERIAL # 손 단위수량 있으면 보임
assert "row.qty != null || anyValue(row.amounts)" in MATERIAL
assert "row.qty != null || row.amount != null" in MATERIAL
# 고친 칸 → 원래 줄(숨긴 줄 건너뜀)
for target in (
+6 -3
View File
@@ -90,11 +90,12 @@ def test_집계_모든_시설_칸_끊으면_미연결(root, monkeypatch):
def test_화면_연결된_칸만_끊기_닫기_양식():
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Link.ts").read_text(encoding="utf-8")
assert 'if (slot.linked) buttons.append(act(L("B08_Quantity_Link_Unlink"), unlink))' in ui
assert "if (slot.linked) {" in ui
assert 'act(L("B08_Quantity_Link_Unlink"), () => unlink(projectId, facility, slot))' in ui
assert ".link(slot.copy, facility.facility, null)" in ui
assert 'showConfirmDialog(L("B08_Quantity_Link_UnlinkAsk")' in ui
assert ui.count("hasPendingDesign(projectId)") == 2 # 선택 · 끊기 둘 다 막음
assert 'size: "sm"' not in ui # 모달 [닫기] = ghost · 기본 크기
assert 'variant: "ghost",\n size: "sm"' in ui # 칩 안 작은 ghost(163-1)
modal = (ROOT / "ui_template/ui_template_modal.ts").read_text(encoding="utf-8")
assert 'label: opts.closeLabel,\n variant: "ghost",\n onClick' in modal
page = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
@@ -108,5 +109,7 @@ def test_156_4_구조물도_모달_큰_창_세_칸():
assert "wide: true" in sheets
css = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.css").read_text(encoding="utf-8")
assert "min(1320px" not in css # 자기 폭 없음 — 공용 wide 그대로
assert "minmax(0, 3fr) minmax(0, 2fr) minmax(0, 2fr)" in css # 도면 · 일위대가 · 면적산출
# 163-2 — 일위대가 · 면적산출 넓게(왼쪽) · 도면 좁게 맨 오른쪽(order 3)
assert "minmax(0, 3fr) minmax(0, 3fr) minmax(0, 2fr)" in css
assert ".b08-sheets .m02-struct > :first-child {\n order: 3;" in css
assert ".b08-sheets__combo + .b08-sheets__combo" in css
@@ -11,8 +11,8 @@ def test_labor_handle_passed():
# createItemCards 에 labor 손잡이 — 없으면 카드 자체를 안 만듦(ItemCards `opts.labor ?`)
call = DRAWING[DRAWING.index("createItemCards({") :]
call = call[: call.index("});")]
assert "labor: () => undefined" in call
assert "editable: () => false" in call # [수정] 숨김
assert 'labor: () => openEdit(copy, name, "inputs", load)' in call # 163-3 넣을 값만
assert "editable: () => true" in call
assert "const laborCard = opts.labor" in CARDS
+77
View File
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""163 B08 구조물도 모달 — 연결 칩 · 접기(163-1) · 사본 고침(163-3) · [업데이트](163-4) · 콘크리트소요 줄(163-5).
화면 글 판정 — 서버 · 짝 계산(Material_Calc)은 안 고침 · 고르기 모달 편집 화면은 재사용만.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
LINK = (ROOT / "B08_Quantity/B08_Quantity_UI_Link.ts").read_text(encoding="utf-8")
DRAWING = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8")
CSS = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.css").read_text(encoding="utf-8")
MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8")
LOCALE = (ROOT / "ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8")
def test_163_1_칩_한_줄_공용_접기():
assert 'line.className = "b08-link__row"' in LINK # 시설마다 한 줄
assert '"b08-link__chip"' in LINK and "chip.dataset.b08LinkSlot = slot.copy" in LINK
# 모두 연결이면 접힘 · 미연결 있으면 펼침
assert 'ui-collapsible${missing ? "" : " is-collapsed"}' in LINK
assert "ui-collapsible__title" in LINK and "attachCollapsible(section)" in LINK
assert "state.title = state.textContent" in LINK # 긴 이름 = 툴팁
assert re.search(r"\.b08-link__row \{[^}]*flex-wrap: wrap", CSS)
assert ".b08-link__slot" not in CSS + LINK # 옛 줄 꼴 없음
def test_163_3_수정_사본_편집_화면():
assert "editable: () => true" in DRAWING
assert "onEdit: (kind) => openEdit(copy, name, kind, load)" in DRAWING
assert 'labor: () => openEdit(copy, name, "inputs", load)' in DRAWING
assert 'import("@ui/ui_template_item_picker_edit")' in DRAWING
assert "createEditView(" in DRAWING and "items: projectItems(request.projectId)" in DRAWING
assert "info: { 폴더: copy.폴더, 원본: copy.항목 }" in DRAWING
# [뒤로] = 몸통 되돌림 · 저장했으면 카드 다시 읽음 · 닫을 때 집계표 다시 셈
assert "body.replaceChildren(...kept)" in DRAWING and "if (saved) reload()" in DRAWING
assert "if (edited) request.onLinked()" in DRAWING
assert "if (editView && !(await editView.leave())) return false" in DRAWING
assert 'kind === "inputs" ? [note(L("B08_Quantity_Sheets_InputsOnly"))]' in DRAWING
assert "saveTemplate" not in DRAWING # 층 쓰기 없음 — 사본 PUT 만(편집 화면 `items.write`)
def test_163_4_배지_업데이트():
assert ".list(folder)" in LINK and "structureFolder(slot.copy)" in LINK
for key in ("Edited", "Gone", "Changed"):
assert f'badge("B08_Quantity_Link_{key}"' in LINK, key
assert "button.disabled = !info.마스터바뀜 || !!info.원본없음" in LINK
assert "updateButton.disabled = true" in LINK # 대조 받기 전 꺼짐
assert "projectItems(projectId).reapply(info)" in LINK
assert 'showConfirmDialog(ask, L("B08_Quantity_Link_Update"))' in LINK
assert 'info.고침 ? ` ${L("B08_Quantity_Link_UpdateEdited")}`' in LINK
# 저장 뒤 = [선택]과 같은 흐름(onLinked)
assert "saved\n ? onLinked()" in LINK
def test_163_5_콘크리트_5줄_늘_까닭():
table = MATERIAL[
MATERIAL.index("function concreteTable") : MATERIAL.index("function totalTable")
]
assert "const keep = rows.map((_row, i) => i)" in table
assert 'editable: (_i, key) => key.startsWith("per:")' in table # 배합 늘 고침
assert "function concreteWhy(" in MATERIAL
assert "matched(m.name, m.spec, m.unit)" in MATERIAL # 짝 계산 맞춤 그대로
assert "source.snapshot?.units?.[row.id]" in MATERIAL
assert "concreteNote.textContent = concreteWhy(sheets, payload.source)" in MATERIAL
assert "Material_Calc" in MATERIAL and "concreteWhy" not in (
ROOT / "B08_Quantity/B08_Quantity_Material_Calc.ts"
).read_text(encoding="utf-8")
def test_locale_keys_exist():
for text in (LINK, DRAWING):
for key in set(re.findall(r'L\("([A-Za-z0-9_]+)"\)', text)) | set(
re.findall(r'badge\("([A-Za-z0-9_]+)"', text)
):
assert f"{key}:" in LOCALE or key.startswith(("B08_Quantity_Summary_", "Common_")), key
@@ -25,11 +25,11 @@ def test_modal_own_class_and_cleanup():
assert 'dialogClass: "b08-sheets"' in DRAWING
def test_read_only():
# 쓰는 길이 없음 — 저장 · 고침 · PUT/POST 없음 · 카드는 고칠 수 없음
def test_no_layer_write():
# 층 쓰기 · 카드 안 저장 없음 — 고침은 [수정] → 사본 편집 화면만(163-3 · 사본 PUT)
for word in ("saveTemplate", "write:", "method:", "sheetEdit", "onImport", "onClear"):
assert word not in DRAWING, word
assert "editable: () => false" in DRAWING
assert "editable: () => true" in DRAWING
def test_copy_only():
+30
View File
@@ -29,6 +29,10 @@ export const ui_locales_b4 = {
B08_Quantity_Sheets_Title: ["구조물도", "Structure Sheets"],
B08_Quantity_Sheets_FromCopy: ["프로젝트 복사본", "Project copy"],
B08_Quantity_Sheets_NoCopy: ["복사본에 없음", "Not in the project copy"],
B08_Quantity_Sheets_InputsOnly: [
"일위대가는 넣을 값만 이 사본에서 고침 — 담은로직 · 자재는 원본 층(M02)에서 고침",
"Only inputs are edited in this copy — logic lines and materials are edited in the source layer (M02)",
],
B08_Quantity_Drawing_Loading: ["구조물도를 불러오는 중…", "Loading structure sheets…"],
B08_Quantity_Drawing_Failed: [
"구조물도를 불러오지 못했습니다",
@@ -77,6 +81,32 @@ export const ui_locales_b4 = {
],
B08_Quantity_Link_Unlinked: ["조합 연결을 끊었습니다.", "Combination unlinked."],
B08_Quantity_Link_UnlinkFailed: ["연결을 끊지 못했습니다", "Failed to unlink"],
B08_Quantity_Link_Update: ["업데이트", "Update"],
B08_Quantity_Link_UpdateTip: [
"마스터가 바뀜 — 원본을 다시 가져와 이 사본에 덮음",
"The master changed — re-import the source into this copy",
],
B08_Quantity_Link_UpdateSame: [
"원본과 같음 — 업데이트할 것 없음",
"Same as the source — nothing to update",
],
B08_Quantity_Link_UpdateAsk: [
"원본을 다시 가져와 이 사본에 덮습니다 — 입력값 · 고른 자재는 살리고(같은 키) 도면 · 면적산출 · 일위대가는 원본 것으로 덮습니다.",
"Re-import the source into this copy — inputs and picked materials are kept (same keys); drawing, area sheet and unit-price sheet are overwritten.",
],
B08_Quantity_Link_UpdateEdited: [
"이 사본에서 고친 것이 덮입니다.",
"Edits made in this copy will be overwritten.",
],
B08_Quantity_Link_UpdateDone: ["업데이트했습니다 — 덮인 것", "Updated — overwritten"],
B08_Quantity_Link_UpdateNone: [
"업데이트했습니다 — 덮인 것 없음(원본과 같았음)",
"Updated — nothing overwritten (same as the source)",
],
B08_Quantity_Link_UpdateFailed: ["업데이트하지 못했습니다", "Failed to update"],
B08_Quantity_Link_Changed: ["마스터가 바뀜", "Master changed"],
B08_Quantity_Link_Edited: ["고침 있음", "Edited"],
B08_Quantity_Link_Gone: ["원본이 없어짐", "Source gone"],
B08_Quantity_Link_Pending: [
"B05 · B06 에 저장 안 한 조작이 있습니다 — B05 · B06 [저장] 먼저 누른 뒤 연결하세요.",
"Unsaved edits in B05 · B06 — save there first, then link.",