refactor(M02): 재료산출 시트 지움 — 종류 material · 재료번 SM · 좌측 줄 · 집계표 「재료산출」 줄 · 카드 · 전체 편집 · 복사 길 · 프로젝트 복사본 「재료」 · 고르기 모달 · 시스템 층 · fixture · 시험

- 새 프로젝트 씨앗(KINDS)에서도 빠짐 · 새 열은 구조물 도면 · 면적산출만 만듦
- storage 층(회사 · 개인 · 프로젝트) material/ 파일은 지우지 않음(읽기만 끊음)
- 장부 재료번 칸은 다음 집계표 [저장] 때 빠짐

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
This commit is contained in:
2026-10-04 17:56:49 +09:00
co-authored by Claude Opus 5.5
parent 54ce67ceb8
commit 61d2ed5997
53 changed files with 198 additions and 1811 deletions
+5 -7
View File
@@ -1,12 +1,12 @@
/* =============================================================================
* ui_template_sheet.ts
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, areaLabel, materialLabel, onSelect, onPick})`
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, areaLabel, onSelect, onPick})`
* → `{getDoc, setDoc, recalc, selectColumn, destroy}`.
*
* master = 시스템 관리자 양식 고치기 — 머리 · 층 이름 · 단위 · 일위대가 ·
* 층마다 열 더하기(「분류1 더하기」 … 층 이름 + 더하기) · 열 지우기.
* 도면 · 면적산출 · 재료산출 줄 = `drawingLabel` · `areaLabel` · `materialLabel` 글 ·
* 칸을 고르면 `onSelect` · `onPick` 을 주면 일위대가 · 도면 · 면적산출 · 재료산출 칸은
* 도면 · 면적산출 줄 = `drawingLabel` · `areaLabel` 글 ·
* 칸을 고르면 `onSelect` · `onPick` 을 주면 일위대가 · 도면 · 면적산출 칸은
* 치지 않고 조회 후 선택.
* `readOnly` = 볼 수만 있음 — 단추 줄 · 안내 글 숨김 · 칸 고치기 · 머리 고치기 · 열 너비 끌기 막음 ·
* 열이 하나도 없으면 [열 더하기] 가 켜져 첫 열을 만듦(머리 글 바로 고치기).
@@ -56,12 +56,11 @@ export interface SheetOptions {
onChange?: (doc: SheetDoc) => void;
/** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */
drawingLabel?: (colId: string) => string;
/** 면적산출 · 재료산출 줄 칸 글(번호) — `drawingLabel` 과 같음. */
/** 면적산출 줄 칸 글(번호) — `drawingLabel` 과 같음. */
areaLabel?: (colId: string) => string;
materialLabel?: (colId: string) => string;
/** 칸을 고름 — 누르기 · 방향키 · Tab. */
onSelect?: (sel: SheetSelection) => void;
/** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 · 재료산출 칸을 두 번 누르기 · Enter · F2 로 부름.
/** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 칸을 두 번 누르기 · Enter · F2 로 부름.
* 글을 돌려주면 일위대가 칸에 넣음 · 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()`.
* 없으면 일위대가는 글로 고침 · 번호 글 줄은 읽기만. */
onPick?: (sel: SheetSelection) => Promise<string | null | undefined> | void;
@@ -90,7 +89,6 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
sel: null,
drawingLabel: opts.drawingLabel,
areaLabel: opts.areaLabel,
materialLabel: opts.materialLabel,
pick: !!opts.onPick,
headLocked: !!opts.lockHead,
combo: !!opts.combo,
+4 -5
View File
@@ -22,16 +22,15 @@ 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 KEY_MATERIAL = "s:material";
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 면적산출 · 재료산출 줄은 손으로 못 침(번호 글). */
export const MASTER_ROWS = [KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_MATERIAL] as const;
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 면적산출 줄은 손으로 못 침(번호 글). */
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, KEY_MATERIAL];
export const REF_ROWS: readonly string[] = [KEY_DRAWING, KEY_AREA];
/** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */
export const PICK_ROWS: readonly string[] = MASTER_ROWS;
/** 글 열(NO · 측점 · 비고 같은 꼴=글)의 일위대가 · 도면 · 면적산출 · 재료산출 칸 — 빈칸 · 잠김. */
/** 글 열(NO · 측점 · 비고 같은 꼴=글)의 일위대가 · 도면 · 면적산출 칸 — 빈칸 · 잠김. */
export const isBlankPick = (key: string, col: SheetColumn): boolean =>
PICK_ROWS.includes(key) && col.꼴 === "글";
+7 -15
View File
@@ -3,8 +3,8 @@
* 표 그리기 — 문서 + 풀이 결과 → 쪽마다 `<table>`(머리 층 · 단위 줄 · 본문 · 합계 줄).
*
* 칸마다 `data-r`(줄 열쇠) · `data-c`(열 id) — 고치기 · 키보드는 격자(`ui_template_sheet.ts`)가 위임으로 받음.
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|price|drawing|area|material`.
* master = 본문 위에 열마다 일위대가 · 도면 · 면적산출 · 재료산출 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|price|drawing|area`.
* master = 본문 위에 열마다 일위대가 · 도면 · 면적산출 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
* 머리 층 줄 머리 칸은 `data-layer`(층 차례) — master 에서 층 이름을 고침.
* ========================================================================== */
@@ -17,7 +17,6 @@ import {
isBlankPick,
isCalcColumn,
KEY_AREA,
KEY_MATERIAL,
KEY_UNIT,
MASTER_ROWS,
type MasterRowKey,
@@ -39,10 +38,9 @@ export interface RenderState {
mode: SheetMode;
result: SheetResult;
sel: { r: string; c: string } | null;
/** 도면 · 면적산출 · 재료산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
/** 도면 · 면적산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
drawingLabel?: (colId: string) => string;
areaLabel?: (colId: string) => string;
materialLabel?: (colId: string) => string;
/** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */
pick?: boolean;
/** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */
@@ -53,13 +51,9 @@ export interface RenderState {
headEditable?: boolean;
}
/** 도면 · 면적산출 · 재료산출 줄 칸 글. */
/** 도면 · 면적산출 줄 칸 글. */
export const refText = (state: RenderState, key: string, colId: string): string =>
(key === KEY_AREA
? state.areaLabel
: key === KEY_MATERIAL
? state.materialLabel
: state.drawingLabel)?.(colId)?.trim() || st("Drawing_None");
(key === KEY_AREA ? state.areaLabel : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None");
/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */
const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>): number =>
@@ -68,7 +62,7 @@ const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>)
mins.get(col.id) ?? 0,
);
/** 구조물 조합 — 일위대가 · 도면 · 면적산출 · 재료산출 줄 글(키 · 번호)이 줄바꿈 없이 들게 최소 폭을 넓힘
/** 구조물 조합 — 일위대가 · 도면 · 면적산출 줄 글(키 · 번호)이 줄바꿈 없이 들게 최소 폭을 넓힘
* (`.ui-sheet__meta td` 글꼴과 같게 잼). */
function fitMeta(state: RenderState, cols: SheetColumn[], mins: Map<string, number>): void {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
@@ -115,7 +109,7 @@ export function renderSheet(state: RenderState): HTMLElement {
const mins = headMinWidths(cols, depth, font);
if (state.combo) fitMeta(state, cols, mins);
const widthDoc = state.combo ? { ...doc, 보기: { ...doc.보기, 열너비: {} } } : doc;
// 줄 머리 칸 폭 — 층 이름 · 단위 · 일위대가 · 도면 · 면적산출 · 재료산출 · 합계 이름이 안 잘리게
// 줄 머리 칸 폭 — 층 이름 · 단위 · 일위대가 · 도면 · 면적산출 · 합계 이름이 안 잘리게
const GUTTER = Math.min(
GUTTER_MAX,
Math.max(
@@ -126,7 +120,6 @@ export function renderSheet(state: RenderState): HTMLElement {
st("Row_Unit_Price"),
st("Row_Drawing"),
st("Row_Area"),
st("Row_Material"),
...(doc.합계줄 ?? []).map((t) => t.이름),
].map((label) => Math.ceil(textWidth(label, font)) + 16),
),
@@ -193,7 +186,6 @@ export function renderSheet(state: RenderState): HTMLElement {
"s:price": "Row_Unit_Price",
"s:drawing": "Row_Drawing",
"s:area": "Row_Area",
"s:material": "Row_Material",
} as const
)[key];
const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] });
@@ -13,7 +13,6 @@ const TEXT = {
Row_Unit_Price: ["일위대가", "Unit price"],
Row_Drawing: ["도면", "Drawing"],
Row_Area: ["면적산출", "Area"],
Row_Material: ["재료산출", "Material"],
Add_Suffix: [" 추가", " +"],
New_Prefix: ["새 ", "New "],
Drawing_None: ["없음", "None"],
+1 -1
View File
@@ -80,7 +80,7 @@ export interface SheetDoc {
보기?: SheetView;
}
/** 고른 칸 — `row` = 줄 열쇠(`d:<줄id>` · `t:<합계id>` · `s:unit|price|drawing|area|material`) ·
/** 고른 칸 — `row` = 줄 열쇠(`d:<줄id>` · `t:<합계id>` · `s:unit|price|drawing|area`) ·
* `col` = 열 차례(0부터 · 없으면 -1) · `colId` = 열 id(없으면 null). */
export interface SheetSelection {
row: string;
+3 -5
View File
@@ -15,7 +15,7 @@ export interface SummaryItem {
}
/** 항목 줄이 가리키는 키 칸 — 서버가 `names` 로 이름을 함께 주면 이름을 보임 */
export type NameField = "일위대가" | "도번" | "면적번" | "재료번";
export type NameField = "일위대가" | "도번" | "면적번";
/** 목록 한 줄(21-1) — 서버는 `?all=1` 을 `[{layer, owner, items}]` 로 주고, 줄마다 `layer` · `owner` 를 붙여 폄.
* `열` 은 집계표 열 id(rv …) — 보이는 이름은 종류 · 분류를 이은 `itemName`. */
@@ -30,7 +30,6 @@ export interface PickerRow {
일위대가?: string;
도번?: string;
면적번?: string;
재료번?: string;
/** 폴더 이름 배열(깊이 자유 · 26-1) — 없으면 종류 · 분류1~3 중 있는 것으로 이음 */
경로?: string[] | null;
/** 위 키 칸의 이름(21-9 서버가 더함) — 없으면 키를 작은 글로 보임 */
@@ -167,7 +166,7 @@ const ancestorIds = (layer: PickerLayer, path: string[]): string[] => [
export const pathIds = (row: PickerRow, _labels?: TreeLabels): string[] =>
ancestorIds(layerOf(row), pathOf(row).slice(0, -1));
export type FolderKind = "drawing" | "area" | "material" | "unitPrice";
export type FolderKind = "drawing" | "area" | "unitPrice";
export interface FolderLine {
kind: FolderKind;
@@ -180,11 +179,10 @@ export interface FolderLine {
const FOLDER_FIELDS: readonly [FolderKind, NameField][] = [
["drawing", "도번"],
["area", "면적번"],
["material", "재료번"],
["unitPrice", "일위대가"],
];
/** 오른쪽 폴더 모양 — 네 줄 모두 서버의 키 칸 + `names` 의 이름 */
/** 오른쪽 폴더 모양 — 세 줄 모두 서버의 키 칸 + `names` 의 이름 */
export function folderLines(row: PickerRow): FolderLine[] {
return FOLDER_FIELDS.map(([kind, field]) => ({
kind,
@@ -1,6 +1,6 @@
/* =============================================================================
* ui_template_item_picker_detail.ts
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b) — **카드 넷**(도면 · 면적산출 · 재료산출 · 선택 입력).
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b) — **카드 셋**(도면 · 면적산출 · 선택 입력).
* 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀
* (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`).
*
@@ -89,7 +89,7 @@ const TEXT = {
reapplyTip:
"원본을 다시 가져옴 — 이 구조물 복사본에서 고친 것(도면 · 시트 · 선택 입력 자리)이 원본 것으로 덮임",
reapplyAsk:
"원본을 다시 가져옴 — 이 구조물 복사본에서 고친 도면 · 면적산출 · 재료산출 · 선택 입력 자리가 원본 것으로 덮임(넣은 값은 같은 키면 남음). 재적용할까요?",
"원본을 다시 가져옴 — 이 구조물 복사본에서 고친 도면 · 면적산출 · 선택 입력 자리가 원본 것으로 덮임(넣은 값은 같은 키면 남음). 재적용할까요?",
reapplied: "재적용했음 — 덮인 것: {value}",
reappliedNone: "재적용했음 — 덮인 것 없음(원본과 같았음)",
reapplyFailed: "재적용하지 못함 — {value}",
@@ -114,7 +114,6 @@ const TEXT = {
parts: {
도면: "도면",
면적: "면적산출",
재료: "재료산출",
입력연결: "선택 입력 자리",
} as Record<string, string>,
};
@@ -361,7 +360,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
await ensureCards();
if (mine !== seq || !cards || !inputs) return;
const label = !info && canCopy ? TEXT.importEdit : null;
cards.relabel({ structure: label, area: label, material: label });
cards.relabel({ structure: label, area: label });
inputs.setEditLabel(label ?? "선택 입력 수정");
cards.root.hidden = false;
paintStatus(row, mine);
+10 -19
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* ui_template_item_picker_edit.ts
* 항목 고르기 모달의 **편집 화면**(계획서 25-2d) — 카드 [수정] 이 모달 안에서 이 화면으로 바뀜.
* 도면 = M02 CAD 부품 · 면적산출 · 재료산출 = M02 스프레드시트 · 선택 입력 = 값 받기 카드.
* 도면 = M02 CAD 부품 · 면적산출 = M02 스프레드시트 · 선택 입력 = 값 받기 카드.
* 고침은 **프로젝트 복사본**에만 씀(`ProjectItems.write`) — 마스터 · 회사 · 개인 원본은 안 바뀜.
* [뒤로] = 선택지(나무 + 카드)로 · 저장 안 한 고침이 있으면 버릴지 물음.
* 편집 부품은 이 화면을 열 때만 `import()`(B05 · B06 번들에 안 섞임).
@@ -40,7 +40,6 @@ const TEXT = {
"면적산출 알약 없음 — 선택 입력에서 자리를 면적산출로 이으면 여기 뜸(마스터 · 회사 · 개인 층에서)",
drawing: "도면",
area: "면적산출",
material: "재료산출",
inputs: "선택 입력",
suffix: "{value} 수정",
};
@@ -61,7 +60,6 @@ export function createEditView(req: EditRequest, onBack: (saved: boolean) => voi
const word = {
structure: TEXT.drawing,
area: TEXT.area,
material: TEXT.material,
inputs: TEXT.inputs,
}[kind];
const back = createButton({ label: TEXT.back, variant: "ghost", size: "sm" });
@@ -96,28 +94,21 @@ export function createEditView(req: EditRequest, onBack: (saved: boolean) => voi
body.replaceChildren(el("p", { className: "ui-item-picker__hint", text }));
};
/** 복사본의 항목 연결 — 자리가 면적 · 재료인 입력이 그 시트 알약(25-1b · 키 모양은 `pillsOf`) */
/** 복사본의 항목 연결 — 자리가 면적인 입력이 그 시트 알약(25-1b · 키 모양은 `pillsOf`) */
const linkOf = async (): Promise<InputLinks | null> =>
(await items.read(info.폴더, "입력연결")) as InputLinks | null;
async function sheetLinks(kindOf: "area" | "material"): Promise<LinkOptions> {
const [list, link, { searchMaterials }] = await Promise.all([
loadInputs(row),
linkOf(),
import("../M01_MasterData/M01_MasterData_Api_Fetch"),
]);
const 항목 = pillsOf(list, link, kindOf === "area" ? "면적" : "재료");
if (kindOf === "area") {
if (!항목.length) note.textContent = TEXT.noAreaPill;
return { 항목, 모드: "입력" };
}
return { 항목, 모드: "자재", 자재찾기: searchMaterials };
async function sheetLinks(): Promise<LinkOptions> {
const [list, link] = await Promise.all([loadInputs(row), linkOf()]);
const 항목 = pillsOf(list, link, "면적");
if (!항목.length) note.textContent = TEXT.noAreaPill;
return { 항목, 모드: "입력" };
}
async function mountSheet(kindOf: "area" | "material"): Promise<void> {
async function mountSheet(): Promise<void> {
const [{ createSpreadsheet }, links] = await Promise.all([
import("../A00_Common/spreadsheet/spreadsheet"),
sheetLinks(kindOf),
sheetLinks(),
]);
const host = el("div", { className: "ui-item-picker__edit-sheet" });
body.replaceChildren(host);
@@ -168,7 +159,7 @@ export function createEditView(req: EditRequest, onBack: (saved: boolean) => voi
}
if (kind === "structure") await mountDrawing();
else if (kind === "inputs") await mountInputs();
else await mountSheet(kind);
else await mountSheet();
} catch (error) {
fail(fill(TEXT.loadFailed, why(error)));
}
@@ -21,13 +21,12 @@ import {
import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data";
/** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */
export type ProjectPart = "도면" | "면적" | "재료" | "입력연결" | "입력값" | "고른자재";
export type ProjectPart = "도면" | "면적" | "입력연결" | "입력값" | "고른자재";
/** 원본 종류 → 복사본 부분 */
export const PART_OF: Record<"structure" | "area" | "material", ProjectPart> = {
export const PART_OF: Record<"structure" | "area", ProjectPart> = {
structure: "도면",
area: "면적",
material: "재료",
};
export interface ProjectItemInfo {
-2
View File
@@ -13,7 +13,6 @@ export const ui_locales_m2 = {
M02_KindDrawing: ["도면 양식", "Drawing templates"],
M02_KindStructure: ["구조물 도면", "Structure drawings"],
M02_KindArea: ["면적산출", "Area takeoff"],
M02_KindMaterial: ["재료산출", "Material takeoff"],
M02_NoTemplates: ["양식 없음", "None"],
M02_New: ["새로", "New"],
M02_Copy: ["본떠 만들기", "Copy as new"],
@@ -87,7 +86,6 @@ export const ui_locales_m2 = {
M02_PickerLoadFailed: ["항목을 못 읽음 — {value}", "Failed to load items — {value}"],
M02_PickerDrawing: ["도면", "Drawing"],
M02_PickerArea: ["면적산출", "Area takeoff"],
M02_PickerMaterial: ["재료산출", "Material takeoff"],
M02_PickerUnitPrice: ["일위대가", "Unit price"],
M02_PickerUnit: ["단위", "Unit"],
M02_PickerNone: ["없음", "None"],