Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into sub_laptop_1
This commit is contained in:
@@ -53,3 +53,22 @@ export function linkCommand(
|
||||
if (!commands.length) return null;
|
||||
return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands };
|
||||
}
|
||||
|
||||
/** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 ·
|
||||
* 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null. 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */
|
||||
export function linkAreaCell<T extends Workbook>(
|
||||
book: T,
|
||||
key: string,
|
||||
cell: string | null,
|
||||
info: LinkInfo = {},
|
||||
): T | null {
|
||||
const sheet = book.시트.find((s) => s.id === book.활성) ?? book.시트[0];
|
||||
if (!sheet) return null;
|
||||
const command = linkCommand(book, sheet.id, key, cell, info);
|
||||
if (command === false) return null;
|
||||
const next = structuredClone(book);
|
||||
const list = !command ? [] : command.종류 === "묶음" ? command.명령 : [command];
|
||||
for (const c of list)
|
||||
if (c.종류 === "시트통째") next.시트 = next.시트.map((s) => (s.id === c.시트.id ? c.시트 : s));
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -226,6 +226,19 @@ export const copyPartFrom = async (
|
||||
});
|
||||
};
|
||||
|
||||
/** 항목 하나 비우기(40-4 · 카드 [삭제]) — 새 열과 같은 빈 문서 · 번호 · 열 그대로 · 지금 층 */
|
||||
export const clearPart = (
|
||||
kind: "structure" | "labor" | "area",
|
||||
column: string,
|
||||
): Promise<TemplateInfo> =>
|
||||
call(
|
||||
owned(
|
||||
activeLayer,
|
||||
`/layers/${activeLayer}/${kind === "structure" ? "structures" : kind}/${enc(column)}/clear`,
|
||||
),
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
/** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */
|
||||
export const copyStructure = async (column: string, from: string): Promise<void> => {
|
||||
await scoped(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_AreaCellPick.ts
|
||||
* 엑셀 칸 고르기 모달(PLAN 40-3) — 그 열 면적산출 문서를 읽기만 시트로 띄우고 칸을 누르면 그 주소를 돌려줌.
|
||||
* 일위대가 상세(sub2)가 「수량 → 엑셀에서 고르기」 에 씀 → 돌려받은 칸을 `linkAreaCell` 로 면적 문서에 이어
|
||||
* 일위대가 [저장] 때 함께 저장(여기는 저장 안 함).
|
||||
* · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 이 알약이 이은 칸은 머리 글에
|
||||
* · 칸 누름 · Enter = 그 칸 · [연결 풀기] = 풀기 · 닫기 · Esc · 바깥 = 그만둠(null)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import { readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import "./M02_MasterTemplete_AreaPanel.css";
|
||||
|
||||
export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set";
|
||||
|
||||
const TEXT = {
|
||||
title: "엑셀에서 고르기 — {value}",
|
||||
close: "닫기",
|
||||
hint: "칸을 누르면 그 칸으로 이음 · Enter 도 됨",
|
||||
now: "지금 연결 {value}",
|
||||
none: "지금 연결 없음",
|
||||
unlink: "연결 풀기",
|
||||
loading: "면적산출을 읽는 중…",
|
||||
failed: "면적산출을 못 읽음 — {value}",
|
||||
};
|
||||
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
|
||||
export interface AreaCellPickOptions {
|
||||
/** 그 열 면적산출 문서(저장 안 한 것 포함) — 없으면 `layer` · `column` 저장본을 읽음 */
|
||||
doc?: Workbook | null;
|
||||
layer: Layer;
|
||||
/** 집계표 열 id */
|
||||
column: string;
|
||||
/** 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — 지금 이은 칸을 보임 */
|
||||
pill: string;
|
||||
/** 모달 제목에 쓸 줄 이름 */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸 */
|
||||
export type AreaCellPick = { 칸: string | null } | null;
|
||||
|
||||
export function pickAreaCell(opts: AreaCellPickOptions): Promise<AreaCellPick> {
|
||||
return new Promise((resolve) => {
|
||||
let handle: SpreadsheetHandle | null = null;
|
||||
let done = false;
|
||||
let picked = "A1";
|
||||
let moved = false; // 누른 사이 고름이 바뀜 — 머리 · 굴림 막대를 눌렀다 뗀 것은 안 고름
|
||||
const finish = (value: AreaCellPick): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
resolve(value);
|
||||
};
|
||||
const { close } = openModal({
|
||||
title: fill(TEXT.title, opts.label),
|
||||
closeLabel: TEXT.close,
|
||||
dialogClass: "m02-modal m02-cellpick",
|
||||
beforeClose: () => (finish(null), true),
|
||||
mount: (body) => {
|
||||
const head = el("p", { className: "m02-cellpick__now", text: TEXT.loading });
|
||||
const unlink = createButton({
|
||||
label: TEXT.unlink,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => (finish({ 칸: null }), close()),
|
||||
});
|
||||
unlink.hidden = true;
|
||||
const host = el("div", { className: "m02-cellpick__sheet" });
|
||||
body.append(
|
||||
el("div", { className: "m02-cellpick__head", children: [head, unlink] }),
|
||||
el("p", { className: "m02-cellpick__hint", text: TEXT.hint }),
|
||||
host,
|
||||
);
|
||||
const choose = (): void => (finish({ 칸: picked }), close());
|
||||
void (async () => {
|
||||
try {
|
||||
const [{ createSpreadsheet }, book] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
opts.doc
|
||||
? Promise.resolve(opts.doc)
|
||||
: readTemplate(opts.layer, "area", opts.column, null).then(
|
||||
(got) => got.문서 as Workbook,
|
||||
),
|
||||
]);
|
||||
if (done) return;
|
||||
const at = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸;
|
||||
head.textContent = at ? fill(TEXT.now, at) : TEXT.none;
|
||||
unlink.hidden = !at;
|
||||
handle = createSpreadsheet(host, structuredClone(book), {
|
||||
readOnly: true,
|
||||
tabs: false,
|
||||
onSelect: (s) => {
|
||||
picked = s.칸;
|
||||
moved = true;
|
||||
},
|
||||
});
|
||||
// 칸을 눌러 뗌 = 그 칸(처음 고름 A1 · 화살표 옮김은 안 닫음)
|
||||
const grid = host.querySelector(".ss-grid-host");
|
||||
grid?.addEventListener("mousedown", () => (moved = false), true);
|
||||
grid?.addEventListener("mouseup", (e) => {
|
||||
if ((e as MouseEvent).button === 0 && moved) choose();
|
||||
});
|
||||
host.addEventListener("keydown", (e) => e.key === "Enter" && choose());
|
||||
} catch (error) {
|
||||
if (done) return;
|
||||
head.textContent = fill(TEXT.failed, error instanceof Error ? error.message : "");
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -62,3 +62,26 @@
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 엑셀 칸 고르기 모달 — 머리(지금 연결 · [연결 풀기]) · 알림 · 읽기만 시트(높이를 정해야 시트가 보임) */
|
||||
.m02-cellpick {
|
||||
width: min(960px, 92vw);
|
||||
}
|
||||
|
||||
.m02-cellpick__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-cellpick__now,
|
||||
.m02-cellpick__hint {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m02-cellpick__sheet {
|
||||
height: min(60vh, 520px);
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
@@ -211,3 +211,56 @@
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
/* 한 줄 표(카드용 · 40-1) — 금액 = 수량 반영 값 · 반영이 없으면 기준 값 흐리게 */
|
||||
.m02-labline {
|
||||
width: 100%;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labline th,
|
||||
.m02-labline td {
|
||||
padding: var(--spacing-4) var(--spacing-6, 6px);
|
||||
}
|
||||
|
||||
.m02-labline th:nth-child(n + 4),
|
||||
.m02-labline .m02-labrow__num {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m02-labline .m02-labrow__name {
|
||||
display: table-cell;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-labline .m02-labrow__qty,
|
||||
.m02-labline .m02-labrow__unit {
|
||||
color: var(--color-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-labline .is-base {
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.m02-labline .m02-labrow__sum td {
|
||||
font-weight: 600;
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 아주 좁으면(카드 3열 · 400px 이하) 결과 단위 칸을 접음 — 수량산출 글에 단위가 같이 보임 */
|
||||
@container m02-labview (max-width: 400px) {
|
||||
.m02-labline th:nth-child(3),
|
||||
.m02-labline .m02-labrow__unit {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-labline th,
|
||||
.m02-labline td {
|
||||
padding: var(--spacing-4) 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const TEXT = {
|
||||
"가운데 단추 또는 Space + 끌기로 판 옮김 · Ctrl + 휠로 확대",
|
||||
"Middle button or Space + drag to move · Ctrl + wheel to zoom",
|
||||
],
|
||||
None: ["담은 로직이 없음", "No logics yet"],
|
||||
None: ["단가산출 로직이 없음", "No logics yet"],
|
||||
Open: [
|
||||
"누르면 이 로직을 단가산출 로직 화면에서 엶",
|
||||
"Open this logic in the unit-price logic screen",
|
||||
|
||||
@@ -51,6 +51,28 @@ export function qtyText(
|
||||
return `${tc("Labor_Qty")} ${cell} = ${v}`;
|
||||
}
|
||||
|
||||
/** 결과단위 기준 수량 — 분모 앞 수 · 분모(「10 ㎡」 · 「1 ㎥」) · 분모 없으면 빈 글 */
|
||||
export function baseQtyText(
|
||||
line: { 배?: number | null; 분모?: string | null } | undefined,
|
||||
): string {
|
||||
if (!line?.분모) return "";
|
||||
return `${num(line.배 || 1)} ${line.분모}`;
|
||||
}
|
||||
|
||||
/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「B6 = 12.5」 · 「B6 = 빈 칸」 · 없으면 「—」 */
|
||||
export function qtyShort(
|
||||
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
|
||||
unit: string,
|
||||
cells: ReadonlyMap<string, string> = new Map(),
|
||||
): string {
|
||||
const at = line?.알약 ? cells.get(line.알약) : undefined;
|
||||
if (!line || line.수량 === null || line.수량 === undefined)
|
||||
return at ? `${at} = ${tc("In_QtyEmpty")}` : "—";
|
||||
if (line.수량출처 === "손")
|
||||
return `${tc("Labor_Hand")} ${num(line.수량)}${unit ? ` ${unit}` : ""}`;
|
||||
return `${at ?? tc("In_QtyCell")} = ${num(line.수량)}`;
|
||||
}
|
||||
|
||||
/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */
|
||||
function moneyCell(
|
||||
base: number | null | undefined,
|
||||
@@ -307,3 +329,114 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
|
||||
? table(heads, trs, [])
|
||||
: el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") });
|
||||
}
|
||||
|
||||
export interface CompactData {
|
||||
rows: LaborRow[];
|
||||
mats: LaborMat[];
|
||||
logics: Map<string, ItemLogic>;
|
||||
calc: LaborCalc | null;
|
||||
fail?: string | null;
|
||||
cells?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/** 한 줄 표(카드용 · 40-1) — 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계 · 금액 = 수량 반영 값 ·
|
||||
* 반영이 없으면 기준 값 흐리게(`is-base`) · 자재 줄은 같은 표 아래(재료비 · 합계만) · 맨 아래 합계 줄 */
|
||||
export function compactTable(data: CompactData): HTMLElement {
|
||||
const wait = !data.calc && !data.fail;
|
||||
const cell = (v: number | null | undefined, base: boolean, label: string): HTMLElement =>
|
||||
el("td", {
|
||||
className: `m02-labrow__num${base ? " is-base" : ""}`,
|
||||
text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v),
|
||||
attrs: { "data-label": label, ...(base ? { title: tc("Labor_Base") } : {}) },
|
||||
});
|
||||
const heads = [
|
||||
tc("Col_Name"),
|
||||
tc("Labor_QtyCol"),
|
||||
tc("Col_Unit"),
|
||||
...COSTS.map((c) => tc(HEAD[c])),
|
||||
];
|
||||
const lines = data.rows.map((row) => {
|
||||
const line = data.calc?.줄.find((l) => l.로직 === row.로직);
|
||||
const money = line?.반영 ?? line?.기준;
|
||||
const base = !line?.반영 && !!line?.기준;
|
||||
const logic = data.logics.get(row.로직);
|
||||
return el("tr", {
|
||||
className: "m02-labrow__row",
|
||||
attrs: { "data-labor-logic": row.로직 },
|
||||
children: [
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
text: logic?.이름 ?? row.로직,
|
||||
attrs: { title: `${row.로직}${line?.까닭 ? ` · ${line.까닭}` : ""}` },
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__qty",
|
||||
text: qtyShort(line, line?.분모 ?? "", data.cells),
|
||||
attrs: { "data-label": tc("Labor_QtyCol") },
|
||||
}),
|
||||
el("td", { className: "m02-labrow__unit", text: logic?.결과단위 ?? "" }),
|
||||
...COSTS.map((c) => cell(money?.[c], base, tc(HEAD[c]))),
|
||||
],
|
||||
});
|
||||
});
|
||||
const mats = data.mats.map((mat) => {
|
||||
const line = data.calc?.자재.find((m) => m.코드 === mat.코드);
|
||||
const has = line?.반영 !== null && line?.반영 !== undefined;
|
||||
const v = has ? line?.반영 : line?.기준단가;
|
||||
return el("tr", {
|
||||
className: "m02-labrow__row is-mat",
|
||||
attrs: { "data-labor-mat": mat.코드 },
|
||||
children: [
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
text: mat.이름 ?? mat.코드,
|
||||
attrs: { title: [mat.코드, mat.규격].filter(Boolean).join(" · ") },
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__qty",
|
||||
text: qtyShort(line, mat.단위 ?? "", data.cells),
|
||||
attrs: { "data-label": tc("Labor_QtyCol") },
|
||||
}),
|
||||
el("td", { className: "m02-labrow__unit", text: mat.단위 ?? "" }),
|
||||
el("td", { className: "m02-labrow__num", attrs: { "data-label": tc(HEAD.노무비) } }),
|
||||
cell(v, !has && v !== null && v !== undefined, tc(HEAD.재료비)),
|
||||
el("td", { className: "m02-labrow__num", attrs: { "data-label": tc(HEAD.경비) } }),
|
||||
cell(v, !has && v !== null && v !== undefined, tc(HEAD.계)),
|
||||
],
|
||||
});
|
||||
});
|
||||
const applied = data.calc?.반영합;
|
||||
const anyApplied = !!applied && (applied.계 ?? 0) !== 0;
|
||||
const total = anyApplied ? applied : data.calc?.기준합;
|
||||
const foot =
|
||||
data.rows.length + data.mats.length
|
||||
? [
|
||||
el("tr", {
|
||||
className: "m02-labrow__sum",
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }),
|
||||
el("td", { className: "m02-labrow__qty", text: "" }),
|
||||
el("td", { className: "m02-labrow__unit", text: "" }),
|
||||
...COSTS.map((c) => cell(total?.[c], !anyApplied && !!total, tc(HEAD[c]))),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
return data.rows.length + data.mats.length
|
||||
? el("div", {
|
||||
className: "ui-general-block__table-wrap m02-labcompact",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-labline",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
el("tbody", { children: [...lines, ...mats] }),
|
||||
...(foot.length ? [el("tfoot", { children: foot })] : []),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
: el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const TEXT = {
|
||||
NeedName: ["이름을 적어야 저장됨", "A name is required"],
|
||||
Load_Failed: ["불러오지 못함", "Load failed"],
|
||||
Origin_Self: ["원본", "Original"],
|
||||
Logics_Title: ["담은 로직", "Logics in it"],
|
||||
Logics_Title: ["단가산출 로직", "Unit-price logics"],
|
||||
Col_Order: ["차례", "No."],
|
||||
Col_Name: ["이름", "Name"],
|
||||
Col_Unit: ["결과 단위", "Result unit"],
|
||||
@@ -33,7 +33,7 @@ const TEXT = {
|
||||
Down: ["내리기", "Down"],
|
||||
Remove: ["빼기", "Remove"],
|
||||
AddLogic: ["로직 추가", "Add logic"],
|
||||
NoLogic: ["담은 로직 없음", "No logics yet"],
|
||||
NoLogic: ["단가산출 로직 없음", "No logics yet"],
|
||||
Open: ["열기", "Open"],
|
||||
Pick_Title: ["로직 고르기", "Pick a logic"],
|
||||
Pick_Search: ["이름·키로 찾기", "Search by name or key"],
|
||||
@@ -56,6 +56,8 @@ const TEXT = {
|
||||
Labor_NoMat: ["담은 자재 없음", "No materials yet"],
|
||||
Labor_AddMat: ["자재 추가", "Add material"],
|
||||
Labor_Qty: ["수량", "Quantity"],
|
||||
Labor_QtyCol: ["수량산출", "Quantity"],
|
||||
Labor_Hand: ["손", "Manual"],
|
||||
Labor_Base: ["기준", "Base"],
|
||||
Labor_Applied: ["반영", "Applied"],
|
||||
Labor_BasePrice: ["기준 단가", "Base price"],
|
||||
@@ -68,7 +70,7 @@ const TEXT = {
|
||||
],
|
||||
Labor_Code: ["코드", "Code"],
|
||||
Labor_Spec: ["규격", "Spec"],
|
||||
Labor_Already: ["이미 담은 로직 — {value}", "Already added — {value}"],
|
||||
Labor_Already: ["이미 담은 단가산출 로직 — {value}", "Already added — {value}"],
|
||||
Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"],
|
||||
In_Title: ["넣을 값 — {value}", "Inputs — {value}"],
|
||||
In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"],
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* · `setQty({알약 키: 칸 값}, {알약 키: 칸 주소}?)` = 면적 칸 값이 바뀌면 서버 없이 짝 셈(`Labor_Calc.settle`)으로
|
||||
* 바로 곱함 · 칸 주소를 주면 저장 전 연결(setLink · 알약 끌기)도 「수량 B6 = 20」 으로 보임(안 주면 저장본 연결)
|
||||
* (그 열 면적 알약 값 전부를 줌 — 빠진 키는 「수량 없음」)
|
||||
* · `compact` = 한 줄 표(구조물 조합 일위대가 카드 · 40-1)
|
||||
* · `onRow` = 줄 누름(알약 키 · 이름 · 단위 — 칸 지정 모달 등) · 없으면 누름 없음
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
type LaborDoc,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { settle } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { compactTable, logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
|
||||
@@ -46,7 +47,15 @@ export interface LaborViewHandle {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewHandle {
|
||||
/** `compact` = 한 줄 표(카드용 · 항목 · 수량산출 · 단위 · 금액 넷 · 자재 줄 같은 표 아래 · 줄 누름 없음) */
|
||||
export interface LaborViewOptions {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function createLaborView(
|
||||
onRow?: (row: LaborViewRow) => void,
|
||||
opts: LaborViewOptions = {},
|
||||
): LaborViewHandle {
|
||||
const root = el("div", { className: "m02-labview" });
|
||||
const logics = new Map<string, ItemLogic>();
|
||||
let doc: LaborDoc | null = null;
|
||||
@@ -84,6 +93,10 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH
|
||||
unit: mat.단위 ?? "",
|
||||
})?.();
|
||||
};
|
||||
if (opts.compact)
|
||||
return void root.replaceChildren(
|
||||
compactTable({ rows: d.담은로직, mats: d.자재, logics, calc, fail, cells }),
|
||||
);
|
||||
root.replaceChildren(
|
||||
el("h4", { className: "m02-labview__title", text: tc("Logics_Title") }),
|
||||
logicTable(
|
||||
|
||||
@@ -317,6 +317,20 @@ def _copy_practice(column: str, key: str) -> dict[str, Any]:
|
||||
return store.copy_from("labor", column, key, body=labor.from_combo(key))
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/{kind}/{column}/clear")
|
||||
def post_clear(
|
||||
layer: Layer,
|
||||
kind: Literal["structures", "labor", "area"],
|
||||
column: str,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
"""항목 하나 비우기(40-4 · 카드 [삭제]) — 빈 문서 · 번호 · 열 그대로 · 받는 층 쓰기 권한."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
part = "structure" if kind == "structures" else kind
|
||||
return _call(folder, store.clear, part, column)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/{kind}/{column}/copy")
|
||||
def post_copy(
|
||||
layer: Layer,
|
||||
|
||||
@@ -351,6 +351,21 @@ def create_structure(column: str) -> dict[str, Any]:
|
||||
return read("structure", column)
|
||||
|
||||
|
||||
def clear(kind: str, column: str) -> dict[str, Any]:
|
||||
"""항목 하나 비우기(40-4) — 새 열 만들 때와 같은 빈 문서로 다시 씀 · 번호 · 열 그대로 ·
|
||||
없는 파일은 404 · 면적산출을 비우면 그 열 일위대가 계산값도 다시 · 장부 맞춤."""
|
||||
if kind not in _KEY:
|
||||
raise StoreError(404, f"없는 종류 「{kind}」")
|
||||
number = read(kind, column)["문서"].get(_KEY[kind])
|
||||
with _LOCK:
|
||||
atomic_write_json(_path(kind, column), {**_blank(kind, column), _KEY[kind]: number})
|
||||
info = _info(kind, _path(kind, column))
|
||||
if kind == "area":
|
||||
_refresh_labor(column)
|
||||
sync_combos()
|
||||
return info
|
||||
|
||||
|
||||
def copy_from(
|
||||
kind: str,
|
||||
column: str,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림.
|
||||
* 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */
|
||||
import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands";
|
||||
import { linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set";
|
||||
import { linkAreaCell, linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set";
|
||||
import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
let pass = 0;
|
||||
@@ -89,4 +89,15 @@ eq("빈 주소", linkCommand(b, "s1", "깔기|㎡", ""), false);
|
||||
eq("없는 시트", linkCommand(b, "s9", "깔기|㎡", "A1"), false);
|
||||
eq("문서 그대로", links(b), links(book()));
|
||||
|
||||
// linkAreaCell — 새 문서(받은 문서 그대로) · 활성 시트 · 번호 칸 그대로 · 틀린 주소 null · 풀기
|
||||
b = { ...book(), 면적번: "SA-000001" } as Workbook & { 면적번: string };
|
||||
const before = JSON.stringify(b);
|
||||
const got = linkAreaCell(b, "철근|ton", "c9", { 이름: "철근", 단위: "ton" });
|
||||
eq("받은 문서 그대로", JSON.stringify(b), before);
|
||||
eq("새 문서 연결", got?.시트[0].연결?.[1], { 키: "철근|ton", 칸: "C9", 이름: "철근", 단위: "ton" });
|
||||
eq("번호 그대로", (got as { 면적번?: string } | null)?.면적번, "SA-000001");
|
||||
eq("틀린 주소", linkAreaCell(b, "철근|ton", "9C"), null);
|
||||
eq("풀기 · 시트 넘어", links(linkAreaCell(b, "치기|㎥", null)!), [links(book())[0], null]);
|
||||
eq("바뀔 것 없음 = 사본", JSON.stringify(linkAreaCell(b, "깔기|㎡", "B6")), before);
|
||||
|
||||
console.log(JSON.stringify({ pass, fail: failures.length, failures }));
|
||||
|
||||
@@ -88,3 +88,14 @@ def test_카드_알약_취향은_계정_받은_뒤_올림():
|
||||
cards = _text("M02_MasterTemplete_ItemCards.ts")
|
||||
load = cards.split("async function load(")[1]
|
||||
assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in load
|
||||
|
||||
|
||||
def test_엑셀_칸_고르기_모달_부품():
|
||||
"""40-3 ① — 읽기만 시트 · 칸 누름 = 주소 · 풀기 = {칸: null} · 그만둠 = null · 저장 안 함."""
|
||||
pick = _text("M02_MasterTemplete_AreaCellPick.ts")
|
||||
assert 'export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set";' in pick
|
||||
assert "readOnly: true," in pick and "beforeClose: () => (finish(null), true)," in pick
|
||||
assert "finish({ 칸: null })" in pick and "finish({ 칸: picked })" in pick
|
||||
assert "if ((e as MouseEvent).button === 0 && moved) choose();" in pick
|
||||
assert "handle?.destroy();" in pick
|
||||
assert "saveTemplate" not in pick
|
||||
|
||||
@@ -127,6 +127,13 @@ function load(f) {
|
||||
const m = load(path.join(root, "M02_MasterTemplete", "M02_MasterTemplete_Labor_Rows.ts"));
|
||||
const cells = new Map([["돌쌓기|㎡", "B6"]]);
|
||||
console.log(JSON.stringify([
|
||||
m.baseQtyText({ 배: 10, 분모: "㎡" }),
|
||||
m.baseQtyText({ 배: 1, 분모: "㎥" }),
|
||||
m.baseQtyText({ 배: 1, 분모: null }),
|
||||
m.qtyShort({ 수량: 5, 수량출처: "손", 알약: "돌쌓기|㎡" }, "㎥", cells),
|
||||
m.qtyShort({ 수량: 12.5, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡", cells),
|
||||
m.qtyShort({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡", cells),
|
||||
m.qtyShort({ 수량: null, 수량출처: "없음", 알약: "없는|㎡" }, "㎡", cells),
|
||||
m.qtyText({ 수량: 20, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡", cells),
|
||||
m.qtyText({ 수량: 20, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡"),
|
||||
m.qtyText({ 수량: 3, 수량출처: "손", 알약: "돌쌓기|㎡" }, "㎡", cells),
|
||||
@@ -145,7 +152,10 @@ def test_수량_글은_이은_칸_주소를_보임() -> None:
|
||||
[node, "-e", ROWS_SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert json.loads(out.stdout) == [
|
||||
got = json.loads(out.stdout)
|
||||
# 수량산출 열(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 짧은 글
|
||||
assert got[:7] == ["10 ㎡", "1 ㎥", "", "손 5 ㎥", "B6 = 12.5", "B6 = 빈 칸", "—"]
|
||||
assert got[7:] == [
|
||||
"수량 B6 = 20 ㎡",
|
||||
"수량 면적산출 칸 = 20 ㎡",
|
||||
"수량 손 입력 3 ㎡",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""항목 하나 비우기(PLAN 40-4 · 카드 [삭제]) — `POST /api/m02/layers/{층}/{종류}/{열}/clear`.
|
||||
|
||||
새 열과 같은 빈 문서 · 번호 · 열 그대로 · 권한 = 받는 층 쓰기(`_root`).
|
||||
시스템 층 = 견본 표(임시 폴더) · 개인 층 = 임시 storage · 로직은 M01 마스터(읽기만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from config import config_system
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester.test_m02_structure import write_sample_table
|
||||
|
||||
TEN = "GC000421"
|
||||
URL = "/api/m02/layers/{}/{}/{}/clear"
|
||||
NUMBER = {
|
||||
"structures": ("structure", "도번"),
|
||||
"labor": ("labor", "일위대가번"),
|
||||
"area": ("area", "면적번"),
|
||||
}
|
||||
|
||||
|
||||
def _fill(folder: Path, cid: str) -> None:
|
||||
"""세 문서를 채움 — 도면 선 · 일위대가 로직 · 면적 칸 + 알약 연결."""
|
||||
with store.at(folder):
|
||||
for kind in ("structure", "labor", "area"):
|
||||
store._ensure(kind, cid)
|
||||
got = store.read("structure", cid)
|
||||
doc = got["문서"]
|
||||
doc["도면"]["entities"] = [{"type": "line"}]
|
||||
store.write("structure", cid, got["판"], doc)
|
||||
got = store.read("labor", cid)
|
||||
store.write("labor", cid, got["판"], {**got["문서"], "담은로직": [{"로직": TEN}]})
|
||||
got = store.read("area", cid)
|
||||
sheet = got["문서"]["시트"][0]
|
||||
sheet["칸"] = {"B6": {"값": "5"}}
|
||||
sheet["연결"] = [{"키": "사석 수상고르기|㎡", "이름": "x", "칸": "B6", "단위": "㎡"}]
|
||||
store.write("area", cid, got["판"], got["문서"])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||
system = tmp_path / "system"
|
||||
system.mkdir()
|
||||
write_sample_table(system)
|
||||
monkeypatch.setattr(store, "FOLDER", system)
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
|
||||
(tmp_path / "storage").mkdir()
|
||||
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
|
||||
with store.at(system):
|
||||
store.create_structure("rv")
|
||||
_fill(system, "rv")
|
||||
personal = layers.personal_dir(7, 42)
|
||||
items_store.ensure(personal)
|
||||
with store.at(personal):
|
||||
got = store.read("table", store.STRUCTURE_TABLE)
|
||||
got["문서"]["열"] = [{"id": "a", "머리": ["돌", "가"], "꼴": "수"}]
|
||||
store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"])
|
||||
_fill(personal, "a")
|
||||
session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
|
||||
app = FastAPI()
|
||||
app.include_router(items_router.router)
|
||||
app.dependency_overrides[verify_session] = lambda: session
|
||||
return {"client": TestClient(app), "session": session, "system": system, "personal": personal}
|
||||
|
||||
|
||||
def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient:
|
||||
world["session"].update(role=role, user_id=user)
|
||||
return world["client"]
|
||||
|
||||
|
||||
def _doc(folder: Path, kind: str, cid: str) -> dict:
|
||||
return json.loads((folder / kind / f"{cid}.json").read_bytes())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["structures", "labor", "area"])
|
||||
def test_세_종류_빈_문서_번호_열_그대로(world, kind):
|
||||
system = world["system"]
|
||||
part, key = NUMBER[kind]
|
||||
before = _doc(system, part, "rv")
|
||||
got = _as(world, "SYSTEM_ADMIN").post(URL.format("system", kind, "rv"))
|
||||
assert got.status_code == 200, got.text
|
||||
after = _doc(system, part, "rv")
|
||||
with store.at(system):
|
||||
blank = store._blank(part, "rv")
|
||||
assert after == {**blank, key: before[key]} # 새 열과 같은 빈 문서 + 번호
|
||||
assert after["열"] == "rv" and after[key]
|
||||
with store.at(system): # 장부 번호 그대로
|
||||
row = next(r for r in store.read_combos()["줄"] if r["열"] == "rv")
|
||||
assert row[key] == before[key]
|
||||
|
||||
|
||||
def test_면적_비우면_일위대가_계산값_수량_없음으로(world):
|
||||
system = world["system"]
|
||||
assert _doc(system, "labor", "rv")["계산값"]["줄"][0]["수량출처"] == "칸"
|
||||
_as(world, "SYSTEM_ADMIN").post(URL.format("system", "area", "rv"))
|
||||
assert _doc(system, "labor", "rv")["계산값"]["줄"][0]["수량출처"] == "없음"
|
||||
assert _doc(system, "labor", "rv")["담은로직"][0]["로직"] == TEN # 일위대가는 그대로
|
||||
|
||||
|
||||
def test_권한_일반_마스터_403_남의_개인_403_내_개인_됨(world):
|
||||
system, personal = world["system"], world["personal"]
|
||||
client = _as(world)
|
||||
assert client.post(URL.format("system", "labor", "rv")).status_code == 403
|
||||
assert _doc(system, "labor", "rv")["담은로직"] # 안 지워짐
|
||||
other = URL.format("personal", "labor", "a") + "?owner=7/99"
|
||||
assert client.post(other).status_code == 403
|
||||
assert client.post(URL.format("personal", "labor", "a")).status_code == 200
|
||||
assert _doc(personal, "labor", "a")["담은로직"] == []
|
||||
|
||||
|
||||
def test_회사_관리자는_동료_개인_층_비움(world):
|
||||
personal = world["personal"]
|
||||
client = _as(world, "ADMIN", user=41)
|
||||
got = client.post(URL.format("personal", "structures", "a") + "?owner=7/42")
|
||||
assert got.status_code == 200, got.text
|
||||
assert _doc(personal, "structure", "a")["도면"]["entities"] == []
|
||||
|
||||
|
||||
def test_없는_열_404_모르는_종류_422(world):
|
||||
client = _as(world, "SYSTEM_ADMIN")
|
||||
assert client.post(URL.format("system", "labor", "없는열")).status_code == 404
|
||||
assert client.post(URL.format("system", "material", "rv")).status_code == 422
|
||||
@@ -55,7 +55,7 @@ def test_setLink_순수_길():
|
||||
assert run.stdout.strip(), run.stderr
|
||||
result = json.loads(run.stdout)
|
||||
assert result["fail"] == 0, result["failures"]
|
||||
assert result["pass"] == 13
|
||||
assert result["pass"] == 19
|
||||
|
||||
|
||||
def test_손잡이_setLink_몸():
|
||||
|
||||
Reference in New Issue
Block a user