Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -299,6 +299,8 @@ const PREFS_ENDPOINT = "/api/dashboard/me/ui-prefs";
|
||||
const PREFS_PUSH_DELAY_MS = 1500;
|
||||
let applyingServerPrefs = false;
|
||||
let prefsPushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 계정 취향 받기 — 받기 전에 올리면 이 브라우저 옛 값이 계정 값을 덮으므로 올리기는 이것 뒤에 */
|
||||
let prefsPull: Promise<void> | null = null;
|
||||
|
||||
function localPrefs(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
@@ -320,17 +322,26 @@ function pushUiPrefs(): void {
|
||||
if (prefsPushTimer) clearTimeout(prefsPushTimer);
|
||||
prefsPushTimer = setTimeout(() => {
|
||||
prefsPushTimer = null;
|
||||
void fetch(PREFS_ENDPOINT, {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prefs: localPrefs() }),
|
||||
}).catch(() => undefined);
|
||||
void (prefsPull ?? Promise.resolve())
|
||||
.then(() =>
|
||||
fetch(PREFS_ENDPOINT, {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prefs: localPrefs() }),
|
||||
}),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
}, PREFS_PUSH_DELAY_MS);
|
||||
}
|
||||
|
||||
/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번. */
|
||||
export async function syncUiPrefs(): Promise<void> {
|
||||
/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번(올리기는 받은 뒤로 미룸). */
|
||||
export function syncUiPrefs(): Promise<void> {
|
||||
prefsPull = pullUiPrefs();
|
||||
return prefsPull;
|
||||
}
|
||||
|
||||
async function pullUiPrefs(): Promise<void> {
|
||||
let stored: Record<string, unknown> = {};
|
||||
try {
|
||||
const response = await fetch(PREFS_ENDPOINT, { credentials: "include" });
|
||||
|
||||
@@ -76,11 +76,11 @@ export interface SpreadsheetHandle {
|
||||
setDoc(book: Workbook): void;
|
||||
/** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */
|
||||
recalc(): CalcValues;
|
||||
/** 알약 키 → 연결 칸 값(빈 칸 0) */
|
||||
/** 알약 키 → 연결 칸 값(빈 칸 = 값 · 식 없는 칸은 빠짐 — 「수량 없음」) */
|
||||
getLinkedValues(): Record<string, CalcValue>;
|
||||
/** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 ·
|
||||
* 주소가 틀리거나 읽기만이면 false */
|
||||
setLink(key: string, cell: string | null, info?: LinkInfo): boolean;
|
||||
* 그 칸 다른 알약은 함께 둠(`replace` 면 뺌) · 주소가 틀리거나 읽기만이면 false */
|
||||
setLink(key: string, cell: string | null, info?: LinkInfo, replace?: boolean): boolean;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
@@ -289,13 +289,15 @@ export function createSpreadsheet(
|
||||
for (const s of ctx.book.시트)
|
||||
for (const l of s.연결 ?? []) {
|
||||
const p = parseA1(l.칸);
|
||||
if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
|
||||
const cell = s.칸[l.칸];
|
||||
const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === "");
|
||||
if (p && !empty) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
setLink(key, cell, info) {
|
||||
setLink(key, cell, info, replace) {
|
||||
if (readOnly) return false;
|
||||
const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info);
|
||||
const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info, replace);
|
||||
if (command === false) return false;
|
||||
if (command) dispatch(command);
|
||||
loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_link_ask.ts (주인 D)
|
||||
* 같은 칸에 이미 다른 알약 — [함께 잇기](기본 · Enter) · [바꾸기] · [취소](Esc) 물음(40 마무리).
|
||||
* 알약 누름 · 끌어 놓기(`spreadsheet_links`) · M02 칸 지정 모달 · 엑셀 칸 고르기가 같이 씀 ·
|
||||
* 모양은 공용 확인창(`ui-confirm`) 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
|
||||
const TEXT = {
|
||||
ask: "{cell} 칸에 이미 {names} — 이 알약도 함께 이을까?",
|
||||
together: "함께 잇기",
|
||||
replace: "바꾸기",
|
||||
cancel: "취소",
|
||||
};
|
||||
|
||||
/** 「함께」 = 그 칸 다른 알약 그대로 · 「바꾸기」 = 다른 알약을 뺌 · null = 그만둠 */
|
||||
export type ShareChoice = "함께" | "바꾸기" | null;
|
||||
|
||||
export function askShare(cell: string, names: string[]): Promise<ShareChoice> {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = el("div", { className: "ui-confirm" });
|
||||
const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리
|
||||
const done = (choice: ShareChoice): void => {
|
||||
overlay.remove();
|
||||
if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true });
|
||||
resolve(choice);
|
||||
};
|
||||
const together = createButton({
|
||||
label: TEXT.together,
|
||||
variant: "filled",
|
||||
onClick: () => done("함께"),
|
||||
});
|
||||
overlay.addEventListener("keydown", (ev) => {
|
||||
if (ev.key !== "Escape") return;
|
||||
ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음
|
||||
done(null);
|
||||
});
|
||||
const message = TEXT.ask
|
||||
.replace("{cell}", cell)
|
||||
.replace("{names}", names.map((n) => `「${n}」`).join(" · "));
|
||||
overlay.append(
|
||||
el("div", {
|
||||
className: "ui-confirm__panel",
|
||||
children: [
|
||||
el("p", { className: "ui-confirm__message", text: message }),
|
||||
el("div", {
|
||||
className: "ui-confirm__actions",
|
||||
children: [
|
||||
createButton({ label: TEXT.cancel, variant: "ghost", onClick: () => done(null) }),
|
||||
createButton({
|
||||
label: TEXT.replace,
|
||||
variant: "ghost",
|
||||
onClick: () => done("바꾸기"),
|
||||
}),
|
||||
together,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
document.body.append(overlay);
|
||||
together.focus(); // Enter = 함께 잇기(기본)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_link_color.ts (주인 D)
|
||||
* 이은 알약 색 — 알약 키로 정해지는 색상(hue). 알약 · 이은 칸 배경 · 칸 세모가 같은 색(PLAN 40-3).
|
||||
* 칸 24 개(30° 바퀴 12 + 15° 어긋난 바퀴 12) — 키 해시로 첫 자리 · 겹치면 다음 빈 자리(같은 문서 안에서 서로 다르게).
|
||||
* 밝기 · 채도는 테마마다 CSS(`spreadsheet_links.css` `--ss-link-l`) · 여기는 색상만 · DOM 모름(시험이 Node 로 부름).
|
||||
* ========================================================================== */
|
||||
|
||||
const SLOTS = 24;
|
||||
|
||||
/** FNV-1a 32비트 — 키 글자로 늘 같은 수 */
|
||||
function hash(text: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
h ^= text.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/** 자리 → 색상(°) — 0~11 = 30° 간격 · 12~23 = 그 사이(15° 어긋남) */
|
||||
const hueOf = (slot: number): number => (slot % 12) * 30 + (slot >= 12 ? 15 : 0);
|
||||
|
||||
/** 이은 알약 키들 → 키마다 색상(°) — 키 차례와 무관(정렬해 자리 배정) · 24 개 넘으면 다시 돎 */
|
||||
export function linkHues(keys: Iterable<string>): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
const used = new Set<number>();
|
||||
for (const key of [...new Set(keys)].sort()) {
|
||||
if (used.size >= SLOTS) used.clear();
|
||||
const start = hash(key) % 12;
|
||||
// 첫 바퀴(30°)를 다 쓴 뒤에야 둘째 바퀴 — 적을 때 서로 30° 넘게 벌어짐
|
||||
let slot = start;
|
||||
for (let i = 1; i < SLOTS && used.has(slot); i += 1)
|
||||
slot = (i < 12 ? 0 : 12) + ((start + i) % 12);
|
||||
used.add(slot);
|
||||
out.set(key, hueOf(slot));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_link_set.ts (주인 D)
|
||||
* 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`).
|
||||
* 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나 = 키 하나 ·
|
||||
* 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름).
|
||||
* 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나에 알약 여럿(같은 수량을 여러
|
||||
* 로직이 씀 · 40 마무리) — `replace` 면 그 칸의 다른 알약을 뺌 · 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름).
|
||||
* ========================================================================== */
|
||||
|
||||
import { parseA1, toA1 } from "./spreadsheet_address";
|
||||
@@ -15,13 +15,28 @@ export interface LinkInfo {
|
||||
자재?: LinkMaterial;
|
||||
}
|
||||
|
||||
/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false */
|
||||
/** 칸 주소 맞춤(「 b6 」 → 「B6」) — 틀리면 null */
|
||||
export function normA1(cell: string): string | null {
|
||||
const at = parseA1(cell.trim().toUpperCase());
|
||||
return at ? toA1(at.r, at.c) : null;
|
||||
}
|
||||
|
||||
/** 그 시트 그 칸에 이은 다른 알약(`key` 빼고 · 이은 차례) — 물음 「함께 잇기 · 바꾸기」 에 씀 */
|
||||
export function linksAt(book: Workbook, sheetId: string, cell: string, key: string): CellLink[] {
|
||||
const a1 = normA1(cell);
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
return (sheet?.연결 ?? []).filter((l) => l.칸 === a1 && l.키 !== key);
|
||||
}
|
||||
|
||||
/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false ·
|
||||
* `replace` = 그 칸의 다른 알약 연결을 뺌(아니면 함께 둠) */
|
||||
export function linkCommand(
|
||||
book: Workbook,
|
||||
sheetId: string,
|
||||
key: string,
|
||||
cell: string | null,
|
||||
info: LinkInfo = {},
|
||||
replace = false,
|
||||
): Command | null | false {
|
||||
const at = cell === null ? null : parseA1(cell.trim().toUpperCase());
|
||||
if (cell !== null && !at) return false;
|
||||
@@ -29,7 +44,8 @@ export function linkCommand(
|
||||
const old = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === key);
|
||||
const home = book.시트.find((s) => s.id === sheetId);
|
||||
if (a1 && !home) return false;
|
||||
if (a1 && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸
|
||||
const others = a1 && replace ? linksAt(book, sheetId, a1, key).length : 0;
|
||||
if (a1 && !others && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸
|
||||
const one: CellLink | null = a1 ? { 키: key, 칸: a1 } : null;
|
||||
if (one) {
|
||||
const 이름 = info.이름 ?? old?.이름;
|
||||
@@ -42,7 +58,7 @@ export function linkCommand(
|
||||
const commands: Command[] = [];
|
||||
for (const s of book.시트) {
|
||||
const was = s.연결 ?? [];
|
||||
const rest = was.filter((l) => l.키 !== key && !(s === home && a1 && l.칸 === a1));
|
||||
const rest = was.filter((l) => l.키 !== key && !(replace && s === home && a1 && l.칸 === a1));
|
||||
const next = s === home && one ? [...rest, one] : rest;
|
||||
if (next.length === was.length && next.every((l, i) => l === was[i])) continue;
|
||||
const copy = structuredClone(s);
|
||||
@@ -53,3 +69,23 @@ export function linkCommand(
|
||||
if (!commands.length) return null;
|
||||
return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands };
|
||||
}
|
||||
|
||||
/** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 ·
|
||||
* 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null · 같은 칸 다른 알약은 함께 둠(`replace` 면 뺌). 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */
|
||||
export function linkAreaCell<T extends Workbook>(
|
||||
book: T,
|
||||
key: string,
|
||||
cell: string | null,
|
||||
info: LinkInfo = {},
|
||||
replace = false,
|
||||
): 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, replace);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -33,14 +33,55 @@
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* 연결됨 — 흐림(연결 현황) */
|
||||
.ss-link.is-linked {
|
||||
opacity: 0.5;
|
||||
border-style: dashed;
|
||||
/* 이은 알약 색(40-3) — 색상 = 알약 키(`--ss-link-h` · spreadsheet_link_color) · 밝기 · 옅기 = 테마 */
|
||||
.ss {
|
||||
--ss-link-s: 70%;
|
||||
--ss-link-l: 31%;
|
||||
--ss-link-fill: 0.16;
|
||||
}
|
||||
|
||||
.ss-link.is-linked:hover {
|
||||
opacity: 0.8;
|
||||
:root[data-theme="dark"] .ss {
|
||||
--ss-link-l: 66%;
|
||||
--ss-link-fill: 0.26;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .ss {
|
||||
--ss-link-l: 66%;
|
||||
--ss-link-fill: 0.26;
|
||||
}
|
||||
}
|
||||
|
||||
/* 연결됨 — 알약 색 테두리 · 왼쪽 띠 · 옅은 배경(글은 본문 색 그대로 · 읽힘) */
|
||||
.ss-link.is-linked {
|
||||
border-color: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
|
||||
background: hsl(var(--ss-link-h, 0) var(--ss-link-s) 50% / var(--ss-link-fill));
|
||||
box-shadow: inset 4px 0 0 hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
|
||||
}
|
||||
|
||||
.ss-link.is-linked code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ss-link[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* 이은 칸 — 알약 색 옅게(칸 글 위 · 누름은 칸으로 지나감) */
|
||||
.ss-link-cell {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
background: hsl(var(--ss-link-h, 0) var(--ss-link-s) 50% / var(--ss-link-fill));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 알약을 끌 때 놓일 칸 */
|
||||
.ss-link-drop {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
box-sizing: border-box;
|
||||
border: 2px dashed var(--color-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-link--add {
|
||||
@@ -55,7 +96,7 @@
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 6px solid transparent;
|
||||
border-left: 6px solid var(--color-accent);
|
||||
border-left: 6px solid hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -136,3 +177,21 @@
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 칸 하나에 알약 여럿 — 오른위 작은 수(먼저 이은 알약 색) */
|
||||
.ss-link-count {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
border-radius: 7px;
|
||||
background: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
|
||||
color: var(--color-canvas);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
transform: translate(-100%, 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_links.ts (주인 D)
|
||||
* 아래 알약 막대(표 · 탭 · 합계 줄 아래 맨 끝) — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장).
|
||||
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결(알약 흐려짐 + 칸 주소 · 칸 왼아래 작은 세모) ·
|
||||
* 흐린 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
|
||||
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(40-3) · 칸 하나에 알약 여럿 —
|
||||
* 이미 다른 알약이 있으면 [함께 잇기] · [바꾸기] · [취소] 물음(`spreadsheet_link_ask`) · 이은 알약 = 키로 정해지는
|
||||
* 색(`spreadsheet_link_color`) · 이은 칸 = 먼저 이은 알약 색 옅게 + 왼아래 세모 + 여럿이면 오른위 작은 수 ·
|
||||
* 칸에 마우스를 올리면 이은 알약 이름들 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] ·
|
||||
* 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
|
||||
* 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] ·
|
||||
* 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음).
|
||||
* 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가
|
||||
@@ -14,6 +17,9 @@ import { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
|
||||
import { applyCommand } from "./spreadsheet_commands";
|
||||
import { askShare } from "./spreadsheet_link_ask";
|
||||
import { linkHues } from "./spreadsheet_link_color";
|
||||
import { linkCommand, linksAt } from "./spreadsheet_link_set";
|
||||
import type { slotOf } from "./spreadsheet_editor";
|
||||
import { shiftSpan } from "./spreadsheet_refshift";
|
||||
import { selectCell } from "./spreadsheet_selection";
|
||||
@@ -55,8 +61,8 @@ export interface Links extends PartHandle {
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
bar: "알약 — 칸을 고른 채 누르면 연결 · 흐린 알약은 누르면 연결 칸으로 · 우클릭 풀기",
|
||||
free: "칸을 고른 채 누르면 이 칸에 연결",
|
||||
bar: "알약 — 칸을 고른 채 누르거나 끌어 칸에 놓으면 연결 · 색 알약은 누르면 연결 칸으로 · 우클릭 풀기",
|
||||
free: "칸을 고른 채 누르거나 끌어 칸에 놓으면 이 칸에 연결",
|
||||
linked: "누르면 연결 칸으로 · 우클릭 연결 풀기",
|
||||
unlink: "연결 풀기",
|
||||
remove: "목록에서 빼기",
|
||||
@@ -136,6 +142,9 @@ export function mountLinks(
|
||||
/** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */
|
||||
const items: LinkItem[] = opts ? [...opts.항목] : [];
|
||||
let dots: HTMLElement[] = [];
|
||||
/** 끄는 중인 알약 · 놓을 칸 표시 */
|
||||
let dragging: LinkItem | null = null;
|
||||
const dropMark = el("div", { className: "ss-link-drop" });
|
||||
let pick: HTMLElement | null = null;
|
||||
|
||||
/** 키 → 연결(시트 · 줄) */
|
||||
@@ -153,15 +162,22 @@ export function mountLinks(
|
||||
ctx.dispatch({ 종류: "시트통째", 시트: next });
|
||||
}
|
||||
|
||||
function link(item: LinkItem): void {
|
||||
/** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 · 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음 */
|
||||
async function link(item: LinkItem, at?: string): Promise<void> {
|
||||
if (ctx.readOnly) return;
|
||||
const sheet = ctx.sheet();
|
||||
const 칸 = toA1(ctx.selection.활성.r, ctx.selection.활성.c);
|
||||
const rest = (sheet.연결 ?? []).filter((l) => l.칸 !== 칸 && l.키 !== item.키);
|
||||
const one: CellLink = { 키: item.키, 이름: item.이름, 칸 };
|
||||
if (item.단위) one.단위 = item.단위;
|
||||
if (item.자재) one.자재 = item.자재;
|
||||
put(sheet, [...rest, one]);
|
||||
const 칸 = at ?? toA1(ctx.selection.활성.r, ctx.selection.활성.c);
|
||||
const others = linksAt(ctx.book, sheet.id, 칸, item.키);
|
||||
const choice = others.length
|
||||
? await askShare(
|
||||
칸,
|
||||
others.map((l) => l.이름 ?? l.키),
|
||||
)
|
||||
: "함께";
|
||||
if (!choice) return back();
|
||||
const info = { 이름: item.이름, 단위: item.단위, 자재: item.자재 };
|
||||
const command = linkCommand(ctx.book, sheet.id, item.키, 칸, info, choice === "바꾸기");
|
||||
if (command) ctx.dispatch(command);
|
||||
back();
|
||||
}
|
||||
|
||||
@@ -184,7 +200,11 @@ export function mountLinks(
|
||||
|
||||
const commit = (): void => opts?.onItemsChange?.(items.map((i) => ({ ...i })));
|
||||
|
||||
function pill(item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined) {
|
||||
function pill(
|
||||
item: LinkItem,
|
||||
hit: { sheet: Sheet; link: CellLink } | undefined,
|
||||
hue: number | undefined,
|
||||
) {
|
||||
const where = hit
|
||||
? hit.sheet.id === ctx.selection.시트
|
||||
? hit.link.칸
|
||||
@@ -199,8 +219,20 @@ export function mountLinks(
|
||||
...(where ? [el("code", { text: where })] : []),
|
||||
],
|
||||
});
|
||||
b.addEventListener("mousedown", (e) => e.preventDefault()); // 초점은 격자에 둠
|
||||
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item)));
|
||||
if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue));
|
||||
b.draggable = !ctx.readOnly;
|
||||
b.addEventListener("dragstart", (e) => {
|
||||
dragging = item;
|
||||
e.dataTransfer?.setData("text/plain", item.키);
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "link";
|
||||
});
|
||||
b.addEventListener("dragend", () => {
|
||||
dragging = null;
|
||||
dropMark.remove();
|
||||
});
|
||||
// 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림)
|
||||
if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item)));
|
||||
const unlink = (): void => {
|
||||
if (hit)
|
||||
put(
|
||||
@@ -274,7 +306,8 @@ export function mountLinks(
|
||||
? ofMaterial(link.자재)
|
||||
: { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 },
|
||||
);
|
||||
const pills = shown.map((item) => pill(item, map.get(item.키)));
|
||||
const hues = linkHues(map.keys());
|
||||
const pills = shown.map((item) => pill(item, map.get(item.키), hues.get(item.키)));
|
||||
if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) {
|
||||
const more = el("button", {
|
||||
className: "ss-link ss-link--add",
|
||||
@@ -287,23 +320,80 @@ export function mountLinks(
|
||||
bar.replaceChildren(...pills);
|
||||
}
|
||||
|
||||
/** 칸 왼아래 작은 세모 — 칸이 든 판 층에 얹어 스크롤을 따라감 */
|
||||
/** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */
|
||||
function placeDots(): void {
|
||||
for (const d of dots) d.remove();
|
||||
dots = [];
|
||||
for (const l of ctx.sheet().연결 ?? []) {
|
||||
const p = parseA1(l.칸);
|
||||
const hues = linkHues(found().keys());
|
||||
// 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수
|
||||
const byCell = new Map<string, CellLink[]>();
|
||||
for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]);
|
||||
for (const [a1, list] of byCell) {
|
||||
const p = parseA1(a1);
|
||||
if (!p) continue;
|
||||
const { layer, box } = slot(ctx, p.r, p.c);
|
||||
if (!box.w || !box.h) continue; // 숨긴 행열
|
||||
const first = list[0];
|
||||
const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": first.키 } });
|
||||
tint.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
|
||||
const dot = el("div", { className: "ss-link-dot" });
|
||||
dot.style.left = `${box.x}px`;
|
||||
dot.style.top = `${box.y + box.h - 7}px`;
|
||||
layer.append(dot);
|
||||
dots.push(dot);
|
||||
const marks = [tint, dot];
|
||||
if (list.length > 1) {
|
||||
const count = el("div", { className: "ss-link-count", text: String(list.length) });
|
||||
count.style.left = `${box.x + box.w}px`;
|
||||
count.style.top = `${box.y}px`;
|
||||
marks.push(count);
|
||||
}
|
||||
for (const d of marks) d.style.setProperty("--ss-link-h", String(hues.get(first.키) ?? 0));
|
||||
layer.append(...marks);
|
||||
dots.push(...marks);
|
||||
}
|
||||
}
|
||||
|
||||
/** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */
|
||||
let tipped = false;
|
||||
const onHover = (e: MouseEvent): void => {
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
const a1 = hit && hit.r >= 0 && hit.c >= 0 ? toA1(hit.r, hit.c) : null;
|
||||
const names = a1
|
||||
? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키)
|
||||
: [];
|
||||
if (names.length) ctx.grid.root.title = names.join(" · ");
|
||||
else if (tipped) ctx.grid.root.removeAttribute("title");
|
||||
tipped = names.length > 0;
|
||||
};
|
||||
|
||||
// ── 알약 끌어 칸에 놓기(40-3) ──────────────────────────────────────────
|
||||
/** 끄는 알약 아래 칸(A1) — 머리 · 격자 밖이면 null */
|
||||
function cellAt(e: DragEvent): { r: number; c: number } | null {
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
return hit && hit.r >= 0 && hit.c >= 0 ? { r: hit.r, c: hit.c } : null;
|
||||
}
|
||||
const onDragOver = (e: DragEvent): void => {
|
||||
const at = dragging && !ctx.readOnly ? cellAt(e) : null;
|
||||
if (!at) return void dropMark.remove();
|
||||
e.preventDefault(); // 놓을 수 있음
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "link";
|
||||
const { layer, box } = slot(ctx, at.r, at.c);
|
||||
dropMark.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
|
||||
if (dropMark.parentElement !== layer) layer.append(dropMark);
|
||||
};
|
||||
const onDragLeave = (e: DragEvent): void => {
|
||||
if (!ctx.grid.root.contains(e.relatedTarget as Node | null)) dropMark.remove();
|
||||
};
|
||||
const onDrop = async (e: DragEvent): Promise<void> => {
|
||||
const item = dragging;
|
||||
const at = item && !ctx.readOnly ? cellAt(e) : null;
|
||||
dropMark.remove();
|
||||
dragging = null;
|
||||
if (!item || !at) return;
|
||||
e.preventDefault();
|
||||
ctx.select(selectCell(ctx.sheet(), at.r, at.c), true);
|
||||
await link(item, toA1(at.r, at.c));
|
||||
};
|
||||
|
||||
// ── 자재 고르기 창 ──────────────────────────────────────────────────────
|
||||
function closePick(): void {
|
||||
pick?.remove();
|
||||
@@ -384,6 +474,11 @@ export function mountLinks(
|
||||
|
||||
const onZoom = (): void => placeDots();
|
||||
ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom);
|
||||
const onDropEvent = (e: DragEvent): void => void onDrop(e);
|
||||
ctx.grid.root.addEventListener("dragover", onDragOver);
|
||||
ctx.grid.root.addEventListener("dragleave", onDragLeave);
|
||||
ctx.grid.root.addEventListener("drop", onDropEvent);
|
||||
ctx.grid.root.addEventListener("mousemove", onHover);
|
||||
|
||||
return {
|
||||
root: opts ? bar : null,
|
||||
@@ -394,6 +489,11 @@ export function mountLinks(
|
||||
},
|
||||
destroy() {
|
||||
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
|
||||
ctx.grid.root.removeEventListener("dragover", onDragOver);
|
||||
ctx.grid.root.removeEventListener("dragleave", onDragLeave);
|
||||
ctx.grid.root.removeEventListener("drop", onDropEvent);
|
||||
ctx.grid.root.removeEventListener("mousemove", onHover);
|
||||
dropMark.remove();
|
||||
closePick();
|
||||
for (const d of dots) d.remove();
|
||||
menu.element.remove();
|
||||
|
||||
@@ -6,6 +6,10 @@ DWG 가 필요하면 LibreDWG 의 `dxf2dwg` 를 **별도 실행 파일로** 불
|
||||
|
||||
그림(Image)은 DXF 에 그대로 담을 수 없어(외부 파일 참조 방식) 내보내지 않는다. 대신 몇 개를
|
||||
건너뛰었는지 세어 화면이 알릴 수 있게 돌려준다.
|
||||
|
||||
표(Table · B07 수량표 · M02 41장 면적산출 표)는 선 + 글로 풀어 쓴다. 짝(CLAUDE.md 5장 ①):
|
||||
`table_parts` ↔ openwebcad `src/helpers/table-geometry.ts`(`tableBorders` · `cellRect` ·
|
||||
`cellTextAnchor`) — 거울 시험 `resources/tester/test_b07_frame_export_table.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,6 +35,14 @@ logger = logging.getLogger(__name__)
|
||||
_DXF_VERSION = "R2018"
|
||||
_DWG_SOURCE_VERSION = "R2004"
|
||||
_DEFAULT_TEXT_MM = 3.0
|
||||
#: 표 기본 글자 크기 · 여백 — openwebcad `DEFAULT_TABLE_STYLE` 과 같게
|
||||
_TABLE_FONT = 2.2
|
||||
_TABLE_PADDING = 1.0
|
||||
_ALIGN = {
|
||||
"left": TextEntityAlignment.MIDDLE_LEFT,
|
||||
"right": TextEntityAlignment.MIDDLE_RIGHT,
|
||||
"center": TextEntityAlignment.MIDDLE_CENTER,
|
||||
}
|
||||
|
||||
|
||||
def _xy(point: Any) -> tuple[float, float] | None:
|
||||
@@ -47,6 +59,96 @@ def _layer_name(raw: Any) -> str:
|
||||
return name[:255] or "0"
|
||||
|
||||
|
||||
def _span(value: Any) -> int:
|
||||
"""병합 칸 수 — TS `Math.max(1, Math.round(v ?? 1))` 와 같게(반올림은 .5 올림)."""
|
||||
number = float(value) if isinstance(value, (int, float)) else 1.0
|
||||
return max(1, math.floor(number + 0.5))
|
||||
|
||||
|
||||
def _owners(cells: list, rows: int, columns: int) -> list[list[str | None]]:
|
||||
owners: list[list[str | None]] = [[None] * columns for _ in range(rows)]
|
||||
for row in range(rows):
|
||||
for column in range(columns):
|
||||
cell = cells[row][column] if row < len(cells) and column < len(cells[row]) else None
|
||||
if not isinstance(cell, dict):
|
||||
continue
|
||||
for r in range(row, min(rows, row + _span(cell.get("rowSpan")))):
|
||||
for c in range(column, min(columns, column + _span(cell.get("colSpan")))):
|
||||
owners[r][c] = f"{row}:{column}"
|
||||
return owners
|
||||
|
||||
|
||||
def table_parts(shape: dict[str, Any]) -> tuple[list[tuple], list[dict[str, Any]]]:
|
||||
"""표 → (경계선 [(x1, y1, x2, y2)], 글 [{text, x, y, align, height}]) — 원점 = 왼쪽 위 · 아래로.
|
||||
|
||||
바깥 테두리는 늘 · 안쪽 선은 양옆(위아래)이 같은 병합 칸이면 끊음 · 글은 칸 가운데 높이 ·
|
||||
정렬에 따라 여백만큼 안쪽."""
|
||||
origin = _xy(shape.get("origin")) or (0.0, 0.0)
|
||||
widths = [float(w) for w in shape.get("columnWidths") or []]
|
||||
heights = [float(h) for h in shape.get("rowHeights") or []]
|
||||
cells = shape.get("cells") or []
|
||||
style = shape.get("style") or {}
|
||||
rows, columns = len(heights), len(widths)
|
||||
if not rows or not columns:
|
||||
return [], []
|
||||
xs, ys = [origin[0]], [origin[1]]
|
||||
for width in widths:
|
||||
xs.append(xs[-1] + width)
|
||||
for height in heights:
|
||||
ys.append(ys[-1] - height)
|
||||
owners = _owners(cells, rows, columns)
|
||||
lines = [
|
||||
(xs[0], ys[0], xs[columns], ys[0]),
|
||||
(xs[0], ys[rows], xs[columns], ys[rows]),
|
||||
(xs[0], ys[0], xs[0], ys[rows]),
|
||||
(xs[columns], ys[0], xs[columns], ys[rows]),
|
||||
]
|
||||
for column in range(1, columns):
|
||||
for row in range(rows):
|
||||
left, right = owners[row][column - 1], owners[row][column]
|
||||
if left is None or left != right:
|
||||
lines.append((xs[column], ys[row], xs[column], ys[row + 1]))
|
||||
for row in range(1, rows):
|
||||
for column in range(columns):
|
||||
above, below = owners[row - 1][column], owners[row][column]
|
||||
if above is None or above != below:
|
||||
lines.append((xs[column], ys[row], xs[column + 1], ys[row]))
|
||||
raw = style.get("padding")
|
||||
padding = float(raw) if isinstance(raw, (int, float)) else _TABLE_PADDING
|
||||
texts = []
|
||||
for row in range(rows):
|
||||
for column in range(columns):
|
||||
cell = cells[row][column] if row < len(cells) and column < len(cells[row]) else None
|
||||
if not isinstance(cell, dict) or not cell.get("text"):
|
||||
continue
|
||||
right = xs[min(columns, column + _span(cell.get("colSpan")))]
|
||||
bottom = ys[min(rows, row + _span(cell.get("rowSpan")))]
|
||||
align = cell.get("align") if cell.get("align") in _ALIGN else "center"
|
||||
x = {"left": xs[column] + padding, "right": right - padding}.get(
|
||||
align, (xs[column] + right) / 2
|
||||
)
|
||||
height = cell.get("fontSize") or style.get("fontSize") or _TABLE_FONT
|
||||
texts.append(
|
||||
{
|
||||
"text": str(cell["text"]),
|
||||
"x": x,
|
||||
"y": (ys[row] + bottom) / 2,
|
||||
"align": align,
|
||||
"height": float(height),
|
||||
}
|
||||
)
|
||||
return lines, texts
|
||||
|
||||
|
||||
def _add_table(space: Any, shape: dict[str, Any], attribs: dict[str, Any]) -> None:
|
||||
lines, texts = table_parts(shape)
|
||||
for x1, y1, x2, y2 in lines:
|
||||
space.add_line((x1, y1), (x2, y2), dxfattribs=attribs)
|
||||
for one in texts:
|
||||
text = space.add_text(one["text"], dxfattribs={**attribs, "height": one["height"]})
|
||||
text.set_placement((one["x"], one["y"]), align=_ALIGN[one["align"]])
|
||||
|
||||
|
||||
def _add_entity(
|
||||
space: Any, entity: dict[str, Any], layers: set[str], skipped: dict[str, int]
|
||||
) -> None:
|
||||
@@ -65,6 +167,9 @@ def _add_entity(
|
||||
# 그림은 DXF 가 외부 파일을 가리키는 방식이라 그대로 옮기지 못한다.
|
||||
skipped["Image"] = skipped.get("Image", 0) + 1
|
||||
return
|
||||
if kind == "Table": # 표 = 선 + 글(칸 정렬 반영)
|
||||
_add_table(space, shape, attribs)
|
||||
return
|
||||
|
||||
start, end = _xy(shape.get("startPoint")), _xy(shape.get("endPoint"))
|
||||
if start and end:
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
revCloudToolStateMachine,
|
||||
updateDimensions,
|
||||
} from '../tools/annotate/leader-table-tools';
|
||||
import { placeTableToolStateMachine } from '../tools/annotate/place-table-tool';
|
||||
import {
|
||||
tableColumnToolStateMachine,
|
||||
tableEditToolStateMachine,
|
||||
@@ -324,6 +325,14 @@ export const TABLE_COMMANDS: CadCommand[] = [
|
||||
tool: Tool.TABLEUNMERGE,
|
||||
machine: tableUnmergeToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'PLACETABLE',
|
||||
label: '표 놓기',
|
||||
glyph: '▦',
|
||||
hint: '부모 화면이 보낸 표(면적산출 범위)를 누른 자리에 놓는다',
|
||||
tool: Tool.PLACETABLE,
|
||||
machine: placeTableToolStateMachine,
|
||||
},
|
||||
];
|
||||
|
||||
export const MARKUP_COMMANDS: CadCommand[] = [
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface TableStyleOptions {
|
||||
textColor: string;
|
||||
/** 문자를 칸 좌우 끝에서 띄우는 거리 */
|
||||
padding: number;
|
||||
/** 면적산출에서 보낸 표의 연결 id(M02 41장) — 옮기기 · 복사(clone)에도 남아 [업데이트]가 이 표를 찾는다 */
|
||||
연결?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_TABLE_STYLE: TableStyleOptions = {
|
||||
@@ -97,6 +99,11 @@ export class TableEntity implements Entity {
|
||||
}
|
||||
|
||||
// ── 쓰기 (표 편집 명령이 쓴다)
|
||||
/** 칸 내용만 통째로 갈아 끼운다 — 자리 · 열 너비 · 행 높이는 그대로(넘치는 칸은 버리고 모자라면 빈 칸) */
|
||||
public replaceCells(cells: TableCells): void {
|
||||
this.cells = normalizeCells(cells, this.rowHeights.length, this.columnWidths.length);
|
||||
}
|
||||
|
||||
public setCell(row: number, column: number, patch: Partial<TableCell>): void {
|
||||
const current = this.cells[row]?.[column];
|
||||
if (!current) return; // 병합에 먹힌 자리는 직접 고치지 않는다
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
*
|
||||
* 표는 셀 격자와 셀 문자만 책임진다. 표 위에 얹는 그림(종단표의 종단곡선 표시선 같은 것)은
|
||||
* 별도 엔티티로 겹쳐 그린다 — 그래야 나중에 종단표도 이 표 객체로 옮길 수 있다.
|
||||
*
|
||||
* 짝(CLAUDE.md 5장 ①): 서버 DXF 내보내기 `B07_DesignDetail_Engine_Frame_Export.table_parts`
|
||||
* (`tableBorders` · `cellRect` · `cellTextAnchor`) — 거울 시험 `test_b07_frame_export_table.py`.
|
||||
*/
|
||||
|
||||
export interface TableCell {
|
||||
|
||||
@@ -33,6 +33,7 @@ import { toast } from 'react-toastify';
|
||||
import { runCommandInput } from '../commands/run-command.ts';
|
||||
import { setRecoveryScope } from '../helpers/autosave.ts';
|
||||
import { registerBoxResizeDrag } from './box-resize-drag.ts';
|
||||
import { handleTableMessage } from './aislo-table-bridge.ts';
|
||||
|
||||
export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing';
|
||||
export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready';
|
||||
@@ -190,6 +191,7 @@ function applyFramePreview(): void {
|
||||
export function registerAisloDrawingBridge() {
|
||||
window.addEventListener('message', async (event: MessageEvent<unknown>) => {
|
||||
if (event.origin !== window.location.origin || event.source !== window.parent) return;
|
||||
if (await handleTableMessage(event.data, notifyParent)) return; // 표 놓기 · 갈기(M02 41장)
|
||||
const message = event.data as Partial<DrawingSaveRequestMessage>;
|
||||
if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TableEntity } from '../entities/TableEntity.ts';
|
||||
import { getEntities, isDrawingDirty, setEntities, setLayers } from '../state.ts';
|
||||
import { getPendingTable, setPendingTable, tableAt } from '../tools/annotate/place-table-tool.ts';
|
||||
import {
|
||||
AISLO_PLACE_TABLE_MESSAGE,
|
||||
AISLO_TABLE_UPDATED_MESSAGE,
|
||||
AISLO_UPDATE_TABLE_MESSAGE,
|
||||
handleTableMessage,
|
||||
updateLinkedTables,
|
||||
} from './aislo-table-bridge.ts';
|
||||
|
||||
const run = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../commands/run-command.ts', () => ({ runCommandInput: run }));
|
||||
|
||||
const layer = { id: 'layer-1', isLocked: false, isVisible: true, name: '작업' };
|
||||
|
||||
function table(link?: string): TableEntity {
|
||||
return new TableEntity(
|
||||
layer.id,
|
||||
new Point(10, 20),
|
||||
[30, 15],
|
||||
[5, 5],
|
||||
[
|
||||
[{ text: '항목', colSpan: 2, key: 'S1!A1' }, null],
|
||||
[
|
||||
{ text: '면적', key: 'S1!A2' },
|
||||
{ text: '12.5', align: 'right', key: 'S1!B2' },
|
||||
],
|
||||
],
|
||||
{ fontSize: 2.5, ...(link ? { 연결: link } : {}) }
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setLayers([layer]);
|
||||
setEntities([]);
|
||||
setPendingTable(null);
|
||||
run.mockClear();
|
||||
});
|
||||
|
||||
describe('표 놓기(PLACETABLE)', () => {
|
||||
it('미리보기 · 놓을 표는 커서 자리(왼쪽 위)에 같은 칸 · 크기 · 연결로', () => {
|
||||
const placed = tableAt(table('T1'), new Point(100, 200), layer.id);
|
||||
expect([placed.getOrigin().x, placed.getOrigin().y]).toEqual([100, 200]);
|
||||
expect(placed.getColumnWidths()).toEqual([30, 15]);
|
||||
expect(placed.getCell(1, 1)?.text).toBe('12.5');
|
||||
expect(placed.getStyle().연결).toBe('T1');
|
||||
});
|
||||
|
||||
it('place-table 메시지 = 연결을 단 표를 맡기고 PLACETABLE 을 켬', async () => {
|
||||
const notify = vi.fn();
|
||||
const handled = await handleTableMessage(
|
||||
{
|
||||
type: AISLO_PLACE_TABLE_MESSAGE,
|
||||
entity: { ...(await table().toJson()), lineColor: '#123456' },
|
||||
link: 'T7',
|
||||
},
|
||||
notify
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(run).toHaveBeenCalledWith('PLACETABLE');
|
||||
expect(getPendingTable()?.getStyle().연결).toBe('T7');
|
||||
expect(getPendingTable()?.getCell(0, 0)?.colSpan).toBe(2);
|
||||
expect(getPendingTable()?.lineColor).toBe('#123456');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('옮기기 · 복사(clone) · JSON 왕복에도 style.연결이 남음', async () => {
|
||||
const linked = table('T1');
|
||||
expect(linked.clone().getStyle().연결).toBe('T1');
|
||||
const json = await linked.toJson();
|
||||
if (!json) throw new Error('표 JSON 없음');
|
||||
const back = await TableEntity.fromJson(json);
|
||||
expect(back.getStyle().연결).toBe('T1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('표 칸 갈기(update-table)', () => {
|
||||
it('연결이 같은 표의 칸만 갈고 자리 · 크기는 그대로 · 되돌리기 한 걸음', () => {
|
||||
const linked = table('T1');
|
||||
const other = table('T2');
|
||||
setEntities([linked, other]);
|
||||
const count = updateLinkedTables('T1', [
|
||||
[{ text: '항목(새)', colSpan: 2 }, null],
|
||||
[{ text: '면적' }, { text: '99.0', align: 'right' }],
|
||||
]);
|
||||
expect(count).toBe(1);
|
||||
expect(linked.getCell(1, 1)?.text).toBe('99.0');
|
||||
expect(linked.getCell(0, 1)).toBeNull(); // 병합 그대로
|
||||
expect(other.getCell(1, 1)?.text).toBe('12.5');
|
||||
expect([linked.getOrigin().x, linked.getColumnWidths()]).toEqual([10, [30, 15]]);
|
||||
expect(isDrawingDirty()).toBe(true);
|
||||
});
|
||||
|
||||
it('넘치는 칸은 버리고 모자란 칸은 빈 칸', () => {
|
||||
const linked = table('T1');
|
||||
setEntities([linked]);
|
||||
updateLinkedTables('T1', [[{ text: 'a' }, { text: 'b' }, { text: '넘침' }]]);
|
||||
expect(linked.getCells().map((row) => row.map((cell) => cell?.text ?? null))).toEqual([
|
||||
['a', 'b'],
|
||||
['', ''],
|
||||
]);
|
||||
});
|
||||
|
||||
it('update-table 메시지 = table-updated{link, count} · 없는 연결은 0 · 도면 그대로', async () => {
|
||||
setEntities([table('T1')]);
|
||||
const before = getEntities();
|
||||
const notify = vi.fn();
|
||||
await handleTableMessage({ type: AISLO_UPDATE_TABLE_MESSAGE, link: 'T9', cells: [] }, notify);
|
||||
expect(notify).toHaveBeenCalledWith(AISLO_TABLE_UPDATED_MESSAGE, { link: 'T9', count: 0 });
|
||||
expect(getEntities()).toBe(before);
|
||||
});
|
||||
|
||||
it('표 메시지가 아니면 false', async () => {
|
||||
expect(await handleTableMessage({ type: 'aislo:b08:save-request' }, vi.fn())).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 부모 화면 ↔ CAD 표 메시지(M02 41장 · 면적산출 범위 → 도면 표). 짝 = `ui_template/cad_host/cad_host.ts`.
|
||||
* place-table{entity, link} 표 JSON(TableEntity 꼴) → `style.연결 = link` 로 표 놓기 도구(PLACETABLE) 시작
|
||||
* update-table{link, cells} `style.연결 === link` 인 표의 칸만 갈아 끼움(자리 · 크기 그대로 · 되돌리기 한 걸음)
|
||||
* → 부모에 table-updated{link, count}
|
||||
* 놓기 · 갈기 모두 `setEntities(…, true)` 를 거쳐 drawing-changed 가 간다. 확정 · 보기 전용이면 안 함.
|
||||
*/
|
||||
import { toast } from 'react-toastify';
|
||||
import { runCommandInput } from '../commands/run-command.ts';
|
||||
import type { JsonEntity } from '../entities/Entity.ts';
|
||||
import { TableEntity } from '../entities/TableEntity.ts';
|
||||
import type { TableCells } from '../helpers/table-geometry.ts';
|
||||
import { getEntities, isDrawingReadOnly, isViewOnly, setEntities } from '../state.ts';
|
||||
import { setPendingTable } from '../tools/annotate/place-table-tool.ts';
|
||||
|
||||
export const AISLO_PLACE_TABLE_MESSAGE = 'aislo:b08:place-table';
|
||||
export const AISLO_UPDATE_TABLE_MESSAGE = 'aislo:b08:update-table';
|
||||
export const AISLO_TABLE_UPDATED_MESSAGE = 'aislo:b08:table-updated';
|
||||
|
||||
/** 두 메시지를 한 꼴로 받음 — place = entity · link · update = link · cells */
|
||||
interface TableMessage {
|
||||
type?: string;
|
||||
entity?: JsonEntity;
|
||||
link?: string;
|
||||
cells?: TableCells;
|
||||
}
|
||||
|
||||
/** 이 표가 그 연결의 것인가 */
|
||||
export const isLinkedTable = (entity: unknown, link: string): entity is TableEntity =>
|
||||
entity instanceof TableEntity && entity.getStyle().연결 === link;
|
||||
|
||||
/** 연결 표마다 칸을 갈아 끼움 — 바꾼 표 수(0 이면 도면을 건드리지 않음) */
|
||||
export function updateLinkedTables(link: string, cells: TableCells): number {
|
||||
const tables = getEntities().filter((entity) => isLinkedTable(entity, link));
|
||||
for (const table of tables) table.replaceCells(cells);
|
||||
if (tables.length) setEntities([...getEntities()], true);
|
||||
return tables.length;
|
||||
}
|
||||
|
||||
/** 표 JSON 을 놓기 도구에 맡기고 도구를 켬 */
|
||||
export async function startPlaceTable(entity: JsonEntity, link: string): Promise<void> {
|
||||
const table = await TableEntity.fromJson(entity as Parameters<typeof TableEntity.fromJson>[0]);
|
||||
const linked = new TableEntity(
|
||||
table.layerId,
|
||||
table.getOrigin(),
|
||||
table.getColumnWidths(),
|
||||
table.getRowHeights(),
|
||||
table.getCells(),
|
||||
{ ...table.getStyle(), 연결: link }
|
||||
);
|
||||
linked.lineColor = table.lineColor; // 보낸 선 색 · 굵기 그대로
|
||||
linked.lineWidth = table.lineWidth;
|
||||
setPendingTable(linked);
|
||||
runCommandInput('PLACETABLE');
|
||||
}
|
||||
|
||||
const blocked = (): boolean => {
|
||||
if (!isDrawingReadOnly() && !isViewOnly()) return false;
|
||||
toast.info('보기 전용 도면 — 표를 넣거나 고치려면 [수정]을 먼저 누르세요.');
|
||||
return true;
|
||||
};
|
||||
|
||||
/** 표 메시지면 처리하고 true — 아니면 false(다른 메시지 갈래로) */
|
||||
export async function handleTableMessage(
|
||||
data: unknown,
|
||||
notify: (type: string, payload: Record<string, unknown>) => void
|
||||
): Promise<boolean> {
|
||||
const message = data as TableMessage | null;
|
||||
if (!message || typeof message !== 'object') return false;
|
||||
if (message.type === AISLO_PLACE_TABLE_MESSAGE && message.entity && message.link) {
|
||||
if (!blocked()) await startPlaceTable(message.entity, message.link);
|
||||
return true;
|
||||
}
|
||||
if (message.type === AISLO_UPDATE_TABLE_MESSAGE && message.link && message.cells) {
|
||||
const count = blocked() ? 0 : updateLinkedTables(message.link, message.cells);
|
||||
notify(AISLO_TABLE_UPDATED_MESSAGE, { link: message.link, count });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -113,6 +113,7 @@ export enum Tool {
|
||||
MLEADERSTYLE = 'MLEADERSTYLE',
|
||||
MLEADERALIGN = 'MLEADERALIGN',
|
||||
TABLE = 'TABLE',
|
||||
PLACETABLE = 'PLACETABLE',
|
||||
TABLESTYLE = 'TABLESTYLE',
|
||||
TABLEEDIT = 'TABLEEDIT',
|
||||
TABLEROW = 'TABLEROW',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 표 놓기(PLACETABLE · M02 41장) — 부모 화면이 보낸 표(면적산출 범위)를 커서에 붙여 따라오게
|
||||
* 그리고, 누르면 그 자리(표 왼쪽 위)에 놓는다. Esc = 그만 · 놓기는 되돌리기 한 걸음.
|
||||
* 놓을 표는 브리지(`integration/aislo-table-bridge.ts`)가 `setPendingTable` 로 맡긴 뒤 이 명령을 켠다.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { TableEntity } from '../../entities/TableEntity';
|
||||
import { addEntities, getActiveLayerId } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
let pending: TableEntity | null = null;
|
||||
|
||||
/** 다음 PLACETABLE 이 놓을 표 — 놓으면 비워진다 */
|
||||
export function setPendingTable(table: TableEntity | null): void {
|
||||
pending = table;
|
||||
}
|
||||
|
||||
export function getPendingTable(): TableEntity | null {
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** `table` 과 같은 꼴(칸 · 크기 · style.연결)을 `at`(왼쪽 위)에 둔 새 표 */
|
||||
export function tableAt(table: TableEntity, at: Point, layerId: string): TableEntity {
|
||||
const placed = new TableEntity(
|
||||
layerId,
|
||||
at.clone(),
|
||||
table.getColumnWidths(),
|
||||
table.getRowHeights(),
|
||||
table.getCells().map((row) => row.map((cell) => (cell ? { ...cell } : null))),
|
||||
table.getStyle()
|
||||
);
|
||||
placed.lineColor = table.lineColor;
|
||||
placed.lineWidth = table.lineWidth;
|
||||
return placed;
|
||||
}
|
||||
|
||||
export const placeTableToolStateMachine = createSequenceTool({
|
||||
tool: Tool.PLACETABLE,
|
||||
steps: [{ kind: 'point', instructions: '표를 놓을 자리(왼쪽 위)를 누르십시오 · Esc 그만.' }],
|
||||
helpers: true,
|
||||
restart: false,
|
||||
preview: (input) => (pending ? [tableAt(pending, input.cursor, getActiveLayerId())] : []),
|
||||
commit: (input) => {
|
||||
if (!pending) return;
|
||||
addEntities([tableAt(pending, input.point(0), getActiveLayerId())], true);
|
||||
pending = null;
|
||||
},
|
||||
});
|
||||
@@ -470,7 +470,7 @@ def _lowest(row: dict) -> object:
|
||||
|
||||
def pick(kind: str, q: str, limit: int) -> dict:
|
||||
"""고르기 창 — `price` 자재품목(관급 줄 포함 · ref = 키) · `job` 값 있는 공표 노임 직종
|
||||
(미확보·추정 빼고 모든 구분)(ref = 키) · 이름·규격 낱말이 모두 들어간 줄."""
|
||||
(미확보·추정 빼고 모든 구분)(ref = 키) · 낱말마다 이름·규격에 들어 있거나 키와 같은 줄."""
|
||||
if kind not in _PICK:
|
||||
raise StoreError(404, f"없는 고르기 「{kind}」")
|
||||
words = [_squash(w) for w in q.split()]
|
||||
@@ -481,7 +481,7 @@ def pick(kind: str, q: str, limit: int) -> dict:
|
||||
):
|
||||
continue
|
||||
text = _squash(f"{row.get('이름')} {row.get('규격')} {' '.join(row.get('옛이름') or [])}")
|
||||
if all(w in text for w in words):
|
||||
if all(w in text or w == _squash(row.get("키")) for w in words):
|
||||
hits.append(
|
||||
{
|
||||
"ref": str(row.get("키")),
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""면적산출 범위 → 도면 표(PLAN 41-1) — 변환은 Node 번들 한 벌(`common_util_spreadsheet_node.ts`
|
||||
`areaTable` · 화면 표시 글과 같은 코드) · 여기는 부르기 · 도면 표 맞춤만(파이썬 이식 없음).
|
||||
|
||||
도면 문서(`structure/<열>.json`) 맨 위 `연결표` = `[{연결, 시트, 범위, 글자크기, 값}]` — 도면 표
|
||||
`shapeData.style.연결` 이 `연결` 과 같은 표를 면적 [저장] 때 칸 글로 다시 적음(자리 · 크기 그대로 ·
|
||||
칸 수가 바뀌면 열 너비 · 행 높이도 새로) · `값` = 마지막 칸 글(`{시트id!A1: 글}` · 다름 알림용).
|
||||
도면에 그 표가 없으면 기록은 그대로 둠. 층 = `store.at(층 폴더)` · 권한은 부르는 쪽.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from common_util import common_util_spreadsheet as sheet_node
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
LINKS = "연결표"
|
||||
|
||||
|
||||
def to_table(column: str, sheet: str, rng: str, font: float | None = None) -> dict[str, Any]:
|
||||
"""그 열 면적산출(저장본) 시트 · 범위 → `{표, 시트, 범위, 값}` — 저장 안 함 · 못 바꾸면 400."""
|
||||
book = store.read("area", column)["문서"]
|
||||
try:
|
||||
got = sheet_node.area_tables([{"book": book, "시트": sheet, "범위": rng, "글자크기": font}])
|
||||
except sheet_node.RecalcError as e:
|
||||
raise store.StoreError(503, str(e)) from e
|
||||
if not got[0].get("ok"):
|
||||
raise store.StoreError(400, got[0].get("까닭"))
|
||||
return {k: got[0][k] for k in ("표", "시트", "범위", "값")}
|
||||
|
||||
|
||||
def _style_link(entity: Any) -> Any:
|
||||
if not isinstance(entity, dict) or entity.get("type") != "Table":
|
||||
return None
|
||||
return ((entity.get("shapeData") or {}).get("style") or {}).get("연결")
|
||||
|
||||
|
||||
def refresh_drawing(column: str) -> list[str]:
|
||||
"""지금 층 그 열 도면의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록."""
|
||||
try:
|
||||
drawing, area = store._path("structure", column), store._path("area", column)
|
||||
except store.StoreError:
|
||||
return []
|
||||
if not drawing.is_file() or not area.is_file():
|
||||
return []
|
||||
doc = json.loads(drawing.read_bytes())
|
||||
links = [x for x in doc.get(LINKS) or [] if isinstance(x, dict) and x.get("연결")]
|
||||
if not links:
|
||||
return []
|
||||
book = json.loads(area.read_bytes())
|
||||
asks = [
|
||||
{"book": book, "시트": x.get("시트"), "범위": x.get("범위"), "글자크기": x.get("글자크기")}
|
||||
for x in links
|
||||
]
|
||||
try:
|
||||
results = sheet_node.area_tables(asks)
|
||||
except sheet_node.RecalcError as e:
|
||||
logger.warning("도면 표 맞춤 건너뜀 %s — %s", column, e)
|
||||
return []
|
||||
entities = (doc.get("도면") or {}).get("entities") or []
|
||||
done: list[str] = []
|
||||
for link, got in zip(links, results):
|
||||
if not got.get("ok"):
|
||||
continue
|
||||
link["값"] = got["값"]
|
||||
new = got["표"]["shapeData"]
|
||||
for entity in entities:
|
||||
if _style_link(entity) != link["연결"]:
|
||||
continue
|
||||
shape = entity["shapeData"]
|
||||
if (len(shape.get("rowHeights") or []), len(shape.get("columnWidths") or [])) != (
|
||||
len(new["rowHeights"]),
|
||||
len(new["columnWidths"]),
|
||||
):
|
||||
shape["rowHeights"], shape["columnWidths"] = new["rowHeights"], new["columnWidths"]
|
||||
shape["cells"] = new["cells"]
|
||||
done.append(link["연결"])
|
||||
with store._LOCK:
|
||||
atomic_write_json(drawing, doc)
|
||||
return done
|
||||
@@ -67,7 +67,7 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
whole = m01.loaded()[1]
|
||||
lines, seen = [], set()
|
||||
for at, one in enumerate(doc.get("담은로직") or [], start=1):
|
||||
where = f"담은 로직 {at}째"
|
||||
where = f"단가산출 로직 {at}째"
|
||||
if not isinstance(one, dict):
|
||||
raise _bad(f"{where} · 묶음 아님")
|
||||
code = str(one.get("로직") or "")
|
||||
|
||||
@@ -22,7 +22,7 @@ import "./M02_Labor_Values.css";
|
||||
const fill = (text: string, values: Record<string, string | number>): string =>
|
||||
text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? ""));
|
||||
|
||||
const CONTROL_TEXT = { auto: "자동값", outOfRange: "범위 {value} 밖" };
|
||||
const CONTROL_TEXT = { outOfRange: "범위 {value} 밖" };
|
||||
|
||||
const autoValue = (input: ItemInput): string | number | null => {
|
||||
const auto = input.자동값;
|
||||
@@ -60,17 +60,20 @@ export function createValueControl(
|
||||
},
|
||||
})
|
||||
) as HTMLInputElement | HTMLSelectElement;
|
||||
// 고르기 칸 — 「자동값 xx」 빈 선택지는 두지 않음(40-1 ③ · 자동값 = 늘 첫 선택지) · 값이 없으면 자동값을
|
||||
// 고른 채 흐리게 보이고 저장은 사용자가 바꿀 때만 · 자동값도 없으면 「—」 빈 선택지 하나
|
||||
if (control instanceof HTMLSelectElement) {
|
||||
control.append(
|
||||
el("option", {
|
||||
attrs: { value: "" },
|
||||
text: auto === null ? "—" : `${CONTROL_TEXT.auto} ${String(auto)}`,
|
||||
}),
|
||||
...(auto === null ? [el("option", { attrs: { value: "" }, text: "—" })] : []),
|
||||
...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })),
|
||||
);
|
||||
}
|
||||
const empty = value === null || value === "";
|
||||
control.value = empty ? "" : String(value);
|
||||
control.value = empty
|
||||
? control instanceof HTMLSelectElement && auto !== null
|
||||
? String(auto)
|
||||
: ""
|
||||
: String(value);
|
||||
control.classList.toggle("is-auto", empty);
|
||||
const error = el("span", { className: "m02-inputval__error" });
|
||||
const out: ValueControl = { control, error, wasBad: false };
|
||||
@@ -81,7 +84,7 @@ export function createValueControl(
|
||||
error.textContent = bad
|
||||
? fill(CONTROL_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` })
|
||||
: "";
|
||||
control.classList.toggle("is-auto", raw === "");
|
||||
control.classList.toggle("is-auto", raw === ""); // 고르기 칸은 고르면 늘 저장 값
|
||||
onChange(storedOf(input, raw), bad);
|
||||
});
|
||||
return out;
|
||||
@@ -124,7 +127,7 @@ const VALUE_TEXT = {
|
||||
title: "넣을 값",
|
||||
missing: "빠진 로직 {value}",
|
||||
edit: "넣을 값 수정",
|
||||
none: "넣을 값이 없음 — 담은 로직에 입력이 없음",
|
||||
none: "넣을 값이 없음 — 단가산출 로직에 입력이 없음",
|
||||
auto: "자동값",
|
||||
unconfirmed: "확인 안 함",
|
||||
confirmed: "✓ 확인함",
|
||||
|
||||
@@ -54,7 +54,8 @@ function reason(detail: unknown, status: number): string {
|
||||
return lines.filter(Boolean).join(" · ") || `HTTP ${status}`;
|
||||
}
|
||||
|
||||
/** 지금 화면의 층 — 시스템 = 옛 길 그대로 · 회사 · 개인 = `/layers/{층}` 아래(구조물집계표 · 조합 길 모두) ·
|
||||
/** 지금 화면의 층 — 시스템 · 회사 · 개인 모두 `/layers/{층}` 아래(구조물집계표 · 조합 길 모두 · 읽기는
|
||||
* 로그인 누구나 · 쓰기는 서버 층 권한 · 옛 `/api/m02/templates/*` 는 시스템 관리자 전용이라 안 씀) ·
|
||||
* `owner` = 남의 층을 볼 때 주인(회사 `{C}` · 개인 `{C}/{U}` · 자기 층 = null · PLAN 27-1c) —
|
||||
* 지금 층 길에 `?owner=` 로 붙음(M02 화면을 떠나면 페이지가 시스템 · null 로 되돌림) */
|
||||
let activeLayer: Layer = "system";
|
||||
@@ -73,24 +74,20 @@ const owned = (layer: Layer, path: string): string =>
|
||||
? `${path}${path.includes("?") ? "&" : "?"}owner=${enc(activeOwner)}`
|
||||
: path;
|
||||
const scoped = <T>(path: string, init: RequestInit = {}): Promise<T> =>
|
||||
call<T>(
|
||||
owned(activeLayer, `${activeLayer === "system" ? "" : `/layers/${activeLayer}`}${path}`),
|
||||
init,
|
||||
);
|
||||
call<T>(owned(activeLayer, `/layers/${activeLayer}${path}`), init);
|
||||
|
||||
const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : "");
|
||||
const head = (layer: Layer): string =>
|
||||
layer === "system" ? "/templates" : `/layers/${layer}/templates`;
|
||||
const head = (layer: Layer): string => `/layers/${layer}/templates`;
|
||||
const item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string =>
|
||||
owned(layer, `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`);
|
||||
|
||||
/** 시스템 길은 배열 · 층 길은 `{층, 양식: [...]}` — 둘 다 목록으로 */
|
||||
/** 층 길은 `{층, 양식: [...]}` · 옛 시스템 길은 배열 — 둘 다 목록으로 */
|
||||
export const listTemplates = async (
|
||||
layer: Layer,
|
||||
projectId: string | null,
|
||||
): Promise<TemplateInfo[]> => {
|
||||
const got = await call<TemplateInfo[] | { 양식: TemplateInfo[] }>(
|
||||
layer === "system" ? head(layer) : owned(layer, `${head(layer)}${q(projectId)}`),
|
||||
owned(layer, `${head(layer)}${layer === "system" ? "" : q(projectId)}`),
|
||||
);
|
||||
return Array.isArray(got) ? got : got.양식;
|
||||
};
|
||||
@@ -114,7 +111,7 @@ export const saveTemplate = (
|
||||
|
||||
/** 시스템 층만 지움 */
|
||||
export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> =>
|
||||
call(`/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" });
|
||||
call(`${head("system")}/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" });
|
||||
|
||||
/** 개인·회사 층에서 지움 — 판이 맞을 때만(다르면 409) */
|
||||
export const deleteLayerTemplate = (
|
||||
@@ -138,6 +135,14 @@ export interface StructureDoc {
|
||||
열: string;
|
||||
도번: string;
|
||||
도면: { entities: unknown[] } & Record<string, unknown>;
|
||||
/** 면적산출 → 도면 표 연결 기록(41 · `AreaToDrawing` · `M02_Area_Table`) — 표 style.연결 = 연결 · 값 = 마지막 칸 글 */
|
||||
연결표?: {
|
||||
연결: string;
|
||||
시트: string;
|
||||
범위: string;
|
||||
글자크기: number;
|
||||
값: Record<string, string>;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** 시스템 층 구조물 도면 목록 */
|
||||
@@ -204,7 +209,8 @@ export interface OptionsDoc {
|
||||
종류: OptionKind[];
|
||||
}
|
||||
|
||||
export const fetchStructureOptions = (): Promise<OptionsDoc> => call("/structure-options");
|
||||
export const fetchStructureOptions = (): Promise<OptionsDoc> =>
|
||||
call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 옛 길(시스템 관리자)
|
||||
|
||||
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
|
||||
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
|
||||
@@ -228,6 +234,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,136 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_AreaCellPick.ts
|
||||
* 엑셀 칸 고르기 모달(PLAN 40-3) — 그 열 면적산출 문서를 읽기만 시트로 띄우고 칸을 누르면 그 주소를 돌려줌.
|
||||
* 일위대가 상세(sub2)가 「수량 → 엑셀에서 고르기」 에 씀 → 돌려받은 칸을 `linkAreaCell` 로 면적 문서에 이어
|
||||
* 일위대가 [저장] 때 함께 저장(여기는 저장 안 함).
|
||||
* · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 이 알약이 이은 칸은 머리 글에
|
||||
* · 칸 누름 · Enter = 그 칸 · [연결 풀기] = 풀기 · 닫기 · Esc · 바깥 = 그만둠(null)
|
||||
* · 그 칸에 다른 알약이 있으면 [함께 잇기](기본) · [바꾸기](`바꾸기: true` → `linkAreaCell(…, true)`) · [취소](모달 그대로)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { askShare } from "../A00_Common/spreadsheet/spreadsheet_link_ask";
|
||||
import { linksAt } from "../A00_Common/spreadsheet/spreadsheet_link_set";
|
||||
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"}` = 그 칸(다른 알약과 함께) ·
|
||||
* `{칸: "B6", 바꾸기: true}` = 그 칸 다른 알약을 빼고 */
|
||||
export type AreaCellPick = { 칸: string | null; 바꾸기?: boolean } | 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,
|
||||
);
|
||||
let book: Workbook | null = null;
|
||||
const choose = async (): Promise<void> => {
|
||||
const at = picked;
|
||||
const sheetId = book?.시트.find((s) => s.id === book?.활성)?.id ?? book?.시트[0]?.id;
|
||||
const others = book && sheetId ? linksAt(book, sheetId, at, opts.pill) : [];
|
||||
const choice = others.length
|
||||
? await askShare(
|
||||
at,
|
||||
others.map((l) => l.이름 ?? l.키),
|
||||
)
|
||||
: "함께";
|
||||
if (!choice || done) return;
|
||||
finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });
|
||||
close();
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
const [{ createSpreadsheet }, got] = 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;
|
||||
book = got;
|
||||
const at = got.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸;
|
||||
head.textContent = at ? fill(TEXT.now, at) : TEXT.none;
|
||||
unlink.hidden = !at;
|
||||
handle = createSpreadsheet(host, structuredClone(got), {
|
||||
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) void choose();
|
||||
});
|
||||
host.addEventListener("keydown", (e) => e.key === "Enter" && void choose());
|
||||
} catch (error) {
|
||||
if (done) return;
|
||||
head.textContent = fill(TEXT.failed, error instanceof Error ? error.message : "");
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import { createButton, createInputField, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { askShare } from "../A00_Common/spreadsheet/spreadsheet_link_ask";
|
||||
import { linksAt, normA1 } from "../A00_Common/spreadsheet/spreadsheet_link_set";
|
||||
import type { CalcValue, LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
|
||||
const TEXT = {
|
||||
@@ -15,6 +17,7 @@ const TEXT = {
|
||||
close: "닫기",
|
||||
now: "지금 연결",
|
||||
none: "연결 없음",
|
||||
empty: "빈 칸",
|
||||
picked: "지금 고른 칸으로 ({value})",
|
||||
address: "칸 주소",
|
||||
hint: "예 B6",
|
||||
@@ -51,6 +54,7 @@ function linkedText(opts: AreaLinkOptions): string | null {
|
||||
.find((l) => l.키 === opts.key);
|
||||
if (!link) return null;
|
||||
const value = opts.sheet.getLinkedValues()[opts.key];
|
||||
if (!value) return `${link.칸} = ${TEXT.empty}`;
|
||||
return [link.칸, "=", cellText(value), opts.info.단위 ?? ""].join(" ").trim();
|
||||
}
|
||||
|
||||
@@ -66,8 +70,21 @@ export function openAreaLink(opts: AreaLinkOptions): void {
|
||||
now.textContent = `${TEXT.now} · ${text ?? TEXT.none}`;
|
||||
unlink.disabled = opts.readOnly || !text;
|
||||
};
|
||||
const done = (cell: string | null): boolean => {
|
||||
if (!opts.sheet.setLink(opts.key, cell, opts.info)) return false;
|
||||
/** 잇기 · 풀기 — 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음(취소 = 모달 그대로) · 틀린 주소 false */
|
||||
const done = async (cell: string | null): Promise<boolean> => {
|
||||
const a1 = cell === null ? null : normA1(cell);
|
||||
if (cell !== null && !a1) return false;
|
||||
const book = opts.sheet.getDoc();
|
||||
const sheetId = (book.시트.find((s) => s.id === book.활성) ?? book.시트[0])?.id ?? "";
|
||||
const others = a1 ? linksAt(book, sheetId, a1, opts.key) : [];
|
||||
const choice = others.length
|
||||
? await askShare(
|
||||
a1 ?? "",
|
||||
others.map((l) => l.이름 ?? l.키),
|
||||
)
|
||||
: "함께";
|
||||
if (!choice) return true;
|
||||
if (!opts.sheet.setLink(opts.key, a1, opts.info, choice === "바꾸기")) return false;
|
||||
close();
|
||||
return true;
|
||||
};
|
||||
@@ -83,9 +100,8 @@ export function openAreaLink(opts: AreaLinkOptions): void {
|
||||
label: TEXT.link,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
if (!done(address.input.value)) address.setError(TEXT.bad);
|
||||
},
|
||||
onClick: () =>
|
||||
void done(address.input.value).then((ok) => ok || address.setError(TEXT.bad)),
|
||||
});
|
||||
const unlink = createButton({
|
||||
label: TEXT.unlink,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -47,13 +47,27 @@ export interface AreaPanel {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
/** 시트 알약 값 → 수만(`{키: 수}`) — 수 아닌 칸은 빠짐(「수량 없음」) */
|
||||
/** 칸 값 → 수(쉼표 뗌) — 빈 글 · 글 · 참거짓은 null(서버 `M02_Labor_Doc._number` 와 같음) */
|
||||
function toNumber(raw: unknown): number | null {
|
||||
if (raw === null || raw === undefined || typeof raw === "boolean") return null;
|
||||
const text = String(raw).replace(/,/g, "").trim();
|
||||
const n = Number(text);
|
||||
return text !== "" && Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** 시트 알약 값 → `{키: 수}` — 서버 `M02_Labor_Doc.area_values` 와 같은 규칙(짝): 식 칸 = 계산 값의 수 ·
|
||||
* 그 밖 = 칸 값 · 빈 칸 · 글은 빠짐(「수량 없음」 · 반영 「—」) */
|
||||
function quantities(sheet: SpreadsheetHandle | null): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const [key, v] of Object.entries(sheet?.getLinkedValues() ?? {})) {
|
||||
const n = "수" in v ? Number(v.수) : NaN;
|
||||
if (Number.isFinite(n)) out[key] = n;
|
||||
}
|
||||
if (!sheet) return out;
|
||||
const values = sheet.getLinkedValues();
|
||||
for (const s of sheet.getDoc().시트)
|
||||
for (const l of s.연결 ?? []) {
|
||||
const cell = s.칸[l.칸];
|
||||
const v = values[l.키];
|
||||
const n = toNumber(cell?.식 !== undefined ? (v && "수" in v ? v.수 : null) : cell?.값);
|
||||
if (n !== null) out[l.키] = n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_AreaToDrawing.ts
|
||||
* 면적산출 범위 → 도면 표(PLAN 41-3) — 편집 칸(`UI_Main`)은 갈고리만 · 몸은 여기.
|
||||
* ① 면적 상세 머리 [도면으로 보내기] — 고른 범위 · 저장 안 한 고침은 먼저 저장 물음 · 작은 창 글자 크기(기본 2.5)
|
||||
* → 서버 표 변환 길(`POST /layers/{층}/area/{열}/table` · 41-1)로 표 객체 받음 → 같은 페이지에서 그 열
|
||||
* 도면 상세로 옮기며 넘김(닫힘 변수 · 캐시 · 서버 안 씀) → 도면 손잡이 `placeTable`(놓기 도구 · 41-2)
|
||||
* ② 도면 [저장] — 문서 맨 위 `연결표[]` = {연결(T1…), 시트, 범위, 글자크기, 값(마지막 칸 글 `{시트!A1: 글}`)} ·
|
||||
* 표 style.연결 = 연결 · 도면에서 지운 표는 기록에서 뺌(면적 [저장] 때 서버가 칸 글 · 값을 다시 적음 · 41-1)
|
||||
* ③ 도면을 열 때 — 연결표마다 지금 면적 값과 마지막 값이 다르면 알림 한 줄 + [업데이트](`updateTable`)
|
||||
* ④ 표 여러 개 — id 마다 한 줄
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { getActiveOwner, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
/** 표 칸 하나(CAD `TableCell` 꼴에서 쓰는 것만) */
|
||||
interface TableCell {
|
||||
text?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
/** CAD 표 객체 JSON(`TableEntity.toJson` 꼴에서 쓰는 것만) */
|
||||
interface TableEntityJson {
|
||||
type: string;
|
||||
shapeData: { cells: (TableCell | null)[][]; style?: Record<string, unknown> } & Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
/** 연결 기록 한 줄 — 도면 문서 맨 위 `연결표[]`(값 = 마지막 칸 글 · 다름 알림 기준) */
|
||||
export type TableLink = NonNullable<StructureDoc["연결표"]>[number];
|
||||
type LinkSpec = Pick<TableLink, "시트" | "범위" | "글자크기">;
|
||||
/** 표 변환 길 답(41-1) — 시트 = 시트 id */
|
||||
interface AreaTable {
|
||||
표: TableEntityJson;
|
||||
시트: string;
|
||||
범위: string;
|
||||
값: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 도면 손잡이에 더해지는 표 함수(41-2 · `DrawingTemplateHandle`) — 없으면 놓기 · 알림이 빠짐 */
|
||||
interface TableHandle {
|
||||
placeTable?: (entity: Record<string, unknown>, link: string) => void;
|
||||
updateTable?: (link: string, cells: unknown[][]) => Promise<number>;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
send: "도면으로 보내기",
|
||||
sendTip: "고른 범위를 이 열 도면에 표로 넣음 — 놓을 자리는 도면에서 누름",
|
||||
title: "도면으로 보내기",
|
||||
range: "범위",
|
||||
size: "글자 크기",
|
||||
ok: "보내기",
|
||||
close: "닫기",
|
||||
badSize: "글자 크기는 0 보다 큰 수",
|
||||
saveFirst: "저장 안 한 고침이 있음 — 저장하고 보낼까요?",
|
||||
saveLabel: "저장하고 보내기",
|
||||
failed: "표를 못 만듦 — {value}",
|
||||
noRange: "보낼 범위를 시트에서 먼저 고름",
|
||||
empty: "보낼 칸에 내용이 없음",
|
||||
place: "도면에서 표를 놓을 자리를 누름(Esc = 그만)",
|
||||
noPlace: "이 도면 화면은 표 놓기를 아직 못 함",
|
||||
stale: "면적산출 값이 바뀐 표 {n}개",
|
||||
update: "업데이트",
|
||||
updated: "표 {n}개를 지금 면적 값으로 바꿈 — [저장]하면 남음",
|
||||
} as const;
|
||||
|
||||
const SIZE = 2.5; // 표 글자 크기 기본(정함 41)
|
||||
const LINK = "연결"; // 표 style 안 표지 칸
|
||||
|
||||
const fill = (text: string, key: string, value: string | number): string =>
|
||||
text.replace(`{${key}}`, String(value));
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
/** 범위 → 표 객체 · 칸 글(41-1 서버 · 저장본 면적 · 저장 안 함) — 지금 보는 층 · 주인 */
|
||||
async function fetchTable(layer: Layer, column: string, spec: LinkSpec): Promise<AreaTable> {
|
||||
const owner = getActiveOwner();
|
||||
const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : "";
|
||||
const res = await fetch(`${API_BASE_URL}/m02/layers/${layer}/area/${enc(column)}/table${q}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ 시트: spec.시트, 범위: spec.범위, 글자크기: spec.글자크기 }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & Partial<AreaTable>;
|
||||
if (!res.ok || !data.표) {
|
||||
const d = data.detail;
|
||||
throw new Error(typeof d === "string" ? d : `HTTP ${res.status}`);
|
||||
}
|
||||
return data as AreaTable;
|
||||
}
|
||||
|
||||
const isTable = (e: unknown): e is TableEntityJson =>
|
||||
!!e && typeof e === "object" && (e as { type?: unknown }).type === "Table";
|
||||
const linkOf = (e: TableEntityJson): string | null => {
|
||||
const id = e.shapeData?.style?.[LINK];
|
||||
return typeof id === "string" && id ? id : null;
|
||||
};
|
||||
/** 칸 글 묶음 견줌 — 열쇠 차례와 무관 */
|
||||
const sameValues = (a: Record<string, string> = {}, b: Record<string, string> = {}): boolean => {
|
||||
const ka = Object.keys(a);
|
||||
return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
|
||||
};
|
||||
|
||||
export interface AreaToDrawingOptions {
|
||||
/** 같은 페이지에서 그 열 도면 상세를 엶(`UI_Main.open`) */
|
||||
open: (sel: Selection) => Promise<void>;
|
||||
/** 지금 화면 [저장] */
|
||||
save: () => Promise<void>;
|
||||
/** 저장 안 한 고침이 남았나 */
|
||||
isDirty: () => boolean;
|
||||
/** 편집 칸 알림 줄 */
|
||||
showNotice: (nodes: (HTMLElement | string)[]) => void;
|
||||
}
|
||||
|
||||
export interface AreaToDrawing {
|
||||
/** 면적 시트 고름(`onSelect`) — 범위를 들고 있음 */
|
||||
select: (s: { 시트: string; 범위: string }) => void;
|
||||
/** 면적 상세가 섰음 — 머리 칸에 [도면으로 보내기](고칠 수 있는 층 · 프로젝트 층 아님) */
|
||||
onArea: (at: Selection, readOnly: boolean, slot: HTMLElement) => void;
|
||||
/** 도면 상세가 섰음 — 넘겨받은 표 놓기 · 연결표 다름 알림 */
|
||||
onDrawing: (handle: unknown, at: Selection, doc: StructureDoc) => void;
|
||||
/** 보낸 표 놓기 기다리는 중 — 편집 칸 「저장 안 한 고침」 에 듦 */
|
||||
waiting: () => boolean;
|
||||
/** 떠나기로 함(물음 [확인]) — 기다리던 표 버림 */
|
||||
drop: () => void;
|
||||
/** 도면 저장 문서 — 맨 위 `연결표` 를 도면의 표에 맞춤 */
|
||||
withLinks: (doc: StructureDoc) => StructureDoc;
|
||||
}
|
||||
|
||||
export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
|
||||
let range: { 시트: string; 범위: string } | null = null;
|
||||
/** 면적 상세 → 도면 상세로 넘길 표(같은 열 · 같은 층일 때만 놓음) */
|
||||
let pending: { layer: Layer; column: string; got: AreaTable; 글자크기: number } | null = null;
|
||||
/** 이 도면에서 새로 놓은 표 · [업데이트]로 바뀐 값(저장 때 기록) · 열어 둔 도면 */
|
||||
let fresh = new Map<string, TableLink>();
|
||||
let latest = new Map<string, Record<string, string>>();
|
||||
let shown: string | null = null;
|
||||
/** 보낸 표가 도면에 놓이길 기다림 — 떠날 때 「저장 안 한 고침」 물음(`waiting`) */
|
||||
let waiting = false;
|
||||
|
||||
/** 글자 크기 작은 창 — 보내기면 크기 · 닫으면 null */
|
||||
const askSize = (spec: { 범위: string }): Promise<number | null> =>
|
||||
new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (v: number | null): void => {
|
||||
if (!done) resolve(v);
|
||||
done = true;
|
||||
};
|
||||
const size = createInputField({ label: TEXT.size, type: "number", value: String(SIZE) });
|
||||
size.input.step = "0.5";
|
||||
size.input.min = "0.1";
|
||||
const { close } = openModal({
|
||||
title: TEXT.title,
|
||||
closeLabel: TEXT.close,
|
||||
dialogClass: "m02-modal",
|
||||
beforeClose: async () => (finish(null), true),
|
||||
mount: (body) => {
|
||||
const ok = createButton({ label: TEXT.ok, variant: "filled" });
|
||||
ok.addEventListener("click", () => {
|
||||
const v = Number(size.input.value);
|
||||
if (!(v > 0)) return showToast(TEXT.badSize, "error");
|
||||
finish(v);
|
||||
close();
|
||||
});
|
||||
size.input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.isComposing) ok.click();
|
||||
});
|
||||
body.append(el("p", { text: `${TEXT.range} ${spec.범위}` }), size.root, ok);
|
||||
size.input.select();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
async function send(at: Selection): Promise<void> {
|
||||
if (!range) return showToast(TEXT.noRange, "error");
|
||||
if (opt.isDirty()) {
|
||||
if (!(await showConfirmDialog(TEXT.saveFirst, TEXT.saveLabel))) return;
|
||||
await opt.save();
|
||||
if (opt.isDirty()) return; // 저장 실패 — 알림은 저장 길이 띄움
|
||||
}
|
||||
const picked = range;
|
||||
const get = (size: number): Promise<AreaTable | null> =>
|
||||
fetchTable(at.layer, at.name, { 시트: picked.시트, 범위: picked.범위, 글자크기: size }).catch(
|
||||
(error: unknown) => (showToast(fill(TEXT.failed, "value", why(error)), "error"), null),
|
||||
);
|
||||
// 보이는 글이 하나도 없는 범위(빈 칸만)는 크기를 묻기 전에 막음
|
||||
let got = await get(SIZE);
|
||||
if (!got) return;
|
||||
if (!Object.values(got.값 ?? {}).some((v) => String(v ?? "").trim())) {
|
||||
return showToast(TEXT.empty, "error");
|
||||
}
|
||||
const size = await askSize(picked);
|
||||
if (size === null) return;
|
||||
if (size !== SIZE) got = await get(size);
|
||||
if (!got) return;
|
||||
pending = { layer: at.layer, column: at.name, got, 글자크기: size };
|
||||
await opt.open({
|
||||
layer: at.layer,
|
||||
projectId: null,
|
||||
kind: "structure",
|
||||
name: at.name,
|
||||
label: at.label,
|
||||
});
|
||||
}
|
||||
|
||||
/** 다음 연결 id — 기록 · 도면 표지에 없는 가장 작은 T번호 */
|
||||
const nextId = (doc: StructureDoc, taken: Set<string>): string => {
|
||||
for (const row of doc.연결표 ?? []) taken.add(row.연결);
|
||||
for (const e of doc.도면?.entities ?? []) if (isTable(e)) taken.add(linkOf(e) ?? "");
|
||||
let n = 1;
|
||||
while (taken.has(`T${n}`)) n += 1;
|
||||
return `T${n}`;
|
||||
};
|
||||
|
||||
/** 연결표마다 지금 면적 값 → 마지막 값과 다르면 알림 + [업데이트] */
|
||||
async function checkStale(handle: TableHandle, at: Selection, doc: StructureDoc): Promise<void> {
|
||||
const rows = doc.연결표 ?? [];
|
||||
if (!rows.length || !handle.updateTable) return;
|
||||
const key = `${at.layer}:${at.name}`;
|
||||
const got = await Promise.all(
|
||||
rows.map((row) =>
|
||||
fetchTable(at.layer, at.name, row).then(
|
||||
(e) => ({ row, e }),
|
||||
() => null,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (shown !== key) return; // 그 사이 다른 화면
|
||||
const stale = got.filter(
|
||||
(x): x is { row: TableLink; e: AreaTable } => !!x && !sameValues(x.row.값, x.e.값),
|
||||
);
|
||||
if (!stale.length) return;
|
||||
const update = createButton({ label: TEXT.update, variant: "filled", size: "sm" });
|
||||
update.addEventListener("click", async () => {
|
||||
update.disabled = true;
|
||||
let n = 0;
|
||||
for (const { row, e } of stale) {
|
||||
const changed = (await handle.updateTable?.(row.연결, e.표.shapeData.cells)) ?? 0;
|
||||
if (changed) latest.set(row.연결, e.값);
|
||||
n += changed ? 1 : 0;
|
||||
}
|
||||
if (shown !== key) return;
|
||||
opt.showNotice([]);
|
||||
showToast(fill(TEXT.updated, "n", n), "success");
|
||||
});
|
||||
opt.showNotice([fill(TEXT.stale, "n", stale.length), update]);
|
||||
}
|
||||
|
||||
return {
|
||||
select: (s) => (range = { 시트: s.시트, 범위: s.범위 }),
|
||||
waiting: () => waiting,
|
||||
drop: () => (waiting = false),
|
||||
onArea: (at, readOnly, slot) => {
|
||||
waiting = false;
|
||||
// 범위는 그대로 — 시트가 처음 고름(A1)을 이 갈고리보다 먼저 알림
|
||||
if (readOnly || at.layer === "project") return;
|
||||
const button = createButton({ label: TEXT.send, variant: "ghost", size: "sm" });
|
||||
button.title = TEXT.sendTip;
|
||||
button.dataset.areaToDrawing = "";
|
||||
button.addEventListener("click", () => void send(at));
|
||||
slot.append(button);
|
||||
},
|
||||
onDrawing: (raw, at, doc) => {
|
||||
waiting = false;
|
||||
const handle = raw as TableHandle;
|
||||
shown = `${at.layer}:${at.name}`;
|
||||
range = null; // 면적 상세를 떠남
|
||||
fresh = new Map();
|
||||
latest = new Map();
|
||||
const give =
|
||||
pending && pending.layer === at.layer && pending.column === at.name ? pending : null;
|
||||
pending = null;
|
||||
if (give) {
|
||||
if (!handle.placeTable) showToast(TEXT.noPlace, "error");
|
||||
else {
|
||||
const id = nextId(doc, new Set());
|
||||
const { 표, 시트, 범위, 값 } = give.got;
|
||||
const style = { ...(표.shapeData.style ?? {}), [LINK]: id };
|
||||
fresh.set(id, { 연결: id, 시트, 범위, 글자크기: give.글자크기, 값 });
|
||||
handle.placeTable({ ...표, shapeData: { ...표.shapeData, style } }, id);
|
||||
waiting = true; // 놓기 전 떠나도 물음(놓으면 도면 고침 · [저장] 때 풂)
|
||||
showToast(TEXT.place, "info");
|
||||
}
|
||||
}
|
||||
void checkStale(handle, at, doc);
|
||||
},
|
||||
withLinks: (doc) => {
|
||||
waiting = false;
|
||||
const old = new Map((doc.연결표 ?? []).map((row) => [row.연결, row]));
|
||||
const rows: TableLink[] = [];
|
||||
for (const e of doc.도면?.entities ?? []) {
|
||||
if (!isTable(e)) continue;
|
||||
const id = linkOf(e);
|
||||
const row = id ? (old.get(id) ?? fresh.get(id)) : undefined;
|
||||
if (!id || !row || rows.some((r) => r.연결 === id)) continue;
|
||||
rows.push({ ...row, 값: latest.get(id) ?? row.값 });
|
||||
}
|
||||
// 기록이 없던 도면은 칸을 안 만듦(옛 문서 그대로)
|
||||
return rows.length || doc.연결표 ? { ...doc, 연결표: rows } : doc;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -77,6 +77,9 @@ function migrateOldRecovery(options: DrawingTemplateOptions, scope: string): voi
|
||||
|
||||
export interface DrawingTemplateHandle {
|
||||
getDoc: () => Promise<DrawingTemplateDoc>;
|
||||
/** 표 놓기 · 칸 갈기(41장) — `cad_host` 그대로 */
|
||||
placeTable: (entity: Record<string, unknown>, link: string) => void;
|
||||
updateTable: (link: string, cells: unknown[][]) => Promise<number>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
@@ -277,6 +280,8 @@ export function mountDrawingTemplate(
|
||||
|
||||
return {
|
||||
getDoc,
|
||||
placeTable: cad.placeTable,
|
||||
updateTable: cad.updateTable,
|
||||
destroy: () => {
|
||||
areaModal?.close();
|
||||
cad.destroy();
|
||||
|
||||
@@ -56,10 +56,10 @@ export const PART_WORD: Record<CardKind, string> = {
|
||||
|
||||
const TEXT = {
|
||||
drawingInfo: "도면 정보",
|
||||
edit: "도면 수정",
|
||||
edit: "수정", // 카드 셋 같은 글(40-2) — 무엇을 고치는지는 툴팁
|
||||
noNumber: "도번 없음",
|
||||
noDrawing: "도면 없음",
|
||||
editSheet: "{value} 수정",
|
||||
editTip: "{value} 수정 — 전체 편집 화면",
|
||||
noSheetDoc: "{value} 없음",
|
||||
noFieldNumber: "{value} 없음",
|
||||
noSheet: "스프레드시트를 못 띄움 — {value}",
|
||||
@@ -68,7 +68,11 @@ const TEXT = {
|
||||
sheetHelp: "단축키 — 칸을 고르고 바로 침",
|
||||
pills: "알약",
|
||||
pillsTip: "아래 알약 줄 보임 · 숨김",
|
||||
take: "복사해 가져오기",
|
||||
take: "가져오기",
|
||||
clear: "삭제",
|
||||
clearTip: "이 항목의 {value} 을(를) 비움 — 번호 · 열은 그대로",
|
||||
clearAsk: "「{name}」 의 {value} 을(를) 비울까? — 번호 · 열은 그대로 · 되돌릴 수 없음",
|
||||
clearFailed: "비우지 못함 — {value}",
|
||||
takeTip: "다른 항목의 {value} 을(를) 골라 이 칸에 덮어씀 — 번호 · 열은 그대로",
|
||||
blank: "비어 있음 — 새로 만들거나 다른 항목에서 복사해 옴",
|
||||
makeNew: "새로 만들기",
|
||||
@@ -117,6 +121,9 @@ export interface ItemCardsOptions {
|
||||
sheetEdit?: SheetEditOptions;
|
||||
/** 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 다른 항목의 이 칸을 골라 덮어씀 · 없으면 단추 없음 */
|
||||
onImport?: (kind: CardKind) => void;
|
||||
/** 카드 [삭제](40-2) — 그 항목 비우기(번호 · 열 그대로 · 물음은 카드가 한 번) · 없으면 단추 없음 ·
|
||||
* 끝나면 카드를 다시 읽음(빈 카드 = [새로 만들기] [복사 생성]) */
|
||||
onClear?: (kind: CardKind) => Promise<void>;
|
||||
/** 일위대가 카드(39-5 · 몸 = 일위대가 보기 갈래 `Labor_View`) — 도면 다음 · 지금 층에서 읽음 ·
|
||||
* [일위대가 수정] · 빈 카드 [새로 만들기] = 이것(일위대가 상세) */
|
||||
labor?: () => void;
|
||||
@@ -174,6 +181,8 @@ interface Card {
|
||||
edit: HTMLButtonElement;
|
||||
/** 「복사해 가져오기」 — 찬 카드만 */
|
||||
take: HTMLButtonElement;
|
||||
/** [삭제] — 찬 카드만 · 쓸 수 있을 때만 */
|
||||
del: HTMLButtonElement;
|
||||
no: HTMLElement;
|
||||
name: HTMLElement;
|
||||
}
|
||||
@@ -209,22 +218,44 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
kind === "structure" ? DRAWING_KEYS : SHEET_KEYS,
|
||||
);
|
||||
const edit = createButton({
|
||||
label: kind === "structure" ? TEXT.edit : fill(TEXT.editSheet, PART_WORD[kind]),
|
||||
label: TEXT.edit,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
});
|
||||
edit.title = fill(TEXT.editTip, PART_WORD[kind]);
|
||||
edit.addEventListener("click", () => (kind === "labor" ? opts.labor?.() : opts.onEdit(kind)));
|
||||
const take = createButton({ label: TEXT.take, variant: "ghost", size: "sm" });
|
||||
take.title = fill(TEXT.takeTip, PART_WORD[kind]);
|
||||
take.addEventListener("click", () => opts.onImport?.(kind));
|
||||
shell.tools.append(help, saver.button, take, edit);
|
||||
return { kind, shell, saver, help, edit, take, no, name };
|
||||
const del = createButton({ label: TEXT.clear, variant: "ghost", size: "sm" });
|
||||
del.title = fill(TEXT.clearTip, PART_WORD[kind]);
|
||||
del.addEventListener("click", () => void clearCard(kind));
|
||||
shell.tools.append(help, saver.button, take, edit, del);
|
||||
return { kind, shell, saver, help, edit, take, del, no, name };
|
||||
};
|
||||
|
||||
/** 카드 [삭제](40-2) — 물음 한 번(저장 안 한 카드 고침도 함께 버려짐) → 비움 → 카드 다시 읽음(빈 카드가 결과) */
|
||||
async function clearCard(kind: CardKind): Promise<void> {
|
||||
if (!opts.onClear || !column) return;
|
||||
const ask = fill(TEXT.clearAsk, PART_WORD[kind]).replace("{name}", label);
|
||||
if (!(await showConfirmDialog(ask))) return;
|
||||
try {
|
||||
await opts.onClear(kind);
|
||||
} catch (error) {
|
||||
// 알림은 카드 틀 안(토스트 안 씀) — 까닭 + [다시 불러오기]
|
||||
const card = cards.find((c) => c.kind === kind);
|
||||
return void card?.shell.fail(fill(TEXT.clearFailed, why(error)), reload);
|
||||
}
|
||||
for (const c of cards) c.saver.mark(false);
|
||||
started.delete(kind);
|
||||
reload();
|
||||
}
|
||||
|
||||
/** 빈 카드(39-5) — 고칠 수 있고(가져오기 길이 있는 M02 화면) [새로 만들기] 를 안 눌렀으면 두 단추 · 아니면 미리보기 */
|
||||
const blank = (c: Card, empty: boolean): boolean => {
|
||||
const show = empty && opts.editable() && !!opts.onImport && !started.has(c.kind);
|
||||
c.take.hidden = show || !opts.editable() || !opts.onImport;
|
||||
c.del.hidden = show || !opts.editable() || !opts.onClear; // 빈 카드엔 비울 것이 없음
|
||||
return show;
|
||||
};
|
||||
/** 빈 카드 몸 — [새로 만들기] = 그 자리에서 빈 미리보기 편집 · [복사 생성] = 가져오기 창 */
|
||||
@@ -306,7 +337,10 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
const paintLabor = async (at: string, mine: number): Promise<void> => {
|
||||
if (!laborCard) return;
|
||||
try {
|
||||
laborView ??= (await import("./M02_MasterTemplete_Labor_View")).createLaborView();
|
||||
// 카드 = 한 줄 표(항목 · 수량산출 · 단위 · 금액 넷 · 40-2) — 자세한 것은 [수정] 상세
|
||||
laborView ??= (await import("./M02_MasterTemplete_Labor_View")).createLaborView(undefined, {
|
||||
compact: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (mine === seq) laborCard.shell.fail(fill(TEXT.noSheet, why(error)), reload);
|
||||
return;
|
||||
@@ -325,8 +359,8 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
const cards: Card[] = [info, ...(laborCard ? [laborCard] : []), ...sheets];
|
||||
const defaults: Record<CardKind, string> = {
|
||||
structure: TEXT.edit,
|
||||
area: fill(TEXT.editSheet, PART_WORD.area),
|
||||
labor: fill(TEXT.editSheet, PART_WORD.labor),
|
||||
area: TEXT.edit,
|
||||
labor: TEXT.edit,
|
||||
};
|
||||
|
||||
const root = el("div", {
|
||||
@@ -344,6 +378,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
const can = c.kind === "structure" ? drawEdit : c.kind !== "labor" && sheetEdit;
|
||||
c.saver.button.hidden = c.help.hidden = !can;
|
||||
c.edit.hidden = !opts.editable();
|
||||
c.del.hidden = !opts.editable() || !opts.onClear; // 빈 카드는 그린 뒤 `blank` 가 다시 숨김
|
||||
}
|
||||
pills.hidden = !sheetEdit;
|
||||
};
|
||||
@@ -478,6 +513,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
return;
|
||||
}
|
||||
root.hidden = false;
|
||||
paintPills(readStateRaw("m02-card-pills") === "true"); // 계정 취향을 늦게 받았어도 열 때마다 따름
|
||||
if (col.id !== column) started.clear();
|
||||
column = col.id;
|
||||
label = col.label;
|
||||
@@ -496,8 +532,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
c.host.replaceChildren();
|
||||
}
|
||||
const source = opts.source();
|
||||
// 0.3초 넘으면 카드마다 칸 안 「불러오는 중」(PLAN 30-6 · 31-7)
|
||||
const stops = cards.map((c) => c.shell.loading());
|
||||
// 0.3초 넘으면 카드마다 칸 안 「불러오는 중」(PLAN 30-6 · 31-7) — 일위대가 카드는 뺌:
|
||||
// 몸(Labor_View)은 따로 그려지고 제 「불러오는 중」 이 있음 · 넣으면 덮였다 지워져 빈 카드(40 D1)
|
||||
const stops = cards.filter((c) => c !== laborCard).map((c) => c.shell.loading());
|
||||
const [drawing, ...books] = await Promise.allSettled([
|
||||
source.read("structure", col.id),
|
||||
...sheets.map((c) => source.read(c.kind, col.id)),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createInputField, el } from "@ui/ui_template_elements";
|
||||
import { createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells";
|
||||
import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Style.css";
|
||||
@@ -22,12 +22,21 @@ export interface ItemRow extends TreeItem {
|
||||
도번?: string | null;
|
||||
면적번?: string | null;
|
||||
수정일?: Partial<Record<"structure" | "area" | "labor", string>>;
|
||||
labor?: { 번호: string | null; 로직수: number; 합계: number | null } | null;
|
||||
labor?: {
|
||||
번호: string | null;
|
||||
로직수: number;
|
||||
/** 수량 반영 합 · 기준(단가) 합 · 일위대가 문서 단위 */
|
||||
합계: number | null;
|
||||
기준?: number | null;
|
||||
단위?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Col<R> {
|
||||
head: string;
|
||||
get: (row: R) => string;
|
||||
/** 값 뒤 작은 글(예 「기준」) — 없으면 없음 */
|
||||
note?: (row: R) => string;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
@@ -51,6 +60,17 @@ const latest = (row: ItemRow): string =>
|
||||
.pop(),
|
||||
);
|
||||
|
||||
/** 일위대가 합계(39-8 B2) — 수량 반영 합이 있으면 그것 · 없으면 기준(단가) 합 + 작은 글 「기준」 ·
|
||||
* 담은 로직 0 · 계산값 없음 = 「—」 */
|
||||
export function laborSum(row: ItemRow): { text: string; note: string } {
|
||||
const l = row.labor;
|
||||
if (!l?.로직수) return { text: DASH, note: "" };
|
||||
if (l.합계) return { text: money(l.합계), note: "" };
|
||||
return typeof l.기준 === "number"
|
||||
? { text: money(l.기준), note: "기준" }
|
||||
: { text: DASH, note: "" };
|
||||
}
|
||||
|
||||
const NAME: Col<ItemRow> = { head: "이름", get: (r) => leafName(r) };
|
||||
const FOLDER: Col<ItemRow> = { head: "폴더", get: (r) => folderOf(r).join(" / ") };
|
||||
const UNIT: Col<ItemRow> = { head: "단위", get: (r) => r.단위 ?? "" };
|
||||
@@ -73,9 +93,9 @@ export const ITEM_COLS: Record<ListKind, Col<ItemRow>[]> = {
|
||||
{ head: "일위대가번", get: (r) => r.labor?.번호 || DASH },
|
||||
NAME,
|
||||
FOLDER,
|
||||
UNIT,
|
||||
{ head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) },
|
||||
{ head: "합계", get: (r) => money(r.labor?.합계) },
|
||||
{ head: "단위", get: (r) => r.labor?.단위 ?? "" }, // 일위대가 문서 단위(39-8 B3)
|
||||
{ head: "단가산출 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) },
|
||||
{ head: "합계", get: (r) => laborSum(r).text, note: (r) => laborSum(r).note },
|
||||
changed("labor"),
|
||||
],
|
||||
item: [
|
||||
@@ -89,6 +109,16 @@ export const ITEM_COLS: Record<ListKind, Col<ItemRow>[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
const PART_NAME = { structure: "도면", area: "면적산출", labor: "일위대가" } as const;
|
||||
|
||||
/** 그 부분 문서가 없는 줄의 까닭(항목 하나 복사 · 39-8 H3) — 옮기기 전 층의 일위대가처럼 파일이 없으면
|
||||
* 가져올 것이 없음 · 항목 통째(복사 생성)는 없는 문서를 빈 문서로 세워 막지 않음 · 있으면 null */
|
||||
export function missingPart(kind: ListKind, row: ItemRow): string | null {
|
||||
if (kind === "item") return null;
|
||||
const has = kind === "labor" ? !!row.labor : !!row.수정일?.[kind];
|
||||
return has ? null : `이 항목에는 ${PART_NAME[kind]} 문서가 없음`;
|
||||
}
|
||||
|
||||
/** 찾기 · 쪽 — 보일 줄 · 맞춘 쪽 · 맞는 줄 수(화면 밖 · 시험이 부름) */
|
||||
export function pageOf<R>(
|
||||
rows: R[],
|
||||
@@ -131,7 +161,12 @@ export function buildAllItems(
|
||||
export interface ItemListHandle {
|
||||
root: HTMLElement;
|
||||
/** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */
|
||||
show: <R>(rows: R[], cols: Col<R>[], onPick: (row: R) => void) => void;
|
||||
show: <R>(
|
||||
rows: R[],
|
||||
cols: Col<R>[],
|
||||
onPick: (row: R) => void,
|
||||
blocked?: (row: R) => string | null,
|
||||
) => void;
|
||||
/** 표 자리에 글 한 줄(불러오는 중 · 못 받음) */
|
||||
message: (text?: string) => void;
|
||||
}
|
||||
@@ -147,7 +182,12 @@ export function buildItemList(): ItemListHandle {
|
||||
grid.replaceChildren(el("p", { className: "m01-master__empty", text }));
|
||||
};
|
||||
|
||||
const show = <R>(rows: R[], cols: Col<R>[], onPick: (row: R) => void): void => {
|
||||
const show = <R>(
|
||||
rows: R[],
|
||||
cols: Col<R>[],
|
||||
onPick: (row: R) => void,
|
||||
blocked?: (row: R) => string | null,
|
||||
): void => {
|
||||
page = 1;
|
||||
draw = (): void => {
|
||||
const got = pageOf(rows, cols, search.value, page);
|
||||
@@ -164,10 +204,23 @@ export function buildItemList(): ItemListHandle {
|
||||
className: "m01-master__table-row",
|
||||
children: cols.map((c) => {
|
||||
const text = c.get(row);
|
||||
return el("td", { text, attrs: { title: text } });
|
||||
const note = c.note?.(row) ?? "";
|
||||
return el("td", {
|
||||
text,
|
||||
attrs: { title: note ? `${text} ${note}` : text },
|
||||
children: note
|
||||
? [el("small", { className: "m01-master__muted", text: ` ${note}` })]
|
||||
: [],
|
||||
});
|
||||
}),
|
||||
});
|
||||
tr.addEventListener("click", () => onPick(row));
|
||||
// 고를 수 없는 줄 — 흐리게 · 누르면 까닭 한 줄(고르기 안 함)
|
||||
const why = blocked?.(row) ?? null;
|
||||
if (why) {
|
||||
tr.classList.add("is-blocked");
|
||||
tr.title = why;
|
||||
}
|
||||
tr.addEventListener("click", () => (why ? showToast(why, "info") : onPick(row)));
|
||||
return tr;
|
||||
});
|
||||
grid.replaceChildren(
|
||||
|
||||
@@ -33,3 +33,9 @@
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 그 부분 문서가 없는 줄(39-8 H3) — 흐리게 · 누르면 까닭만 */
|
||||
.m02-itemlist .m01-master__table-row.is-blocked {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
buildItemList,
|
||||
ITEM_COLS,
|
||||
loadItems,
|
||||
missingPart,
|
||||
type Col,
|
||||
type ItemRow,
|
||||
type ListKind,
|
||||
@@ -61,7 +62,7 @@ const PRACTICE_COLS: Col<ComboSummary>[] = [
|
||||
{ head: "구분", get: (c) => c.구분 ?? "" },
|
||||
{ head: "상세구분", get: (c) => c.상세구분 ?? "" },
|
||||
{ head: "단위", get: (c) => c.단위 ?? "" },
|
||||
{ head: "담은 로직 수", get: (c) => String(c.count) },
|
||||
{ head: "단가산출 로직 수", get: (c) => String(c.count) },
|
||||
];
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
@@ -141,6 +142,7 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } {
|
||||
rows.filter((row) => !opt.skip?.(from, row)),
|
||||
ITEM_COLS[opt.kind],
|
||||
(row) => opt.onPick({ from, row }),
|
||||
(row) => missingPart(opt.kind, row), // 그 부분 문서 없는 줄 = 흐림 · 까닭(39-8 H3)
|
||||
);
|
||||
} catch (error) {
|
||||
if (mine === seq) list.message(TEXT.failed.replace("{value}", why(error)));
|
||||
@@ -183,10 +185,21 @@ export function buildTwoWay(opt: TwoWayOptions): HTMLElement {
|
||||
const copy = createButton({ label: TEXT.makeCopy, variant: "ghost" });
|
||||
const below = el("div", { className: "m02-itempick__below" });
|
||||
let busy = false;
|
||||
fresh.addEventListener("click", opt.onNew);
|
||||
/** 누른 단추 = 지금 화면 — 진한 단추가 고른 쪽 · 복사 생성 목록은 그쪽일 때만 보임 */
|
||||
const choose = (which: HTMLElement): void => {
|
||||
for (const b of [fresh, copy]) {
|
||||
b.classList.toggle("ui-btn--filled", b === which);
|
||||
b.classList.toggle("ui-btn--ghost", b !== which);
|
||||
}
|
||||
below.hidden = which !== copy;
|
||||
};
|
||||
fresh.addEventListener("click", () => {
|
||||
choose(fresh);
|
||||
opt.onNew();
|
||||
});
|
||||
copy.addEventListener("click", () => {
|
||||
choose(copy);
|
||||
if (below.firstChild) return;
|
||||
copy.classList.add("is-active");
|
||||
const picker = buildPicker({
|
||||
...opt.picker,
|
||||
onPick: (picked) =>
|
||||
|
||||
@@ -86,9 +86,19 @@ def items(folder: Path) -> list[dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def _number(sums: Any) -> float | None:
|
||||
"""`계산값` 합 칸의 `계` — 수 아닌 값(옛 꼴) · 없음 = null(「—」)."""
|
||||
total = sums.get("계") if isinstance(sums, dict) else None
|
||||
try:
|
||||
return None if total is None or isinstance(total, bool) else float(total)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _list_extras(folder: Path, col: str) -> dict[str, Any]:
|
||||
"""목록 칸(39-5) — `수정일` = 도면 · 면적산출 · 일위대가 파일 시각 · `labor` = 일위대가 문서의
|
||||
번호 · 담은 로직 수 · 합계(`계산값.반영합.계` · 없으면 null) — 파일이 없으면 칸 없음."""
|
||||
번호 · 담은 로직 수 · 합계(`계산값.반영합.계`) · 기준(`계산값.기준합.계`) · 단위 —
|
||||
파일이 없으면 칸 없음."""
|
||||
stamps: dict[str, str] = {}
|
||||
for kind in ("structure", "area", "labor"):
|
||||
path = folder / kind / f"{col}.json"
|
||||
@@ -103,15 +113,13 @@ def _list_extras(folder: Path, col: str) -> dict[str, Any]:
|
||||
doc = json.loads(path.read_bytes())
|
||||
except ValueError:
|
||||
doc = {}
|
||||
total = ((doc.get("계산값") or {}).get("반영합") or {}).get("계")
|
||||
try:
|
||||
total = None if total is None or isinstance(total, bool) else float(total)
|
||||
except (TypeError, ValueError):
|
||||
total = None # 수 아닌 값(옛 꼴)은 「—」
|
||||
calc = doc.get("계산값") or {}
|
||||
labor = {
|
||||
"번호": doc.get("일위대가번"),
|
||||
"로직수": len(doc.get("담은로직") or []),
|
||||
"합계": total,
|
||||
"합계": _number(calc.get("반영합")),
|
||||
"기준": _number(calc.get("기준합")), # 수량 없는 일위대가 = 단가 합(39-8 B2)
|
||||
"단위": doc.get("단위"), # 일위대가 문서 단위(구조물 열 단위 아님 · B3)
|
||||
}
|
||||
return {"수정일": stamps, "labor": labor}
|
||||
|
||||
|
||||
@@ -122,3 +122,232 @@
|
||||
margin: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 보기 갈래가 좁으면(면적산출 오른쪽 패널 등) 표를 줄마다 카드로 — 가로 스크롤 없이.
|
||||
줄 = 이름 한 줄(차례 · 단위 곁) → 금액 넷 한 줄(칸 이름은 `data-label` · 합계만 굵게) ·
|
||||
자재 = 코드 · 이름 · 규격 한 줄 → 단위 · 수량 · 기준 단가 · 반영 금액 한 줄. 넓은 상세는 그대로 */
|
||||
.m02-labview {
|
||||
container: m02-labview / inline-size;
|
||||
}
|
||||
|
||||
@container m02-labview (max-width: 640px) {
|
||||
.m02-labrow,
|
||||
.m02-labrow tbody,
|
||||
.m02-labrow tfoot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m02-labrow thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-labrow tr {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 2px var(--spacing-8);
|
||||
padding: var(--spacing-8) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-labrow td {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 이름 줄 — 차례 · 이름 · 결과 단위(자재 = 코드 · 이름 · 규격) */
|
||||
.m02-labrow .m02-labrow__name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-column: 1 / -1;
|
||||
order: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-labrow .m02-labrow__no,
|
||||
.m02-labrow .m02-labrow__unit:not([data-label]),
|
||||
.m02-labrow .m02-labrow__code,
|
||||
.m02-labrow .m02-labrow__spec {
|
||||
grid-column: span 2;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labrow .m02-labrow__unit:not([data-label]),
|
||||
.m02-labrow .m02-labrow__spec {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 수량산출 칸 — 윗줄에(차례 · 단위와 한 줄) · 칸 이름 없이 */
|
||||
.m02-labrow:not(.m02-labline) tr:has(.m02-labrow__qty) .m02-labrow__no,
|
||||
.m02-labrow tr:has(.m02-labrow__qty) .m02-labrow__unit:not([data-label]) {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.m02-labrow .m02-labrow__qty {
|
||||
order: 0;
|
||||
grid-column: span 2;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.m02-labrow .m02-labrow__qty::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.m02-labrow__sum .m02-labrow__qty,
|
||||
.m02-labrow__sum .m02-labrow__no,
|
||||
.m02-labrow__sum .m02-labrow__unit {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 금액 · 수 칸 — 칸 이름을 위에 작게 */
|
||||
.m02-labrow [data-label] {
|
||||
order: 2;
|
||||
text-align: right;
|
||||
white-space: normal;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labrow [data-label]::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labrow .m02-labrow__money:last-of-type .m02-labrow__applied {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.m02-labrow__move {
|
||||
grid-column: 1 / -1;
|
||||
order: 3;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 더 좁으면(카드 320px 안팎 · 340px 이하) 줄마다 두 줄 — 윗줄 항목(3칸) · 수량산출(1칸) · 아랫줄 금액 넷 ·
|
||||
머리 줄도 같은 꼴(글은 그대로) · 가로 넘침 없음 */
|
||||
@container m02-labview (max-width: 340px) {
|
||||
.m02-labline,
|
||||
.m02-labline thead,
|
||||
.m02-labline tbody,
|
||||
.m02-labline tfoot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m02-labline tr {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-labline th,
|
||||
.m02-labline td {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.m02-labline th:first-child,
|
||||
.m02-labline .m02-labrow__name {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.m02-labline th:nth-child(2),
|
||||
.m02-labline .m02-labrow__qty {
|
||||
text-align: right;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.m02-labline th:nth-child(3),
|
||||
.m02-labline .m02-labrow__unit {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-labline .m02-labrow__num {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */
|
||||
.m02-labrow__qty {
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.m02-labrow__qtybtn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 2px var(--spacing-4);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-labrow__qtybtn:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,15 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
|
||||
import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import {
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
type ItemLogic,
|
||||
type Layer,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { linkAreaCell, pickAreaCell } from "./M02_MasterTemplete_AreaCellPick";
|
||||
import { settle } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import {
|
||||
areaLinks,
|
||||
@@ -27,7 +35,7 @@ import {
|
||||
diagramTitle,
|
||||
renderComboDiagram,
|
||||
} from "./M02_MasterTemplete_Labor_Diagram";
|
||||
import { openLaborInput, openQtyModal } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openLaborInput, openQtyModal, type GoArea } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
@@ -44,12 +52,15 @@ export interface LaborDetailOptions {
|
||||
readOnly: boolean;
|
||||
/** 저장 안 한 고침이 생기거나 사라짐 */
|
||||
onChanged: (dirty: boolean) => void;
|
||||
/** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(없으면 단추 없음) */
|
||||
goArea: (() => void) | null;
|
||||
/** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(옮겨 가면 참 · 취소 = 거짓 · 없으면 단추 없음) */
|
||||
goArea: GoArea | null;
|
||||
}
|
||||
|
||||
export interface LaborDetailHandle {
|
||||
getDoc: () => LaborDoc;
|
||||
/** [엑셀에서 고르기]로 바뀐 면적산출 문서를 저장(일위대가 [저장] 앞) · 저장했으면 참(없으면 거짓) —
|
||||
* 서버가 면적 저장 때 그 열 일위대가 계산값을 다시 적어 일위대가 판이 바뀜 → 부르는 쪽이 판을 다시 읽음 */
|
||||
saveArea: () => Promise<boolean>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
@@ -72,6 +83,12 @@ export function mountLaborDetail(
|
||||
const cells = (): Map<string, string> => new Map([...links].map(([k, v]) => [k, v.칸]));
|
||||
const given = (): Record<string, number> =>
|
||||
Object.fromEntries([...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값]])));
|
||||
/** 그 열 면적산출 문서 · 판 — [엑셀에서 고르기]로 이으면 바뀜(일위대가 [저장] 때 함께 저장) */
|
||||
let area: { doc: Workbook; 판: string } | null = null;
|
||||
let areaDirty = false;
|
||||
/** 알약 키 → 이름 · 단위(calc 답 `알약`) — 칸에 이을 때 연결 정보 */
|
||||
let pills = new Map<string, { 이름?: string; 단위?: string }>();
|
||||
const dirty = (): boolean => areaDirty || JSON.stringify(cur) !== saved;
|
||||
|
||||
const logicHost = el("div");
|
||||
const matHost = el("div");
|
||||
@@ -112,7 +129,7 @@ export function mountLaborDetail(
|
||||
logicTable(
|
||||
{ rows: cur.담은로직, logics, calc, fail, cells: cells() },
|
||||
// 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠)
|
||||
edit ? { open: openRow, move, remove: removeRow } : { open: openRow },
|
||||
edit ? { open: openRow, qty: openQty, move, remove: removeRow } : { open: openRow },
|
||||
),
|
||||
);
|
||||
matHost.replaceChildren(
|
||||
@@ -133,9 +150,13 @@ export function mountLaborDetail(
|
||||
fail = null;
|
||||
draw();
|
||||
try {
|
||||
const got = await calcLabor(opt.layer, opt.column, cur);
|
||||
// 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음)
|
||||
const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined);
|
||||
if (!alive || mine !== seq) return;
|
||||
calc = got;
|
||||
pills = new Map(
|
||||
(got.알약 as { 키: string; 이름?: string; 단위?: string }[]).map((p) => [p.키, p]),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!alive || mine !== seq) return;
|
||||
fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail");
|
||||
@@ -146,7 +167,7 @@ export function mountLaborDetail(
|
||||
|
||||
/** `qtyOnly` = 수량만 바뀜 — 기준은 그대로라 짝 셈(`Labor_Calc.settle`)으로 바로 곱함(서버 안 부름) */
|
||||
const changed = (qtyOnly = false): void => {
|
||||
opt.onChanged(JSON.stringify(cur) !== saved);
|
||||
opt.onChanged(dirty());
|
||||
if (qtyOnly && calc) {
|
||||
calc = settle(calc, cur, given());
|
||||
draw();
|
||||
@@ -160,6 +181,42 @@ export function mountLaborDetail(
|
||||
return `${at.칸} = ${at.값 === null ? "—" : at.값.toLocaleString("ko-KR")}${unit ? ` ${unit}` : ""}`;
|
||||
};
|
||||
|
||||
/** [엑셀에서 고르기] — 그 열 면적산출 칸을 골라 그 알약을 이음(화면 문서 · 저장은 [저장]) · 이었으면 참 */
|
||||
const pickCell = async (pill: string | null | undefined, label: string): Promise<boolean> => {
|
||||
if (!pill || !area) {
|
||||
showToast(tc(area ? "Labor_PickWait" : "Labor_NoArea"), "error");
|
||||
return false;
|
||||
}
|
||||
const got = await pickAreaCell({
|
||||
doc: area.doc,
|
||||
layer: opt.layer,
|
||||
column: opt.column,
|
||||
pill,
|
||||
label,
|
||||
});
|
||||
if (!got || !alive) return false;
|
||||
const info = pills.get(pill);
|
||||
const next = linkAreaCell(
|
||||
area.doc,
|
||||
pill,
|
||||
got.칸,
|
||||
{
|
||||
...(info?.이름 ? { 이름: info.이름 } : {}),
|
||||
...(info?.단위 ? { 단위: info.단위 } : {}),
|
||||
},
|
||||
got.바꾸기 === true, // 그 칸 다른 알약 — 기본 함께 · [바꾸기]면 뺌(물음은 고르기 모달 안)
|
||||
);
|
||||
if (!next) {
|
||||
showToast(tc("Labor_BadCell"), "error");
|
||||
return false;
|
||||
}
|
||||
next.계산값 = recalcWorkbook(next); // 식 칸 값도 바로 — 저장 때 서버가 다시 셈
|
||||
area.doc = next;
|
||||
links = areaLinks(next);
|
||||
areaDirty = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
/** 아직 안 받은 로직 입력 목록만 받음 */
|
||||
const loadLogics = async (): Promise<void> => {
|
||||
const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k));
|
||||
@@ -200,6 +257,7 @@ export function mountLaborDetail(
|
||||
qty: row.수량,
|
||||
linked: linkedText(line?.알약, line?.분모 ?? ""),
|
||||
unit: line?.분모 ?? "",
|
||||
pick: () => pickRow(i),
|
||||
},
|
||||
readOnly: !edit,
|
||||
goArea: opt.goArea,
|
||||
@@ -214,6 +272,37 @@ export function mountLaborDetail(
|
||||
});
|
||||
}
|
||||
|
||||
/** 그 줄 알약을 칸에 이음 — 이으면 손 수량은 지움(칸을 따름) */
|
||||
async function pickRow(i: number): Promise<boolean> {
|
||||
const row = cur.담은로직[i];
|
||||
const logic = logics.get(row.로직);
|
||||
if (!(await pickCell(lineOf(row.로직)?.알약, logic?.이름 ?? row.로직))) return false;
|
||||
row.수량 = null;
|
||||
changed(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 수량산출 칸 누름 — 손 입력 · 엑셀에서 고르기 */
|
||||
function openQty(i: number): void {
|
||||
const row = cur.담은로직[i];
|
||||
const line = lineOf(row.로직);
|
||||
openQtyModal(
|
||||
`${row.로직} ${logics.get(row.로직)?.이름 ?? ""}`.trim(),
|
||||
{
|
||||
qty: row.수량,
|
||||
linked: linkedText(line?.알약, line?.분모 ?? ""),
|
||||
unit: line?.분모 ?? "",
|
||||
pick: () => pickRow(i),
|
||||
},
|
||||
!edit,
|
||||
opt.goArea,
|
||||
(qty) => {
|
||||
row.수량 = qty;
|
||||
changed(true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function openMat(i: number): void {
|
||||
const mat = cur.자재[i];
|
||||
const line = matOf(mat.코드);
|
||||
@@ -223,6 +312,12 @@ export function mountLaborDetail(
|
||||
qty: mat.수량,
|
||||
linked: linkedText(line?.알약, mat.단위 ?? ""),
|
||||
unit: mat.단위 ?? "",
|
||||
pick: async () => {
|
||||
if (!(await pickCell(line?.알약, mat.이름 ?? mat.코드))) return false;
|
||||
mat.수량 = null;
|
||||
changed(true);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
!edit,
|
||||
opt.goArea,
|
||||
@@ -236,7 +331,6 @@ export function mountLaborDetail(
|
||||
const addLogic = createButton({
|
||||
label: tc("AddLogic"),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openLogicPicker((x) => {
|
||||
if (cur.담은로직.some((r) => r.로직 === x.키))
|
||||
@@ -248,7 +342,6 @@ export function mountLaborDetail(
|
||||
const addMat = createButton({
|
||||
label: tc("Labor_AddMat"),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
pickMaterial((m) => {
|
||||
if (cur.자재.some((x) => x.코드 === m.코드))
|
||||
@@ -268,7 +361,7 @@ export function mountLaborDetail(
|
||||
unit.input.setAttribute("data-labor-field", "단위");
|
||||
unit.input.addEventListener("input", () => {
|
||||
cur.단위 = unit.input.value.trim() || null;
|
||||
opt.onChanged(JSON.stringify(cur) !== saved);
|
||||
opt.onChanged(dirty());
|
||||
});
|
||||
const origin = createInputField({ type: "text", label: tc("Labor_Origin") });
|
||||
origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self");
|
||||
@@ -306,7 +399,10 @@ export function mountLaborDetail(
|
||||
void Promise.all([
|
||||
loadLogics(),
|
||||
readTemplate(opt.layer, "area", opt.column, null).then(
|
||||
(got) => (links = areaLinks(got.문서)),
|
||||
(got) => {
|
||||
area = { doc: got.문서 as Workbook, 판: got.판 ?? "" };
|
||||
links = areaLinks(got.문서);
|
||||
},
|
||||
() => undefined,
|
||||
),
|
||||
]).then(() => {
|
||||
@@ -315,6 +411,13 @@ export function mountLaborDetail(
|
||||
|
||||
return {
|
||||
getDoc: () => cur,
|
||||
saveArea: async () => {
|
||||
if (!area || !areaDirty) return false;
|
||||
const info = await saveTemplate(opt.layer, "area", opt.column, null, area.판, area.doc);
|
||||
area.판 = info.판;
|
||||
areaDirty = false;
|
||||
return true;
|
||||
},
|
||||
destroy: () => {
|
||||
alive = false;
|
||||
window.clearTimeout(timer);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -17,6 +17,15 @@ import "./M02_MasterTemplete_Labor.css";
|
||||
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
|
||||
/** 면적산출 상세로 감 — 옮겨 갔으면 참(물음에서 취소 = 거짓) */
|
||||
export type GoArea = () => Promise<boolean>;
|
||||
|
||||
/** 옮겨 갔을 때만 모달을 닫음 — 취소면 모달 · 넣던 값 그대로 */
|
||||
const leaveTo =
|
||||
(go: GoArea, close: () => void): (() => void) =>
|
||||
() =>
|
||||
void go().then((moved) => moved && close());
|
||||
|
||||
export interface QtySeat {
|
||||
/** 지금 수량 */
|
||||
qty: LaborQty;
|
||||
@@ -24,6 +33,8 @@ export interface QtySeat {
|
||||
linked: string | null;
|
||||
/** 손 입력 단위 표기(분모 · 자재 단위) */
|
||||
unit: string;
|
||||
/** [엑셀에서 고르기](40-1) — 그 열 면적산출 칸을 골라 이음 · 이었으면 참(모달 닫음) · 없으면 단추 없음 */
|
||||
pick?: (() => Promise<boolean>) | null;
|
||||
}
|
||||
|
||||
interface QtyPart {
|
||||
@@ -33,7 +44,12 @@ interface QtyPart {
|
||||
}
|
||||
|
||||
/** 수량 칸 — 「면적산출 칸」 / 「손 입력」 · 손이 칸보다 앞섬 */
|
||||
function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): QtyPart {
|
||||
function qtyPart(
|
||||
seat: QtySeat,
|
||||
readOnly: boolean,
|
||||
goArea: (() => void) | null,
|
||||
close: () => void,
|
||||
): QtyPart {
|
||||
const name = `m02-labin-qty-${Math.random().toString(36).slice(2)}`;
|
||||
const radio = (value: "cell" | "hand"): HTMLInputElement => {
|
||||
const input = el("input", { attrs: { type: "radio", name, value } }) as HTMLInputElement;
|
||||
@@ -58,6 +74,16 @@ function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null):
|
||||
const go = goArea
|
||||
? createButton({ label: tc("In_QtyGoArea"), variant: "ghost", size: "sm", onClick: goArea })
|
||||
: null;
|
||||
const pickFn = seat.pick;
|
||||
const pick =
|
||||
pickFn && !readOnly
|
||||
? createButton({
|
||||
label: tc("In_QtyPick"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void pickFn().then((linked) => linked && close()),
|
||||
})
|
||||
: null;
|
||||
const line = (input: HTMLInputElement, label: string, ...rest: HTMLElement[]): HTMLElement =>
|
||||
el("label", {
|
||||
className: "m02-labin__qty-line",
|
||||
@@ -74,6 +100,7 @@ function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null):
|
||||
className: "m02-labin__muted",
|
||||
text: seat.linked ? fill(tc("In_QtyLinked"), seat.linked) : tc("In_QtyNoLink"),
|
||||
}),
|
||||
...(pick ? [pick] : []),
|
||||
...(go ? [go] : []),
|
||||
),
|
||||
line(hand, tc("In_QtyHand"), number, el("small", { text: seat.unit })),
|
||||
@@ -116,8 +143,8 @@ export interface LaborInputOptions {
|
||||
memo: string | null;
|
||||
seat: QtySeat;
|
||||
readOnly: boolean;
|
||||
/** 「면적산출에서 잇기」 — 모달을 닫고 면적산출 상세로(없으면 단추 없음) */
|
||||
goArea: (() => void) | null;
|
||||
/** 「면적산출에서 잇기」 — 면적산출 상세로 감(옮겨 가면 참 · 「저장 안 한 고침」 물음에서 취소면 거짓 · 없으면 단추 없음) */
|
||||
goArea: GoArea | null;
|
||||
done: (next: LaborInputResult) => void;
|
||||
}
|
||||
|
||||
@@ -165,15 +192,7 @@ export function openLaborInput(opt: LaborInputOptions): void {
|
||||
}) as HTMLInputElement;
|
||||
memo.value = opt.memo ?? "";
|
||||
memo.readOnly = opt.readOnly;
|
||||
const qty = qtyPart(
|
||||
opt.seat,
|
||||
opt.readOnly,
|
||||
opt.goArea &&
|
||||
(() => {
|
||||
close();
|
||||
opt.goArea?.();
|
||||
}),
|
||||
);
|
||||
const qty = qtyPart(opt.seat, opt.readOnly, opt.goArea && leaveTo(opt.goArea, close), close);
|
||||
const ok = (): void => {
|
||||
if (bad.size) return showToast(tc("In_Bad"), "error");
|
||||
const 수량 = qty.read();
|
||||
@@ -217,7 +236,7 @@ export function openQtyModal(
|
||||
title: string,
|
||||
seat: QtySeat,
|
||||
readOnly: boolean,
|
||||
goArea: (() => void) | null,
|
||||
goArea: GoArea | null,
|
||||
done: (qty: LaborQty) => void,
|
||||
): void {
|
||||
openModal({
|
||||
@@ -225,15 +244,7 @@ export function openQtyModal(
|
||||
closeLabel: tc("In_Close"),
|
||||
dialogClass: "m02-modal m02-labin",
|
||||
mount: (body, close) => {
|
||||
const qty = qtyPart(
|
||||
seat,
|
||||
readOnly,
|
||||
goArea &&
|
||||
(() => {
|
||||
close();
|
||||
goArea();
|
||||
}),
|
||||
);
|
||||
const qty = qtyPart(seat, readOnly, goArea && leaveTo(goArea, close), close);
|
||||
const ok = (): void => {
|
||||
const 수량 = qty.read();
|
||||
if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error");
|
||||
|
||||
@@ -33,29 +33,88 @@ const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
|
||||
const num = (v: number): string =>
|
||||
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · 없으면 빈 글 */
|
||||
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) ·
|
||||
* 이은 칸이 비었으면 「수량 A1 = 빈 칸」(수량 없음 · 서버와 같음) · 아니면 빈 글 */
|
||||
export function qtyText(
|
||||
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
|
||||
unit: string,
|
||||
cells: ReadonlyMap<string, string> = new Map(),
|
||||
): string {
|
||||
if (!line || line.수량 === null || line.수량 === undefined) return "";
|
||||
if (!line) return "";
|
||||
if (line.수량 === null || line.수량 === undefined) {
|
||||
const at = line.알약 ? cells.get(line.알약) : undefined;
|
||||
return at ? `${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}` : "";
|
||||
}
|
||||
const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`;
|
||||
if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`;
|
||||
const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell");
|
||||
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.수량)}`;
|
||||
}
|
||||
|
||||
/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「B6 = 12.5」 · 「—」) ·
|
||||
* `open` 이 있으면 단추(줄 누름과 따로) */
|
||||
function qtyCell(
|
||||
line: LaborCalcLine | undefined,
|
||||
cells: ReadonlyMap<string, string> | undefined,
|
||||
open: (() => void) | null,
|
||||
): HTMLElement {
|
||||
const parts = [
|
||||
el("small", { className: "m02-labrow__base", text: baseQtyText(line) || "—" }),
|
||||
el("span", { className: "m02-labrow__applied", text: qtyShort(line, line?.분모 ?? "", cells) }),
|
||||
];
|
||||
const body = open
|
||||
? el("button", {
|
||||
className: "m02-labrow__qtybtn",
|
||||
attrs: { type: "button", title: tc("Labor_QtyPick"), "data-labor-qty-open": "" },
|
||||
children: parts,
|
||||
})
|
||||
: el("span", { children: parts });
|
||||
if (open)
|
||||
body.addEventListener("click", (e) => {
|
||||
e.stopPropagation(); // 줄 누름(입력 모달)과 따로
|
||||
open();
|
||||
});
|
||||
return el("td", {
|
||||
className: "m02-labrow__qty",
|
||||
attrs: { "data-label": tc("Labor_QtyCol") },
|
||||
children: [body],
|
||||
});
|
||||
}
|
||||
|
||||
/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */
|
||||
function moneyCell(
|
||||
base: number | null | undefined,
|
||||
applied: number | null | undefined,
|
||||
wait: boolean,
|
||||
label: string,
|
||||
): HTMLElement {
|
||||
const show = (v: number | null | undefined): string =>
|
||||
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
|
||||
return el("td", {
|
||||
className: "m02-labrow__money",
|
||||
attrs: { "data-label": label },
|
||||
children: [
|
||||
el("small", {
|
||||
className: "m02-labrow__base",
|
||||
@@ -91,6 +150,8 @@ const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLE
|
||||
/** 줄 누름 · 순서 · 빼기 — 순서 · 빼기가 없으면 그 칸 없음 */
|
||||
export interface RowHandlers {
|
||||
open: (i: number) => void;
|
||||
/** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */
|
||||
qty?: (i: number) => void;
|
||||
move?: (i: number, d: number) => void;
|
||||
remove?: (i: number) => void;
|
||||
}
|
||||
@@ -117,14 +178,12 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
const line = lineOf(row.로직);
|
||||
const unit = logic?.결과단위 ?? "";
|
||||
const values = valueLine(logic?.입력 ?? [], row.입력값);
|
||||
const qty = qtyText(line, line?.분모 ?? "", data.cells);
|
||||
const note = [values.text, qty].filter(Boolean).join(" · ");
|
||||
const note = values.text;
|
||||
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
|
||||
const small = (label: string, run: () => void): HTMLElement =>
|
||||
createButton({
|
||||
label,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation(); // 줄 누름(입력 모달)과 따로
|
||||
run();
|
||||
@@ -134,7 +193,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
|
||||
attrs: { "data-labor-logic": row.로직 },
|
||||
children: [
|
||||
el("td", { text: String(i + 1) }),
|
||||
el("td", { className: "m02-labrow__no", text: String(i + 1) }),
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
@@ -160,8 +219,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
el("td", { text: unit }),
|
||||
...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait)),
|
||||
qtyCell(line, data.cells, on?.qty ? () => on.qty?.(i) : null),
|
||||
el("td", { className: "m02-labrow__unit", text: unit }),
|
||||
...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait, tc(HEAD[c]))),
|
||||
...(move && remove
|
||||
? [
|
||||
el("td", {
|
||||
@@ -186,11 +246,12 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
el("tr", {
|
||||
className: "m02-labrow__sum",
|
||||
children: [
|
||||
el("td", { text: "" }),
|
||||
el("td", { text: tc("Prev_Sum") }),
|
||||
el("td", { text: "" }),
|
||||
el("td", { className: "m02-labrow__no", text: "" }),
|
||||
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) =>
|
||||
moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait),
|
||||
moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait, tc(HEAD[c])),
|
||||
),
|
||||
...(tools ? [el("td", { text: "" })] : []),
|
||||
],
|
||||
@@ -200,6 +261,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
const heads = [
|
||||
tc("Col_Order"),
|
||||
tc("Col_Name"),
|
||||
tc("Labor_QtyCol"),
|
||||
tc("Col_Unit"),
|
||||
...COSTS.map((c) => tc(HEAD[c])),
|
||||
...(tools ? [tc("Col_Move")] : []),
|
||||
@@ -229,7 +291,7 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
|
||||
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
|
||||
attrs: { "data-labor-mat": mat.코드 },
|
||||
children: [
|
||||
el("td", { text: mat.코드 }),
|
||||
el("td", { className: "m02-labrow__code", text: mat.코드 }),
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
@@ -239,15 +301,30 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
el("td", { text: mat.규격 ?? "" }),
|
||||
el("td", { text: mat.단위 ?? "" }),
|
||||
el("td", { className: "m02-labrow__spec", text: mat.규격 ?? "" }),
|
||||
el("td", {
|
||||
className: "m02-labrow__unit",
|
||||
text: mat.단위 ?? "",
|
||||
attrs: { "data-label": tc("Labor_Unit") },
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__num",
|
||||
text: qty,
|
||||
attrs: { title: qtyText(line, mat.단위 ?? "", data.cells) },
|
||||
attrs: {
|
||||
title: qtyText(line, mat.단위 ?? "", data.cells),
|
||||
"data-label": tc("Labor_Qty"),
|
||||
},
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__num",
|
||||
text: show(line?.기준단가),
|
||||
attrs: { "data-label": tc("Labor_BasePrice") },
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__num",
|
||||
text: show(line?.반영),
|
||||
attrs: { "data-label": tc("Labor_AppliedSum") },
|
||||
}),
|
||||
el("td", { className: "m02-labrow__num", text: show(line?.기준단가) }),
|
||||
el("td", { className: "m02-labrow__num", text: show(line?.반영) }),
|
||||
...(remove
|
||||
? [
|
||||
el("td", {
|
||||
@@ -256,7 +333,6 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
|
||||
createButton({
|
||||
label: tc("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
remove(i);
|
||||
@@ -285,3 +361,115 @@ 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") } : {}) },
|
||||
});
|
||||
// 머리 글 = 사용자 글 그대로(40-1 · 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계)
|
||||
const heads = [
|
||||
tc("Labor_Item"),
|
||||
tc("Labor_QtyCol"),
|
||||
tc("Labor_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,14 @@ const TEXT = {
|
||||
Labor_NoMat: ["담은 자재 없음", "No materials yet"],
|
||||
Labor_AddMat: ["자재 추가", "Add material"],
|
||||
Labor_Qty: ["수량", "Quantity"],
|
||||
Labor_QtyCol: ["수량산출", "Quantity"],
|
||||
Labor_Item: ["항목", "Item"],
|
||||
Labor_Hand: ["손", "Manual"],
|
||||
Labor_QtyPick: ["누르면 손 입력 · 엑셀에서 고르기", "Manual or pick a cell"],
|
||||
In_QtyPick: ["엑셀에서 고르기", "Pick in sheet"],
|
||||
Labor_PickWait: ["계산이 끝난 뒤 고름", "Wait for the calculation"],
|
||||
Labor_NoArea: ["이 열 면적산출을 못 읽음", "Area sheet not loaded"],
|
||||
Labor_BadCell: ["칸 주소가 틀림", "Invalid cell"],
|
||||
Labor_Base: ["기준", "Base"],
|
||||
Labor_Applied: ["반영", "Applied"],
|
||||
Labor_BasePrice: ["기준 단가", "Base price"],
|
||||
@@ -68,13 +76,14 @@ 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"],
|
||||
In_Memo: ["메모", "Memo"],
|
||||
In_Qty: ["수량", "Quantity"],
|
||||
In_QtyCell: ["면적산출 칸", "Area sheet cell"],
|
||||
In_QtyEmpty: ["빈 칸", "empty cell"],
|
||||
In_QtyHand: ["손 입력", "Manual"],
|
||||
In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"],
|
||||
In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -288,6 +288,14 @@ def get_numbers(layer: Layer, owner: str | None = None, session: Session = Depen
|
||||
return _call(_root(session, layer, owner), store.structure_numbers)
|
||||
|
||||
|
||||
@router.get("/layers/system/structure-options")
|
||||
def get_structure_options(session: Session = Depends(verify_session)) -> dict:
|
||||
"""구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 옛 길 시스템 관리자만)."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Options as options
|
||||
|
||||
return _call(_root(session, "system"), options.listing)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/structure-combos")
|
||||
def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
|
||||
return _call(_root(session, layer, owner), store.read_combos)
|
||||
@@ -309,6 +317,43 @@ def _copy_practice(column: str, key: str) -> dict[str, Any]:
|
||||
return store.copy_from("labor", column, key, body=labor.from_combo(key))
|
||||
|
||||
|
||||
class AreaTableBody(BaseModel):
|
||||
"""면적산출 범위 → 도면 표(41-1) — `시트` = 시트 id · 이름 · `글자크기` 기본 2.5."""
|
||||
|
||||
시트: str
|
||||
범위: str
|
||||
글자크기: float | None = None
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/area/{column}/table")
|
||||
def post_area_table(
|
||||
layer: Layer,
|
||||
column: str,
|
||||
body: AreaTableBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""그 열 면적산출(저장본) 범위 → CAD 표 `{표, 시트, 범위, 값}` — 저장 안 함 · 층 읽기 권한."""
|
||||
from M02_MasterTemplete import M02_Area_Table as area_table
|
||||
|
||||
folder = _root(session, layer, owner)
|
||||
return _call(folder, area_table.to_table, column, body.시트, body.범위, body.글자크기)
|
||||
|
||||
|
||||
@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,
|
||||
|
||||
@@ -276,6 +276,8 @@ async def list_layer(
|
||||
manifest = await asyncio.to_thread(layers.read_manifest, folder)
|
||||
for row in rows:
|
||||
row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}")
|
||||
if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음
|
||||
row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])]
|
||||
return {"층": layer, "양식": rows}
|
||||
|
||||
|
||||
@@ -359,9 +361,18 @@ async def save_layer_template(
|
||||
) from error
|
||||
except ValueError as error:
|
||||
raise _bad(error) from error
|
||||
if kind == "area": # 그 열 도면 연결표 칸 글도(41-1 · 시스템 · 회사 · 개인 층은 Store.write 가)
|
||||
await asyncio.to_thread(_refresh_drawing, folder, name)
|
||||
return {"종류": kind, "이름": name, "판": version, "층": layer}
|
||||
|
||||
|
||||
def _refresh_drawing(folder: Path, column: str) -> None:
|
||||
from M02_MasterTemplete import M02_Area_Table as area_table
|
||||
|
||||
with store.at(folder):
|
||||
area_table.refresh_drawing(column)
|
||||
|
||||
|
||||
def _store_save(folder: Path, kind: str, name: str, version: str, doc: Any, layer: str) -> dict:
|
||||
with store.at(folder):
|
||||
try:
|
||||
|
||||
@@ -170,6 +170,7 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
|
||||
info = _info(kind, path)
|
||||
if kind == "area":
|
||||
_refresh_labor(name) # 면적 칸 값이 바뀌면 일위대가 계산값(목록 합계)도
|
||||
_refresh_drawing(name) # 도면 연결표 칸 글도(41-1)
|
||||
if kind == "table" and name == STRUCTURE_TABLE:
|
||||
for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦
|
||||
for part in _KEY:
|
||||
@@ -351,6 +352,22 @@ 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)
|
||||
_refresh_drawing(column)
|
||||
sync_combos()
|
||||
return info
|
||||
|
||||
|
||||
def copy_from(
|
||||
kind: str,
|
||||
column: str,
|
||||
@@ -376,6 +393,7 @@ def copy_from(
|
||||
info = _info(kind, _path(kind, column))
|
||||
if kind == "area":
|
||||
_refresh_labor(column)
|
||||
_refresh_drawing(column)
|
||||
return info
|
||||
|
||||
|
||||
@@ -467,6 +485,13 @@ def _server(kind: str, doc: Any) -> Any:
|
||||
return doc
|
||||
|
||||
|
||||
def _refresh_drawing(column: str) -> None:
|
||||
"""그 열 도면의 면적 연결표 칸 글을 지금 면적 값으로(`M02_Area_Table` · 41-1)."""
|
||||
from M02_MasterTemplete import M02_Area_Table as area_table
|
||||
|
||||
area_table.refresh_drawing(column)
|
||||
|
||||
|
||||
def _refresh_labor(column: str) -> None:
|
||||
"""그 열 일위대가 `계산값` 을 지금 면적 값으로 다시 적음 — 문서가 틀어졌으면 그대로 둠."""
|
||||
path = _path("labor", column)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* M02 구조물집계표 아래 설계 컨테이너 — 카드 셋(도면 정보 · 면적산출 · 재료산출) ·
|
||||
/* M02 구조물집계표 아래 설계 컨테이너 — 카드 셋(도면 정보 · 일위대가 · 면적산출) ·
|
||||
카드 모양은 좌측 패널과 같은 공용 ui-sidebar-*(ui_template_overlay.css). */
|
||||
|
||||
/* 표 밖 형제 자리 — 비어 있을 때 메인 간격이 벌어지지 않게 칸을 지움. */
|
||||
@@ -6,8 +6,8 @@
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* 카드 넷(도면 정보 · 면적산출 · 재료산출 · 선택 입력) = 2행 2열 · 카드 높이 = 화면 높이에 맞춰 크게(PLAN 31-6) ·
|
||||
칸이 좁으면(720px 아래) 1열 */
|
||||
/* 카드 셋(도면 정보 · 일위대가 · 면적산출) = 1행 3열(40-2) · 카드 높이 = 화면 높이에 맞춰 크게(PLAN 31-6) ·
|
||||
상세 칸이 좁으면 1열 */
|
||||
.m02-struct {
|
||||
/* 카드 본문은 카드 높이를 채움 — 넘치면 카드 안에서 스크롤 */
|
||||
--m02-struct-body-min: 0;
|
||||
@@ -17,13 +17,19 @@
|
||||
display: grid;
|
||||
flex: none;
|
||||
grid-auto-rows: var(--m02-struct-card-h);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)); /* 도면 · 일위대가 · 면적산출(40-2) */
|
||||
padding-top: var(--spacing-12);
|
||||
border-top: 1px solid var(--color-border);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
/* 상세 칸이 좁으면(카드 하나 300px 아래) 1열 — 좌측 패널을 접어도 맞게 상세 칸 폭으로 잼
|
||||
(카드 자리 `m02-main__extra` 는 상자 없음 — 상세 칸이 잣대) */
|
||||
.m02-main:has(> .m02-main__extra > .m02-struct) {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@container (max-width: 960px) {
|
||||
.m02-struct {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
@@ -84,7 +90,10 @@
|
||||
|
||||
.m02-struct__card-tools {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
flex: 0 1 auto; /* 좁은 카드(1열 · 400px)에서 단추가 많으면 줄 바꿈 — 카드 밖으로 안 넘침(40-2) */
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import {
|
||||
clearPart,
|
||||
copyPartFrom,
|
||||
fetchLaborPills,
|
||||
fetchStructureNumbers,
|
||||
@@ -50,7 +51,7 @@ const TEXT = {
|
||||
pickCopy: "{value} 복사해 가져오기",
|
||||
copied: "복사해 가져옴",
|
||||
copyFailed: "복사 실패 — {value}",
|
||||
noPills: "이 열 일위대가에 담은 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
|
||||
noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
|
||||
pillsFailed: "일위대가 알약을 못 받음 — {value}",
|
||||
discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
|
||||
};
|
||||
@@ -222,6 +223,10 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
warnOnFail: (id) => !!numbers[id],
|
||||
// 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길
|
||||
onImport: (kind) => void pickCopy(kind, shown ?? undefined),
|
||||
// 카드 [삭제](40-2) — 그 항목 비우기(서버 clear 길 · 번호 · 열 그대로 · 쓰기 권한 없으면 403)
|
||||
onClear: async (kind) => {
|
||||
if (column) await clearPart(kind, column);
|
||||
},
|
||||
labor: () => void openEdit("labor"), // 일위대가 카드(보기 갈래 · 39-3) — 도면 다음
|
||||
// 면적산출 카드 미리보기 수정(31-3) — 전체 편집기와 같은 알약 · 저장 길
|
||||
sheetEdit: {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움.
|
||||
* ========================================================================== */
|
||||
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model";
|
||||
@@ -22,5 +23,6 @@ export async function askCopyFolder(
|
||||
): Promise<string[] | null> {
|
||||
const view = await fetchTree(to.layer, to.owner).catch(() => null);
|
||||
const folders = view ? listFolders(buildFolders(view)) : [];
|
||||
return askFolder(to.label.replace("{n}", String(n)), folders, () => false, []);
|
||||
const title = to.label.replace("{n}", String(n));
|
||||
return askFolder(title, folders, () => false, [], L("M02_TreeCopyFolder"));
|
||||
}
|
||||
|
||||
@@ -142,12 +142,14 @@ export function askNewItem(opt: {
|
||||
});
|
||||
}
|
||||
|
||||
/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 */
|
||||
/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 ·
|
||||
* `fieldLabel` = 칸 글(복사는 「복사할 폴더」) */
|
||||
export function askFolder(
|
||||
title: string,
|
||||
folders: string[][],
|
||||
blocked: (path: string[]) => boolean,
|
||||
current: string[],
|
||||
fieldLabel: string = L("M02_TreeMoveTo"),
|
||||
): Promise<string[] | null> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
@@ -164,7 +166,7 @@ export function askFolder(
|
||||
beforeClose: () => (finish(null), true),
|
||||
mount: (body, close) => {
|
||||
const field = createSelectField({
|
||||
label: L("M02_TreeMoveTo"),
|
||||
label: fieldLabel,
|
||||
options: choices.map((path, i) => ({
|
||||
value: String(i),
|
||||
text: path.length ? path.join(" / ") : L("M02_TreeTop"),
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel";
|
||||
import { createAreaToDrawing } from "./M02_MasterTemplete_AreaToDrawing";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
export interface Selection {
|
||||
@@ -113,7 +114,7 @@ export interface MainExtras {
|
||||
|
||||
/** 상세 제목 줄 오른쪽 끝 「새 …」 단추 — 공용 모양 `m02-main__new`(일위대가 「새 조합」과 같게 · 30-5) */
|
||||
export function newItemButton(run: () => void, label = L("M02_TreeNewItem")): HTMLElement {
|
||||
const button = createButton({ label, variant: "filled", size: "sm" });
|
||||
const button = createButton({ label, variant: "filled" }); // 공용 버튼 크기(40-2 · 저장과 같음)
|
||||
button.classList.add("m02-main__new");
|
||||
button.addEventListener("click", run);
|
||||
return button;
|
||||
@@ -184,7 +185,7 @@ export function createMain(
|
||||
let seq = 0;
|
||||
let dirty = false;
|
||||
/** 저장 안 한 고침 — 이 화면 + 카드(도면 31-4 · 면적산출 31-3) */
|
||||
const busy = (): boolean => dirty || !!panel?.dirty();
|
||||
const busy = (): boolean => dirty || !!panel?.dirty() || toDrawing.waiting(); // + 놓기 기다리는 표(41)
|
||||
|
||||
const guard = (event: BeforeUnloadEvent): void => {
|
||||
if (!root.isConnected) return window.removeEventListener("beforeunload", guard);
|
||||
@@ -203,6 +204,7 @@ export function createMain(
|
||||
return false;
|
||||
}
|
||||
dirty = false;
|
||||
toDrawing.drop();
|
||||
return true;
|
||||
};
|
||||
setLeaveGuard(confirmLeave); // 머리 메뉴 · 뒤로 가기로 떠날 때도 물음
|
||||
@@ -220,6 +222,9 @@ export function createMain(
|
||||
back.hidden = !sel || !(sel.kind in NUMBER_FIELD);
|
||||
};
|
||||
|
||||
// 면적산출 범위 → 도면 표(41-3) — 몸은 `AreaToDrawing` · 여기는 갈고리만
|
||||
const toDrawing = createAreaToDrawing({ open, save: doSave, isDirty: busy, showNotice });
|
||||
|
||||
const drop = (): void => {
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
@@ -275,12 +280,13 @@ export function createMain(
|
||||
dirty = !readOnly;
|
||||
apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함
|
||||
},
|
||||
onSelect: (s) => (picked = s.칸),
|
||||
onSelect: (s) => ((picked = s.칸), toDrawing.select(s)),
|
||||
links,
|
||||
},
|
||||
);
|
||||
editor = handle;
|
||||
sheetHandle = handle;
|
||||
toDrawing.onArea(at, readOnly, slot); // 머리 [도면으로 보내기]
|
||||
if (at.layer === "project") return;
|
||||
if (kind === "area") {
|
||||
// 오른쪽 일위대가 패널 — 담은 로직 + 자재목록(기준 · 반영) · 줄 누름 = 칸 지정 모달
|
||||
@@ -329,6 +335,7 @@ export function createMain(
|
||||
onChange: () => (dirty = !readOnly),
|
||||
...(struct
|
||||
? {
|
||||
laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? "",
|
||||
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
|
||||
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
|
||||
combo: {
|
||||
@@ -364,15 +371,27 @@ export function createMain(
|
||||
if (at.kind === "labor") {
|
||||
// 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로
|
||||
const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail");
|
||||
editor = mountLaborDetail(host, doc, {
|
||||
const labor = mountLaborDetail(host, doc, {
|
||||
layer: at.layer,
|
||||
column: at.name,
|
||||
readOnly,
|
||||
onChanged: (d) => (dirty = d && !readOnly),
|
||||
goArea: async () => {
|
||||
if (await confirmLeave()) void open({ ...at, kind: "area" });
|
||||
if (!(await confirmLeave())) return false; // 취소 — 입력 모달 그대로
|
||||
void open({ ...at, kind: "area" });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
// [저장] = 엑셀에서 고른 칸 연결(면적 문서) 먼저 · 그다음 일위대가(서버가 새 칸 값으로 계산값)
|
||||
editor = {
|
||||
getDoc: async () => {
|
||||
// 면적 저장이 일위대가 계산값을 다시 적어 판이 바뀜 — 판을 다시 읽어 409 를 막음
|
||||
if (await labor.saveArea())
|
||||
version = (await readTemplate(at.layer, "labor", at.name, at.projectId)).판 ?? "";
|
||||
return labor.getDoc();
|
||||
},
|
||||
destroy: labor.destroy,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (at.kind === "area") {
|
||||
@@ -398,6 +417,7 @@ export function createMain(
|
||||
originMarker: at.kind === "structure",
|
||||
noArea: at.kind === "structure",
|
||||
});
|
||||
if (at.kind === "structure") toDrawing.onDrawing(editor, at, doc as StructureDoc);
|
||||
return;
|
||||
}
|
||||
empty(L("M02_NoDrawing"));
|
||||
@@ -457,7 +477,10 @@ export function createMain(
|
||||
// 면적산출은 스프레드시트 문서로 통째 갈아 끼우되 번호는 계약 밖(엔진이 모름)이라 옮겨 붙임.
|
||||
const doc =
|
||||
at.kind === "structure" && loaded
|
||||
? { ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] }
|
||||
? toDrawing.withLinks({
|
||||
...(loaded as StructureDoc),
|
||||
도면: edited as StructureDoc["도면"],
|
||||
})
|
||||
: at.kind === "area" && loaded
|
||||
? { ...(loaded as Record<string, unknown>), ...(edited as Record<string, unknown>) }
|
||||
: edited;
|
||||
|
||||
@@ -97,7 +97,10 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
canEdit: canEdit(layer, isAdmin),
|
||||
onPick: (item) => void main.openTable(layer, item.열),
|
||||
settle: () => main.settle(),
|
||||
onChanged: (colId) => main.openTable(layer, colId, true),
|
||||
// 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표 + 좌측 하위 열 줄 · 번호
|
||||
onChanged: async (colId) => {
|
||||
await Promise.all([main.openTable(layer, colId, true), side.refresh()]);
|
||||
},
|
||||
});
|
||||
const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise<void> => {
|
||||
// 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서
|
||||
|
||||
@@ -54,3 +54,14 @@ def with_server_values(book: dict[str, Any]) -> dict[str, Any]:
|
||||
clean.pop("계산값", None)
|
||||
clean["계산값"] = recalc_workbooks([clean])[0]
|
||||
return clean
|
||||
|
||||
|
||||
def area_tables(asks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""면적산출 시트 · 범위 → CAD 표 객체(41-1 · 변환은 번들 `areaTable` 한 벌) — 물음 차례 그대로
|
||||
`{ok, 표, 시트, 범위, 값}` 또는 `{ok: False, 까닭}`. 번들을 못 돌리면 `RecalcError`."""
|
||||
if _stale() and not build_bundle(NPM_SCRIPT):
|
||||
raise RecalcError("스프레드시트 계산 번들을 못 만듦")
|
||||
out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"tables": asks})
|
||||
if out is None:
|
||||
raise RecalcError("스프레드시트 계산 번들 실행 실패")
|
||||
return out["tables"]
|
||||
|
||||
@@ -6,15 +6,145 @@
|
||||
* 파이썬 껍데기 = `common_util_spreadsheet.py` · 번들 = `npm run build:spreadsheet`.
|
||||
*
|
||||
* 실행: node <번들> <입력.json> <출력.json>
|
||||
* 입력 { books: Workbook[] }
|
||||
* 출력 { results: ({ ok: true, 계산값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 한 권이 죽어도 나머지는 풂
|
||||
* 입력 { books?: Workbook[], tables?: TableAsk[] }
|
||||
* 출력 { results: ({ ok: true, 계산값 } | { ok: false, 까닭 })[],
|
||||
* tables: ({ ok: true, 표, 시트, 범위, 값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 하나가 죽어도 나머지는 풂
|
||||
* 표(41-1) = 면적산출 시트 · 범위 → CAD 표 객체(TableEntity 꼴) — 표시 글(숫자 형식) · 병합 · 가로 정렬 · 굵게 ·
|
||||
* 열 너비 · 행 높이 비율 · 숨긴 행열 뺌 · 칸 key 「시트id!A1」 · style.연결 빈 글(화면이 붙임) · 저장 안 함.
|
||||
* 끝 코드: 0 성공 / 2 인자 오류
|
||||
* ⚠ `A00_Common/spreadsheet/` 밖에 둠 — node:fs 가 브라우저 번들로 새지 않게.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
colName,
|
||||
parseRange,
|
||||
rangeToA1,
|
||||
rangesOverlap,
|
||||
toA1,
|
||||
} from "../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import { cellStyleAt } from "../A00_Common/spreadsheet/spreadsheet_cellstyle";
|
||||
import { createCalcEngine } from "../A00_Common/spreadsheet/spreadsheet_graph";
|
||||
import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "../A00_Common/spreadsheet/spreadsheet_headers";
|
||||
import { formatValue } from "../A00_Common/spreadsheet/spreadsheet_numfmt";
|
||||
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { CellRange, HAlign, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
/** 표 하나 물음 — `시트` = 시트 id 또는 이름 · `범위` = `B3:F12` · `글자크기` = 도면 단위(기본 2.5) */
|
||||
interface TableAsk {
|
||||
book: Workbook;
|
||||
시트: string;
|
||||
범위: string;
|
||||
글자크기?: number;
|
||||
한도?: number;
|
||||
}
|
||||
|
||||
/** CAD `TableCell`(openwebcad `helpers/table-geometry.ts`) 꼴 */
|
||||
interface TableCell {
|
||||
text: string;
|
||||
colSpan?: number;
|
||||
rowSpan?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
bold?: boolean;
|
||||
key: string;
|
||||
}
|
||||
|
||||
const FONT = 2.5;
|
||||
const LIMIT = 2000;
|
||||
/** 엑셀 글자 폭 1 = 글자 크기 × 0.7 · 기본 행 높이(15pt) = 글자 크기 × 1.8 */
|
||||
const COL_K = 0.7;
|
||||
const ROW_K = 1.8;
|
||||
const ALIGN: Partial<Record<HAlign, TableCell["align"]>> = {
|
||||
left: "left",
|
||||
center: "center",
|
||||
right: "right",
|
||||
distributed: "center",
|
||||
centerContinuous: "center",
|
||||
};
|
||||
|
||||
function areaTable(ask: TableAsk) {
|
||||
const book = ask.book;
|
||||
const sheet =
|
||||
book.시트.find((s) => s.id === ask.시트) ?? book.시트.find((s) => s.이름 === ask.시트);
|
||||
if (!sheet) throw new Error(`없는 시트 「${ask.시트}」`);
|
||||
const range = parseRange(ask.범위);
|
||||
if (!range) throw new Error(`범위 꼴 아님 「${ask.범위}」`);
|
||||
const font = ask.글자크기 && ask.글자크기 > 0 ? ask.글자크기 : FONT;
|
||||
const limit = ask.한도 ?? LIMIT;
|
||||
const span = (a: number, b: number): number[] =>
|
||||
Array.from({ length: b - a + 1 }, (_, i) => a + i);
|
||||
const rowShown = (r: number): boolean => {
|
||||
const info = sheet.행?.[String(r + 1)];
|
||||
return !info?.숨김 && !info?.걸러짐;
|
||||
};
|
||||
const colShown = (c: number): boolean => !sheet.열?.[colName(c)]?.숨김;
|
||||
if ((range.r1 - range.r0 + 1) * (range.c1 - range.c0 + 1) > limit * 4)
|
||||
throw new Error(`칸이 너무 많음 — 한도 ${limit}`); // 숨김을 세기 전 큰 범위 막음
|
||||
const rows = span(range.r0, range.r1).filter(rowShown);
|
||||
const cols = span(range.c0, range.c1).filter(colShown);
|
||||
if (!rows.length || !cols.length) throw new Error("보이는 칸이 없음");
|
||||
if (rows.length * cols.length > limit)
|
||||
throw new Error(`칸이 너무 많음 ${rows.length * cols.length} — 한도 ${limit}`);
|
||||
const engine = createCalcEngine(book);
|
||||
const merges: { whole: CellRange; part: CellRange }[] = [];
|
||||
for (const text of sheet.병합 ?? []) {
|
||||
const whole = parseRange(text);
|
||||
if (!whole || !rangesOverlap(whole, range)) continue;
|
||||
const part = {
|
||||
r0: Math.max(whole.r0, range.r0),
|
||||
c0: Math.max(whole.c0, range.c0),
|
||||
r1: Math.min(whole.r1, range.r1),
|
||||
c1: Math.min(whole.c1, range.c1),
|
||||
}; // 범위 밖 병합은 잘라 냄
|
||||
merges.push({ whole, part });
|
||||
}
|
||||
const mergeAt = (r: number, c: number) =>
|
||||
merges.find(({ part }) => r >= part.r0 && r <= part.r1 && c >= part.c0 && c <= part.c1);
|
||||
const 값: Record<string, string> = {};
|
||||
const cellOf = (r: number, c: number, at: { r: number; c: number }): TableCell => {
|
||||
const style = cellStyleAt(book, sheet, at.r, at.c);
|
||||
const has = sheet.칸[toA1(at.r, at.c)];
|
||||
const shown =
|
||||
has && (has.값 !== undefined || has.식 !== undefined)
|
||||
? formatValue(engine.value(sheet.id, at.r, at.c), style.형식)
|
||||
: { 글: "", 정렬: "left" as const };
|
||||
const key = `${sheet.id}!${toA1(r, c)}`;
|
||||
값[key] = shown.글;
|
||||
const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]);
|
||||
return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key };
|
||||
};
|
||||
const cells = rows.map((r) =>
|
||||
cols.map((c): TableCell | null => {
|
||||
const merge = mergeAt(r, c);
|
||||
if (!merge) return cellOf(r, c, { r, c });
|
||||
const inRows = rows.filter((x) => x >= merge.part.r0 && x <= merge.part.r1);
|
||||
const inCols = cols.filter((x) => x >= merge.part.c0 && x <= merge.part.c1);
|
||||
if (r !== inRows[0] || c !== inCols[0]) return null; // 앞 칸 병합에 먹힌 자리
|
||||
const cell = cellOf(r, c, { r: merge.whole.r0, c: merge.whole.c0 }); // 글 = 병합 왼위 칸
|
||||
return {
|
||||
...cell,
|
||||
...(inCols.length > 1 ? { colSpan: inCols.length } : {}),
|
||||
...(inRows.length > 1 ? { rowSpan: inRows.length } : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const colChars = sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS;
|
||||
const rowPt = sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT;
|
||||
const 표 = {
|
||||
type: "Table",
|
||||
lineWidth: 1,
|
||||
shapeData: {
|
||||
origin: { x: 0, y: 0 },
|
||||
columnWidths: cols.map((c) => (sheet.열?.[colName(c)]?.폭 ?? colChars) * font * COL_K),
|
||||
rowHeights: rows.map(
|
||||
(r) => ((sheet.행?.[String(r + 1)]?.높이 ?? rowPt) / DEFAULT_ROW_PT) * font * ROW_K,
|
||||
),
|
||||
cells,
|
||||
style: { fontSize: font, padding: 1, 연결: "" },
|
||||
},
|
||||
};
|
||||
return { 표, 시트: sheet.id, 범위: rangeToA1(range), 값 };
|
||||
}
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
if (!inputPath || !outputPath) {
|
||||
@@ -22,7 +152,10 @@ if (!inputPath || !outputPath) {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8")) as { books?: Workbook[] };
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8")) as {
|
||||
books?: Workbook[];
|
||||
tables?: TableAsk[];
|
||||
};
|
||||
const results = (input.books ?? []).map((book) => {
|
||||
try {
|
||||
return { ok: true, 계산값: recalcWorkbook(book) };
|
||||
@@ -30,4 +163,11 @@ const results = (input.books ?? []).map((book) => {
|
||||
return { ok: false, 까닭: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
});
|
||||
writeFileSync(outputPath, JSON.stringify({ results }));
|
||||
const tables = (input.tables ?? []).map((ask) => {
|
||||
try {
|
||||
return { ok: true, ...areaTable(ask) };
|
||||
} catch (error) {
|
||||
return { ok: false, 까닭: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
});
|
||||
writeFileSync(outputPath, JSON.stringify({ results, tables }));
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/* 표 기하(`openwebcad/src/helpers/table-geometry.ts`) 거울 헬퍼 — 서버 DXF `table_parts` 와 대조.
|
||||
* stdin JSON [표 shapeData] → stdout JSON [{lines, texts}] (TS 규칙 그대로). */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"B07_DesignDetail",
|
||||
"openwebcad",
|
||||
"src",
|
||||
"helpers",
|
||||
"table-geometry.ts",
|
||||
);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const g = box.exports;
|
||||
|
||||
const shapes = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = shapes.map((s) => {
|
||||
const lines = g
|
||||
.tableBorders(s.origin, s.columnWidths, s.rowHeights, s.cells)
|
||||
.map((b) => [b.x1, b.y1, b.x2, b.y2]);
|
||||
const padding = s.style?.padding ?? 1;
|
||||
const texts = [];
|
||||
for (let row = 0; row < s.rowHeights.length; row += 1)
|
||||
for (let column = 0; column < s.columnWidths.length; column += 1) {
|
||||
const cell = s.cells[row]?.[column];
|
||||
if (!cell?.text) continue;
|
||||
const rect = g.cellRect(s.origin, s.columnWidths, s.rowHeights, row, column, cell);
|
||||
const at = g.cellTextAnchor(rect, cell.align, padding);
|
||||
texts.push({ text: cell.text, x: at.x, y: at.y, align: cell.align ?? "center" });
|
||||
}
|
||||
return { lines, texts };
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -33,7 +33,7 @@ const row = (열, 경로, extra = {}) => ({
|
||||
});
|
||||
const rows = [
|
||||
row("c1", ["기슭막이", "메쌓기", "1.2m"], {
|
||||
labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4 },
|
||||
labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4, 기준: 99, 단위: "개소" },
|
||||
수정일: { structure: "2026-10-01T09:00:00", labor: "2026-10-04T11:00:00" },
|
||||
}),
|
||||
row("c2", ["기슭막이", "찰쌓기"], { labor: { 번호: "SU-000002", 로직수: 0, 합계: null } }),
|
||||
@@ -61,5 +61,16 @@ process.stdout.write(
|
||||
),
|
||||
pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page,
|
||||
pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page,
|
||||
// 그 부분 문서 없는 줄(39-8 H3) — c1 = 일위대가 있음 · 면적 수정일 없음 · c3 = 수정일 · 일위대가 없음
|
||||
sums: [
|
||||
rows[0], // 반영 합 있음
|
||||
{ labor: { 로직수: 14, 합계: 0, 기준: 167746.6 } }, // 수량 없음 = 기준
|
||||
rows[1], // 담은 로직 0
|
||||
{ labor: { 로직수: 2, 합계: null, 기준: null } }, // 계산값 없음
|
||||
rows[2], // 일위대가 없음
|
||||
].map((r) => m.laborSum(r)),
|
||||
missing: ["structure", "area", "labor", "item"].map((k) =>
|
||||
rows.map((r) => m.missingPart(k, r)),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/* 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 { linkHues } from "../../../A00_Common/spreadsheet/spreadsheet_link_color";
|
||||
import {
|
||||
linkAreaCell,
|
||||
linkCommand,
|
||||
linksAt,
|
||||
} from "../../../A00_Common/spreadsheet/spreadsheet_link_set";
|
||||
import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
let pass = 0;
|
||||
@@ -57,10 +62,34 @@ b = book();
|
||||
run(b, linkCommand(b, "s1", "깔기|㎡", "B1"));
|
||||
eq("옮김", b.시트[0].연결, [{ 키: "깔기|㎡", 칸: "B1", 이름: "깔기", 단위: "㎡" }]);
|
||||
|
||||
// 같은 칸에 다른 키 — 그 칸 옛 키 빠짐(칸 하나 = 키 하나)
|
||||
// 같은 칸에 다른 키 — 기본 = 함께(칸 하나에 알약 여럿 · 40 마무리) · 바꾸기 = 그 칸 옛 키 빠짐
|
||||
b = book();
|
||||
run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" }));
|
||||
eq("칸 하나 키 하나", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]);
|
||||
eq("칸 하나 알약 여럿", b.시트[0].연결, [
|
||||
{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" },
|
||||
{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" },
|
||||
]);
|
||||
eq(
|
||||
"그 칸 다른 알약",
|
||||
linksAt(b, "s1", " b6 ", "새|m").map((l) => l.키),
|
||||
["깔기|㎡"],
|
||||
);
|
||||
b = book();
|
||||
const undoSwap = run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" }, true));
|
||||
eq("바꾸기", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]);
|
||||
run(b, undoSwap);
|
||||
eq("바꾸기 되돌림", links(b), links(book()));
|
||||
b = book();
|
||||
run(b, linkCommand(b, "s1", "새|m", "B6"));
|
||||
eq("함께 둔 칸에 바꾸기 = 명령 있음", !!linkCommand(b, "s1", "새|m", "B6", {}, true), true);
|
||||
eq(
|
||||
"면적 문서 함께 · 바꾸기",
|
||||
[
|
||||
linkAreaCell(book(), "새|m", "B6")!.시트[0].연결!.length,
|
||||
linkAreaCell(book(), "새|m", "B6", {}, true)!.시트[0].연결!.length,
|
||||
],
|
||||
[2, 1],
|
||||
);
|
||||
|
||||
// 다른 시트에 있던 키를 이 시트로 — 저쪽 연결 빠짐(연결 칸 없으면 `연결` 지움) · 되돌리기 한 번에 둘 다
|
||||
b = book();
|
||||
@@ -89,4 +118,38 @@ 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);
|
||||
|
||||
// linkHues — 키로 정해짐 · 차례 무관 · 24 개까지 서로 다름 · 12 개까지 30° 바퀴 · 15° 눈금
|
||||
const keys = Array.from({ length: 24 }, (_, i) => `알약${i}|㎡`);
|
||||
const h1 = linkHues(keys);
|
||||
const h2 = linkHues([...keys].reverse());
|
||||
eq("차례 무관", [...h1.entries()].sort(), [...h2.entries()].sort());
|
||||
eq("24 개 서로 다름", new Set(h1.values()).size, 24);
|
||||
eq(
|
||||
"15° 눈금",
|
||||
[...h1.values()].every((h) => h % 15 === 0 && h >= 0 && h < 360),
|
||||
true,
|
||||
);
|
||||
const few = linkHues(keys.slice(0, 12));
|
||||
eq(
|
||||
"12 개 = 30° 바퀴",
|
||||
[...few.values()].every((h) => h % 30 === 0),
|
||||
true,
|
||||
);
|
||||
eq(
|
||||
"같은 키 같은 색",
|
||||
linkHues(["철근|ton", "깔기|㎡"]).get("철근|ton"),
|
||||
linkHues(["철근|ton"]).get("철근|ton"),
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({ pass, fail: failures.length, failures }));
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""DXF 내보내기 표 = 선 + 글(PLAN 41-2) — `B07_DesignDetail_Engine_Frame_Export`.
|
||||
|
||||
- 거울: `table_parts` 경계선 · 글 자리 = openwebcad `table-geometry.ts`(Node 헬퍼) —
|
||||
병합 · 정렬 · 여백
|
||||
- DXF: M02 면적산출 표 · B07 수량표(`table_entity`) 모두 LINE · TEXT 로 나옴(전에는 조용히 빠짐) ·
|
||||
글 정렬(MIDDLE_LEFT · CENTER · RIGHT) · 높이 = 칸 글자 크기 → 표 글자 크기
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import ezdxf
|
||||
import pytest
|
||||
|
||||
from B07_DesignDetail import B07_DesignDetail_Engine_Frame_Export as export
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import table_entity
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
AREA_TABLE = {
|
||||
"origin": {"x": 10, "y": 50},
|
||||
"columnWidths": [30, 15, 15],
|
||||
"rowHeights": [5, 5, 5],
|
||||
"cells": [
|
||||
[{"text": "항목", "colSpan": 3}, None, None],
|
||||
[
|
||||
{"text": "면적", "rowSpan": 2, "align": "left"},
|
||||
{"text": "12.5", "align": "right"},
|
||||
{"text": "㎡"},
|
||||
],
|
||||
[None, {"text": "1,250", "align": "right", "fontSize": 3}, {"text": ""}],
|
||||
],
|
||||
"style": {"fontSize": 2.5, "padding": 1, "연결": "T1"},
|
||||
}
|
||||
PLAIN = {
|
||||
"origin": {"x": 0, "y": 0},
|
||||
"columnWidths": [10, 10],
|
||||
"rowHeights": [4],
|
||||
"cells": [[{"text": "a"}, {"text": "b", "align": "left"}]],
|
||||
"style": {"padding": 0},
|
||||
}
|
||||
|
||||
|
||||
def _mirror(shapes: list[dict]) -> list[dict]:
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b07_table_geometry.cjs")],
|
||||
input=json.dumps(shapes, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_거울_경계선_글_자리가_TS_와_같음():
|
||||
shapes = [AREA_TABLE, PLAIN]
|
||||
for shape, ts in zip(shapes, _mirror(shapes)):
|
||||
lines, texts = export.table_parts(shape)
|
||||
flat = [value for line in lines for value in line]
|
||||
assert flat == pytest.approx([value for line in ts["lines"] for value in line])
|
||||
assert len(lines) == len(ts["lines"])
|
||||
assert [(t["text"], t["x"], t["y"], t["align"]) for t in texts] == [
|
||||
(t["text"], pytest.approx(t["x"]), pytest.approx(t["y"]), t["align"])
|
||||
for t in ts["texts"]
|
||||
]
|
||||
|
||||
|
||||
def test_병합_자리는_선을_끊음():
|
||||
lines, _ = export.table_parts(AREA_TABLE)
|
||||
# 바깥 4 · 안쪽 세로 4(1행 병합 자리 끊김) · 가로 5(「면적」 세로 병합 자리 끊김)
|
||||
vertical = [line for line in lines[4:] if line[0] == line[2]]
|
||||
horizontal = [line for line in lines[4:] if line[1] == line[3]]
|
||||
assert len(vertical) == 4 and len(horizontal) == 5
|
||||
|
||||
|
||||
def _dxf(entities: list[dict], tmp_path) -> ezdxf.document.Drawing:
|
||||
raw, skipped = export.drawing_to_dxf({"entities": entities, "layers": []})
|
||||
assert skipped == {}
|
||||
path = tmp_path / "out.dxf"
|
||||
path.write_bytes(raw)
|
||||
return ezdxf.readfile(path)
|
||||
|
||||
|
||||
def test_면적산출_표가_LINE_TEXT_로_나옴(tmp_path):
|
||||
doc = _dxf([{"type": "Table", "layerId": "기본", "shapeData": AREA_TABLE}], tmp_path)
|
||||
lines, texts = export.table_parts(AREA_TABLE)
|
||||
space = doc.modelspace()
|
||||
assert len(space.query("LINE")) == len(lines) == 13
|
||||
found = {t.dxf.text: t for t in space.query("TEXT")}
|
||||
assert set(found) == {"항목", "면적", "12.5", "㎡", "1,250"} and len(texts) == 5
|
||||
assert found["면적"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_LEFT
|
||||
assert found["12.5"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_RIGHT
|
||||
assert found["항목"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_CENTER
|
||||
assert found["1,250"].dxf.height == 3 and found["항목"].dxf.height == 2.5
|
||||
|
||||
|
||||
def test_B07_수량표도_같이_나옴(tmp_path):
|
||||
qtable = table_entity(
|
||||
"t:qtable",
|
||||
(0, 0),
|
||||
[20, 20],
|
||||
[5, 5],
|
||||
[
|
||||
[{"text": "토공", "colSpan": 2}, None],
|
||||
[{"text": "절토"}, {"text": "12.30", "align": "right"}],
|
||||
],
|
||||
"b08-cross-table",
|
||||
"#ffffff",
|
||||
2.0,
|
||||
"#ffffff",
|
||||
)
|
||||
space = _dxf([qtable], tmp_path).modelspace()
|
||||
assert len(space.query("LINE")) == 7 # 바깥 4 · 세로 1(1행 병합 자리 끊김) · 가로 2
|
||||
assert sorted(t.dxf.text for t in space.query("TEXT")) == ["12.30", "절토", "토공"]
|
||||
@@ -341,6 +341,13 @@ def test_고르기_자재품목_직종(client: TestClient) -> None:
|
||||
market = _get(client, "/api/m01/pick", kind="price", q="H형강 관급", limit=50)
|
||||
# 조달청 값이 아직 안 이어진 줄도 섞여 있음 — 이어진 줄이 하나라도 있으면 됨
|
||||
assert any(i["관급"] and i["값"] > 0 for i in market["items"])
|
||||
# 키로도 찾음(찾기 칸 글 「이름 · 규격 · 코드」) · 이름 낱말 AND 는 그대로
|
||||
cement = _get(client, "/api/m01/pick", kind="price", q="시멘트 전국")["items"]
|
||||
code = cement[0]["ref"]
|
||||
by_code = _get(client, "/api/m01/pick", kind="price", q=code.lower())["items"]
|
||||
assert [i["ref"] for i in by_code] == [code]
|
||||
assert _get(client, "/api/m01/pick", kind="price", q=f"시멘트 {code}")["total"] == 1
|
||||
assert _get(client, "/api/m01/pick", kind="price", q=f"없는낱말 {code}")["total"] == 0
|
||||
job = _get(client, "/api/m01/pick", kind="job", q="보통인부")
|
||||
assert job["items"][0]["값"] > 0 and job["items"][0]["ref"] == key_of("LB:1002")
|
||||
assert _get(client, "/api/m01/pick", kind="job", q="CAD설계사")["items"][0]["ref"] == key_of(
|
||||
|
||||
@@ -42,8 +42,65 @@ def test_패널_몸은_보기_갈래_줄_누름은_칸_지정():
|
||||
|
||||
def test_칸_지정_모달은_setLink_만():
|
||||
link = _text("M02_MasterTemplete_AreaLink.ts")
|
||||
assert "opts.sheet.setLink(opts.key, cell, opts.info)" in link
|
||||
assert 'opts.sheet.setLink(opts.key, a1, opts.info, choice === "바꾸기")' in link
|
||||
assert (
|
||||
"linksAt(book, sheetId, a1, opts.key)" in link and "askShare(" in link
|
||||
) # 칸 하나 알약 여럿
|
||||
assert "done(here)" in link and "done(address.input.value)" in link and "done(null)" in link
|
||||
assert "address.setError(TEXT.bad)" in link # 틀린 · 빈 주소는 모달 그대로
|
||||
assert "pick.disabled = link.disabled = address.input.disabled = opts.readOnly;" in link
|
||||
assert "saveTemplate" not in link # 저장은 [저장]
|
||||
|
||||
|
||||
def test_빈_칸은_수량_없음_서버와_같음():
|
||||
"""39-8 ② — 빈 칸에 이은 알약 = 수량 없음(반영 「—」 · 「수량 A1 = 빈 칸」) · 0 칸 = 0 · 값 칸 = 값."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
doc = {
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"칸": {"A2": {"값": 0}, "A3": {"값": "1,250"}, "A4": {"값": "글"}},
|
||||
"연결": [
|
||||
{"키": k, "칸": c}
|
||||
for k, c in (("빈", "A1"), ("영", "A2"), ("값", "A3"), ("글", "A4"))
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
assert {k: float(v) for k, v in labor.area_values(doc).items()} == {"영": 0, "값": 1250}
|
||||
panel = _text("M02_MasterTemplete_AreaPanel.ts")
|
||||
body = panel.split("function quantities")[1].split("\n}\n")[0]
|
||||
assert 'toNumber(cell?.식 !== undefined ? (v && "수" in v ? v.수 : null) : cell?.값)' in body
|
||||
assert 'String(raw).replace(/,/g, "").trim()' in panel and 'typeof raw === "boolean"' in panel
|
||||
sheet = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8")
|
||||
assert 'const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === "");' in sheet
|
||||
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
|
||||
assert '${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}' in rows
|
||||
assert "if (!value) return `${link.칸} = ${TEXT.empty}`;" in _text(
|
||||
"M02_MasterTemplete_AreaLink.ts"
|
||||
)
|
||||
|
||||
|
||||
def test_카드_알약_취향은_계정_받은_뒤_올림():
|
||||
"""39-8 ③ — 화면이 뜰 때 브라우저 옛 값이 계정 값을 덮지 않게 · 카드는 열 때마다 취향을 다시 읽음."""
|
||||
state = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8")
|
||||
push = state.split("function pushUiPrefs")[1].split("export function syncUiPrefs")[0]
|
||||
assert "void (prefsPull ?? Promise.resolve())" in push and "prefs: localPrefs()" in push
|
||||
assert "prefsPull = pullUiPrefs();" in state
|
||||
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
|
||||
assert 'finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });' in pick
|
||||
assert "linksAt(book, sheetId, at, opts.pill)" in pick and "askShare(" in pick
|
||||
assert "if ((e as MouseEvent).button === 0 && moved) void choose();" in pick
|
||||
assert "handle?.destroy();" in pick
|
||||
assert "saveTemplate" not in pick
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""면적산출 범위 → 도면 표(PLAN 41-1) — 변환(Node 번들 `areaTable`) · 길 · 저장 때 도면 표 맞춤.
|
||||
|
||||
시스템 층 = 견본 표(임시 폴더) · 개인 층 = 임시 storage · 마스터 양식 · storage 는 안 씀.
|
||||
"""
|
||||
|
||||
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 import common_util_spreadsheet as sheet_node
|
||||
from common_util.common_util_auth import verify_session
|
||||
from config import config_system
|
||||
from M02_MasterTemplete import M02_Area_Table as area_table
|
||||
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_Router_Layers as layers_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
|
||||
|
||||
SHEET = {
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {
|
||||
"A1": {"값": "제목", "서식": 2},
|
||||
"B1": {"값": 1234.5, "서식": 1},
|
||||
"A2": {"값": "합"},
|
||||
"B2": {"식": "B1*2", "서식": 1},
|
||||
"A3": {"값": "숨김행"},
|
||||
"C1": {"값": "x"},
|
||||
},
|
||||
"병합": ["A1:A2", "C1:E5"],
|
||||
"행": {"3": {"숨김": True}},
|
||||
"열": {"D": {"숨김": True}, "B": {"폭": 12}},
|
||||
}
|
||||
BOOK = {
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "rv",
|
||||
"서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}],
|
||||
"시트": [SHEET],
|
||||
"활성": "s1",
|
||||
}
|
||||
|
||||
|
||||
def _one(**ask) -> dict:
|
||||
return sheet_node.area_tables([{"book": BOOK, **ask}])[0]
|
||||
|
||||
|
||||
def test_표시_글_형식_굵게_정렬_칸_key():
|
||||
got = _one(시트="면적산출", 범위="A1:B2")
|
||||
cells = got["표"]["shapeData"]["cells"]
|
||||
assert cells[0][1] == {"text": "1,234.50", "align": "right", "bold": True, "key": "s1!B1"}
|
||||
assert cells[1][1]["text"] == "2,469.00" # 식 칸 = 풀이 값에 형식
|
||||
assert cells[0][0]["align"] == "center" # 가로 서식
|
||||
assert (got["시트"], got["범위"]) == ("s1", "A1:B2")
|
||||
assert got["값"]["s1!B2"] == "2,469.00"
|
||||
assert got["표"]["shapeData"]["style"] == {"fontSize": 2.5, "padding": 1, "연결": ""}
|
||||
|
||||
|
||||
def test_병합_잘라냄_숨긴_행열_뺌_빈_칸():
|
||||
shape = _one(시트="s1", 범위="A1:D4", 글자크기=5)["표"]["shapeData"]
|
||||
cells = shape["cells"]
|
||||
assert len(cells) == 3 and all(len(row) == 3 for row in cells) # 행 3 · 열 D 뺌
|
||||
assert cells[0][0]["rowSpan"] == 2 and cells[1][0] is None
|
||||
assert cells[0][2]["rowSpan"] == 3 and "colSpan" not in cells[0][2] # C1:E5 → C1:C4(D 숨김)
|
||||
assert cells[1][2] is None and cells[2][2] is None
|
||||
assert cells[2][0] == {"text": "", "align": "left", "key": "s1!A4"} # 빈 칸
|
||||
assert shape["style"]["fontSize"] == 5
|
||||
assert shape["columnWidths"][1] == pytest.approx(12 * 5 * 0.7) # 열 폭 비율
|
||||
assert shape["rowHeights"] == [pytest.approx(5 * 1.8)] * 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ask, why",
|
||||
[
|
||||
({"시트": "없음", "범위": "A1"}, "없는 시트"),
|
||||
({"시트": "s1", "범위": "가나"}, "범위 꼴 아님"),
|
||||
({"시트": "s1", "범위": "A3"}, "보이는 칸이 없음"),
|
||||
({"시트": "s1", "범위": "A1:Z200", "한도": 100}, "칸이 너무 많음"),
|
||||
({"시트": "s1", "범위": "A1:J20", "한도": 100}, "칸이 너무 많음 171"),
|
||||
],
|
||||
)
|
||||
def test_못_바꾸는_범위는_까닭(ask, why):
|
||||
got = _one(**ask)
|
||||
assert not got["ok"] and why in 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")
|
||||
got = store.read("area", "rv")
|
||||
store.write("area", "rv", got["판"], {**got["문서"], "서식": BOOK["서식"], "시트": [SHEET]})
|
||||
items_store.ensure(layers.personal_dir(7, 99))
|
||||
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}
|
||||
|
||||
|
||||
URL = "/api/m02/layers/{}/area/{}/table"
|
||||
|
||||
|
||||
def test_길_읽기_권한이면_표_저장_안_함_남의_층_403_없는_열_404(world):
|
||||
client, system = world["client"], world["system"]
|
||||
before = (system / "area" / "rv.json").read_bytes()
|
||||
got = client.post(URL.format("system", "rv"), json={"시트": "s1", "범위": "A1:B2"})
|
||||
assert got.status_code == 200, got.text
|
||||
assert got.json()["표"]["shapeData"]["cells"][0][1]["text"] == "1,234.50"
|
||||
assert (system / "area" / "rv.json").read_bytes() == before # 저장 안 함
|
||||
bad = client.post(URL.format("system", "rv"), json={"시트": "s1", "범위": "가"})
|
||||
assert bad.status_code == 400
|
||||
other = client.post(
|
||||
URL.format("personal", "rv") + "?owner=7/99", json={"시트": "s1", "범위": "A1"}
|
||||
)
|
||||
assert other.status_code == 403
|
||||
assert (
|
||||
client.post(URL.format("system", "없는열"), json={"시트": "s1", "범위": "A1"}).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
def _link_drawing(folder: Path) -> None:
|
||||
"""도면에 연결표 둘(T1 = 표 있음 · T2 = 표 없음) + 낡은 칸 글의 T1 표 하나 · 다른 표 하나."""
|
||||
with store.at(folder):
|
||||
got = store.read("structure", "rv")
|
||||
doc = got["문서"]
|
||||
stale = {
|
||||
"type": "Table",
|
||||
"shapeData": {
|
||||
"origin": {"x": 10, "y": 20},
|
||||
"columnWidths": [9, 9],
|
||||
"rowHeights": [3, 3],
|
||||
"cells": [[{"text": "낡음"}, {"text": "낡음"}], [None, {"text": "낡음"}]],
|
||||
"style": {"fontSize": 2.5, "연결": "T1"},
|
||||
},
|
||||
}
|
||||
other = json.loads(json.dumps(stale))
|
||||
other["shapeData"]["style"]["연결"] = "남의표"
|
||||
doc["도면"]["entities"] = [stale, other]
|
||||
doc["연결표"] = [
|
||||
{"연결": "T1", "시트": "s1", "범위": "A1:B2", "글자크기": 2.5, "값": {}},
|
||||
{"연결": "T2", "시트": "s1", "범위": "B1", "값": {}},
|
||||
]
|
||||
store.write("structure", "rv", got["판"], doc)
|
||||
|
||||
|
||||
def test_면적_저장_뒤_도면_표_글_바뀜_자리_크기_그대로_표_없는_기록은_둠(world):
|
||||
system = world["system"]
|
||||
_link_drawing(system)
|
||||
with store.at(system):
|
||||
got = store.read("area", "rv")
|
||||
got["문서"]["시트"][0]["칸"]["B1"] = {"값": 10, "서식": 1}
|
||||
store.write("area", "rv", got["판"], got["문서"])
|
||||
doc = store.read("structure", "rv")["문서"]
|
||||
t1, other = doc["도면"]["entities"]
|
||||
shape = t1["shapeData"]
|
||||
assert shape["cells"][0][1]["text"] == "10.00" and shape["cells"][1][1]["text"] == "20.00"
|
||||
assert (shape["origin"], shape["columnWidths"], shape["rowHeights"]) == (
|
||||
{"x": 10, "y": 20},
|
||||
[9, 9],
|
||||
[3, 3],
|
||||
) # 칸 수 같으면 크기 그대로
|
||||
assert other["shapeData"]["cells"][0][0]["text"] == "낡음" # 다른 연결 표는 안 건드림
|
||||
t1_rec, t2_rec = doc["연결표"]
|
||||
assert t1_rec["값"]["s1!B2"] == "20.00" and t2_rec["값"] == {"s1!B1": "10.00"} # 표 없어도 기록
|
||||
|
||||
|
||||
def test_프로젝트_층_길도_같은_맞춤(world, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
import shutil
|
||||
|
||||
shutil.copytree(world["system"], project) # 프로젝트 작업본 꼴(같은 종류 폴더)
|
||||
_link_drawing(project)
|
||||
area = json.loads((project / "area" / "rv.json").read_bytes())
|
||||
area["시트"][0]["칸"]["B1"] = {"값": 7, "서식": 1}
|
||||
(project / "area" / "rv.json").write_text(
|
||||
json.dumps(area, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
layers_router._refresh_drawing(project, "rv")
|
||||
doc = json.loads((project / "structure" / "rv.json").read_bytes())
|
||||
assert doc["도면"]["entities"][0]["shapeData"]["cells"][0][1]["text"] == "7.00"
|
||||
system_doc = json.loads((world["system"] / "structure" / "rv.json").read_bytes())
|
||||
assert "연결표" not in system_doc # 시스템 층은 그대로
|
||||
assert area_table.refresh_drawing("없는열") == []
|
||||
@@ -0,0 +1,186 @@
|
||||
"""면적산출 범위 → 도면 표(PLAN 41-3 · `M02_MasterTemplete_AreaToDrawing.ts`) — Node 로 TS 를 돌려 판정.
|
||||
|
||||
못박는 것
|
||||
- 면적 상세 [도면으로 보내기] = 고칠 수 있는 층만(읽기 · 프로젝트 층 단추 0).
|
||||
- 보냄 → 표 변환 길(POST …/area/{열}/table) → 같은 열 도면 상세를 엶 → placeTable(style.연결 = T번호).
|
||||
- 다른 열 도면이면 안 놓음 · 기존 기록 · 표지와 안 겹치는 id(T1 이 있으면 T2).
|
||||
- [저장] 문서 `연결표` = 도면에 남은 표만(지운 표는 빠짐) · 값 = 그 표 칸 글 · 기록 없던 도면은 칸 안 만듦.
|
||||
- 다시 열 때 지금 면적 값 ≠ 마지막 값이면 알림 + [업데이트] → updateTable(id, 칸).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = r"""
|
||||
const fs = require("fs");
|
||||
const ts = require(process.argv[1] + "/config/node_modules/typescript");
|
||||
const src = fs.readFileSync(process.argv[1] + "/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts", "utf8");
|
||||
const js = ts.transpileModule(src, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
// 화면 부품 · 길은 가짜 — 단추 · 창 · 알림만 흉내
|
||||
const made = [];
|
||||
const button = (o) => {
|
||||
const b = { label: o.label, title: "", dataset: {}, on: {}, addEventListener(k, f) { this.on[k] = f; } };
|
||||
made.push(b);
|
||||
return b;
|
||||
};
|
||||
const toasts = [];
|
||||
const stubs = {
|
||||
"@config/config_frontend": { API_BASE_URL: "/api" },
|
||||
"@ui/ui_template_elements": {
|
||||
createButton: button,
|
||||
createInputField: (o) => ({ root: {}, input: { value: o.value, step: "", min: "", select() {}, addEventListener() {} } }),
|
||||
el: (tag, o) => ({ tag, ...o }),
|
||||
showConfirmDialog: async () => true,
|
||||
showToast: (m, k) => toasts.push([k, m]),
|
||||
},
|
||||
"@ui/ui_template_modal": {
|
||||
// 창이 뜨자마자 [보내기]
|
||||
openModal: (o) => {
|
||||
const body = { parts: [], append(...x) { this.parts.push(...x); } };
|
||||
const res = { close() {} };
|
||||
setTimeout(() => o.mount(body, res.close), 0);
|
||||
setTimeout(() => made.at(-1).on.click(), 1);
|
||||
return res;
|
||||
},
|
||||
},
|
||||
"./M02_MasterTemplete_Api_Fetch": { getActiveOwner: () => null },
|
||||
};
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n));
|
||||
const { createAreaToDrawing } = box.exports;
|
||||
|
||||
const table = (texts, style = {}) => ({
|
||||
type: "Table",
|
||||
shapeData: { cells: texts.map((r) => r.map((t) => ({ text: t }))), style },
|
||||
});
|
||||
let now = [["1.5", "2"]];
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push([url, JSON.parse(init.body)]);
|
||||
const b = JSON.parse(init.body);
|
||||
return { ok: true, json: async () => ({ 표: table(now), 시트: "s1", 범위: b.범위, 값: { "s1!B2": now[0][0], "s1!C2": now[0][1] } }) };
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const opened = [];
|
||||
const notices = [];
|
||||
const ad = createAreaToDrawing({
|
||||
open: async (s) => opened.push(s),
|
||||
save: async () => {},
|
||||
isDirty: () => false,
|
||||
showNotice: (n) => notices.push(n),
|
||||
});
|
||||
const slot = { kids: [], append(x) { this.kids.push(x); } };
|
||||
const area = { layer: "system", projectId: null, kind: "area", name: "item1", label: "기슭막이" };
|
||||
ad.onArea({ ...area, layer: "project" }, false, slot);
|
||||
ad.onArea(area, true, slot);
|
||||
const ro = slot.kids.length;
|
||||
ad.onArea(area, false, slot);
|
||||
ad.select({ 시트: "산출", 칸: "B2", 범위: "B2:C2" });
|
||||
await slot.kids[0].on.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const sent = { calls: calls.slice(), opened: opened.slice() };
|
||||
|
||||
// 같은 열 도면 — 기록 T1 이 있으니 새 표는 T2
|
||||
const placed = [];
|
||||
const handle = { placeTable: (e, id) => placed.push([e, id]), updateTable: async (id, c) => (placed.push(["u", id, c]), 1) };
|
||||
const old = { 연결: "T1", 시트: "s1", 범위: "A1:A1", 글자크기: 3, 값: { "s1!A1": "옛" } };
|
||||
const drawing = (ents, links) => ({ 양식: "구조물도면", 종류: "structure", 판: 1, 열: "item1", 도번: "SD-1", 도면: { entities: ents }, ...(links ? { 연결표: links } : {}) });
|
||||
ad.onDrawing(handle, { ...area, kind: "structure" }, drawing([table([["옛"]], { 연결: "T1" })], [old]));
|
||||
const waitPlaced = ad.waiting(); // 놓기 기다림 = 저장 안 한 고침
|
||||
ad.drop();
|
||||
const waitDropped = ad.waiting();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const notice = notices.at(-1) ?? [];
|
||||
const upd = notice.find((x) => x && x.on);
|
||||
if (upd) await upd.on.click();
|
||||
|
||||
// 저장 — T1(업데이트로 값 바뀜) · T2 · 지운 T3 기록은 빠짐
|
||||
const t3 = { ...old, 연결: "T3" };
|
||||
const saved = ad.withLinks(drawing([{ type: "Line" }, table([["옛"]], { 연결: "T1" }), table([["9", "8"]], { 연결: "T2" })], [old, t3]));
|
||||
const plain = ad.withLinks(drawing([table([["x"]])]));
|
||||
// 다른 열 도면이면 안 놓음
|
||||
const before = placed.length;
|
||||
ad.select({ 시트: "산출", 칸: "A1", 범위: "A1" });
|
||||
await slot.kids[0].on.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
ad.onDrawing(handle, { ...area, kind: "structure", name: "item2" }, drawing([]));
|
||||
// 빈 칸만 고른 범위 — 크기를 묻기 전에 막고 알림 · 도면 안 엶
|
||||
now = [["", " "]];
|
||||
const openedBefore = opened.length;
|
||||
ad.select({ 시트: "산출", 칸: "E1", 범위: "E1:F1" });
|
||||
await slot.kids[0].on.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const empty = { opened: opened.length - openedBefore, toast: toasts.at(-1) };
|
||||
console.log(JSON.stringify({ ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, plain: "연결표" in plain, other: placed.length - before, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty }));
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got():
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
proc = subprocess.run(
|
||||
["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def test_단추는_고칠_수_있는_층만(got):
|
||||
assert got["ro"] == 0 and got["buttons"] == 1
|
||||
|
||||
|
||||
def test_보냄은_표_변환_길_뒤_같은_열_도면을_엶(got):
|
||||
url, body = got["sent"]["calls"][0]
|
||||
assert url == "/api/m02/layers/system/area/item1/table"
|
||||
assert body == {"시트": "산출", "범위": "B2:C2", "글자크기": 2.5}
|
||||
assert got["sent"]["opened"][0]["kind"] == "structure"
|
||||
assert got["sent"]["opened"][0]["name"] == "item1"
|
||||
|
||||
|
||||
def test_놓을_표는_겹치지_않는_연결_id(got):
|
||||
entity, link = got["placed"][0]
|
||||
assert link == "T2" and entity["shapeData"]["style"]["연결"] == "T2"
|
||||
|
||||
|
||||
def test_다시_열면_다름_알림과_업데이트(got):
|
||||
assert got["notice"][0].startswith("면적산출 값이 바뀐 표 1")
|
||||
assert got["notice"][1] == "업데이트"
|
||||
assert ["u", "T1", [[{"text": "1.5"}, {"text": "2"}]]] in got["placed"]
|
||||
|
||||
|
||||
def test_저장_연결표는_남은_표만(got):
|
||||
now = {"s1!B2": "1.5", "s1!C2": "2"}
|
||||
assert got["saved"] == [
|
||||
{"연결": "T1", "시트": "s1", "범위": "A1:A1", "글자크기": 3, "값": now}, # [업데이트] 값
|
||||
{"연결": "T2", "시트": "s1", "범위": "B2:C2", "글자크기": 2.5, "값": now},
|
||||
]
|
||||
assert got["plain"] is False
|
||||
|
||||
|
||||
def test_다른_열_도면에는_안_놓음(got):
|
||||
assert got["other"] == 0
|
||||
|
||||
|
||||
def test_편집_칸은_갈고리만():
|
||||
main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8")
|
||||
# select · onArea · onDrawing · withLinks · waiting(저장 안 한 고침) · drop(떠나기 확인)
|
||||
assert main.count("toDrawing.") == 6
|
||||
assert "|| toDrawing.waiting()" in main.split("const busy = ")[1].splitlines()[0]
|
||||
|
||||
|
||||
def test_놓기_기다림은_저장_안_한_고침(got):
|
||||
assert got["waitPlaced"] is True and got["waitDropped"] is False and got["waitSaved"] is False
|
||||
|
||||
|
||||
def test_빈_범위는_막고_알림(got):
|
||||
assert got["empty"] == {"opened": 0, "toast": ["error", "보낼 칸에 내용이 없음"]}
|
||||
@@ -40,7 +40,7 @@ def test_저장_단추_알림_한_벌():
|
||||
def test_네_카드가_틀을_씀_수정_단추_한_모양():
|
||||
cards = _text("M02_MasterTemplete_ItemCards.ts")
|
||||
assert "const shell = cardShell({ title, body, sub: [no, name] });" in cards
|
||||
assert "shell.tools.append(help, saver.button, take, edit);" in cards # 복사해 가져오기(39-5)
|
||||
assert "shell.tools.append(help, saver.button, take, edit, del);" in cards # 40-2
|
||||
assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost)
|
||||
assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임
|
||||
assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards
|
||||
|
||||
@@ -43,7 +43,7 @@ def test_종류마다_칸(ts):
|
||||
"이름",
|
||||
"폴더",
|
||||
"단위",
|
||||
"담은 로직 수",
|
||||
"단가산출 로직 수",
|
||||
"합계",
|
||||
"고친 날",
|
||||
]
|
||||
@@ -54,11 +54,23 @@ def test_칸_값_빈_번호_합계_날짜(ts):
|
||||
s, labor = ts["cells"]["structure"], ts["cells"]["labor"]
|
||||
assert s[0] == ["SD-c1", "1.2m", "기슭막이 / 메쌓기", "m", "2026-10-01"]
|
||||
assert s[2][0] == "—" and s[2][2] == "" # 빈 도번 · 맨 위 항목(폴더 없음)
|
||||
assert labor[0][3:] == ["m", "3", "1,234,567", "2026-10-04"] # 합계 원 단위 반올림
|
||||
assert labor[0][3:] == ["개소", "3", "1,234,567", "2026-10-04"] # 일위대가 단위 · 원 단위
|
||||
assert labor[1][5] == "—" and labor[2][0] == labor[2][4] == "—" # 합계 없음 · 일위대가 없음
|
||||
assert ts["cells"]["item"][0][-1] == "2026-10-04" # 항목 통째 = 가장 늦게 고친 날
|
||||
|
||||
|
||||
def test_일위대가_합계_반영_기준_빈(ts):
|
||||
"""39-8 B2 — 반영 합이 있으면 그것 · 없으면 기준 합 + 「기준」 ·
|
||||
담은 로직 0 · 계산값 없음 = 「—」."""
|
||||
assert ts["sums"] == [
|
||||
{"text": "1,234,567", "note": ""},
|
||||
{"text": "167,747", "note": "기준"},
|
||||
{"text": "—", "note": ""},
|
||||
{"text": "—", "note": ""},
|
||||
{"text": "—", "note": ""},
|
||||
]
|
||||
|
||||
|
||||
def test_찾기_쪽(ts):
|
||||
assert ts["find"] == ["c2"] # 낱말 둘 AND(폴더 · 이름 칸)
|
||||
assert ts["findLabor"] == ["c1"] # 번호 칸도 찾음
|
||||
@@ -67,6 +79,15 @@ def test_찾기_쪽(ts):
|
||||
assert ts["pageOver"] == 3 and ts["pageEmpty"] == 1
|
||||
|
||||
|
||||
def test_부분_문서_없는_줄_까닭(ts):
|
||||
"""39-8 H3 — 그 부분 문서가 없으면 고르기 막음(흐림 · 까닭) · 항목 통째는 막지 않음."""
|
||||
structure, area, labor, item = ts["missing"]
|
||||
assert structure == [None, None, "이 항목에는 도면 문서가 없음"]
|
||||
assert area == ["이 항목에는 면적산출 문서가 없음", None, "이 항목에는 면적산출 문서가 없음"]
|
||||
assert labor == [None, None, "이 항목에는 일위대가 문서가 없음"]
|
||||
assert item == [None, None, None]
|
||||
|
||||
|
||||
def test_서버_목록_칸_수정일_labor(world):
|
||||
t = Tree(_as(world), "personal")
|
||||
t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m")
|
||||
@@ -82,14 +103,27 @@ def test_서버_목록_칸_수정일_labor(world):
|
||||
doc = {
|
||||
"일위대가번": "SU-000001",
|
||||
"담은로직": [{"로직": "GF000219"}, {"로직": "GF000137"}],
|
||||
"계산값": {"반영합": {"계": 12345.6}},
|
||||
"단위": "개소",
|
||||
"계산값": {"반영합": {"계": 12345.6}, "기준합": {"계": 2469.12}},
|
||||
}
|
||||
(root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8")
|
||||
(root / "labor" / f"{wall['열']}.json").write_text("{깨짐", encoding="utf-8")
|
||||
rows = {r["열"]: r for r in t.client.get(t.url).json()["items"]}
|
||||
assert rows[ditch["열"]]["labor"] == {"번호": "SU-000001", "로직수": 2, "합계": 12345.6}
|
||||
assert rows[ditch["열"]]["labor"] == {
|
||||
"번호": "SU-000001",
|
||||
"로직수": 2,
|
||||
"합계": 12345.6,
|
||||
"기준": 2469.12,
|
||||
"단위": "개소",
|
||||
}
|
||||
assert "labor" in rows[ditch["열"]]["수정일"]
|
||||
assert rows[wall["열"]]["labor"] == {"번호": None, "로직수": 0, "합계": None}
|
||||
assert rows[wall["열"]]["labor"] == {
|
||||
"번호": None,
|
||||
"로직수": 0,
|
||||
"합계": None,
|
||||
"기준": None,
|
||||
"단위": None,
|
||||
}
|
||||
doc["계산값"] = {"반영합": {"계": "글"}}
|
||||
(root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8")
|
||||
again = {r["열"]: r for r in t.client.get(t.url).json()["items"]}
|
||||
|
||||
@@ -53,3 +53,13 @@ def test_층_단추는_구조물_조합_한_벌():
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
|
||||
|
||||
def test_나무가_바뀌면_좌측_하위_열_줄도_다시_받음():
|
||||
"""39-8 H1 — 새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기 = 나무 `apply` →
|
||||
`onChanged` 한 길 · 집계표만 다시 열면 좌측 하위 줄이 새로고침 전까지 안 따라옴."""
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
changed = page.split("onChanged: async (colId) => {")[1].split("},")[0]
|
||||
assert "main.openTable(layer, colId, true)" in changed and "side.refresh()" in changed
|
||||
panel = _text("M02_MasterTemplete_TreePanel.ts")
|
||||
assert panel.count("await opt.onChanged(") == 1 # 나무 고침은 모두 apply 한 곳을 거침
|
||||
|
||||
@@ -127,10 +127,18 @@ 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),
|
||||
m.qtyText({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡", cells),
|
||||
m.qtyText({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡"),
|
||||
]));
|
||||
"""
|
||||
|
||||
@@ -144,9 +152,13 @@ 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 ㎡",
|
||||
"수량 B6 = 빈 칸", # 이은 칸이 비었음 = 수량 없음(39-8 ② · 서버 area_values 와 같음)
|
||||
"",
|
||||
]
|
||||
|
||||
@@ -206,7 +206,9 @@ def test_층_단추_셋_남의_회사_사람_나무():
|
||||
|
||||
def test_층_길_접두어와_고정_풀림():
|
||||
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
|
||||
assert 'activeLayer === "system" ? "" : `/layers/${activeLayer}`' in api
|
||||
# 시스템 층도 층 길 한 벌(39-8 H2 · 옛 /templates/* 는 시스템 관리자 전용)
|
||||
assert "owned(activeLayer, `/layers/${activeLayer}${path}`)" in api
|
||||
assert 'activeLayer === "system" ? "" :' not in api
|
||||
for path in ("/structures/numbers", "/structure-combos"):
|
||||
assert re.search(rf'scoped\(\s*[`"]{re.escape(path)}', api), path
|
||||
assert "/layers/personal/items/copy" in api
|
||||
|
||||
@@ -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
|
||||
@@ -92,7 +92,11 @@ def test_집계표만_컨테이너를_붙임():
|
||||
body,
|
||||
)
|
||||
# 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로
|
||||
assert '{ ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] }' in body
|
||||
# 도면 칸만 갈아 끼움 · 맨 위 연결표는 도면 표에 맞춤(41-3 `toDrawing.withLinks`)
|
||||
assert re.search(
|
||||
r'toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*도면: edited as StructureDoc\["도면"\],\s*\}\)',
|
||||
body,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""마스터 층 읽기는 층 길 한 벌(39-8 · sub1 검증 흠 H2) — 회사 관리자 · 일반도 `/layers/system/…` 로
|
||||
표 · 카드 · 일위대가 · 번호 · 장부 · 옵션을 읽음(200) · 쓰기는 서버가 그대로 막음(403).
|
||||
|
||||
시스템 층 = 견본 표(임시 폴더) · storage = 임시 폴더.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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_Router_Items as items_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_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
|
||||
|
||||
SYS = "/api/m02/layers/system"
|
||||
TABLE = f"{SYS}/templates/table/구조물집계표"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def world(tmp_path, 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")
|
||||
session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
|
||||
app = FastAPI()
|
||||
app.include_router(items_router.router)
|
||||
app.include_router(layers_router.router)
|
||||
app.dependency_overrides[verify_session] = lambda: session
|
||||
return {"client": TestClient(app), "session": session}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["USER", "ADMIN", "SYSTEM_ADMIN"])
|
||||
def test_세_계정_모두_마스터_층_읽기_200(world, role):
|
||||
world["session"]["role"] = role
|
||||
client = world["client"]
|
||||
for url in (
|
||||
f"{SYS}/templates",
|
||||
TABLE,
|
||||
f"{SYS}/templates/structure/rv",
|
||||
f"{SYS}/templates/labor/rv",
|
||||
f"{SYS}/templates/area/rv",
|
||||
f"{SYS}/structures/numbers",
|
||||
f"{SYS}/structure-combos",
|
||||
f"{SYS}/structure-options",
|
||||
):
|
||||
assert client.get(url).status_code == 200, url
|
||||
rows = client.get(f"{SYS}/templates").json()["양식"]
|
||||
table = next(r for r in rows if (r["종류"], r["이름"]) == ("table", "구조물집계표"))
|
||||
assert table["쓰는곳"] # 지우기 확인 창 전에 막는 글
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["USER", "ADMIN"])
|
||||
def test_회사_관리자_일반은_마스터_쓰기_403(world, role):
|
||||
world["session"]["role"] = role
|
||||
client = world["client"]
|
||||
got = client.get(TABLE).json()
|
||||
put = client.put(TABLE, json={"판": got["판"], "문서": got["문서"]})
|
||||
assert put.status_code == 403
|
||||
assert client.post(f"{SYS}/structures/c1").status_code == 403
|
||||
assert client.post(f"{SYS}/labor/rv/copy?from=rv").status_code == 403
|
||||
gone = client.delete(f"{SYS}/templates/area/rv?판=x")
|
||||
assert gone.status_code == 403
|
||||
@@ -138,7 +138,7 @@ def test_항목_추가_창_카드_격자_표_접기():
|
||||
assert "일위대가:" not in panel and "pickCombo" not in panel + dlg # 39-1d — 고르기 줄 없음
|
||||
assert "askNewItem({" in panel and "M02_TreeUnit" not in panel
|
||||
css = (m02 / "M02_MasterTemplete_Structure.css").read_text("utf-8")
|
||||
assert "grid-template-columns: repeat(2, minmax(0, 1fr));" in css
|
||||
assert "grid-template-columns: repeat(3, minmax(0, 1fr));" in css # 카드 셋 3열(40-2)
|
||||
assert "grid-auto-rows: var(--m02-struct-card-h);" in css
|
||||
assert "--m02-struct-card-h: min(640px, 70vh);" in css # 31-6 화면 높이에 맞춤
|
||||
assert "flex: 1 0 var(--m02-struct-grid-min);" in css # 엑셀 격자가 카드 높이를 채움
|
||||
|
||||
@@ -8,6 +8,7 @@ master 만 · 단위 줄 바로 아래 차례(키보드) · project 에는 없
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -70,3 +71,18 @@ def test_text_column_pick_rows_blank():
|
||||
assert _run()["blank"] == [
|
||||
[True, False], [True, False], [True, False], [False, False], [False, False],
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def test_labor_row_shows_su_number():
|
||||
"""마스터 일위대가 줄 = 그 열 일위대가번(SU) — 열의 옛 `일위대가` 칸(UA 키)은 안 읽음."""
|
||||
ops = OPS.read_text(encoding="utf-8")
|
||||
assert "REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA]" in ops
|
||||
render = (OPS.parent / "ui_template_sheet_render.ts").read_text(encoding="utf-8")
|
||||
assert re.search(r"key === KEY_PRICE\s*\?\s*state\.laborLabel", render)
|
||||
assert "col.일위대가" not in render
|
||||
sheet = (OPS.parent / "ui_template_sheet.ts").read_text(encoding="utf-8")
|
||||
assert "col.일위대가" not in sheet # 읽기 · 쓰기 · 조회 후 선택 모두 안 건드림
|
||||
main = (ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_UI_Main.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert 'laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? ""' in main
|
||||
|
||||
@@ -55,14 +55,38 @@ 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"] == 29
|
||||
|
||||
|
||||
def test_손잡이_setLink_몸():
|
||||
src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8")
|
||||
assert "setLink(key: string, cell: string | null, info?: LinkInfo): boolean;" in src
|
||||
body = src.split(" setLink(key, cell, info) {")[1].split(" destroy() {")[0]
|
||||
assert (
|
||||
"setLink(key: string, cell: string | null, info?: LinkInfo, replace?: boolean): boolean;"
|
||||
in src
|
||||
)
|
||||
body = src.split(" setLink(key, cell, info, replace) {")[1].split(" destroy() {")[0]
|
||||
assert "if (readOnly) return false;" in body
|
||||
assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body
|
||||
assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info, replace)" in body
|
||||
assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님)
|
||||
assert "loadLinks();" in body
|
||||
|
||||
|
||||
def test_알약_끌어_놓기_색():
|
||||
"""40-3 ② ③ — 알약 끌어 칸에 놓기(다른 알약 칸이면 물음) · 이은 알약 · 칸 = 키 색 · 두 테마 밝기."""
|
||||
src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_links.ts").read_text(encoding="utf-8")
|
||||
assert "b.draggable = !ctx.readOnly;" in src and 'addEventListener("drop", onDropEvent)' in src
|
||||
assert "await link(item, toA1(at.r, at.c));" in src
|
||||
# 칸 하나에 알약 여럿 — 다른 알약 칸이면 함께 · 바꾸기 · 취소 물음(알약 누름 · 끌어 놓기 한 길)
|
||||
body = src.split("async function link(")[1].split("\n }\n")[0]
|
||||
assert "linksAt(ctx.book, sheet.id, 칸, item.키)" in body and "askShare(" in body
|
||||
assert 'choice === "바꾸기"' in body
|
||||
assert 'className: "ss-link-count"' in src and 'ctx.grid.root.title = names.join(" · ")' in src
|
||||
assert 'if (!b.draggable) b.addEventListener("mousedown"' in src # 끌기 막지 않음
|
||||
assert "const hues = linkHues(found().keys());" in src and 'className: "ss-link-cell"' in src
|
||||
css = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_links.css").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert (
|
||||
"--ss-link-l: 31%;" in css and css.count("--ss-link-l: 66%;") == 2
|
||||
) # 밝음 · 어두움(설정 · 시스템)
|
||||
assert "ss-link-flash" in css # 반짝임 그대로
|
||||
|
||||
@@ -82,5 +82,7 @@ def test_실패_글과_다시_불러오기_M02_자리():
|
||||
assert "const stop = showLoading(view);" in practice
|
||||
assert "loadingError(why(error), () => void drawList())" in practice
|
||||
assert "loadingError(why(error), () => void drawOne(key))" in practice
|
||||
assert "cards.map((c) => c.shell.loading())" in _text(M02 / "M02_MasterTemplete_ItemCards.ts")
|
||||
cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts")
|
||||
# 일위대가 카드는 뺌 — 몸(Labor_View)을 덮었다 지우면 빈 카드(40 D1)
|
||||
assert "cards.filter((c) => c !== laborCard).map((c) => c.shell.loading())" in cards
|
||||
assert "loading: () => showLoading(host)," in _text(M02 / "M02_MasterTemplete_CardShell.ts")
|
||||
|
||||
@@ -28,6 +28,10 @@ const CAD_HOST_SAVE_MESSAGE = "aislo:b08:host-save";
|
||||
* CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */
|
||||
const CAD_TOAST_MESSAGE = "aislo:b08:toast";
|
||||
const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
|
||||
/** 표(M02 41장) — 짝 = CAD `integration/aislo-table-bridge.ts` */
|
||||
const CAD_PLACE_TABLE_MESSAGE = "aislo:b08:place-table";
|
||||
const CAD_UPDATE_TABLE_MESSAGE = "aislo:b08:update-table";
|
||||
const CAD_TABLE_UPDATED_MESSAGE = "aislo:b08:table-updated";
|
||||
|
||||
/** CAD 가 주고받는 도면 JSON — 도형·층 밖의 칸은 페이지마다 다르다. */
|
||||
export interface CadHostDrawing {
|
||||
@@ -93,6 +97,11 @@ export interface CadHost<D extends CadHostDrawing, M, Q> {
|
||||
) => void;
|
||||
/** CAD 의 지금 편집본을 받는다. */
|
||||
requestSave: () => Promise<CadSaveResult<D, Q>>;
|
||||
/** 표 놓기(41장) — `entity` = 표 JSON(TableEntity 꼴) · CAD 가 `style.연결 = link` 로 커서에 붙여 놓게 함.
|
||||
* 도면이 아직 안 열렸으면 열린 뒤 보냄 · 놓기는 사용자가 누를 때 · 저장은 [저장]. */
|
||||
placeTable: (entity: Record<string, unknown>, link: string) => void;
|
||||
/** 표 칸 갈기(41장) — `style.연결 === link` 인 표의 칸만(자리 · 크기 그대로) · 돌려받는 수 = 바꾼 표 수(0 = 없음). */
|
||||
updateTable: (link: string, cells: unknown[][]) => Promise<number>;
|
||||
/** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */
|
||||
destroy: () => void;
|
||||
}
|
||||
@@ -177,6 +186,35 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
frame.contentWindow?.postMessage(message, window.location.origin);
|
||||
};
|
||||
|
||||
/** 도면이 열린 뒤에만 보낼 것(표) — 열리기 전에 보내면 싣기가 덮어씀 */
|
||||
let drawingLoaded = false;
|
||||
let afterLoad: Record<string, unknown>[] = [];
|
||||
const postAfterLoad = (message: Record<string, unknown>): void => {
|
||||
if (drawingLoaded) post(message);
|
||||
else afterLoad.push(message);
|
||||
};
|
||||
const tableWaits = new Map<string, ((count: number) => void)[]>();
|
||||
|
||||
const placeTable: CadHost<D, M, Q>["placeTable"] = (entity, link) =>
|
||||
postAfterLoad({ type: CAD_PLACE_TABLE_MESSAGE, entity, link });
|
||||
|
||||
const updateTable: CadHost<D, M, Q>["updateTable"] = (link, cells) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const waits = tableWaits.get(link) ?? [];
|
||||
waits.push(resolve);
|
||||
tableWaits.set(link, waits);
|
||||
postAfterLoad({ type: CAD_UPDATE_TABLE_MESSAGE, link, cells });
|
||||
window.setTimeout(() => {
|
||||
const left = tableWaits.get(link) ?? [];
|
||||
if (!left.includes(resolve)) return;
|
||||
tableWaits.set(
|
||||
link,
|
||||
left.filter((one) => one !== resolve),
|
||||
);
|
||||
reject(new Error("CAD 표 갈기 응답 시간이 초과되었습니다."));
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
const load: CadHost<D, M, Q>["load"] = (
|
||||
drawing,
|
||||
meta,
|
||||
@@ -185,6 +223,7 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
extra = {},
|
||||
) => {
|
||||
pendingLoad = { drawing, meta, frameEdit, frameFields, extra };
|
||||
drawingLoaded = false; // 새로 싣는 중 — 표는 열린 뒤에
|
||||
if (!cadReady) return;
|
||||
post({ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields, ...extra });
|
||||
pendingLoad = undefined;
|
||||
@@ -215,6 +254,8 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
text?: string;
|
||||
actionId?: string;
|
||||
durationMs?: number;
|
||||
link?: string;
|
||||
count?: number;
|
||||
};
|
||||
if (message.type === CAD_TOAST_MESSAGE) {
|
||||
const kind = (["info", "success", "warning", "error"] as const).find(
|
||||
@@ -240,6 +281,14 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
} else if (message.type === CAD_LOADED_MESSAGE) {
|
||||
stopLoadWatchdog();
|
||||
element.dataset.loading = "false";
|
||||
drawingLoaded = true;
|
||||
const queued = afterLoad;
|
||||
afterLoad = [];
|
||||
queued.forEach(post);
|
||||
} else if (message.type === CAD_TABLE_UPDATED_MESSAGE && message.link) {
|
||||
const [first, ...rest] = tableWaits.get(message.link) ?? [];
|
||||
tableWaits.set(message.link, rest);
|
||||
first?.(message.count ?? 0);
|
||||
} else if (message.type === CAD_ERROR_MESSAGE) {
|
||||
stopLoadWatchdog();
|
||||
showFailure(message.detail ?? "CAD 도면을 표시하지 못했습니다.");
|
||||
@@ -272,6 +321,8 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
},
|
||||
load,
|
||||
requestSave,
|
||||
placeTable,
|
||||
updateTable,
|
||||
destroy: () => {
|
||||
stopLoadWatchdog();
|
||||
window.removeEventListener("message", onMessage);
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
addLevel,
|
||||
deleteColumn,
|
||||
isBlankPick,
|
||||
KEY_PRICE,
|
||||
KEY_UNIT,
|
||||
navRows,
|
||||
PICK_ROWS,
|
||||
@@ -54,6 +53,8 @@ export interface SheetOptions {
|
||||
/** master — 열 더하기 · 지우기 · 머리 · 층 이름 고치기 없음(열 · 머리는 나무가 정함) */
|
||||
lockHead?: boolean;
|
||||
onChange?: (doc: SheetDoc) => void;
|
||||
/** 일위대가 줄 칸 글(일위대가번 SU · 열 id) — `drawingLabel` 과 같음(열의 옛 `일위대가` 칸은 안 읽음). */
|
||||
laborLabel?: (colId: string) => string;
|
||||
/** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */
|
||||
drawingLabel?: (colId: string) => string;
|
||||
/** 면적산출 줄 칸 글(번호) — `drawingLabel` 과 같음. */
|
||||
@@ -61,8 +62,7 @@ export interface SheetOptions {
|
||||
/** 칸을 고름 — 누르기 · 방향키 · Tab. */
|
||||
onSelect?: (sel: SheetSelection) => void;
|
||||
/** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 칸을 두 번 누르기 · Enter · F2 로 부름.
|
||||
* 글을 돌려주면 일위대가 칸에 넣음 · 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()`.
|
||||
* 없으면 일위대가는 글로 고침 · 번호 글 줄은 읽기만. */
|
||||
* 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()` · 없으면 읽기만. */
|
||||
onPick?: (sel: SheetSelection) => Promise<string | null | undefined> | void;
|
||||
/** 구조물 조합 표(PLAN 31-1) — 단위 줄 · 안내 글 없음 · 열 폭 자동 · 고른 열은 가장 깊은 이름 칸을 밝힘 ·
|
||||
* `onHeadEdit` 를 주면 이름 칸을 두 번 누르기 · F2 · Enter 로 고쳐 부른 쪽에 넘김(표 문서는 안 고침 —
|
||||
@@ -87,6 +87,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
mode: opts.mode,
|
||||
result: { 계산: {}, 합계: {}, 오류: [] },
|
||||
sel: null,
|
||||
laborLabel: opts.laborLabel,
|
||||
drawingLabel: opts.drawingLabel,
|
||||
areaLabel: opts.areaLabel,
|
||||
pick: !!opts.onPick,
|
||||
@@ -236,7 +237,6 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
// ── 칸 고치기 ─────────────────────────────────────────────────────
|
||||
const rawText = (key: string, col: SheetColumn): string => {
|
||||
if (key === KEY_UNIT) return col.단위 ?? "";
|
||||
if (key === KEY_PRICE) return col.일위대가 ?? "";
|
||||
if (REF_ROWS.includes(key)) return refText(state, key, col.id);
|
||||
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
|
||||
const formula = row?.식?.[col.id] ?? col.식;
|
||||
@@ -248,15 +248,12 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
if (ro || REF_ROWS.includes(key)) return; // 읽기만
|
||||
const value = text.trim();
|
||||
if (key === KEY_UNIT) col.단위 = value || null;
|
||||
else if (key === KEY_PRICE) col.일위대가 = value || null;
|
||||
else setCell(state.doc, key.slice(2), col, text);
|
||||
changed();
|
||||
};
|
||||
/** 조회 후 선택 — 부른 쪽이 고른 글을 돌려주면 일위대가 칸에 넣음(그 사이 문서가 바뀌었으면 새 열에) */
|
||||
/** 조회 후 선택 — 부른 쪽이 가져옴(번호 글 줄 · 문서는 안 고침) */
|
||||
const pick = async (key: string, colId: string): Promise<void> => {
|
||||
const got = await opts.onPick?.(selectionOf(state.doc, key, colId));
|
||||
const col = colOf(colId);
|
||||
if (key === KEY_PRICE && typeof got === "string" && col) write(key, col, got);
|
||||
await opts.onPick?.(selectionOf(state.doc, key, colId));
|
||||
root.focus({ preventScroll: true });
|
||||
};
|
||||
/** 칸 고치기 — 두 번 누르기 = 글 전부 고름 · 바로 치기(initial) = 그 글자로 바꿈 · Enter · F2(atEnd) = 커서를 글 끝에 */
|
||||
|
||||
@@ -22,11 +22,11 @@ export const KEY_UNIT = "s:unit";
|
||||
export const KEY_PRICE = "s:price";
|
||||
export const KEY_DRAWING = "s:drawing";
|
||||
export const KEY_AREA = "s:area";
|
||||
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 면적산출 줄은 손으로 못 침(번호 글). */
|
||||
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 일위대가 · 도면 · 면적산출 줄은 손으로 못 침(번호 글). */
|
||||
export const MASTER_ROWS = [KEY_PRICE, KEY_DRAWING, KEY_AREA] as const;
|
||||
export type MasterRowKey = (typeof MASTER_ROWS)[number];
|
||||
/** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임. */
|
||||
export const REF_ROWS: readonly string[] = [KEY_DRAWING, KEY_AREA];
|
||||
/** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임(일위대가 = SU · 열의 옛 `일위대가` 칸은 안 읽음). */
|
||||
export const REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA];
|
||||
/** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */
|
||||
export const PICK_ROWS: readonly string[] = MASTER_ROWS;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
isBlankPick,
|
||||
isCalcColumn,
|
||||
KEY_AREA,
|
||||
KEY_PRICE,
|
||||
KEY_UNIT,
|
||||
MASTER_ROWS,
|
||||
type MasterRowKey,
|
||||
@@ -38,7 +39,8 @@ export interface RenderState {
|
||||
mode: SheetMode;
|
||||
result: SheetResult;
|
||||
sel: { r: string; c: string } | null;
|
||||
/** 도면 · 면적산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
|
||||
/** 일위대가 · 도면 · 면적산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
|
||||
laborLabel?: (colId: string) => string;
|
||||
drawingLabel?: (colId: string) => string;
|
||||
areaLabel?: (colId: string) => string;
|
||||
/** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */
|
||||
@@ -51,9 +53,13 @@ export interface RenderState {
|
||||
headEditable?: boolean;
|
||||
}
|
||||
|
||||
/** 도면 · 면적산출 줄 칸 글. */
|
||||
/** 일위대가 · 도면 · 면적산출 줄 칸 글(번호). */
|
||||
export const refText = (state: RenderState, key: string, colId: string): string =>
|
||||
(key === KEY_AREA ? state.areaLabel : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None");
|
||||
(key === KEY_AREA
|
||||
? state.areaLabel
|
||||
: key === KEY_PRICE
|
||||
? state.laborLabel
|
||||
: state.drawingLabel)?.(colId)?.trim() || st("Drawing_None");
|
||||
|
||||
/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */
|
||||
const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>): number =>
|
||||
@@ -68,7 +74,7 @@ function fitMeta(state: RenderState, cols: SheetColumn[], mins: Map<string, numb
|
||||
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
const font = `400 ${rem * 0.76}px ${getComputedStyle(document.body).fontFamily}`;
|
||||
for (const col of cols) {
|
||||
const texts = [col.일위대가 ?? "", ...REF_ROWS.map((key) => refText(state, key, col.id))];
|
||||
const texts = REF_ROWS.map((key) => refText(state, key, col.id));
|
||||
const need = Math.ceil(Math.max(...texts.map((t) => textWidth(t, font)))) + 14;
|
||||
mins.set(col.id, Math.max(mins.get(col.id) ?? 0, need));
|
||||
}
|
||||
@@ -191,11 +197,7 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] });
|
||||
cols.forEach((col, j) => {
|
||||
const blank = isBlankPick(key, col);
|
||||
const text = blank
|
||||
? ""
|
||||
: REF_ROWS.includes(key)
|
||||
? refText(state, key, col.id)
|
||||
: (col.일위대가 ?? "");
|
||||
const text = blank ? "" : refText(state, key, col.id); // 마스터 줄 = 모두 번호 글
|
||||
const td = bodyCell(key, col, j, text);
|
||||
td.classList.remove("is-num");
|
||||
if (state.pick && PICK_ROWS.includes(key) && !blank) td.classList.add("is-pick");
|
||||
|
||||
@@ -36,7 +36,7 @@ const TEXT = {
|
||||
loadFailed: "복사본을 못 읽음 — {value}",
|
||||
discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?",
|
||||
outOfRange: "범위 밖 값이 있어 저장하지 못함",
|
||||
noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 담은 로직 · 자재가 없음",
|
||||
noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 단가산출 로직 · 자재가 없음",
|
||||
drawing: "도면",
|
||||
area: "면적산출",
|
||||
inputs: "넣을 값",
|
||||
|
||||
@@ -14,7 +14,7 @@ const TEXT = {
|
||||
title: "일위대가",
|
||||
none: "일위대가 없음",
|
||||
failed: "일위대가를 못 읽음 — {value}",
|
||||
logics: "담은 로직 {value}개",
|
||||
logics: "단가산출 로직 {value}개",
|
||||
materials: "자재 {value}개",
|
||||
base: "기준 합 {value}원",
|
||||
reflected: "반영 합 {value}원",
|
||||
|
||||
@@ -143,6 +143,7 @@ export const ui_locales_m2 = {
|
||||
M02_TreeItemName: ["항목 이름", "Item name"],
|
||||
M02_TreeUnit: ["단위", "Unit"],
|
||||
M02_TreeMoveTo: ["옮길 폴더", "Move to"],
|
||||
M02_TreeCopyFolder: ["복사할 폴더", "Copy to folder"],
|
||||
M02_TreeTop: ["맨 위 (폴더 밖)", "Top (no folder)"],
|
||||
M02_TreeOk: ["확인", "OK"],
|
||||
M02_TreeCancel: ["취소", "Cancel"],
|
||||
@@ -180,7 +181,7 @@ export const ui_locales_m2 = {
|
||||
M02_PracticeLogics: ["로직 수", "Logics"],
|
||||
M02_PracticeOrder: ["차례", "No."],
|
||||
M02_PracticeMemo: ["메모", "Memo"],
|
||||
M02_PracticeNoLogic: ["담은 로직 없음", "No logics"],
|
||||
M02_PracticeNoLogic: ["단가산출 로직 없음", "No logics"],
|
||||
M02_PracticeBack: ["목록으로", "Back to list"],
|
||||
M02_PracticeDrop: ["통째 지우기", "Delete all"],
|
||||
M02_PracticeDropAsk: ["실무문서 {n}개를 모두 지울까요?", "Delete all {n} practice documents?"],
|
||||
|
||||
Reference in New Issue
Block a user