refactor(785-7): 옛 맞춤 화면 TS 지움 · 옛 [저장] · [모두 고정] 단추 걷음 · 390 줄 판 높이

- 지움(git rm) 15 파일 3,318 줄: Hopyo_Check · CheckRows · Choose · WizardSk · SkApi · SkLines · logic/SkPick · logic/Draft ·
  Left · Lost · Pick · SheetMake · Kids · ToItem · Compare(모두 옛 확인 창 · 마법사 단가산출 · 안 이은 줄 길 몫 · 부르는 곳 0)
- 단추 판단: 엑셀 보기 [저장](체크 호표) · [모두 고정으로 들여오기] 화면 단추 걷음 → 만들기 = [옮기기] 하나 ·
  모두 고정 서버 길(Builder · build_all)은 B09 · 붙여넣기가 써서 남김 · 목록 체크 칸 · 모달 껍데기(openHopyoSheet · raise · lower) 걷음
- Import: 「안 이은 줄」 띠 · [안 이은 줄 고르기] · 남은 줄 셈 걷음 · [끝내기] = 물음(확인하고 안 옮긴 수 = 판정 고름 캐시 moveBody)
- Api: 옛 맞춤 타입 · 길(readHopyoOne · guessHopyo · buildHopyo · buildHopyoAll · leftHopyo · listHopyoPicks · hopyoSiblings ·
  buildHopyoItem …) 걷음 · POLL_MS · HOPYO_BUILT 옮겨 옴 · 목록 꼴 = 1차 판정 줄상태(Judge.view)
- Labor_Pick: withHopyo(실무 호표 덧줄) → withSangeun(찾아 넣기 창 표시만) · Detail · Table 부름 바꿈
- Ledger: unitField 옮겨 옴 · SheetNav: 옛 확인 캐시 칠 걷음 · UI_Page: [구조물 조합으로] 듣기 걷음
- CSS: 옛 확인 창 · 고르기 창 규칙 34 걷음(481 → 287 줄) · 390 폭 = 줄 판 열리면 위 칸 넓힘 · 줄 판 높이 바닥 16rem

시험 정리(까닭 = 지운 화면 파일만 읽음):
- 지움: test_729_3_hopyo_left_screen · test_746_2_hopyo_compare_screen · test_778_5_sk_pick_screen · helper_729_3 · helper_m02_hopyo_check · pick · seam
- test_m02_hopyo_screen = 좌측 판 · 끝내기 둘만 · 627 · helper_778_5 = SheetSk 판 바꿈만 · 785_4 · 721 · 717_4 · 720 · 739_1 = 지금 꼴로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 01:32:17 +09:00
co-authored by Claude Opus 5.5
parent 48848bb287
commit cf4e2a0339
45 changed files with 126 additions and 5791 deletions
+13 -195
View File
@@ -28,60 +28,12 @@
max-width: 320px;
}
.m02-hopyo__row {
cursor: pointer;
}
.m02-hopyo__row:hover {
background: var(--color-accent-soft);
}
.m02-hopyo__num {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* 실무자료 표 — 한글은 낱말 단위로만 줄바꿈(두세 글자마다 안 갈라짐) · 너무 긴 낱말만 쪼갬 */
.m02-hopyo__table :is(th, td) {
word-break: keep-all;
overflow-wrap: break-word;
}
/* 확인 창 줄 표(338 · 339 · 359-3) — 일위대가 상세 꼴(m02-labrow) · 체크 칸 · 수량 · 단위 = 줄바꿈 없음 ·
단추 = 세로로 쌓아 좁게 · 이름 · 별칭 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */
.m02-hopyo__table.m02-hopyo__lines :is(th, td) {
padding: var(--spacing-8);
}
.m02-hopyo__table.m02-hopyo__lines td:first-child,
.m02-hopyo__lines :is(.m02-labrow__qty, .m02-labrow__unit) {
white-space: nowrap;
}
/* 이름 칸 대상 줄 — 키 · 이름 · 배 칸 · 몫 한 줄(상세 이름 칸의 한 줄 말줄임을 풂) */
.m02-hopyo__lines .m02-labrow__name > .m02-hopyo__part {
display: flex;
max-width: none;
overflow: visible;
}
.m02-hopyo__lines .m02-labrow__name > .m02-hopyo__state,
.m02-hopyo__lines .m02-labrow__kind > .m02-hopyo__state {
display: inline-block;
margin-left: var(--spacing-4);
}
/* 품 비교 — 항목마다 한 줄(글의 줄바꿈 그대로) */
.m02-hopyo__table.m02-hopyo__lines td.m02-hopyo__compare {
min-width: 9em;
white-space: pre-line;
}
.m02-hopyo__subhead {
margin: var(--spacing-8) 0 0;
font-size: var(--text-body-sm);
}
.m02-hopyo-ledger__table td:nth-child(1) {
min-width: 7em;
}
@@ -90,62 +42,6 @@
min-width: 6em;
}
/* 단추 칸 — 단추 · 넣음/뺌을 세로로 쌓아 칸 폭 = 가장 긴 단추 */
.m02-hopyo__table.m02-hopyo__lines td.m02-hopyo__acts {
width: 1%;
}
.m02-hopyo__lines .m02-hopyo__acts > * {
display: flex;
width: 100%;
white-space: nowrap;
}
.m02-hopyo__lines .m02-hopyo__acts > * + * {
margin: var(--spacing-4) 0 0;
}
.m02-hopyo__line.is-out {
color: var(--color-text-muted);
}
.m02-hopyo__state {
display: inline-block;
padding: 0 var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--text-caption);
white-space: nowrap;
}
/* 상태 색(338) — 맞음 초록 · 별칭 파랑 · 다른 점 주황 · 후보 회색(테두리 기본) · 없음 빨강 */
.m02-hopyo__state.is-맞음,
.m02-hopyo__state.is-호표 {
border-color: var(--color-success);
color: var(--color-success);
}
.m02-hopyo__state.is-별칭 {
border-color: var(--color-info);
color: var(--color-info);
}
.m02-hopyo__state.is-다른점 {
border-color: var(--color-warning);
color: var(--color-warning);
}
.m02-hopyo__state.is-후보,
.m02-hopyo__state.is-뺌,
.m02-hopyo__state.is-노임 {
color: var(--color-text-secondary);
}
.m02-hopyo__state.is-없음 {
border-color: var(--color-danger);
color: var(--color-danger);
}
/* 우리 로직 칸 — 대상 여럿 = 칸 안 여러 줄(이름 · 키 · 몫 · 배 칸 · [옵션]) */
.m02-hopyo__parts {
min-width: 11em;
@@ -162,12 +58,6 @@
margin-top: var(--spacing-4);
}
.m02-hopyo__whole {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.m02-hopyo__times {
width: 6em;
}
@@ -193,56 +83,6 @@
margin-left: var(--spacing-4);
}
.m02-hopyo__block {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m02-hopyo__block h4 {
margin: 0;
font-size: var(--text-body-sm);
}
.m02-hopyo__built {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
}
/* 고르기 창 머리 — 제목이 두 줄이어도 [닫기] 글이 안 갈라지고 안 줄어듦 */
.m02-hopyo__choose .ui-modal__top > .ui-btn {
flex-shrink: 0;
white-space: nowrap;
}
.m02-hopyo__cands {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-4);
margin-bottom: var(--spacing-8);
}
.m02-hopyo__cand {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
}
/* 고르기 창 후보 묶음(358-1) — 뜻 낱말 안 겹치는 「후보」 = 회색 · 숫자만 닮음 = 접힘 */
.m02-hopyo__cand.is-weak {
color: var(--color-text-muted);
}
.m02-hopyo__group > summary {
cursor: pointer;
color: var(--color-text-secondary);
margin-bottom: var(--spacing-4);
}
/* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */
.m02-hopyo-ledger__row.is-upper {
color: var(--color-text-muted);
@@ -252,11 +92,7 @@
box-shadow: inset 3px 0 0 var(--color-accent);
}
/* 엑셀 보기 창(329) — 왼쪽 읽기 전용 시트 · 오른쪽 호표 목록 · 불러오기 결과 */
.m02-hopyo-sheet__dialog > .ui-modal__body {
overflow: hidden;
}
/* 엑셀 보기(329) — 왼쪽 읽기 전용 시트 · 오른쪽 호표 목록 */
.m02-hopyo-sheet {
display: grid;
grid-template-columns: minmax(0, 1fr) 22rem;
@@ -325,6 +161,18 @@
display: none;
}
/* 좁은 폭(785-7 · 390) — 줄 판이 열리면 위 칸(시트 + 줄 판)을 넓히고 줄 판 높이 바닥을 둠 */
@media (max-width: 900px) {
.m02-hopyo-sheet:has(.m02-hopyo-sheet__lines:not([hidden])) {
grid-template-rows: minmax(0, 5fr) minmax(0, 1fr);
}
.m02-hopyo-sheet__lines {
flex: 1 1 60%;
min-height: 16rem;
}
}
/* 세 보기 막대 · 범례(779-4) — 시트 위 한 줄씩 · 넘치면 감김 */
.m02-hopyo-sheet__nav {
display: flex;
@@ -433,36 +281,6 @@
flex-shrink: 0;
}
.m02-hopyo-sheet__result {
max-height: 40%;
overflow: auto;
}
.m02-hopyo-sheet__result:empty {
display: none;
}
/* 고르기 창 근거(330) — 후보 꼬리표 · 엑셀 근거 줄 */
.m02-hopyo__basis {
margin-left: var(--spacing-8);
padding: 0 var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
font-size: var(--text-caption);
white-space: nowrap;
}
.m02-hopyo__basis.is-ok {
border-color: var(--color-success);
color: var(--color-success);
}
.m02-hopyo__source {
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
/* 머리 글 ↔ [파일 업로드] — 구조물 조합 머리 ↔ 층 단추와 같은 12 px */
.m02-hopyo-side > .m02-side__row {
margin-top: var(--spacing-12);
@@ -2,14 +2,19 @@
* M02_MasterTemplete_Hopyo_Api.ts
* 일위대가 실무자료(PLAN 324 · 338) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) ·
* 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로) ·
* 맞춤 응답 꼴 = 설계 3-3(상태 · 체크 제안 · 대상 목록 · 품 비교) · 만들기 몸 = 확인한 줄만(3-6) · 별칭 장부(3-5).
* 목록 · 올림 · 엑셀 보기 머리(1차 판정 줄상태) · 격자 · 지우기 · 별칭 장부 · 붙여넣기 · 다시 잇기 —
* 판정 · 검산 · 옮기기 길 = `Hopyo_JudgeApi` · 옛 맞춤 꼴(줄 맞춤 · 확인 만들기 · 실무 호표)은 785-7 에서 지움.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import type { MatMarks } from "./M02_MasterTemplete_Labor_Api";
import type { LaborFixed } from "./M02_MasterTemplete_Labor_Calc";
/** 뒤 셈 중 다시 읽기 사이(밀리초) */
export const POLL_MS = 3000;
/** 사전조합 · 단가산출을 만들었음(document 알림) — 좌측 목록 · 엑셀 보기 칠 다시 */
export const HOPYO_BUILT = "m02-hopyo-built";
/** 층 · 주인(null = 자기 층) */
export interface HopyoScope {
@@ -35,11 +40,11 @@ export interface HopyoBrief {
규격: string;
단위: string;
금액: Money;
/** [체크 제안 수, 줄 수] */
/** [1차 판정 제안 있음 수, 줄 수](785-7 `Judge.view`) */
맞춤: [number, number];
/** 연결할 줄 수(% · 노임 · 뺌 밖 · 358-3) · 연결된 줄 수(그중 체크 제안 — 확인 창과 한 규칙) */
연결할?: number;
연결된?: number;
/** 줄마다 [차례, 1차 판정, 제안 있음] · 만든 SC 키(칠 · `SheetMap.BriefMarks`) */
줄상태?: [number, string, boolean][];
만든?: string | null;
/** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */
행?: [number, number];
}
@@ -62,294 +67,8 @@ export interface HopyoView extends HopyoFile {
검산: { 어긋남: string[]; 총괄표: number };
호표: HopyoBrief[];
맞춤: [number, number];
/** 파일 전체 연결할 · 연결된 줄 수(358-3) */
연결할?: number;
연결된?: number;
/** 뒤 숫자 판정 셈(끝나면 맞춤 수가 바뀜) */
셈?: HopyoCalc;
}
/** 후보가 단가산출 근거와 맞는 것(330-2) — 맞은 조항 글 · 겹친 직종 · 단위 같음 · 점수 · 까닭 꼬리표 */
export interface HopyoBasisHit {
조항: string[];
직종: string[];
단위: boolean;
점수: number;
까닭: string;
}
/** 단산 줄 근거(330-1) — 단가산출 블록의 출처([책, 번호]) · 직종 · 엑셀 행 [시작, 끝] */
export interface HopyoLineBasis {
출처: [string, string][];
직종: string[];
행: [number, number];
}
/** 후보 · 고른 대상 글 — 로직 · 자재 · 다른 호표 */
export interface HopyoTarget {
로직?: string;
자재?: string;
호표?: number;
/** 「전국」 자리표(361-1) — 지역 변형만 있는 자재 · 키 · 단가 없음 */
전국?: boolean;
/** 729-2 사용자 이음 — 노임 LB · 기계 EQ · % 줄 밑수(엑셀 숫자 + 이음 · 하나만) */
노임?: string;
기계?: string;
비율?: LinkBase;
/** 같은 품목 지역 · 관급/사급 변형(361-1 · 고르기 창이 접음) */
변형?: boolean;
이름?: string;
규격?: string;
단위?: string | null;
점수?: number;
/** 근거 맞춤(330 · 옛 파일 · 근거 없는 줄은 없음) */
근거?: HopyoBasisHit;
/** 후보 판정(338) — 맞음 · 다른 점 · 후보 */
판정?: "맞음" | "다른 점" | "후보";
글?: string;
/** 묶음 표시(358-1 · 로직 후보만) — 품명 뜻 낱말 겹침 · 엑셀 출처 조항 같음(≥ 0.9) · 책(산림품셈 · 건설품셈) */
뜻겹침?: boolean;
출처같음?: boolean;
/** 745-1 미리 고름 점 — 「다른 점」 1위 + 출처 같음(서버) · 자동 확정 아님 · 만들기는 사용자가 누름 */
미리?: boolean;
책?: string;
}
/** 맞춤 대상 하나(338 · 로직 여럿 가능) — 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배 */
export interface HopyoPart extends HopyoTarget {
입력값: Record<string, unknown>;
못읽음?: string[];
/** 칸마다 값 출처 — 별칭 · 블록 · 규격 · 자동값 */
출처?: Record<string, string>;
환산?: { 배: number | null; 갈래: string } | null;
몫?: number | null;
배: number | null;
/** 블록 절 제목(조합 덧 로직) */
어디?: string;
/** 자재 세 칸(361-2) — 만들기에 적힐 값(서버 `Material.marks`) */
세칸?: MatMarks;
}
/** 품 묶음 — 직종 품 · 장비 시간 · 자재 수량 */
export interface HopyoQty {
인력?: Record<string, number>;
장비?: { 갈래: string | string[]; 시간: number }[];
자재?: { 이름: string; 수량: number }[];
}
export type HopyoState =
"맞음" | "별칭" | "다른 점" | "후보" | "없음" | "뺌" | "호표" | "%" | "노임";
export interface HopyoMatch {
열쇠: string;
상태: HopyoState;
/** 처음 체크 제안(확인 대기 — 들어감 아님) */
체크: boolean;
대상: HopyoPart[];
후보?: HopyoTarget[];
/** 이름 닮은 로직 다섯(358-1 · 옛 324 판 그대로 · 숫자 판정 차례와 따로 · 로직 줄만) */
이름후보?: HopyoTarget[];
글?: string;
꼬리표?: string[];
품?: { 엑셀: HopyoQty; 우리: HopyoQty; 같음: Record<string, boolean> };
갈리는칸?: string[];
별칭?: { 층: Layer; 주인: string | null; 규격다름?: boolean; 근거다름?: boolean };
뺌?: string;
/** 739-1 내 제안(승인 전 회사 · 개인 층 장부 짝) — 자동 아님 · 고르기 창 맨 위 */
제안?: { 갈래: string; 짝: LedgerPair[]; 대상: HopyoPart[]; 층: Layer; 때: string | null };
}
/** 숫자 판정 셈 — 끝 · 한 블록 · 모두 · 까닭(우리 로직 준비 중 등) */
export interface HopyoCalc {
끝: boolean;
한: number;
모두: number;
까닭?: string;
}
export interface HopyoLine {
차례: number;
이름: string;
규격: string;
수량: number | null;
단위: string;
갈래: string;
단가: Money;
금액: Money;
비고: string;
맞춤: HopyoMatch;
/** 단가산출 근거(330 · 「단산N참조」 줄만) */
근거?: HopyoLineBasis;
}
export interface HopyoOne extends HopyoFile {
호표: HopyoBrief & {
줄: HopyoLine[];
/** 직접 줄 호표 통째 제안(노임 · 장비를 바로 적은 호표) */
통째?: { 대상: HopyoPart[]; 글?: string; 체크: boolean } | null;
셈?: HopyoCalc;
/** 만들 층에 이 호표로 만든 사전조합 */
만든?: {
키: string;
확인: boolean;
/** 705-3 — 품셈으로 넣은 엑셀 줄 차례(옛 것 = null) · 고정 줄 수 */
품셈?: number[] | null;
고정?: number;
} | null;
};
}
/** 미리 셈(저장 안 함) — 고른 대상 · 옵션으로 우리 품 */
export interface HopyoGuess {
대상: HopyoPart[];
품?: HopyoMatch["품"];
글?: string;
}
/** 만들기 몸 대상 하나 — 로직 · 자재 · 호표 중 하나(금액 · 품 칸 없음 — 서버가 셈) */
export interface BuildTarget {
로직?: string;
자재?: string;
호표?: number;
/** 「전국」 자재(361-1) — 이름 · 규격 · 단위는 서버가 줄에서 다시 셈 */
전국?: boolean;
/** 729-2 사용자 이음(하나만 · 입력값 없음) */
노임?: string;
기계?: string;
비율?: LinkBase;
입력값?: Record<string, unknown>;
배: number;
몫?: number | null;
}
/** % 줄 밑수 비목(729-2) */
export type LinkBase = "노무비" | "재료비" | "경비";
/** 단가산출 기초 후보(729-1 고름 대기) — 판정 · 글 */
export interface SangeunCand {
로직: string;
이름: string;
단위: string;
판정: string;
글: string;
/** 729-4 — 기준단위 ↔ 엑셀 단위 성격 다름 = 환산 계수 필요(가 1 = 나 몇) */
계수?: { 가: string; 나: string; 갈래: string } | null;
}
/** M01 요소 후보(746-1) — 못옮김 · 안 이은 기계 줄 · 고르면 `{노임|기계|자재: 키}` 로 이음 */
export interface ElemCand {
키: string;
갈래: "노임" | "기계" | "자재";
이름: string;
규격?: string;
단위?: string;
단가?: number | null;
}
/** 요소 후보인가(기초 후보 `로직` 과 가름) */
export const isElem = (c: unknown): c is ElemCand =>
typeof c === "object" && c !== null && "키" in c && "갈래" in c;
/** 안 이은 줄(729-2) — 만들면 남을 엑셀 줄 · 단가산출 줄 후보 = 고름 대기 기초 후보 */
export interface HopyoLeft {
차례: number;
이름: string;
규격: string;
단위: string;
수량: number | null;
갈래: string;
까닭: string;
후보: (SangeunCand | ElemCand)[];
/** 729-4 — 못옮김 남은 SK 를 쓰는 줄 = 그 블록 · SK · 못옮김(잇기 = [단가산출 만들기]) */
단산?: number;
SK?: string;
못옮김?: SangeunLost[];
}
/** 옮기지 못한 엑셀 줄(729-4) — M01 요소로 이으면 SK 더한줄 · id = 「갈래:이름」 */
export interface SangeunLost {
id: string;
이름: string;
규격?: string;
단위?: string;
수량?: number | null;
갈래: "자재" | "기계" | "노임";
글?: string;
후보?: ElemCand[];
}
/** 못옮김 잇기 한 줄(729-4) — 이음 키(LB · EQ · MT) 또는 뺌 까닭 */
export type SangeunLink = { id: string; 뺌?: string } & Partial<
Record<"노임" | "기계" | "자재", string>
>;
/** [단가산출 만들기](729-1) 답 — 만듦 = 기초 · SK · 고름 대기 = 블록마다 후보 */
export interface SangeunBuilt {
블록: number;
만듦: { 번: number; 이름: string; 키: string; 갈래: "기초" | "SK"; 못옮김?: SangeunLost[] }[];
그대로: number;
기초: number;
SK: number;
고름대기: {
번: number;
이름: string;
단위: string;
갈래: string;
까닭: string;
후보: SangeunCand[];
}[];
별칭: number;
/** 729-5 — 이미 만든(그대로) SK 중 못옮김 남은 것 */
남은못옮김?: { 번: number; 이름: string; 키: string; 못옮김: SangeunLost[] }[];
/** 733-1 — 이 층 같은 내용 SK 를 씀(새로 안 만듦) */
같은것씀?: { 번: number; 키: string }[];
/** 733-1 — 위 층(회사 · 마스터)에 같은 내용 SK(알림만 · 연결 안 함) */
위층같음?: { 번: number; 층: string; 주인: string | null; 키: string }[];
}
/** 확인한 줄 — 대상 목록 또는 뺌 까닭 */
export interface BuildLine {
차례: number;
대상?: BuildTarget[];
뺌?: "우리에 없음" | "안 씀";
}
export interface HopyoMissing {
차례: number;
이름: string;
규격: string;
수량: number | null;
단위: string;
금액: number | null;
까닭: string;
}
export interface HopyoBuilt {
참조: LaborRef;
이름: string;
다시씀: boolean;
빠진줄: HopyoMissing[];
비교: {
엑셀: number | null;
우리: number | null;
차이: number | null;
/** 705-3 — 엑셀 끝수 규칙으로 같음 */
같음?: boolean;
까닭: string | null;
};
장부?: { 씀: number };
/** 705-3 — 엑셀 줄 갈래 수 */
줄수?: HopyoLineCount;
}
/** 705-3 줄 갈래 수 — 품셈 · 고정 · 호표(하위 참조) · 빠짐 · 이음(저절로) · 이음후보(단가 다름) */
export interface HopyoLineCount {
품셈: number;
고정: number;
호표: number;
빠짐: number;
이음: number;
이음후보: number;
/** 블록 풀이 뒤 셈(785-5 파생 저장본 · 끝 = 판정 칠 다 셈) */
셈?: { 끝: boolean; 까닭?: string };
}
/** [모두 고정으로 들여오기] 결과(705-3) */
@@ -376,15 +95,6 @@ export interface HopyoBuiltAll {
만듦?: { 번: number; 이름: string; 키: string }[];
}
/** [로직 추가] 「실무 호표」 줄 하나 — 확인 = 문서 층에 확인된 사전조합 */
export interface HopyoPickRow extends Omit<HopyoBrief, "금액"> {
층: Layer;
주인: string | null;
키: string;
파일: string;
확인?: LaborRef | null;
}
/** 별칭 장부 짝 하나 */
export interface LedgerPair {
로직?: string;
@@ -434,30 +144,6 @@ export const readHopyo = (scope: HopyoScope, key: string): Promise<HopyoView> =>
export const readHopyoGrid = (scope: HopyoScope, key: string): Promise<HopyoGrid> =>
call(at(scope, `/hopyo-grid/${enc(key)}`));
/** 호표 하나 — `to` = 만들 층(호표 참조 줄 체크 · 만든 · 없으면 파일 층) */
export const readHopyoOne = (
scope: HopyoScope,
key: string,
no: number,
to?: HopyoScope,
): Promise<HopyoOne> => {
const want = to ? `?to=${to.layer}${to.owner ? `&to_owner=${enc(to.owner)}` : ""}` : "";
return call(at(scope, `/hopyo/${enc(key)}/${no}${want}`));
};
/** 미리 셈(저장 안 함) — 고른 대상 목록 · 옵션 → 우리 품 */
export const guessHopyo = (
scope: HopyoScope,
key: string,
no: number,
차례: number,
대상: BuildTarget[],
): Promise<HopyoGuess> =>
call(at(scope, `/hopyo/${enc(key)}/${no}/guess`), {
method: "POST",
body: JSON.stringify({ 차례, 대상 }),
});
export const readLedger = async (scope: HopyoScope): Promise<LedgerRow[]> =>
(await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"))).줄;
@@ -477,42 +163,6 @@ export const saveLedger = async (
export const deleteHopyo = (scope: HopyoScope, key: string, keepalive = false): Promise<unknown> =>
call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE", keepalive });
/** [확인하고 사전조합 만들기] — 확인한 줄만 · 직접 줄 호표 = `통째` → `target` 층 사전조합(파일 층 = `from`) */
export const buildHopyo = (
target: HopyoScope,
from: { 층: Layer; 주인: string | null; 키: string; 번: number },
줄: BuildLine[],
통째?: { 대상: BuildTarget[] } | null,
): Promise<HopyoBuilt> =>
call(at(target, "/hopyo-build"), {
method: "POST",
body: JSON.stringify(통째 ? { ...from, 통째 } : { ...from, 줄 }),
});
/** [모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 숫자는 서버가 파일에서 */
export const buildHopyoAll = (
target: HopyoScope,
from: { 층: Layer; 주인: string | null; 키: string },
): Promise<HopyoBuiltAll> =>
call(at(target, "/hopyo-build-all"), { method: "POST", body: JSON.stringify(from) });
/** 안 이은 줄 미리 보기(729-2 · 저장 없음) — 몸 = 만들기 몸 */
export const leftHopyo = (
target: HopyoScope,
from: { 층: Layer; 주인: string | null; 키: string; 번: number },
줄: BuildLine[],
): Promise<{ 남은줄: HopyoLeft[]; 수: number }> =>
call(at(target, "/hopyo-unlinked"), { method: "POST", body: JSON.stringify({ ...from, 줄 }) });
/** 409 몸의 남은 줄(729-2 · 만들기 막힘) — 아니면 null */
export const leftOf = (error: unknown): HopyoLeft[] | null => {
const got = (error as { detail?: { 남은줄?: unknown } } | null)?.detail?.남은줄;
return Array.isArray(got) ? (got as HopyoLeft[]) : null;
};
export const listHopyoPicks = async (scope: HopyoScope): Promise<HopyoPickRow[]> =>
(await call<{ 줄: HopyoPickRow[] }>(at(scope, "/hopyo-picks"))).줄;
/** 엑셀 올리기 — 서버 까닭 글 그대로(시트 없음 · 20 MB · 엉터리) */
export async function uploadHopyo(scope: HopyoScope, file: File): Promise<HopyoFile> {
const form = new FormData();
@@ -606,27 +256,3 @@ export const relinkApply = (
문서: { 키: string; 이름: string; 전: number | null; 후: number | null }[];
되돌림: RelinkChange[];
}> => call(at(scope, "/hopyo-relink"), { method: "POST", body: JSON.stringify({ 바꿈 }) });
/** 규격 묶음 제안(704-4) — 이름 · 단위 · 줄 구성 같고 규격만 다른 호표(연 호표 먼저) */
export const hopyoSiblings = (
scope: HopyoScope,
key: string,
no: number,
): Promise<{ 묶음: { 번: number; 규격: string; 열: string }[]; 이름: string | null }> =>
call(at(scope, `/hopyo/${enc(key)}/${no}/siblings`));
/** [구조물 조합으로](704-4) — 확인 창 몸 그대로 → 이 층 구조물 조합 항목(사전조합 안 만듦) ·
* `extra` = 같은 내용 409 뒤 다시 보냄(733-2 · 씀 = 그 항목 · 답 `씀` · 새로 = 그래도 새로) */
export const buildHopyoItem = (
target: HopyoScope,
from: { 층: Layer; 주인: string | null; 키: string; 번: number },
줄: BuildLine[],
통째: { 대상: BuildTarget[] } | null,
묶음: number[],
이름: string,
extra: { 씀?: string; 새로?: boolean } = {},
): Promise<{ key: string; 열: string; 이름: string; 줄수?: number; 열수?: number; 씀?: boolean }> =>
call(at(target, "/hopyo-item"), {
method: "POST",
body: JSON.stringify({ ...from, ...(통째 ? { 통째 } : { 줄 }), 묶음, 이름, ...extra }),
});
@@ -1,662 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Check.ts
* 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1 · 359-3) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 ·
* 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [사전조합 생성]으로 서버에 보냄.
* · 자리 = 엑셀 보기 창 호표 [수정] · [로직 추가] 「실무 호표」 모달
* · 머리 = 호표 · 「제안 n / 줄 m」 · 숫자 판정 셈 중이면 3 초마다 다시 읽음(끝나면 멈춤 · 만진 줄은 그대로)
* · 줄 표 = 구조물 조합 일위대가 상세 꼴(로직 표 · 자재 표 · 품 비교 · 단추 — `M02_MasterTemplete_Hopyo_CheckRows`)
* · 금액 = 줄마다 서버 calc 미리 셈(`logic/M02_MasterTemplete_Hopyo_Draft` 문서 · 저장 안 함 · 줄마다 최신 응답만)
* · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈
* · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음
* · 화면 잠금은 [사전조합 생성] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만.
* · 705-3 — 체크 안 한 줄 = 고정 줄(엑셀 숫자 그대로) · 만든 사전조합이 있으면 첫 체크 = 그 품셈 줄(`만든.품셈`) ·
* [품셈으로 바꾸기 (n)] = 맞음 · 별칭 줄 체크 + 생성 · [모두 고정으로] = 체크 모두 끔 + 생성(되돌리기) ·
* 생성 물음에 전후 금액(품셈 = 우리 미리 셈 · 고정 = 엑셀) · 품 다른 줄 수.
* · 704-4 — [구조물 조합으로] = 같은 몸으로 구조물 조합 항목 바로(`Hopyo_ToItem` · 규격 묶음 제안)
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build` · `/labor/{열}/calc`) — 화면은 보이기 · 체크 · 고름만.
* ========================================================================== */
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { openModal } from "@ui/ui_template_modal";
import { t as L } from "@ui/ui_template_locale";
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import {
guessHopyo,
readHopyoOne,
type BuildLine,
type BuildTarget,
type HopyoBuilt,
type HopyoLine,
type HopyoMatch,
type HopyoOne,
type HopyoPart,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { buildWithKids as buildHopyo } from "./M02_MasterTemplete_Hopyo_Kids"; // 777-1 하위 함께
import { builtWord, openHopyoChoose, targetText } from "./M02_MasterTemplete_Hopyo_Choose";
import {
checkTables,
type LinePrice,
type RowsContext,
} from "./M02_MasterTemplete_Hopyo_CheckRows";
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
import { calcLabor, fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { draftDoc } from "./logic/M02_MasterTemplete_Hopyo_Draft";
import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem";
import { openLeftOf } from "./M02_MasterTemplete_Hopyo_Left";
import { formatNumber, formatPrice } from "../common_util/common_util_money";
import "./M02_MasterTemplete_Hopyo.css";
export const POLL_MS = 3000;
/** 사전조합을 만들었음(document 알림) — 좌측 「일위대가 사전조합」 목록 다시 */
export const HOPYO_BUILT = "m02-hopyo-built";
/** 미리 셈 문서 열 이름(서버 calc 는 보낸 문서로 셈 · 저장 안 함) */
const DRAFT_COLUMN = "hopyo-draft";
/** 확인 창 캐시 — 사용자가 만진 것만(서버 제안 위에 덮어 보임) */
export interface CheckDraft {
체크: Record<number, boolean>;
대상: Record<number, HopyoPart[]>;
뺌: Record<number, string>;
품: Record<number, HopyoMatch["품"]>;
통째?: boolean;
}
const empty = (): CheckDraft => ({ 체크: {}, 대상: {}, 뺌: {}, 품: {} });
export const draftKey = (scope: HopyoScope, key: string, no: number): string =>
`m02-hopyo-check:${scope.layer}:${scope.owner ?? ""}:${key}:${no}`;
export function loadDraft(k: string): CheckDraft {
try {
const raw = sessionStorage.getItem(k);
return raw ? { ...empty(), ...(JSON.parse(raw) as Partial<CheckDraft>) } : empty();
} catch {
return empty();
}
}
/** 확인 창을 열어 만진 적 있음(캐시 있음) */
export function hasDraft(k: string): boolean {
try {
return sessionStorage.getItem(k) !== null;
} catch {
return false;
}
}
export function saveDraft(k: string, d: CheckDraft): void {
try {
sessionStorage.setItem(k, JSON.stringify(d));
} catch {} // 캐시 못 씀 = 이 화면에만 남음
document.dispatchEvent(new Event("m02-hopyo-draft")); // 엑셀 보기 창 다시 칠(779-4 SheetNav.HOPYO_DRAFT)
}
export function dropDraft(k: string): void {
try {
sessionStorage.removeItem(k);
} catch {
/* 없음 */
}
}
/** 줄의 지금 대상 — 사용자가 고른 것(캐시) · 없으면 서버 제안 */
export const partsOf = (line: HopyoLine, d: CheckDraft): HopyoPart[] => {
const got = d.대상[line.차례] ?? line.맞춤.대상;
return Array.isArray(got) ? got : []; // 옛 응답(대상 하나) = 대상 없음
};
/** 체크 — 사용자가 만진 줄은 캐시 · 아니면 서버 제안 */
export const isChecked = (line: HopyoLine, d: CheckDraft): boolean =>
d.체크[line.차례] ?? !!line.맞춤.체크;
/** 체크할 수 있음 — % 줄 아님 · 뺌 까닭 또는 대상(배 0 넘음) */
export function checkable(line: HopyoLine, d: CheckDraft): boolean {
if (d.뺌[line.차례]) return true;
if (line.맞춤.상태 === "%") return !!partsOf(line, d)[0]?.비율; // 729-3 밑수 고른 % 줄만
const parts = partsOf(line, d);
return parts.length > 0 && parts.every((p) => p.배 !== null && p.배 > 0);
}
/** 대상 → 만들기 몸 대상(이름 · 품 같은 보이기 칸은 안 보냄) */
export function toTarget(p: HopyoPart): BuildTarget {
if (p.호표 !== undefined) return { 호표: p.호표, 배: p.배 ?? 1 };
const link = (["노임", "기계", "비율"] as const).find((x) => p[x]); // 729-3 사용자 이음(엑셀 숫자 + 이음)
if (link) return { [link]: p[link], 배: 1 } as BuildTarget;
const head = p.로직
? { 로직: p.로직, 입력값: p.입력값 ?? {} }
: p.전국
? { 전국: true }
: { 자재: p.자재 };
return { ...head, 배: p.배 ?? 1, ...(p.몫 != null ? { 몫: p.몫 } : {}) };
}
/** 체크한 줄만 → 만들기 몸 `줄` */
export const bodyOf = (lines: HopyoLine[], d: CheckDraft): BuildLine[] =>
lines
.filter((l) => isChecked(l, d) && checkable(l, d))
.map((l) =>
d.뺌[l.차례]
? { 차례: l.차례, 뺌: d.뺌[l.차례] as BuildLine["뺌"] }
: { 차례: l.차례, 대상: partsOf(l, d).map(toTarget) },
);
/** 빠질 줄 까닭별 수 — 확인 안 함 · 뺌 · % 줄 */
export function leftOut(lines: HopyoLine[], d: CheckDraft): Record<string, number> {
const out: Record<string, number> = {};
for (const l of lines) {
const why =
l.맞춤.상태 === "%"
? "% 줄"
: !isChecked(l, d) || !checkable(l, d)
? "확인 안 함"
: d.뺌[l.차례]
? "뺌"
: null;
if (why) out[why] = (out[why] ?? 0) + 1;
}
return out;
}
const n4 = (v: number | undefined): string => (v === undefined ? "–" : formatNumber(v));
/** 품 비교 글 — 「콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠」 */
export function compareText(q: HopyoMatch["품"] | undefined): string {
if (!q) return "";
const ex = q.엑셀.인력 ?? {};
const ours = q.우리.인력 ?? {};
const mark = (k: string): string => (q.같음[k] ? "✓" : "≠");
const out = [...new Set([...Object.keys(ex), ...Object.keys(ours)])].map(
(k) => `${k} ${n4(ex[k])} → ${n4(ours[k])} ${mark(k)}`,
);
// 같은 갈래 여러 줄은 합쳐 한 줄(서버 `_qty` 와 같은 열쇠 — 갈래 가나다 차례 + 로 묶음)
const name = (g: string | string[]): string => [g].flat().sort().join("+");
const sum = (xs: { 갈래: string | string[]; 시간: number }[], k: string): number | undefined =>
xs
.filter((y) => name(y.갈래) === k)
.reduce<number | undefined>((s, y) => (s ?? 0) + y.시간, undefined);
for (const k of new Set((q.엑셀.장비 ?? []).map((x) => name(x.갈래)))) {
out.push(`${k} ${n4(sum(q.엑셀.장비 ?? [], k))} → ${n4(sum(q.우리.장비 ?? [], k))} ${mark(k)}`);
}
return out.join(" · ");
}
/** 줄마다 최신 요청만 — `ask(줄)` 이 돌려준 함수가 그 뒤 같은 줄 요청이 없을 때만 true */
export function newest(): (key: number) => () => boolean {
const last = new Map<number, number>();
return (key) => {
const my = (last.get(key) ?? 0) + 1;
last.set(key, my);
return () => last.get(key) === my;
};
}
/** [맞음 · 별칭 일괄 체크] 대상 줄 */
export const sureLines = (lines: HopyoLine[], d: CheckDraft): HopyoLine[] =>
lines.filter((l) => ["맞음", "별칭"].includes(l.맞춤.상태) && checkable(l, d));
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "" : formatPrice(v);
const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error");
const note = (text: string): HTMLElement =>
el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } });
const td = (text: string, cls = ""): HTMLElement =>
el("td", { text, className: cls, attrs: { title: text } });
const openSheet = (o: HopyoSheetOptions): void =>
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail);
export interface CheckOptions {
/** 실무자료 파일 층 */
scope: HopyoScope;
key: string;
no: number;
/** 사전조합을 만들 층(없으면 파일 층) */
target?: HopyoScope;
/** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */
canWrite: boolean;
/** 만들었음 — [로직 추가] 참조 줄 · 모달 닫기 */
onBuilt?: (got: HopyoBuilt, hopyo: HopyoOne["호표"]) => void;
}
/** 확인 창 몸 — 모달(`openHopyoCheck`)에 담김 */
export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
const root = el("div", {
className: "m02-hopyo m02-hopyo-check",
attrs: { "data-hopyo-check": `${opt.key}|${opt.no}` },
});
const target = opt.target ?? opt.scope;
const k = draftKey(opt.scope, opt.key, opt.no);
let d = loadDraft(k);
let got: HopyoOne | null = null;
let built: HopyoBuilt | null = null;
let seq = 0;
let timer = 0;
const ask = newest(); // 줄마다 최신 미리 셈만
const askPrice = newest(); // 줄마다 최신 금액 미리 셈만
const priced = new Map<number, { sig: string; got: LinePrice }>();
let pending = false;
const keep = (): void => saveDraft(k, d);
async function load(quiet = false): Promise<void> {
const my = ++seq;
const stop = quiet ? () => {} : showLoading(root);
try {
const one = await readHopyoOne(opt.scope, opt.key, opt.no, opt.target);
if (my !== seq) return;
got = one;
const made = one.호표.만든?.품셈; // 705-3 — 만든 사전조합의 품셈 줄 = 첫 체크
if (made && !hasDraft(k)) for (const l of one.호표.줄) d.체크[l.차례] = made.includes(l.차례);
draw();
} catch (error) {
if (my === seq && !quiet) root.replaceChildren(loadingError(why(error), () => void load()));
} finally {
stop();
}
}
/** 대상 바꿈 → 캐시 · 체크 · 서버 미리 셈(최신 응답만) */
function setParts(line: HopyoLine, parts: HopyoPart[]): void {
d.대상[line.차례] = parts;
d.체크[line.차례] = true;
delete d.뺌[line.차례];
keep();
draw();
const mine = ask(line.차례);
guessHopyo(opt.scope, opt.key, opt.no, line.차례, parts.map(toTarget)).then((g) => {
if (!mine()) return;
if (Array.isArray(g.대상)) d.대상[line.차례] = g.대상;
if (g.품) d.품[line.차례] = g.품;
keep();
draw();
}, fail);
}
function editOptions(line: HopyoLine, at: number): void {
const parts = partsOf(line, d);
const p = parts[at];
if (!p?.로직) return;
fetchLogicInputs([p.로직]).then(
(res) =>
openLaborInput({
title: `${p.로직} ${p.이름 ?? ""}`.trim(),
logic: res.로직[0] ?? null,
values: (p.입력값 ?? {}) as never,
memo: null,
readOnly: !opt.canWrite,
done: (r) =>
setParts(
line,
parts.map((x, i) => (i === at ? { ...x, 입력값: r.입력값 } : x)),
),
}),
fail,
);
}
/** [원문 보기] — 엑셀 인용 조항 · 우리 로직 출처 두 갈래 */
async function showSource(line: HopyoLine): Promise<void> {
const excel = (line.근거?.출처 ?? []).map((x) => x.join(" ").trim()).filter(Boolean);
const codes = partsOf(line, d).flatMap((p) => (p.로직 ? [p.로직] : []));
const ours = codes.length
? (await fetchLogicInputs(codes).catch(() => ({ 로직: [] }))).로직.flatMap((x) =>
x.출처 ? [`${x.로직} — ${x.출처}`] : [],
)
: [];
openModal({
title: `원문 — ${line.이름}`,
closeLabel: "닫기",
wide: true,
dialogClass: "m02-modal m02-hopyo__src",
mount: (body) => {
const part = (head: string, list: string[], cut: (s: string) => string): void => {
body.append(el("h4", { text: head }));
if (!list.length) body.append(note("없음"));
for (const s of list) {
const view = createSourceView({
path: "/m02/logic-lib/source",
pdf: "/m02/logic-lib/source-pdf",
});
view.show(cut(s));
body.append(note(s), view.root);
}
};
part("엑셀 인용 조항", excel, (s) => s);
part("우리 로직 출처", ours, (s) => s.split(" — ").slice(1).join(" — "));
},
});
}
/** 줄마다 금액 미리 셈(359-3) — 서버 calc(저장 안 함 · 줄마다 최신 응답만 · 문서가 같으면 다시 안 셈) */
function price(line: HopyoLine): LinePrice | null {
const doc = draftDoc(line, partsOf(line, d));
if (!doc) return null;
const sig = JSON.stringify(doc);
const have = priced.get(line.차례);
if (have?.sig === sig) return have.got;
const slot = { sig, got: {} as LinePrice };
priced.set(line.차례, slot);
const mine = askPrice(line.차례);
calcLabor(target.layer, DRAFT_COLUMN, doc, {}, target.owner).then(
(calc) => mine() && ((slot.got = { calc }), soon()),
(error: unknown) => mine() && ((slot.got = { fail: why(error) }), soon()),
);
return slot.got;
}
/** 응답 여럿 = 한 번 그림(뒤 탭에서도 — rAF 는 멈춤) */
function soon(): void {
if (pending) return;
pending = true;
window.setTimeout(() => {
pending = false;
if (root.isConnected) draw();
}, 0);
}
const ctx = (): RowsContext => ({
canWrite: opt.canWrite,
d,
price,
check: (line, on) => {
d.체크[line.차례] = on;
keep();
draw();
},
out: (line, value) => {
if (value) d.뺌[line.차례] = value;
else delete d.뺌[line.차례];
d.체크[line.차례] = !!value || d.체크[line.차례];
keep();
draw();
},
times: (line, at, value) => {
d.대상[line.차례] = partsOf(line, d).map((x, i) => (i === at ? { ...x, 배: value } : x));
keep();
draw();
},
choose: (line) =>
openHopyoChoose({
line,
openSheet: sheetAt,
done: (ts) =>
setParts(
line,
ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })),
),
}),
source: (line) => void showSource(line),
sheet: sheetAt,
options: editOptions,
});
const sheetAt = (행: [number, number]): void =>
openSheet({
scope: opt.scope,
key: opt.key,
파일: got?.파일 ?? "",
canWrite: opt.canWrite,
target,
단산행: 행,
});
/** 1 단위 금액(705-3) — 품셈 줄 = 우리 미리 셈 · 고정 줄 = 엑셀 · 셈 중 = null */
function sumOf(lines: HopyoLine[], poom: (l: HopyoLine) => boolean): number | null {
let total = 0;
for (const l of lines) {
const p = poom(l) ? price(l) : null;
if (p && !p.calc && !p.fail) return null;
total += p?.calc?.반영합?.계 ?? l.금액.계 ?? 0;
}
return total;
}
async function make(h: HopyoOne["호표"]): Promise<void> {
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
const lines = whole ? [] : bodyOf(h.줄, d);
const poom = (l: HopyoLine): boolean => isChecked(l, d) && checkable(l, d) && !d.뺌[l.차례];
const now = (l: HopyoLine): boolean => (h.만든?.품셈 ?? []).includes(l.차례);
const outs = h.줄.filter((l) => isChecked(l, d) && d.뺌[l.차례] === "안 씀").length;
const put = h.줄.filter(poom);
const diff = put.filter((l) => {
const q = d.품[l.차례] ?? l.맞춤.품;
return !!q && Object.values(q.같음).some((v) => !v);
}).length;
const after = sumOf(h.줄, poom);
const known = !!h.만든?.품셈; // 옛 사전조합(품셈 줄 기록 없음) = 지금 금액 모름
const before = known ? sumOf(h.줄, now) : null;
const money =
`1 단위 ${known ? `지금 ${before === null ? "셈 중" : won(Math.round(before))} → ` : ""}` +
`바뀐 뒤 ${after === null ? "셈 중" : won(Math.round(after))} · 엑셀 ${won(h.금액.계)}`;
const ask = whole
? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}`
: `품셈 줄 ${put.length}${diff ? `(품 다름 ${diff})` : ""} · 고정 줄 ${h.줄.length - put.length - outs}` +
` · 뺌 ${outs} — ${money}(미리 셈 · 끝수 차 있음) — ${L("M02_PrecomboTitle")} 생성`;
if (!(await showConfirmDialog(ask, "생성"))) return;
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no };
showLoadingOverlay();
try {
const res = await buildHopyo(
target,
from,
lines,
whole ? { 대상: h.통째!.대상.map(toTarget) } : null,
);
dropDraft(k);
d = empty();
built = res;
const miss = res.빠진줄.length;
showToast(`${res.참조.키} ${builtWord(res)}${miss ? ` — 빠진 줄 ${miss}` : ""}`, "success");
document.dispatchEvent(new Event(HOPYO_BUILT));
opt.onBuilt?.(res, h);
await load(true);
} catch (error) {
const at = { scope: opt.scope, target, key: opt.key, 파일: got?.파일 ?? "", 번: opt.no };
if (!openLeftOf(error, at, () => ((d = loadDraft(k)), draw()))) fail(error); // 729-3 남은 줄
} finally {
hideLoadingOverlay();
}
}
function builtBlock(res: HopyoBuilt): HTMLElement {
const c = res.비교;
const rows = res.빠진줄.map((x) =>
el("tr", {
children: [
td(x.이름),
td(x.규격),
td(x.수량 === null ? "" : formatNumber(x.수량), "m02-hopyo__num"),
td(x.단위),
td(won(x.금액), "m02-hopyo__num"),
td(x.까닭),
],
}),
);
return el("div", {
className: "m02-hopyo__block m02-hopyo__built",
attrs: { "data-built": res.참조.키 },
children: [
note(res.다시씀 ? `있던 ${res.참조.키} 를 확인한 내용으로 덮음` : `${res.참조.키} 만듦`),
...(rows.length
? [
el("h4", { text: `빠진 줄 ${rows.length}` }),
el("table", {
className: "ui-general-block__table m02-hopyo__table",
children: [
el("thead", {
children: [
el("tr", {
children: ["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"].map((t) =>
el("th", { text: t }),
),
}),
],
}),
el("tbody", { children: rows }),
],
}),
]
: []),
note(
`값 비교(참고) — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` +
` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`,
),
],
});
}
const toItemButton = (h: HopyoOne["호표"], whole: boolean): HTMLElement =>
createButton({
label: "구조물 조합으로",
size: "sm",
onClick: () =>
void toItem({ scope: opt.scope, target, key: opt.key, no: opt.no, hopyo: h,
lines: whole ? [] : bodyOf(h.줄, d), whole: whole ? { 대상: h.통째!.대상.map(toTarget) } : null }), // prettier-ignore
});
function draw(): void {
if (!got) return;
const h = got.호표;
const checked = h.줄.filter((l) => isChecked(l, d) && checkable(l, d)).length;
const calc = h.셈;
window.clearTimeout(timer);
if (calc && !calc.끝)
timer = window.setTimeout(() => root.isConnected && void load(true), POLL_MS);
const tools: HTMLElement[] = [];
if (opt.canWrite) {
const sure = sureLines(h.줄, d);
const all = createButton({ label: `맞음 · 별칭 일괄 체크 (${sure.length})`, size: "sm" });
all.disabled = !sure.length;
all.addEventListener("click", () => {
for (const l of sure) d.체크[l.차례] = true;
keep();
draw();
});
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
const go = createButton({
label: `${L("M02_PrecomboTitle")} 생성`,
variant: "filled",
size: "sm",
});
go.setAttribute("data-hopyo-make", "");
go.addEventListener("click", () => void make(h));
tools.push(all);
if (h.만든 && !whole) {
// 705-3 — 고정 ↔ 품셈 바꾸기(줄 하나 = 체크 하나 + [사전조합 생성])
const poom = createButton({ label: `품셈으로 바꾸기 (${sure.length})`, size: "sm" });
poom.setAttribute("data-hopyo-to-poom", "");
poom.disabled = !sure.length;
poom.addEventListener("click", () => {
for (const l of sure) d.체크[l.차례] = true;
keep();
draw();
void make(h);
});
const back = createButton({ label: "모두 고정으로", size: "sm" });
back.setAttribute("data-hopyo-to-fixed", "");
back.disabled = !h.만든.품셈?.length;
back.addEventListener("click", () => {
for (const l of h.줄) d.체크[l.차례] = false;
keep();
draw();
void make(h);
});
tools.push(poom, back);
}
tools.push(go, toItemButton(h, whole)); // 704-4 — 사전조합 안 거치고 구조물 조합
}
const head = el("div", {
className: "ui-toolbar m02-hopyo__bar",
children: [
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
note(h.규격),
note(h.단위),
note(`엑셀 단가 ${won(h.금액.계)}`),
note("금액 칸 위 = 엑셀 · 아래 = 우리(미리 셈)"),
note(`제안 ${h.맞춤[0]} / 줄 ${h.맞춤[1]} · 체크 ${checked}`),
...(calc && !calc.끝
? [note(`숫자 판정 셈 중 ${calc.한}/${calc.모두}${calc.까닭 ? ` · ${calc.까닭}` : ""}`)]
: []),
...(h.만든
? [
note(
`만든 ${L("M02_PrecomboTitle")} ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}` +
(h.만든.품셈
? ` · 품셈 줄 ${h.만든.품셈.length} · 고정 줄 ${h.만든.고정 ?? 0}`
: ""),
),
]
: []),
createButton({
label: "엑셀 보기",
size: "sm",
onClick: () =>
openSheet({
scope: opt.scope,
key: opt.key,
파일: got!.파일,
canWrite: opt.canWrite,
target,
호표: opt.no,
}),
}),
...tools,
],
});
const whole = h.통째
? (() => {
const box = el("input", {
attrs: { type: "checkbox", "data-hopyo-whole": "" },
}) as HTMLInputElement;
box.checked = d.통째 ?? h.통째.체크;
box.disabled = !opt.canWrite;
box.addEventListener("change", () => {
d.통째 = box.checked;
keep();
draw();
});
return [
el("label", {
className: "ui-toolbar m02-hopyo__bar m02-hopyo__whole",
children: [
box,
note(
`통째 = ${h.통째.대상.map(targetText).join(" · ")}${h.통째.글 ? ` — ${h.통째.글}` : ""}`,
),
],
}),
];
})()
: [];
root.replaceChildren(
head,
...whole,
...(h.줄.length ? checkTables(h.줄, ctx()) : [note("구성 줄 없음")]),
...(built ? [builtBlock(built)] : []),
);
}
void load();
return root;
}
/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 호표 [수정] · 만들면 닫음 */
export function openHopyoCheck(opt: CheckOptions & { title?: string }): void {
openModal({
title: opt.title ?? `확인 — 제${opt.no}호표`,
closeLabel: "닫기",
wide: true,
dialogClass: "m02-modal m02-hopyo__check-modal",
mount: (body, close) =>
body.append(
buildHopyoCheck({
...opt,
onBuilt: (got, h) => {
opt.onBuilt?.(got, h);
close();
},
}),
),
});
}
@@ -1,373 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_CheckRows.ts
* 확인 창 줄 표(PLAN 359-3) — 구조물 조합 일위대가 상세 꼴(`Labor_Rows` logicTable · matTable)에 품 비교 · 단추만 더함.
* · 로직 표 = [✓] · 차례 · 구분(엑셀 갈래 + 상태) · 이름(우리 로직 · 단위 환산 · 옵션 글) · 별칭(엑셀 품명 = 만들 때 별칭) ·
* 수량산출(위 엑셀 / 아래 우리 = 엑셀 × 배) · 결과 단위 · 노무비 · 재료비 · 경비 · 합계 · 품 비교 · 단추
* · 자재 표 = [✓] · 차례 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(자재구분 · 별도계상 · 운반비 · 글만) ·
* 가격 다섯 · 엑셀 / 우리 금액 · 단추
* · 금액 칸 = 위 작게 엑셀 · 아래 우리(서버 calc 미리 셈 · 저장 안 함) · 셈 중 「…」
* · 단추 = [고르기] · [원문 보기] · [엑셀 보기] · [옵션](로직 대상마다) · 드롭다운 추가 · 로직없음 · 사용안함
* (화면 글만 · 저장 값 = 빈 값 · 「우리에 없음」 · 「안 씀」 그대로)
* 줄 표 = `tableOf`(대상이 모두 자재 · 대상 없는 자재 · 별도계상 줄 = 자재 표).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import type { HopyoLine, HopyoPart } from "./M02_MasterTemplete_Hopyo_Api";
import {
checkable,
compareText,
isChecked,
partsOf,
type CheckDraft,
} from "./M02_MasterTemplete_Hopyo_Check";
import { inputsText, sourceText } from "./M02_MasterTemplete_Hopyo_Choose";
import type { LaborCalc } from "./M02_MasterTemplete_Labor_Api";
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { formatNumber, formatPrice } from "../common_util/common_util_money";
import { tableOf } from "./logic/M02_MasterTemplete_Hopyo_Draft";
import { unitView, type UnitView } from "./logic/M02_MasterTemplete_Hopyo_Unit";
/** 드롭다운 — 저장 값(서버 뺌 까닭) → 화면 글 */
export const OUT_TEXT: [string, string][] = [
["", "추가"],
["우리에 없음", "로직없음"],
["안 씀", "사용안함"],
];
const COSTS = ["노무비", "재료비", "경비", "계"] as const;
const COST_HEAD = {
노무비: "Prev_Labor",
재료비: "Prev_Material",
경비: "Prev_Expense",
계: "Prev_Sum",
} as const;
/** 줄 하나 미리 셈 — 셈 중 = 둘 다 없음 */
export interface LinePrice {
calc?: LaborCalc;
fail?: string;
}
export interface RowsContext {
canWrite: boolean;
d: CheckDraft;
price: (line: HopyoLine) => LinePrice | null;
check: (line: HopyoLine, on: boolean) => void;
out: (line: HopyoLine, value: string) => void;
times: (line: HopyoLine, at: number, value: number | null) => void;
choose: (line: HopyoLine) => void;
source: (line: HopyoLine) => void;
sheet: (행: [number, number]) => void;
options: (line: HopyoLine, at: number) => void;
}
const num = formatNumber;
const small = (text: string, cls = "m02-labrow__note"): HTMLElement =>
el("small", { className: cls, text, attrs: { title: text } });
/** 금액 칸 — 위 엑셀(작게) · 아래 우리 */
function pairCell(
excel: number | null | undefined,
ours: number | null | undefined,
wait: boolean,
label: string,
): HTMLElement {
const td = moneyCell(excel, ours, wait, label);
td.querySelector(".m02-labrow__base")?.setAttribute("title", "엑셀");
td.querySelector(".m02-labrow__applied")?.setAttribute("title", "우리(미리 셈)");
return td;
}
/** 수량산출 — 위 엑셀 수량 · 아래 우리(엑셀 × 배 · 대상마다) */
function qtyCell(line: HopyoLine, parts: HopyoPart[]): HTMLElement {
const ours = parts
.map((p) => (p.배 === null || line.수량 === null ? "—" : num(line.수량 * p.배)))
.join(" · ");
return el("td", {
className: "m02-labrow__qty",
attrs: { "data-label": tc("Labor_QtyCol") },
children: [
el("small", {
className: "m02-labrow__base",
text: line.수량 === null ? "—" : `${num(line.수량)} ${line.단위}`.trim(),
attrs: { title: "엑셀" },
}),
el("span", { className: "m02-labrow__applied", text: ours || "—", attrs: { title: "우리" } }),
],
});
}
const stateChip = (line: HopyoLine): HTMLElement => {
const m = line.맞춤;
const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : "";
const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n");
return el("span", {
className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`,
text: `${m.상태}${alias}`,
attrs: { title: tags },
});
};
/** 단위 환산 칸 — 「엑셀 1 ㎡ = 우리 [ ] 본」(장부 화면도 같은 꼴) */
export function unitField(input: HTMLInputElement, v: Pick<UnitView, "from" | "to">): HTMLElement {
input.title = L("M02_HopyoUnitTip");
const word = (text: string): HTMLElement =>
el("span", { className: "m02-hopyo__unit-text", text });
return el("span", {
className: "m02-hopyo__unit",
children: [
word(L("M02_HopyoUnitLead").replace("{from}", v.from || "?")),
input,
...(v.to ? [word(v.to)] : []),
],
});
}
/** 대상마다 한 줄 — 키 · 이름 · 단위 환산(같음 = 글만 · 몫 줄 = 몫만 · 빈칸 · 추정 = 입력 칸) · 몫 */
function partLines(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLElement[] {
return parts.map((p, at) => {
const v = unitView(p, line.단위, p.단위);
const times = el("input", {
className: "ui-input ui-input--sm m02-hopyo__times",
attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) },
}) as HTMLInputElement;
times.disabled = !ctx.canWrite;
times.addEventListener("change", () =>
ctx.times(line, at, times.value === "" ? null : Number(times.value)),
);
const key =
p.로직 ?? p.자재 ?? (p.호표 !== undefined ? `제${p.호표}호표` : p.전국 ? "전국" : "");
const note = v.estimated
? v.thick !== null
? L("M02_HopyoUnitThick").replace("{n}", String(v.thick))
: L("M02_HopyoUnitEst")
: "";
return el("div", {
className: "m02-hopyo__part",
attrs: { title: p.어디 ?? "" },
children: [
el("span", { className: "m02-labrow__key", text: key }),
el("strong", { text: p.이름 ?? "" }),
...(v.mode === "same" ? [small(L("M02_HopyoUnitSame"))] : []),
...(v.mode === "input" ? [unitField(times, v)] : []),
...(note ? [small(note)] : []),
...(p.몫 != null && p.몫 !== 1 ? [small(`몫 ${p.몫}`)] : []),
],
});
});
}
/** 이름 칸 덧글 — 옵션(칸=값 · 출처) · 못 가름 · 셈 못 함 까닭 */
function nameNotes(line: HopyoLine, parts: HopyoPart[], price: LinePrice | null): HTMLElement[] {
const opts = parts
.filter((p) => p.로직)
.map((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처))
.filter(Boolean);
if (line.맞춤.갈리는칸?.length) opts.push(`⚠ 못 가름: ${line.맞춤.갈리는칸.join(", ")}`);
const why = [
price?.fail,
...(price?.calc?.줄 ?? []).map((l) => l.까닭),
...(price?.calc?.자재 ?? []).map((m) => m.까닭),
].filter(Boolean) as string[];
return [
...opts.map((t) => small(t, "m02-labrow__note m02-hopyo__opts")),
...why.map((t) => small(`셈 못 함: ${t}`, "ui-text ui-text--error")),
...(parts.some((p) => p.호표 !== undefined) ? [small("하위 호표 = 만든 뒤 셈")] : []),
];
}
function checkCell(line: HopyoLine, ctx: RowsContext): HTMLElement {
const box = el("input", {
attrs: { type: "checkbox", "data-check": String(line.차례) },
}) as HTMLInputElement;
box.checked = isChecked(line, ctx.d) && checkable(line, ctx.d);
box.disabled = !ctx.canWrite || !checkable(line, ctx.d);
box.addEventListener("change", () => ctx.check(line, box.checked));
return el("td", { children: [box] });
}
function actsCell(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLElement {
const m = line.맞춤;
const ghost = (label: string, run: () => void): HTMLElement =>
createButton({ label, variant: "ghost", size: "sm", onClick: run });
const out = el("select", {
className: "ui-input ui-input--sm",
attrs: {
title: `로직없음 · 사용안함 — ${L("M02_PrecomboTitle")}에 안 넣고 장부에 남김`,
"data-out": "",
},
children: OUT_TEXT.map(([value, text]) => el("option", { text, attrs: { value } })),
}) as HTMLSelectElement;
out.value = ctx.d.뺌[line.차례] ?? "";
out.disabled = !ctx.canWrite || m.상태 === "%";
out.addEventListener("change", () => ctx.out(line, out.value));
const options = parts.flatMap((p, at) => {
if (!p.로직) return [];
const b = ghost("옵션", () => ctx.options(line, at));
b.title = `${p.로직} 옵션`;
return [b];
});
return el("td", {
className: "m02-hopyo__acts",
children: [
...(ctx.canWrite && m.상태 !== "%"
? [ghost(m.제안?.짝.length ? "고르기 · 내 제안" : "고르기", () => ctx.choose(line))]
: []),
ghost("원문 보기", () => ctx.source(line)),
...(line.근거 ? [ghost("엑셀 보기", () => ctx.sheet(line.근거!.행))] : []),
...options,
out,
],
});
}
const rowOf = (line: HopyoLine, ctx: RowsContext, cells: HTMLElement[]): HTMLElement => {
const on = isChecked(line, ctx.d) && checkable(line, ctx.d);
return el("tr", {
className: `m02-labrow__row m02-hopyo__line is-${on ? "in" : "out"}`,
attrs: { "data-line": String(line.차례), "data-state": line.맞춤.상태 },
children: [checkCell(line, ctx), ...cells, actsCell(line, partsOf(line, ctx.d), ctx)],
});
};
function logicRow(line: HopyoLine, ctx: RowsContext): HTMLElement {
const parts = partsOf(line, ctx.d);
const price = ctx.price(line);
const wait = !!price && !price.calc && !price.fail;
const ours = price?.calc?.반영합;
const unit = price?.calc?.줄[0]?.분모 ?? parts[0]?.단위 ?? line.단위;
const compare = compareText(ctx.d.품[line.차례] ?? line.맞춤.품);
return rowOf(line, ctx, [
el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }),
el("td", { className: "m02-labrow__kind", children: [line.갈래, stateChip(line)] }),
el("td", {
className: "m02-labrow__name m02-hopyo__parts",
children: [
...(parts.length ? partLines(line, parts, ctx) : [small("—", "ui-text ui-text--muted")]),
...nameNotes(line, parts, price),
],
}),
el("td", {
className: "m02-labrow__alias",
attrs: { title: `${line.이름} ${line.규격}`.trim() },
children: [line.이름, ...(line.규격 ? [small(line.규격)] : [])],
}),
qtyCell(line, parts),
el("td", { className: "m02-labrow__unit", text: unit ?? "" }),
...COSTS.map((c) =>
pairCell(line.금액[c], price?.calc ? (ours?.[c] ?? null) : null, wait, tc(COST_HEAD[c])),
),
el("td", { className: "m02-hopyo__compare", text: compare.split(" · ").join("\n") }),
]);
}
function matRow(line: HopyoLine, ctx: RowsContext): HTMLElement {
const parts = partsOf(line, ctx.d);
const price = ctx.price(line);
const wait = !!price && !price.calc && !price.fail;
const mats = price?.calc?.자재 ?? [];
const first = mats[0];
const ours = mats.length ? mats.reduce((s, m) => s + (m.반영 ?? 0), 0) : null;
const show = (v: number | null | undefined): string =>
wait ? "…" : v === null || v === undefined ? "—" : formatPrice(v);
const p = parts[0];
return rowOf(line, ctx, [
el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }),
el("td", {
className: "m02-labrow__code",
text: parts.map((x) => x.자재 ?? (x.전국 ? "전국" : "")).join(" · ") || "—",
}),
el("td", {
className: "m02-labrow__name m02-hopyo__parts",
children: [
stateChip(line),
...(parts.length ? partLines(line, parts, ctx) : []),
small(`엑셀 ${line.이름}`.trim()),
...nameNotes(line, parts, price),
],
}),
el("td", {
className: "m02-labrow__spec",
text: p?.규격 ?? line.규격,
attrs: { title: p?.규격 ?? line.규격 },
}),
qtyCell(line, parts),
el("td", { className: "m02-labrow__unit", text: p?.단위 ?? line.단위 }),
...markCells(p?.세칸 ?? { 별도계상: line.갈래 === "별도계상" ? "별도계상" : "직접계산" }),
...PRICE_SLOTS.map((slot) =>
el("td", {
className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`,
text: show(first?.가격?.[slot]),
attrs: { "data-label": slot, "data-price-slot": slot },
}),
),
pairCell(line.금액.계, price?.calc ? ours : null, wait, tc("Labor_AppliedSum")),
]);
}
const grid = (heads: string[], rows: HTMLElement[], kind: string): HTMLElement =>
el("div", {
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
children: [
el("table", {
className: "ui-general-block__table m02-labrow m02-hopyo__table m02-hopyo__lines",
attrs: { "data-hopyo-table": kind },
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: rows }),
],
}),
],
});
/** 줄 표 둘 — 로직 표 · 자재 표(줄 없는 표는 안 그림) */
export function checkTables(lines: HopyoLine[], ctx: RowsContext): HTMLElement[] {
const logic = lines.filter((l) => tableOf(l, partsOf(l, ctx.d)) === "logic");
const mat = lines.filter((l) => !logic.includes(l));
const out: HTMLElement[] = [];
if (logic.length)
out.push(
grid(
[
"",
tc("Col_Order"),
tc("Sub"),
tc("Col_Name"),
tc("Col_Alias"),
tc("Labor_QtyCol"),
tc("Col_Unit"),
...COSTS.map((c) => tc(COST_HEAD[c])),
"품 비교",
"",
],
logic.map((l) => logicRow(l, ctx)),
"logic",
),
);
if (mat.length)
out.push(
el("h4", { className: "m02-hopyo__subhead", text: "자재" }),
grid(
[
"",
tc("Col_Order"),
tc("Labor_Code"),
tc("Col_Name"),
tc("Labor_Spec"),
tc("Labor_QtyCol"),
tc("Labor_Unit"),
...markHeads(),
...PRICE_SLOTS,
`엑셀 / 우리 ${tc("Labor_AppliedSum")}`,
"",
],
mat.map((l) => matRow(l, ctx)),
"mat",
),
);
return out;
}
@@ -1,383 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Choose.ts
* 일위대가 실무자료 고르기 창(PLAN 338-3 · 358 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미.
* · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀 보기](단가산출 그 행)
* · 후보 묶음(358-1 · 숨김 없음) = 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 324 다섯) / 숫자만 닮음(접힘 ·
* 뜻 낱말 안 겹치는 「후보」는 회색 끝) · 같은 이름이면 책(산림 · 건설) · 판정 꼬리표 · 근거 꼬리표(근거 맞음 초록)
* · 고름 = [후보](한 로직) · 또는 체크 + 몫 칸 → [일괄 적용(n)](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움
* · [다른 항목 검색](736-1) = 찾아 넣기 창(`Find_Pick`) 하나 · 품명 첫 뜻 낱말 · 기초 = 자재 아닌 줄 · 재료 = 자재 · 재료비 있는 줄(358-2)
* · 자재 줄(361-1) = 1위 / 전국(자리표 · 지역 고른 뒤 되돌림) / 지역 변형(접힘 · 하나 고르면 그 지역) / 그 밖
* 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감.
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type {
BuildTarget,
HopyoBasisHit,
HopyoLine,
HopyoLineBasis,
HopyoMatch,
HopyoQty,
HopyoTarget,
} from "./M02_MasterTemplete_Hopyo_Api";
import { getActiveLayer, getActiveOwner } from "./M02_MasterTemplete_Api_Fetch";
import { type FindKind, openFindPicker } from "./M02_MasterTemplete_Find_Pick";
import { formatNumber } from "../common_util/common_util_money";
import "./M02_MasterTemplete_Hopyo.css";
const MATERIAL = new Set(["자재", "별도계상"]);
const FOLD_TEXT: Record<string, string> = {
"지역 변형": "하나 고르면 그 지역 · 「전국」으로 되돌림은 위",
숫자: "이름 · 출처 안 닮음 · 숫자 지문만",
};
const PRE_TIP = "미리 고름 — 엑셀 인용 조항 = 1위 기초 조항 · 누르기는 사용자";
const note = (text: string): HTMLElement =>
el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } });
const num = formatNumber;
/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */
export const targetText = (t: HopyoTarget | null | undefined): string => {
if (!t) return "";
if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim();
const spec = t.규격 ? ` ${t.규격}` : "";
return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`;
};
/** 입력값 글 — 「이름=값」 · 칸 출처(블록 · 규격 · 별칭) · 못 가르는 칸 표시 */
export const inputsText = (
values: Record<string, unknown>,
unread: string[],
from: Record<string, string> = {},
): string =>
[
Object.entries(values)
.map(
([k, v]) =>
`${k}=${formatNumber(v)}${from[k] && from[k] !== "자동값" ? `(${from[k]})` : ""}`,
)
.join(", "),
unread.length ? `⚠ 못 가름: ${unread.join(", ")}` : "",
]
.filter(Boolean)
.join(" · ");
/** 만들기 결과 낱말 — 있던 것을 덮음 · 새로 만듦 */
export const builtWord = (got: { 다시씀: boolean }): string =>
got.다시씀 ? "있던 것을 확인한 내용으로 덮음" : "만듦";
/** 근거 맞음 — 조항 같음(앞 마디 아님) · 단위 맞음 · 직종 겹침
* (줄 근거 직종이 보통인부뿐이거나 없으면 — 기계 등 — 직종은 안 봄) */
export const basisOk = (b: HopyoBasisHit | undefined, line?: HopyoLineBasis): boolean =>
!!b &&
b.까닭.startsWith("조항 같음") &&
b.단위 &&
(b.직종.length > 0 || !(line?.직종 ?? []).some((x) => x !== "보통인부"));
/** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */
export const basisTag = (b: HopyoBasisHit | undefined): string =>
b
? [
b.조항.length ? `조항 ${b.조항.join(", ")}` : "",
b.직종.length ? `직종 ${b.직종.join(", ")}` : "",
b.단위 ? "단위 같음" : "",
]
.filter(Boolean)
.join(" · ")
: "";
/** 엑셀 근거 줄 — 「출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행」 · 없으면 "" */
export const sourceText = (b: HopyoLineBasis | undefined): string =>
b
? [
b.출처.length ? `출처 ${b.출처.map((x) => x.join(" ").trim()).join(", ")}` : "",
b.직종.length ? `직종 ${b.직종.join(", ")}` : "",
`단가산출 ${b.행[0] === b.행[1] ? b.행[0] : `${b.행[0]}–${b.행[1]}`}행`,
]
.filter(Boolean)
.join(" · ")
: "";
/** 품 글 — 「콘크리트공 0.1091 · 굴착기 0.29hr · 레미콘 1.02」 */
export const qtyText = (q: HopyoQty | undefined): string =>
q
? [
...Object.entries(q.인력 ?? {}).map(([k, v]) => `${k} ${num(v)}`),
...(q.장비 ?? []).map((x) => `${[x.갈래].flat().join("+")} ${num(x.시간)}hr`),
...(q.자재 ?? []).map((x) => `${x.이름} ${num(x.수량)}`),
].join(" · ")
: "";
/** [다른 항목 검색] 미리 넣을 글 — 품명 괄호 밖 첫 뜻 낱말(358-1 · 조항 번호는 모든 낱말 맞춤이라 0건 위험) */
export function searchHint(line: Pick<HopyoLine, "이름">): string {
return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? "";
}
/** 후보 묶음 하나 — 제목 · 접힘(숫자만 닮음) · 후보 */
export interface CandGroup {
제목: string;
접힘: boolean;
후보: HopyoTarget[];
}
/** 약한 후보 — 판정 「후보」 + 품명 뜻 낱말 안 겹침(회색 · 묶음 끝) */
export const weakCand = (c: HopyoTarget): boolean => c.판정 === "후보" && c.뜻겹침 === false;
/** 후보 묶음(358-1 · 숨김 없음 · 차례는 서버 그대로) — 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 판 다섯) /
* 숫자만 닮음(접힘 · 약한 후보 뒤로) · 한 후보는 한 묶음에만 · 자재 줄 = 1위 / 그 밖 후보 */
export function candGroups(
m: Pick<HopyoMatch, "후보" | "이름후보">,
material = false,
): CandGroup[] {
const all = m.후보 ?? [];
const seen = new Set<string>();
const take = (list: HopyoTarget[]): HopyoTarget[] =>
list.filter((c) => {
const id = c.로직 ?? c.자재 ?? c.이름 ?? "";
if (seen.has(id)) return false;
seen.add(id);
return true;
});
const top = take(all.slice(0, 1));
if (material)
return [
{ 제목: "1위", 접힘: false, 후보: top },
{ 제목: "전국", 접힘: false, 후보: take(all.filter((c) => c.전국)) },
{ 제목: "지역 변형", 접힘: true, 후보: take(all.filter((c) => c.변형)) },
{ 제목: "그 밖 후보", 접힘: false, 후보: take(all.slice(1)) },
].filter((g) => g.후보.length);
const named = m.이름후보 ?? [];
const source = take([...all.slice(1), ...named].filter((c) => c.출처같음));
const name = take(named);
const rest = take(all.slice(1));
rest.sort((a, b) => Number(weakCand(a)) - Number(weakCand(b)));
return [
{ 제목: "1위", 접힘: false, 후보: top },
{ 제목: "출처 같음", 접힘: false, 후보: source },
{ 제목: "이름 닮은 로직", 접힘: false, 후보: name },
{ 제목: "숫자만 닮음", 접힘: true, 후보: rest },
].filter((g) => g.후보.length);
}
/** 같은 이름 후보 — 책(산림 · 건설)을 붙여 가를 이름 */
export function sameNames(groups: CandGroup[]): Set<string> {
const count = new Map<string, number>();
for (const c of groups.flatMap((g) => g.후보))
count.set(c.이름 ?? "", (count.get(c.이름 ?? "") ?? 0) + 1);
return new Set([...count].filter(([name, n]) => name && n > 1).map(([name]) => name));
}
/** 후보 단추 글 — 「이름 [단위] · 키」 · 같은 이름이면 책 두 글자(산림 · 건설) */
export const candText = (c: HopyoTarget, twins: Set<string>): string =>
`${targetText(c)} · ${c.로직 ?? c.자재 ?? "키 없음"}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`;
/** 자재 고름 — 자재 · 별도계상 줄 · 재료비 있는 단가산출 줄(358-2 · P.V.C파이프설치 등) */
export const canPickMaterial = (line: Pick<HopyoLine, "갈래" | "단가">): boolean =>
MATERIAL.has(line.갈래) || !!line.단가?.재료비;
/** 736-2 [다른 항목 검색…] 탭 — 줄 갈래대로(서버 이음 칸 = 노임 LB · 기계 EQ · 자재 · 로직) */
export const kindsOf = (line: Pick<HopyoLine, "갈래" | "단가">): FindKind[] =>
line.갈래 === "노임"
? ["labor"]
: line.갈래 === "기계"
? ["machine"]
: [
...(MATERIAL.has(line.갈래) ? [] : (["logic"] as const)),
...(canPickMaterial(line) ? (["material"] as const) : []),
];
/** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */
export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] =>
picked.map(({ c, 몫 }) => ({
...(c.로직 ? { 로직: c.로직 } : c.전국 ? { 전국: true } : { 자재: c.자재 }),
입력값: {},
배: 몫 ?? 1,
몫,
}));
export interface ChooseOptions {
line: HopyoLine;
/** [엑셀 보기] — 단가산출 그 행 */
openSheet?: (행: [number, number]) => void;
done: (targets: BuildTarget[]) => void;
}
/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 묶음(하나 또는 여럿 · 몫) · 다른 로직 · 자재 검색 */
export function openHopyoChoose(opt: ChooseOptions): void {
const { line } = opt;
const material = MATERIAL.has(line.갈래);
openModal({
title: `고르기 — ${line.이름} ${line.규격}`.trim(),
closeLabel: "닫기",
dialogClass: "m02-modal m02-hopyo__choose",
mount: (body, close) => {
const finish = (targets: BuildTarget[]): void => {
close();
opt.done(targets);
};
const picked = new Map<HopyoTarget, HTMLInputElement>();
const together = createButton({ label: "일괄 적용(0)", size: "sm", disabled: true });
const paint = (): void => {
together.querySelector(".ui-btn__label")!.textContent = `일괄 적용(${picked.size})`;
together.disabled = picked.size < 2;
};
together.addEventListener("click", () =>
finish(
pickedTargets(
[...picked].map(([c, box]) => ({ c, 몫: box.value === "" ? null : Number(box.value) })),
),
),
);
const groups = candGroups(line.맞춤, material);
// 739-1 내 제안 — 맨 위 · 한 번 눌러 짝 통째로(입력값 · 배 · 몫 그대로)
const mine = line.맞춤.제안?.짝.length ? line.맞춤.제안 : null;
const mineRow = mine
? el("div", {
attrs: { "data-group": "내 제안" },
children: [
note("내 제안 — 승인 전(M01 별칭 장부 승인 뒤 자동)"),
el("div", {
className: "m02-hopyo__cand",
attrs: { "data-cand": "내 제안" },
children: [
createButton({
label: mine.대상.map((c) => candText(c, new Set())).join(" + "),
variant: "ghost",
size: "sm",
onClick: () =>
finish(
mine.짝.map((p) => ({
...(p.로직 ? { 로직: p.로직 } : { 자재: p.자재 }),
입력값: p.입력값 ?? {},
배: p.배,
몫: p.몫 ?? null,
})),
),
}),
el("span", { className: "m02-hopyo__state is-별칭", text: "내 제안" }),
],
}),
],
})
: null;
const twins = sameNames(groups);
const row = (c: HopyoTarget): HTMLElement => {
const share = el("input", {
className: "ui-input ui-input--sm m02-hopyo__times",
attrs: { type: "number", step: "0.05", min: "0", max: "1", placeholder: "몫" },
}) as HTMLInputElement;
const box = el("input", { attrs: { type: "checkbox", title: "함께 고름(조합)" } });
box.addEventListener("change", () => {
if ((box as HTMLInputElement).checked) picked.set(c, share);
else picked.delete(c);
paint();
});
const tag = basisTag(c.근거);
return el("div", {
className: `m02-hopyo__cand${weakCand(c) ? " is-weak" : ""}`,
attrs: {
"data-cand": c.로직 ?? c.자재 ?? (c.전국 ? "전국" : ""),
...(c.미리 ? { "data-pre": "" } : {}),
},
children: [
...(c.미리 ? [createBadge(PRE_TIP, "accent", { kind: "dot" })] : []),
box,
createButton({
label: candText(c, twins),
variant: "ghost",
size: "sm",
onClick: () => finish(pickedTargets([{ c, 몫: null }])),
}),
...(c.판정
? [
el("span", {
className: `m02-hopyo__state is-${c.판정.replace(" ", "")}`,
text: c.판정,
}),
]
: []),
...(c.미리 ? [note("미리 고름")] : []),
...(c.글 ? [note(c.글)] : []),
...(weakCand(c) ? [note("이름 뜻 안 겹침")] : []),
...(tag && c.근거
? [
el("span", {
className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`,
text: tag,
attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` },
}),
]
: []),
share,
],
});
};
const sections = groups.map((g) => {
const list = el("div", { className: "m02-hopyo__cands", children: g.후보.map(row) });
const title = `${g.제목} (${g.후보.length})`;
if (!g.접힘)
return el("div", { attrs: { "data-group": g.제목 }, children: [note(title), list] });
const fold = el("details", {
className: "m02-hopyo__group",
attrs: { "data-group": g.제목 },
children: [
el("summary", { text: `${title} — ${FOLD_TEXT[g.제목] ?? FOLD_TEXT.숫자}` }),
list,
],
});
return fold;
});
const excel = qtyText(line.맞춤.품?.엑셀);
const basis = line.근거;
const head = el("div", {
className: "ui-toolbar m02-hopyo__bar m02-hopyo__source",
children: [
...(excel ? [note(`엑셀 품 — ${excel}`)] : []),
...(basis ? [note(`엑셀 근거 — ${sourceText(basis)}`)] : []),
...(basis && opt.openSheet
? [
createButton({
label: "엑셀 보기",
variant: "ghost",
size: "sm",
onClick: () => opt.openSheet?.(basis.행),
}),
]
: []),
],
});
// 736-1 · 736-2 찾아 넣기 창 하나 — 줄 갈래대로 탭(노임 = 인력 · 기계 = 기계 · 자재 = 재료 · 그 밖 = 기초 로직 · 재료비 있는 줄은 재료도)
const kinds = kindsOf(line);
const find = createButton({
label: "다른 항목 검색…",
size: "sm",
onClick: () => {
close();
openFindPicker({
layer: getActiveLayer(),
owner: getActiveOwner(),
kinds,
query: searchHint(line.갈래 === "기계" ? { 이름: line.규격 || line.이름 } : line), // 기계 = 규격 낱말(굴착기)
logic: (x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]),
material: (e) => opt.done([{ 자재: e.코드, 배: 1 }]),
element: (e) => opt.done([{ 노임: e.코드, 배: 1 }]),
machine: (e) => opt.done([{ 기계: e.코드, 배: 1 }]),
});
},
});
const more = kinds.length ? [find] : [];
body.append(
...(head.childElementCount ? [head] : []),
note(
groups.length
? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [일괄 적용]"
: "가까운 것 없음 — 아래 검색",
),
...(mineRow ? [mineRow] : []),
...sections,
el("div", { className: "ui-toolbar m02-hopyo__bar", children: [together, ...more] }),
);
},
});
}
@@ -1,32 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Compare.ts
* 불러오기 M01 요소 후보 단추(PLAN 746-2 · 요구 585 · 서버 746-1 `후보`) —
* · `elemChoices` = 안 이은 줄의 M01 요소 후보 셋 단추(누르면 부르는 쪽이 이음)
* · 옛 고름 대기 기초 줄 비교(`compareView`)는 778-5 D-5 줄 고름으로 걷음
* 보이기만 — 판정 · 순위는 서버 그대로 · 고르기는 부르는 쪽.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { formatPrice } from "../common_util/common_util_money";
import type { ElemCand } from "./M02_MasterTemplete_Hopyo_Api";
/** 요소 후보 단추 줄(셋까지) — 누르면 `pick(후보)` */
export function elemChoices(cands: ElemCand[], pick: (c: ElemCand) => void): HTMLElement | null {
if (!cands.length) return null;
const box = el("div", { className: "ui-toolbar", attrs: { "data-elem": "" } });
box.title = L("M02_ElemTitle");
for (const c of cands.slice(0, 3)) {
const price = c.단가 ? ` · ${formatPrice(c.단가)}` : "";
const b = createButton({
label: `${c.이름}${c.규격 ? ` ${c.규격}` : ""} [${c.단위 ?? ""}]${price}`,
size: 24,
variant: "ghost",
onClick: () => pick(c),
});
b.dataset.elemKey = c.키;
b.title = `${c.키} · ${c.갈래}`;
box.append(b);
}
return box;
}
@@ -2,37 +2,22 @@
* M02_MasterTemplete_Hopyo_Import.ts
* 일위대가 불러오기 본 칸 보기(PLAN 785-4 · 요구 642-11 — 마법사 `Hopyo_Wizard` 갈음 · 가운데 상세 페이지 없음) —
* 왼쪽 [불러오기] → 층 · 파일 고름 → 올림(`Hopyo_Side`) → 바로 이 보기:
* · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 제안)의 머리에 층 · 안 이은 줄 배지 ·
* [안 이은 줄 고르기] · [끝내기]를 덧붙임
* · 아래 = 엑셀(탭 셋) + 오른쪽 호표 · 단가산출 목록(`Hopyo_Sheet` · `Hopyo_SheetSk`) · 만들기 = 목록 아래 막대
* · 안 이은 줄 수 = 엑셀 보기가 읽은 호표 번으로 셈([끝내기] 전 「0」 오표시 흠 고침 — 빈 호표 번 안 씀)
* · [끝내기] = 남은 줄 다시 셈 → 있으면 막음(배지 · 남은 수 · 표) · 없으면 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 ·
* 만든 일위대가 · 단가산출(엑셀 사본 걷고 출처만) · 별칭 장부 남음
* · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 제안)의 머리에 층 · [끝내기]를 덧붙임
* · 아래 = 엑셀(탭 셋) + 오른쪽 호표 · 단가산출 목록(`Hopyo_Sheet` · `Hopyo_SheetSk`) · 만들기 = 목록 아래 [옮기기]
* · [끝내기] = 물음(확인하고 안 옮긴 수 · 안 옮긴 단가산출 알림) → 서버가 이번 HP · 격자 · 블록 풀이 저장본 지움 ·
* 만든 일위대가 · 단가산출(엑셀 사본 걷고 출처만) · 별칭 장부 남음 · 옛 「안 이은 줄」 띠는 785-7 에서 지움(판정 「고를 것」)
* · 작업 중 = 이 화면 동안만(743-1) — 화면 떠남(`leave` · 페이지 닫음) = 이 화면에서 올리거나 이은 파일 지움 ·
* 하루 지난 것은 서버가 지움 · 엑셀 자료는 보관하지 않음(필요하면 다시 올림)
* ========================================================================== */
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import { createBadge } from "@ui/ui_template_badge";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import {
deleteHopyo,
readHopyo,
type HopyoFile,
type HopyoScope,
type HopyoView,
} from "./M02_MasterTemplete_Hopyo_Api";
import { draftKey, hasDraft } from "./M02_MasterTemplete_Hopyo_Check";
import { deleteHopyo, type HopyoFile, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import type { HopyoSheetBody } from "./M02_MasterTemplete_Hopyo_Sheet";
import {
countLeft,
leftCount,
openLeft,
type LeftMap,
type LeftScope,
} from "./M02_MasterTemplete_Hopyo_Left";
import { draftKey, moveBody, parseDraft } from "./logic/M02_MasterTemplete_Hopyo_Picks";
import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer";
import "./M02_MasterTemplete_Hopyo.css";
@@ -63,16 +48,13 @@ export interface HopyoImport {
root: HTMLElement;
/** 화면 떠남 — 이 화면에서 올리거나 이은 파일 지움(743-1) */
leave: () => void;
/** 고른 뒤 안 만든 확인 창 캐시가 있음 — 다른 엑셀로 옮기기 전 물음 */
/** 확인하고 안 옮긴 판정 고름이 있음 — 다른 엑셀로 옮기기 전 물음 */
dirty: () => boolean;
}
export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
const { layer, file } = a;
const scope: HopyoScope = { layer, owner: null };
const at: LeftScope = { scope, target: scope, key: file.키 };
let nos: number[] = [];
let map: LeftMap | null = null;
let held = true;
let body: HopyoSheetBody | null = null;
@@ -91,88 +73,26 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
window.addEventListener("pagehide", leave);
// ── 머리 막대에 덧붙일 것 ──
const badge = el("span", { attrs: { "data-hopyo-left": "" } });
const finish = createButton({ label: "끝내기", variant: "filled", size: 28, onClick: () => void end() }); // prettier-ignore
finish.dataset.hopyoFinish = "";
const onSk = (번: number): void => body?.showSk(번);
const pickBtn = createButton({
label: L("M02_LeftOpen"),
variant: "ghost",
size: 28,
onClick: () => void openPick(),
});
pickBtn.dataset.hopyoLeftOpen = "";
const layerNote = createBadge(`${layerText(layer)} 층`, "neutral");
/** 안 이은 줄 창 — 호표 번이 아직 없으면 먼저 읽음(빈 번으로 열면 「0」 오표시) */
async function openPick(): Promise<void> {
try {
await ensureNos();
} catch (error) {
return void showToast(why(error), "error");
}
openLeft({ ...at, 파일: file.파일, nos, onSk, ...(map ? { map } : {}) }, paint);
}
async function ensureNos(): Promise<void> {
if (!nos.length) nos = (await readHopyo(scope, file.키)).호표.map((h) => h.번);
}
/** 배지 — 남은 줄 있으면 [끝내기] 막음 · 까닭 · 남은 수 */
function paint(got: LeftMap | null): void {
map = got;
const n = got ? leftCount(got) : null;
const w = body?.skCount();
const skLeft = w?.블록 ? `${L("M02_SkLeft").replace("{n}", String(w.블록)).replace("{m}", String(w.줄))} · ` : ""; // prettier-ignore
finish.disabled = !!n?.줄;
finish.title = n?.줄 ? skLeft + L("M02_LeftBlocked") : "";
badge.dataset.left = n ? String(n.줄) : "";
const tone: BadgeTone = !n ? "info" : n.줄 ? "warning" : "success";
const text = !n
? L("M02_LeftUnknown")
: n.줄
? L("M02_LeftBand").replace("{n}", String(n.줄)).replace("{h}", String(n.호표))
: L("M02_LeftZero");
badge.replaceChildren(createBadge(text, tone));
}
paint(null);
async function recount(): Promise<LeftMap> {
await ensureNos();
const got = await countLeft(at, nos, (i, all) =>
badge.replaceChildren(createText(`${L("M02_LeftCounting")} ${i + 1} / ${all}`, "muted", 14)),
);
paint(got);
return got;
}
/** 확인하고 안 옮긴 줄 · 블록 수(판정 고름 캐시 — 옮기면 캐시에서 빠짐) */
const unmoved = (): number =>
moveBody(parseDraft(sessionStorage.getItem(draftKey(layer, null, file.키))), scope).수;
async function end(): Promise<void> {
finish.disabled = true;
let got: LeftMap;
try {
got = await recount();
} catch (error) {
showToast(why(error), "error");
finish.disabled = false;
return;
}
if (leftCount(got).줄) {
showToast(L("M02_LeftBlocked"), "warning");
return openLeft({ ...at, 파일: file.파일, nos, onSk, map: got }, paint);
}
const loose = [...got.values()].filter(
(x) => !x.one.호표.만든?.확인 && hasDraft(draftKey(scope, file.키, x.one.호표.번)),
).length;
const loose = unmoved();
const w = body?.skCount(); // 안 옮긴 단가산출(일위대가 밖 포함) — 묻기만
const ok = await showConfirmDialog(
`「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출(출처만 남김)과 별칭 장부는 남음 · 같은 엑셀은 필요하면 다시 올림` +
(loose ? ` · ${L("M02_LeftLoose").replace("{n}", String(loose))}` : "") +
`「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 블록 풀이)을 지움 · 만든 일위대가 · 단가산출(출처만 남김)과 별칭 장부는 남음 · 같은 엑셀은 필요하면 다시 올림` +
(loose ? ` · 확인하고 안 옮긴 줄 · 블록 ${loose} 개는 빠짐` : "") +
(w?.블록
? ` · ${L("M02_SkUnmoved").replace("{n}", String(w.블록)).replace("{m}", String(w.밖))}`
: ""), // 780-2 막지 않음
"끝내기",
);
if (!ok) return paint(map);
if (!ok) return void (finish.disabled = false);
try {
const out = (await deleteHopyo(scope, file.키)) as { 만든?: number };
held = false;
@@ -181,7 +101,8 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
showToast(`끝냄 — 만든 일위대가 · 단가산출 ${out.만든 ?? 0} · 올린 파일 지움`, "success");
} catch (error) {
showToast(why(error), "error");
return paint(map);
finish.disabled = false;
return;
}
a.onEnded();
}
@@ -196,9 +117,7 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
파일: file.파일,
target: scope,
canWrite: true,
headExtra: [layerNote, badge, pickBtn, finish],
onSkChange: () => paint(map),
onRead: (got: HopyoView) => void (nos = got.호표.map((h) => h.번)),
headExtra: [layerNote, finish],
});
body = view;
root.replaceChildren(...view.parts);
@@ -208,6 +127,6 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
return {
root,
leave,
dirty: () => nos.some((no) => hasDraft(draftKey(scope, file.키, no))),
dirty: () => unmoved() > 0,
};
}
@@ -1,130 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Kids.ts
* 일위대가 실무자료 만들기 몸 · 하위 호표 함께(PLAN 777-1 · 요구 621-4) — 호표가 다른 호표를 부르면 일위대가 둘
* (하위 일위대가 따로 + 부모 줄 = 하위 사본).
* · bodyFor · wholeFor = 확인 창 캐시 위 체크 제안(짝 = 서버 `M02_Hopyo_Kids.proposal` · 캐시 빈 것)
* · unmadeKids = 고른 호표 줄이 부르는 확인 안 된 하위 호표(깊이 끝까지 · 하위 먼저) — [저장]이 그 캐시대로 함께 만듦
* · buildWithKids = 확인 창 [생성] — 확인 안 된 하위를 그 캐시대로 먼저 → 부모(실패 = 「하위 제N호표 실패」)
* · 캐시 없는 하위도 서버가 부모 저장 때 제안대로 함께 만듦(`M02_Hopyo_Kids.first`) — 화면은 캐시 고름을 살림
* · kidsText = 결과 글 「· 하위 제4호표 SC-11(사본 담음)」 · leftAt = 하위 실패의 남은 줄 = 그 하위 [고르기]
* ========================================================================== */
import { t as L } from "@ui/ui_template_locale";
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
import {
buildHopyo,
readHopyoOne,
type HopyoBuilt,
type HopyoOne,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { bodyOf, draftKey, loadDraft, partsOf, toTarget } from "./M02_MasterTemplete_Hopyo_Check";
type Body = Parameters<typeof buildHopyo>[2];
type Whole = Parameters<typeof buildHopyo>[3];
type From = Parameters<typeof buildHopyo>[1];
/** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */
export function bodyFor(scope: HopyoScope, key: string, one: HopyoOne): Body {
const h = one.호표;
const d = loadDraft(draftKey(scope, key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d);
}
export function wholeFor(scope: HopyoScope, key: string, one: HopyoOne): Whole {
const h = one.호표;
const d = loadDraft(draftKey(scope, key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null;
}
/** (줄, 통째) 몸 한 벌 */
export const bodyPair = (scope: HopyoScope, key: string, one: HopyoOne): [Body, Whole] => [
bodyFor(scope, key, one),
wholeFor(scope, key, one),
];
/** 호표 줄이 부르는 호표 번(체크 안 한 호표 줄 · 캐시에서 고른 호표 대상까지) */
const kidNos = (scope: HopyoScope, key: string, one: HopyoOne): number[] => {
const d = loadDraft(draftKey(scope, key, one.호표.번));
return one.호표.줄.flatMap((l) =>
partsOf(l, d).flatMap((p) => (p.호표 !== undefined ? [p.호표] : [])),
);
};
/** [저장] 할 호표 — 함께 만드는 하위 · 체크 줄(통째) 있음 · 하위를 부름(호표 줄 = 하위 사본) */
export function readyFor(scope: HopyoScope, key: string, one: HopyoOne, picked: number[]): boolean {
const [lines, whole] = bodyPair(scope, key, one);
return (
!picked.includes(one.호표.번) || !!lines.length || !!whole || !!kidNos(scope, key, one).length
);
}
/** 확인 안 된 하위 호표 보기 — 하위 먼저 · `ones` 와 이미 만든 하위(그 아래도) 뺌 */
export async function unmadeKids(
scope: HopyoScope,
key: string,
target: HopyoScope,
ones: HopyoOne[],
): Promise<HopyoOne[]> {
const out: HopyoOne[] = [];
const seen = new Set(ones.map((one) => one.호표.번));
async function visit(one: HopyoOne): Promise<void> {
for (const no of kidNos(scope, key, one)) {
if (seen.has(no)) continue;
seen.add(no);
const kid = await readHopyoOne(scope, key, no, target);
if (kid.호표.만든) continue;
await visit(kid);
out.push(kid);
}
}
for (const one of ones) await visit(one);
return out;
}
/** 하위 실패 — 까닭 「하위 제N호표 실패 — …」 · 남은 줄은 그 하위 몫(`leftAt`) */
function kidFail(error: unknown, no: number): StaleError {
const left = (error as { detail?: { 남은줄?: unknown } } | null)?.detail?.남은줄;
const why = error instanceof Error ? error.message : String(error);
const text = `${L("M02_HopyoKidFail").replace("{n}", String(no))} — ${why}`;
return new StaleError(text, { 까닭: text, 하위: no, 하위남은줄: left ?? [] });
}
/** 확인 창 [생성] — 확인 안 된 하위를 그 캐시대로 먼저 만들고 부모 */
export async function buildWithKids(
target: HopyoScope,
from: From,
줄: Body,
통째?: Whole,
): Promise<HopyoBuilt> {
const scope = { layer: from.층, owner: from.주인 };
const me = await readHopyoOne(scope, from.키, from.번, target);
for (const kid of await unmadeKids(scope, from.키, target, [me])) {
const no = kid.호표.번;
const body = [bodyFor(scope, from.키, kid), wholeFor(scope, from.키, kid)] as const;
await buildHopyo(target, { ...from, 번: no }, ...body).catch((error: unknown) => {
throw kidFail(error, no);
});
}
return buildHopyo(target, from, 줄, 통째);
}
/** 하위 실패면 (그 하위 남은 줄 몸, 하위 번) · 아니면 그대로 — 결과 표 [고르기]가 그 하위를 엶 */
export function leftAt(error: unknown, no: number): [unknown, number] {
const d = (error as { detail?: { 하위?: number; 하위남은줄?: unknown } } | null)?.detail;
return d?.하위 !== undefined ? [{ detail: { 남은줄: d.하위남은줄 } }, d.하위] : [error, no];
}
/** 결과 글 꼬리 — 같은 내용 재사용 · 하위 일위대가(사본 담음 · 함께 만듦) */
export function kidsText(got: HopyoBuilt): string {
const more = got as HopyoBuilt & {
재사용?: boolean;
하위?: { 번: number; 키: string | null; 새로?: boolean }[];
};
const kids = (more.하위 ?? []).map((k) => {
const head = L("M02_HopyoKid")
.replace("{n}", String(k.번))
.replace("{key}", k.키 ?? "");
return ` · ${head}${k.새로 ? ` · ${L("M02_HopyoKidNew")}` : ""}`;
});
return (more.재사용 ? ` · ${L("M02_HopyoReused")}` : "") + kids.join("");
}
@@ -27,7 +27,6 @@ import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
import { dateTimeText } from "../common_util/common_util_time";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
import { unitField } from "./M02_MasterTemplete_Hopyo_CheckRows";
import { formatNumber } from "../common_util/common_util_money";
import "./M02_MasterTemplete_Hopyo.css";
@@ -391,3 +390,18 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
void load();
return root;
}
/** 단위 환산 칸 — 「엑셀 1 {from} =」 입력 「{to}」(옛 확인 창 `CheckRows.unitField` 옮김 · 785-7) */
function unitField(input: HTMLInputElement, v: { from: string; to: string }): HTMLElement {
input.title = L("M02_HopyoUnitTip");
const word = (text: string): HTMLElement =>
el("span", { className: "m02-hopyo__unit-text", text });
return el("span", {
className: "m02-hopyo__unit",
children: [
word(L("M02_HopyoUnitLead").replace("{from}", v.from || "?")),
input,
...(v.to ? [word(v.to)] : []),
],
});
}
@@ -1,327 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Left.ts
* 일위대가 불러오기 안 이은 줄(PLAN 729-3 · 요구서 내역19 1장 8 · 2장 4) — 만들면 남을 엑셀 줄 표 · 줄마다 고름.
* · 줄 표 = 서버 `/hopyo-unlinked`(저장 없음 · 몸 = 확인 창 캐시로 지을 만들기 몸) · 409 `{까닭, 남은줄}` 도 그 표
* · 고름 = 확인 창 캐시(`Hopyo_Check` draft) 대상 · 뺌 — [저장] · [생성] 몸이 그대로 씀(새 저장 꼴 없음)
* [고르기] = 확인 창 고르기 창(후보 · 기초 · 자재 찾기 · 옵션은 서버 `guess`) · [찾기] = 기초 · 노임(LB) · 자재(MT) ·
* [기계] = M01 기계(EQ) · % 줄 = 밑수 비목 · [우리에 없음] = 엑셀 숫자 그대로(사용자가 고른 것)
* · 단가산출 줄(참조 `단산`) = [단가산출 걸음에서 확인 ▸](`onSk` — 마법사 단가산출 칸 그 블록 펼침) — 기초 고르기 · SK 만들기 없음(778-5 D-3 · 줄로만)
* · 못옮김 남은 SK 를 쓰는 줄(729-4 `단산` · `SK` · `못옮김`) = [못옮김 잇기 (n)] → `Hopyo_Lost` 창
* · 남은 수 = 안 만든 호표만(만든 호표는 서버가 남은 줄 있으면 안 만듦) — 마법사 [끝내기] 막음 · 띠
* 계산 · 판정은 서버 — 화면은 보이기 · 고름만.
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { createTable, type Cell } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import {
guessHopyo,
leftHopyo,
leftOf,
readHopyoOne,
type BuildLine,
type HopyoLeft,
type HopyoLine,
type HopyoOne,
type HopyoPart,
type HopyoScope,
type LinkBase,
isElem,
} from "./M02_MasterTemplete_Hopyo_Api";
import { bodyOf, draftKey, loadDraft, saveDraft, toTarget } from "./M02_MasterTemplete_Hopyo_Check";
import { elemChoices } from "./M02_MasterTemplete_Hopyo_Compare";
import { kindsOf, openHopyoChoose } from "./M02_MasterTemplete_Hopyo_Choose";
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
import { openLost } from "./M02_MasterTemplete_Hopyo_Lost";
/** 파일 층 · 만들 층 · 파일 키 */
export interface LeftScope {
scope: HopyoScope;
target: HopyoScope;
key: string;
}
/** 호표마다 남은 줄(그 호표 읽은 것과 함께) */
export type LeftMap = Map<number, { one: HopyoOne; left: HopyoLeft[] }>;
const NONE = "우리에 없음";
const BASES: LinkBase[] = ["노무비", "재료비", "경비"];
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
/** 남은 줄 수 · 호표 수 */
export const leftCount = (map: LeftMap): { 줄: number; 호표: number } => {
const nos = [...map.values()].filter((x) => x.left.length);
return { 줄: nos.reduce((n, x) => n + x.left.length, 0), 호표: nos.length };
};
/** 캐시로 지을 만들기 몸 — 확인 창 · [저장]과 같은 규칙(통째 호표는 줄 없음) */
export function draftLines(at: LeftScope, one: HopyoOne): BuildLine[] {
const h = one.호표;
const d = loadDraft(draftKey(at.scope, at.key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d);
}
/** 호표 하나 다시 셈 — 서버 미리 보기(만든 호표 = 남은 줄 없음) */
async function recount(at: LeftScope, no: number, map: LeftMap): Promise<void> {
const one = await readHopyoOne(at.scope, at.key, no, at.target);
const from = { 층: at.scope.layer, 주인: at.scope.owner, 키: at.key, 번: no };
const made = !!one.호표.만든?.확인;
const left = made ? [] : (await leftHopyo(at.target, from, draftLines(at, one))).남은줄;
map.set(no, { one, left });
}
/** 호표 여럿 남은 줄(차례로 · `step` = 몇째) — 실패한 호표는 까닭 글로 알림 */
export async function countLeft(
at: LeftScope,
nos: number[],
step?: (done: number, all: number) => void,
): Promise<LeftMap> {
const map: LeftMap = new Map();
for (const [i, no] of nos.entries()) {
step?.(i, nos.length);
try {
await recount(at, no, map);
} catch (error) {
showToast(`제${no}호표 — ${why(error)}`, "warning");
}
}
return map;
}
/** 캐시에 한 줄 고름 — 대상 또는 뺌 「우리에 없음」(parts = null · 체크 켬) */
export function put(at: LeftScope, no: number, 차례: number, parts: HopyoPart[] | null): void {
const k = draftKey(at.scope, at.key, no);
const d = loadDraft(k);
d.체크[차례] = true;
if (parts) {
d.대상[차례] = parts;
delete d.뺌[차례];
} else {
d.뺌[차례] = NONE;
delete d.대상[차례];
}
saveDraft(k, d);
}
/** 고른 것 글 — 캐시 대상 · 뺌 */
function pickedText(at: LeftScope, no: number, 차례: number): string {
const d = loadDraft(draftKey(at.scope, at.key, no));
if (d.뺌[차례]) return d.뺌[차례];
const parts = d.대상[차례] ?? [];
return parts
.map((p) => p.이름 ?? p.로직 ?? p.자재 ?? p.노임 ?? p.기계 ?? p.비율 ?? "")
.filter(Boolean)
.join(" + ");
}
/** 남은 줄 창 — `map` 이 없으면 `nos` 를 서버에 물어 채움 · 닫을 때 `onClose(map)` */
export function openLeft(
at: LeftScope & { 파일: string; nos?: number[]; map?: LeftMap; onSk?: (block: number) => void },
onClose?: (map: LeftMap) => void,
): void {
const map: LeftMap = at.map ?? new Map();
openModal({
title: `${L("M02_LeftTitle")} — ${at.파일}`,
closeLabel: L("M02_Close"),
width: "1100px",
wide: true,
dialogClass: "m02-modal",
beforeClose: () => {
onClose?.(map);
return true;
},
mount: (body, close) => {
const head = el("div");
const list = el("div");
body.append(createText(L("M02_LeftHint"), "hint", 14), head, list);
const again = async (no: number): Promise<void> => {
try {
await recount(at, no, map);
} catch (error) {
showToast(why(error), "error");
}
draw();
};
const choose = (no: number, line: HopyoLine): void =>
openHopyoChoose({
line,
done: (ts) => {
const parts = ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null }));
guessHopyo(at.scope, at.key, no, line.차례, parts.map(toTarget))
.then((g) => (Array.isArray(g.대상) ? g.대상 : parts))
.catch(() => parts)
.then((got) => {
put(at, no, line.차례, got);
void again(no);
});
},
});
const link = (no: number, 차례: number, part: Partial<HopyoPart>, name: string): void => {
put(at, no, 차례, [{ ...part, 이름: name, 입력값: {}, 배: 1 }]);
void again(no);
};
const find = (no: number, line: HopyoLine): void =>
openFindPicker({
layer: at.target.layer,
owner: at.target.owner,
title: `${line.이름} ${line.규격}`.trim(),
kinds: kindsOf(line), // 736-2 줄 갈래대로 탭(노임 = 인력 · 기계 = 기계 · 자재 = 재료)
logic: (x) => link(no, line.차례, { 로직: x.키 }, x.이름),
element: (e) =>
link(no, line.차례, e.종류 === "인력" ? { 노임: e.코드 } : { 자재: e.코드 }, e.이름),
machine: (e) => link(no, line.차례, { 기계: e.코드 }, e.이름),
});
const actions = (no: number, row: HopyoLeft, line: HopyoLine | undefined): HTMLElement => {
const box = el("div", { className: "ui-toolbar" });
const block = (line as { 참조?: { 종류?: string; 번?: number } } | undefined)?.참조;
if (at.onSk && block?.종류 === "단산" && block.번 !== undefined) {
const go = createButton({
label: L("M02_LeftSkGo"),
size: 24,
onClick: () => {
close();
onClose?.(map);
at.onSk!(block.번!);
},
});
go.dataset.leftSk = String(block.번);
box.append(go);
}
if (row.못옮김?.length && row.단산 !== undefined && row.SK) {
const lost = createButton({
label: `${L("M02_LostTitle")} (${row.못옮김.length})`,
size: 24,
onClick: () =>
openLost({ scope: at.scope, key: at.key, 번: row.단산!, sk: row.SK!, 이름: row.이름,
lost: row.못옮김!, done: () => void again(no) }), // prettier-ignore
});
lost.dataset.leftLost = String(row.단산);
box.append(lost);
}
const elems = elemChoices(row.후보.filter(isElem), (c) => line && link(no, row.차례, { [c.갈래]: c.키 }, c.이름)); // prettier-ignore
if (elems) box.append(elems);
if (row.갈래 === "%" && line) {
const base = createSelectField({
options: [{ value: "", text: L("M02_LeftBase") }].concat(
BASES.map((b) => ({ value: b, text: b })),
),
compact: true,
size: 24,
ariaLabel: L("M02_LeftBase"),
onChange: (v) => v && link(no, row.차례, { 비율: v as LinkBase }, v),
});
box.append(base.root);
}
if (line && row.갈래 !== "%")
box.append(
createButton({ label: L("M02_LeftChoose"), size: 24, variant: "ghost", onClick: () => choose(no, line) }),
createButton({ label: L("M02_LeftFind"), size: 24, variant: "ghost", onClick: () => find(no, line) }),
); // prettier-ignore
const none = createButton({
label: NONE,
size: 24,
variant: "ghost",
onClick: () => {
put(at, no, row.차례, null);
void again(no);
},
});
none.title = L("M02_LeftNoneTip");
box.append(none);
return box;
};
const draw = (): void => {
const n = leftCount(map);
head.replaceChildren(
n.줄
? createBanner({
tone: "warning",
text: L("M02_LeftBand").replace("{n}", String(n.줄)).replace("{h}", String(n.호표)),
})
: createBanner({ tone: "success", text: L("M02_LeftZero") }),
);
head.dataset.left = String(n.줄);
const rows: Cell[][] = [];
for (const [no, { one, left }] of [...map].sort((a, b) => a[0] - b[0]))
for (const row of left) {
const line = one.호표.줄.find((l) => l.차례 === row.차례);
rows.push([
`제${no}호표`,
row.이름,
row.규격,
`${row.수량 ?? ""} ${row.단위}`.trim(),
createBadge(row.갈래, "warning"),
row.까닭,
pickedText(at, no, row.차례),
actions(no, row, line),
]);
}
list.replaceChildren(
createTable(
[
{ label: L("M02_LeftHopyo"), width: "88px" },
{ label: L("M02_SangeunColName") },
{ label: L("M02_LeftSpec") },
{ label: L("M02_SangeunColQty"), width: "80px" },
{ label: L("M02_LeftKind"), width: "80px" },
{ label: L("M02_LeftWhy") },
{ label: L("M02_LeftPicked") },
{ label: L("M02_LeftPick") },
],
rows,
{ density: "data", empty: L("M02_LeftZero") },
),
);
};
if (at.map) draw();
else {
list.replaceChildren(createText(L("M02_LeftCounting"), "muted", 14));
void countLeft(at, at.nos ?? [], (i, all) => {
list.replaceChildren(
createText(`${L("M02_LeftCounting")} ${i + 1} / ${all}`, "muted", 14),
);
}).then((got) => {
for (const [k, v] of got) map.set(k, v);
draw();
});
}
},
});
}
/** 만들기 실패 까닭이 남은 줄이면 그 표를 엶(참) · 아니면 거짓(부르는 쪽이 까닭 글) */
export function openLeftOf(
error: unknown,
at: LeftScope & { 파일: string; 번: number },
onClose?: (map: LeftMap) => void,
): boolean {
const left = leftOf(error);
if (!left) return false;
void readHopyoOne(at.scope, at.key, at.번, at.target).then(
(one) => openLeft({ ...at, map: new Map([[at.번, { one, left }]]) }, onClose),
(e) => showToast(why(e), "error"),
);
return true;
}
/** 결과 표 칸 단추 — 남은 줄로 막힌 호표면 [고르기 (n)] · 아니면 null */
export function leftButton(
error: unknown,
at: LeftScope & { 파일: string; 번: number },
): HTMLButtonElement | null {
const left = leftOf(error);
if (!left) return null;
const go = createButton({
label: `${L("M02_LeftChoose")} (${left.length})`,
size: 24,
onClick: () => void openLeftOf(error, at),
});
go.dataset.leftOpen = String(at.번);
return go;
}
@@ -1,93 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Lost.ts
* 단가산출 줄 찾기 단추 · 옛 못옮김 창(PLAN 729-4 · 778-5 D-5).
* · `lineFind` = 줄 묶음대로 찾아 넣기 창(`Find_Pick` 탭 하나 · 노임 = 인력 · 기계 = 기계 · 자재 = 재료) →
* 고름 `{노임|기계|자재: 키}` — 마법사 단가산출 줄 표(`Hopyo_SkLines`)가 씀
* · `openLost` = 옛 SK(기초 + 엑셀 차이)의 남은 못옮김 보기만 — 잇기는 마법사 단가산출 칸에서 그 블록 줄 고름 →
* [확인] → [확인한 것 옮기기](서버 778-5 D-3 · 옛 `바탕` 몸 걷음)
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { createTable } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
import type { HopyoScope, SangeunBuilt, SangeunLost } from "./M02_MasterTemplete_Hopyo_Api";
import type { SkPick } from "./logic/M02_MasterTemplete_Hopyo_SkPick";
const KIND = { 노임: "labor", 기계: "machine", 자재: "material" } as const;
/** 줄 찾기 단추 — 묶음 갈래 탭 하나 · 고르면 `done({갈래: 키})` */
export function lineFind(opt: {
scope: HopyoScope;
갈래: "노임" | "기계" | "자재";
이름: string;
규격?: string;
done: (p: SkPick) => void;
}): HTMLButtonElement {
const go = createButton({
label: opt.갈래 === "기계" ? L("M02_LeftMachine") : L("M02_LeftFind"),
size: 24,
variant: "ghost",
onClick: () =>
openFindPicker({
layer: opt.scope.layer,
owner: opt.scope.owner,
title: `${opt.이름} ${opt.규격 ?? ""}`.trim(),
kinds: [KIND[opt.갈래]],
query: opt.이름.split(/[\s(]/)[0] ?? "",
logic: () => showToast(L("M02_GridLinkNoLogic"), "info"),
element: (e) => opt.done({ [opt.갈래]: e.코드 } as SkPick),
machine: (e) => opt.done({ 기계: e.코드 }),
}),
});
go.dataset.skFind = opt.갈래;
return go;
}
export interface LostOptions {
scope: HopyoScope;
key: string;
/** 블록 번(단산N) */
번: number;
/** 그 블록 SK 키 */
sk: string;
이름?: string;
lost: SangeunLost[];
/** 옛 꼴 그대로(부르는 쪽) — 보기만이라 안 부름 */
done?: (got: SangeunBuilt) => void;
}
/** 옛 못옮김 창 — 보기만 · 잇기 = 마법사 단가산출 칸 */
export function openLost(opt: LostOptions): void {
openModal({
title: `${L("M02_LostTitle")} — 단산${opt.번} ${opt.이름 ?? ""}`.trim(),
closeLabel: L("M02_Close"),
width: "960px",
dialogClass: "m02-modal",
mount: (body) => {
const rows = opt.lost.map((x) => [
x.갈래,
x.이름,
x.규격 ?? "",
`${x.수량 ?? ""} ${x.단위 ?? ""}`.trim(),
x.글 ?? "",
]);
body.append(
createText(L("M02_LostHint").replace("{n}", String(opt.번)), "hint", 14),
createTable(
[
{ label: L("M02_LeftKind"), width: "56px" },
{ label: L("M02_SangeunColName") },
{ label: L("M02_LeftSpec") },
{ label: L("M02_SangeunColQty"), width: "90px" },
{ label: L("M02_LeftWhy") },
],
rows,
{ density: "data", empty: L("M02_LostZero") },
),
);
},
});
}
@@ -1,104 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Pick.ts
* [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4 · 338-3) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(제안 n/m) ·
* 고름 = 문서 층에 확인된 사전조합이 있으면 그것을 바로 참조 · 없으면 확인 창 모달(`Hopyo_Check` · 늦게 불러옴) →
* [확인하고 사전조합 만들기] → `lib.pick` 참조 줄(별칭 = 호표 품명) · 확인 없이 만들지 않음 ·
* 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 · 고른 호표마다 참조 줄). 프로젝트 층엔 없음.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api";
import type { LibraryPick, PickExtra } from "./M02_MasterTemplete_Labor_Pick";
import { tc } from "./M02_MasterTemplete_Labor_Text";
export const HOPYO_KIND = "hopyo";
const LAYER_TEXT: Record<string, string> = { system: "마스터", company: "회사", personal: "개인" };
const ALIAS_MAX = 40; // 별칭 상한(44-6 · 서버 `ALIAS_MAX`)
/** 서고 고르기에 「실무 호표」 덧줄 · 종류 고르기(단가산출 · 사전조합 · 실무 호표)를 붙임 */
export function withHopyo(lib: LibraryPick): LibraryPick {
if (lib.layer === "project") return lib;
const scope = { layer: lib.layer, owner: lib.owner };
return {
...lib,
sangeun: true, // 717-4 — 일위대가 [로직 추가] 「단가산출」 탭
kindFilter: lib.kindFilter ?? [
{ value: "logic", text: tc("Kind_Logic") },
{ value: "precombo", text: tc("Kind_Combo") },
{ value: HOPYO_KIND, text: "실무 호표" },
],
more: async () => {
const rows = await listHopyoPicks(scope);
const files = new Map<string, HopyoPickRow[]>();
for (const row of rows) {
const key = `${row.층}|${row.주인 ?? ""}|${row.키}`;
files.set(key, [...(files.get(key) ?? []), row]);
}
return [
...rows.map((row) => hopyoExtra(row, lib)),
...[...files].map(([key, list]) => sheetExtra(key, list[0], list.length, lib)),
];
},
};
}
/** 파일 하나 — 엑셀 보기 창에서 여러 호표를 골라 한 번에(만드는 층 = 문서 층) */
export function sheetExtra(key: string, row: HopyoPickRow, n: number, lib: LibraryPick): PickExtra {
return {
kind: HOPYO_KIND,
key: `${key}|엑셀`,
tag: "실무 호표",
name: "엑셀로 보기 · 여러 호표",
sub: `${row.파일} · ${LAYER_TEXT[row.층] ?? row.층}`,
unit: `호표 ${n}`,
onPick: () =>
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) =>
m.openHopyoSheet({
scope: { layer: row.층, owner: row.주인 },
key: row.키,
파일: row.파일,
canWrite: true,
target: { layer: lib.layer, owner: lib.owner },
onImport: (got, h) =>
lib.pick(got.참조, got.이름, h.이름.slice(0, ALIAS_MAX).trim() || null),
}),
),
};
}
/** 실무 호표 한 줄 */
export function hopyoExtra(row: HopyoPickRow, lib: LibraryPick): PickExtra {
return {
kind: HOPYO_KIND,
key: `${row.층}|${row.주인 ?? ""}|${row.키}|${row.번}`,
tag: "실무 호표",
name: [row.이름, row.규격].filter(Boolean).join(" "),
sub: `${row.파일} · 제${row.번}호표 · ${LAYER_TEXT[row.층] ?? row.층}`,
unit: `${row.단위} · ${row.확인 ? `확인 ${row.확인.키}` : `제안 ${row.맞춤[0]}/${row.맞춤[1]}`}`,
onPick: () => void pickHopyo(row, lib),
};
}
/** 고름 — 확인된 사전조합이면 바로 참조 · 아니면 확인 창 모달 → 만든 뒤 참조 */
export async function pickHopyo(row: HopyoPickRow, lib: LibraryPick): Promise<void> {
const alias = row.이름.slice(0, ALIAS_MAX).trim() || null;
if (row.확인) {
showToast(`「${row.이름}」 → 확인한 ${L("M02_PrecomboTitle")} ${row.확인.키}`, "success");
return lib.pick(row.확인, row.이름, alias);
}
try {
const check = await import("./M02_MasterTemplete_Hopyo_Check");
check.openHopyoCheck({
title: `확인 — ${row.파일} 제${row.번}호표 ${row.이름}`,
scope: { layer: row.층, owner: row.주인 },
key: row.키,
no: row.번,
target: { layer: lib.layer, owner: lib.owner },
canWrite: true,
onBuilt: (got) => lib.pick(got.참조, got.이름, alias),
});
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}
@@ -1,36 +1,33 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Sheet.ts
* 일위대가 실무자료 엑셀 보기 몸(PLAN 329 · 338-3 · 359-1 · 359-2 · 785-4) — 본 칸 불러오기 보기(`Hopyo_Import`)가 바로 담음 ·
* 머리 막대 = 시트 · 호표 수 · 검산 · 제안 n / 줄 m · 숫자 판정 셈 중 + 부르는 쪽 덧붙임(`headExtra` — 층 · 안 이은 줄 · [끝내기]) ·
* 머리 막대 = 시트 · 호표 수 · 검산 · 제안 n / 줄 m(1차 판정) · 블록 풀이 셈 중 + 부르는 쪽 덧붙임(`headExtra` — 층 · [끝내기]) ·
* 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 셋 일위대가표 · 단가산출표 · 단가산출 상세 ·
* 칠 · 범례 · 뛰기 · 뒤로 = `Hopyo_SheetNav` · 779-4 · 같은 파일 몸은 다시 씀 = 그 자리로 뜀 · 779-6) ·
* 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장] ·
* 칠 · 범례 · 뛰기 · 뒤로 = `Hopyo_SheetNav` · 779-4 · 같은 파일 격자 다시 씀 · 779-6) ·
* 오른쪽 = 호표 목록(제N호표 [수정] · 찾기 · 제안 n/m — 다 제안이면 녹색 · 뒤 셈 중이면 3 초마다 다시) ·
* 단가산출표 · 상세 탭이면 단가산출 목록으로 바뀜(780-2 · `Hopyo_SheetSk`).
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 머리 행이면 영역 고름
* · [수정] = 시트 아래 줄 판(785-6 `Hopyo_Rows` · 줄 누름과 따로) · 단가산출 탭 블록 = 같은 자리 블록 판 ·
* 오른쪽 판 아래 [옮기기](확인한 줄 · 블록 · 고름 캐시 `createJudgeState` 한 벌)
* · [저장] · [모두 고정으로 들여오기] = `Hopyo_SheetMake`
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 저장은 됨
* · `buildHopyoSheet` = 몸(머리 · 띠 · 시트 + 목록) · `openHopyoSheet` = 모달 껍데기([로직 추가] 「실무 호표」 길 — 785-7 이 지움)
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록은 됨
* · `buildHopyoSheet` = 몸(머리 · 띠 · 시트 + 목록) · 옛 [저장] · [모두 고정으로 들여오기] · 모달 껍데기는 785-7 에서 지움
* (만들기 = [옮기기] · 모두 고정 서버 길은 B09 · 붙여넣기가 씀)
* ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createInputField } from "@ui/ui_template_input";
import { openModal } from "@ui/ui_template_modal";
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import {
POLL_MS,
readHopyo,
type HopyoBrief,
type HopyoBuilt,
type HopyoGrid,
type HopyoSpan,
type HopyoView,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { POLL_MS } from "./M02_MasterTemplete_Hopyo_Check";
import {
createJudgeMove,
createJudgeRows,
@@ -39,17 +36,18 @@ import {
import { lineOf } from "./logic/M02_MasterTemplete_Hopyo_Picks";
import * as Nav from "./M02_MasterTemplete_Hopyo_SheetNav";
import { createSheetSk, type SheetSk } from "./M02_MasterTemplete_Hopyo_SheetSk";
import { note, why, wireMake } from "./M02_MasterTemplete_Hopyo_SheetMake";
import "./M02_MasterTemplete_Hopyo.css";
export { buildOrder } from "./M02_MasterTemplete_Hopyo_SheetMake";
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const note = (text: string): HTMLElement =>
el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } });
export interface HopyoSheetOptions {
/** 실무자료 파일 층 */
scope: HopyoScope;
key: string;
파일: string;
/** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */
/** 만들 층 쓰기 권한 — 없으면 옮기기 없음(보기만) */
canWrite: boolean;
/** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */
target?: HopyoScope;
@@ -57,8 +55,6 @@ export interface HopyoSheetOptions {
호표?: number;
/** 처음 보일 단가산출 행(330-3 근거 [엑셀 보기]) */
단산행?: number | [number, number];
/** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */
onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void;
/** 창을 닫았음(좌측 파일 줄 고름 표시) */
onClose?: () => void;
/** 머리 막대 끝에 덧붙일 것(본 칸 불러오기 보기 — 층 · 안 이은 줄 · [끝내기]) */
@@ -74,8 +70,6 @@ export interface HopyoSheetBody {
parts: HTMLElement[];
/** 몸을 걷음(마지막 창 손잡이 · 격자 · 스프레드시트) */
destroy: () => void;
/** 모달에서 앞으로 꺼낸 몸을 제자리로 — 했으면 참(닫기 대신) */
lower: () => boolean;
/** 안 옮긴 단가산출 블록 수(판정 전 null) */
skCount: () => { 블록: number; 밖: number; 줄: number } | null;
/** 그 단가산출 블록으로 뜀 + 줄 판 엶 */
@@ -84,8 +78,6 @@ export interface HopyoSheetBody {
const ILWI = "일위대가";
const DANSAN = ["단가산출", "단가산출근거"]; // 형식별 블록 시트(EST · STmate · 역할 id 없는 옛 격자)
/** 열린 창 손잡이(779-6) — 파일마다 · 다시 부르면 그 창이 앞으로 와 그 자리로 뜀 */
const opened = new Map<string, (o: HopyoSheetOptions) => void>();
const sheetId = (o: HopyoSheetOptions): string =>
`${o.scope.layer}:${o.scope.owner ?? ""}:${o.key}`;
@@ -125,32 +117,12 @@ export function lastColumn(sheet: Pick<Sheet, "칸" | "열">): string {
export const rowsText = (행: number | [number, number]): string =>
typeof 행 === "number" ? `B${행}:F${행}` : `B${행[0]}:F${행[1]}`;
/** 모달 껍데기 — 같은 파일 몸이 이미 있으면(본 칸 · 모달) 그 몸을 그 자리로 뜀 */
export function openHopyoSheet(opt: HopyoSheetOptions): void {
const id = sheetId(opt);
if (opened.has(id)) return opened.get(id)!(opt);
const view = buildHopyoSheet(opt);
openModal({
title: `엑셀 보기 — ${opt.파일}`,
closeLabel: "닫기",
wide: true,
dialogClass: "m02-modal m02-hopyo-sheet__dialog",
beforeClose: async () => {
if (view.lower()) return false; // 꺼낸 창 = 닫기 대신 제자리로
view.destroy();
return true;
},
mount: (body) => body.append(...view.parts),
});
}
export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
const id = sheetId(opt);
let sheet: SpreadsheetHandle | null = null;
let nav: Nav.SheetNav | null = null;
let sk: SheetSk | null = null; // 단가산출 탭 오른쪽 판(780-2)
let detail = ""; // 단가산출 상세 시트 id
let above: Element[] = []; // 앞으로 꺼냈을 때 원래 위에 있던 창
let closed = false;
let briefs: HopyoBrief[] = [];
let spans: HopyoSpan[] = [];
@@ -158,7 +130,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
let last = "O";
let at = { 시트: "", 칸: "", 범위: "" }; // 시트 고름
let current: number | null = opt.호표 ?? null;
const checked = new Set<number>();
const items = new Map<number, HTMLElement>();
const target = opt.target ?? opt.scope;
const state = createJudgeState(opt.scope, opt.key); // 판정 고름 캐시(785-6)
@@ -203,13 +174,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
const search = searchField.input;
const list = el("div", { className: "m02-hopyo-sheet__list" });
const count = note("");
const go = createButton({ label: "저장", variant: "filled", size: "sm" });
go.disabled = true;
go.setAttribute("data-hopyo-save", "");
const fixAll = createButton({ label: "모두 고정으로 들여오기", size: "sm" });
fixAll.setAttribute("data-hopyo-all-fixed", "");
fixAll.disabled = true; // 엑셀을 다 읽은 뒤 켬
const result = el("div", { className: "m02-hopyo-sheet__result" });
const move = createJudgeMove({
file: opt.scope,
key: opt.key,
@@ -224,10 +188,9 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
searchField.root,
el("div", {
className: "ui-toolbar m02-hopyo__bar",
children: [count, ...(opt.canWrite ? [fixAll, go] : [])],
children: [count],
}),
list,
result,
move.root,
],
});
@@ -235,7 +198,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
const finish = (): void => {
if (closed) return;
closed = true;
opened.delete(id);
closeLines();
offPaint();
move.destroy();
@@ -245,25 +207,16 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
opt.onClose?.();
};
const paintGo = (): void => {
go.querySelector(".ui-btn__label")!.textContent = checked.size
? `저장 (${checked.size})`
: "저장";
go.disabled = !checked.size;
};
/** 머리 막대 — 시트 · 호표 수 · 검산 · 제안 · 셈 중 + 덧붙임 */
function paintHead(file: HopyoView): void {
const wrong = file.검산.어긋남;
const c = file.셈 && !file.셈.끝 ? file.셈 : null;
const busy = !!file.셈 && !file.셈.끝;
head.replaceChildren(
note(`시트 ${file.시트}`),
note(`호표 ${file.호표수}`),
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`),
...(c
? [note(`숫자 판정 셈 중 ${c.한}/${c.모두 ?? "?"}${c.까닭 ? ` · ${c.까닭}` : ""}`)]
: []),
...(busy ? [note(`${file.셈?.까닭 ?? "블록 풀이"} 셈 중`)] : []),
...(opt.headExtra ?? []),
);
opt.onRead?.(file);
@@ -288,11 +241,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
const text = spanText(span, last);
if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi);
}
opened.set(id, (o) => {
above = Nav.raise(left); // 모달이 아니면 [] — 그 자리로만 뜀
if (o.단산행 !== undefined && detail) nav?.jumpTo(detail, o.단산행);
else if (o.호표 !== undefined) focus(o.호표);
});
/** [수정] — 그 호표 자리 + 시트 아래 줄 판(줄 누름과 따로) */
const editButton = (h: HopyoBrief): HTMLElement => {
@@ -330,23 +278,12 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
);
list.replaceChildren(
...shown.map((h) => {
const box = createCheckbox("", {
checked: checked.has(h.번),
onChange: (on) => {
if (on) checked.add(h.번);
else checked.delete(h.번);
paintGo();
},
});
box.root.hidden = !opt.canWrite;
box.root.addEventListener("click", (e) => e.stopPropagation());
const linked = note(linkText(h));
linked.classList.toggle("is-linked", allLinked(h));
const row = el("div", {
className: `m02-hopyo-sheet__item${h.번 === current ? " is-current" : ""}`,
attrs: { "data-hopyo-no": String(h.번), title: `${h.이름} ${h.규격}`.trim() },
children: [
box.root,
el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }),
editButton(h),
el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }),
@@ -438,7 +375,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
]);
if (closed) return;
briefs = file.호표;
fixAll.disabled = !briefs.length;
spans = grid instanceof Error ? [] : grid.호표;
paintHead(file);
paintList();
@@ -456,25 +392,10 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
}
}
wireMake({
scope: opt.scope,
key: opt.key,
파일: opt.파일,
target,
...(opt.onImport ? { onImport: opt.onImport } : {}),
checked,
briefs: () => briefs,
closed: () => closed,
go,
fixAll,
result,
paintGo,
});
void load();
return {
parts: [head, warn, el("div", { className: "m02-hopyo-sheet", children: [col, right] })],
destroy: finish,
lower: () => Nav.lower(left, above.splice(0)),
skCount: () => sk?.count() ?? null,
showSk: (번) => sk?.show(번),
};
@@ -1,256 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_SheetMake.ts
* 엑셀 보기 몸의 만들기 셋(PLAN 785-4 · `Hopyo_Sheet` 에서 옮김 — 줄 바꿈 없음) —
* · [저장] = 체크한 호표 + 확인 안 된 하위 호표(777-1 · `Hopyo_Kids`)마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로
* 하위 호표부터 차례로 `/hopyo-build` — 물음 한 번 · 체크 줄 없는 고른 호표 건너뜀 · 하위 실패 = 그 하위 [고르기] ·
* 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
* · [모두 고정으로 들여오기](705-3) = 물음 한 번 → 서버 `/hopyo-build-all`(호표 모두 · 하위 먼저 · 엑셀 숫자 그대로 ·
* 이미 만든 호표 그대로) → 알림 「호표 n · 줄 m · 품셈 줄 a · 고정 줄 b · 빠짐 0 · 엑셀 합계 = 우리 합계 n/n」 + 다음 걸음
* ========================================================================== */
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import {
buildHopyo,
buildHopyoAll,
readHopyoOne,
type HopyoBrief,
type HopyoBuilt,
type HopyoBuiltAll,
type HopyoOne,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import {
draftKey,
hasDraft,
HOPYO_BUILT,
loadDraft,
partsOf,
} from "./M02_MasterTemplete_Hopyo_Check";
import { bodyPair, kidsText, leftAt, readyFor, unmadeKids } from "./M02_MasterTemplete_Hopyo_Kids";
import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose";
import { leftButton } from "./M02_MasterTemplete_Hopyo_Left";
import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink";
import { formatPrice } from "../common_util/common_util_money";
export const why = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
export const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "" : formatPrice(v);
export const note = (text: string): HTMLElement =>
el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } });
/** 만들 차례 — 하위 호표(호표 참조 줄이 가리키는 고른 호표)부터 · 순환은 남은 차례대로 */
export function buildOrder(ones: { 번: number; 아래: number[] }[]): number[] {
const left = new Map(ones.map((x) => [x.번, x.아래.filter((n) => n !== x.번)]));
const out: number[] = [];
while (left.size) {
const ready = [...left].find(([, kids]) => kids.every((n) => !left.has(n)));
const no = ready ? ready[0] : [...left.keys()][0];
out.push(no);
left.delete(no);
}
return out;
}
export interface MakeContext {
scope: HopyoScope;
key: string;
파일: string;
/** 만들 층 */
target: HopyoScope;
onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void;
checked: Set<number>;
briefs: () => HopyoBrief[];
closed: () => boolean;
go: HTMLButtonElement;
fixAll: HTMLButtonElement;
result: HTMLElement;
paintGo: () => void;
}
/** [저장] · [모두 고정으로 들여오기] 누름을 이음 */
export function wireMake(c: MakeContext): void {
const { scope, key, target, go, fixAll, result } = c;
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
el("table", {
className: "ui-general-block__table m02-hopyo__table",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: rows }),
],
});
const body = (one: HopyoOne) => bodyPair(scope, key, one);
const headCell = (no: number, name: string): HTMLElement =>
el("td", { text: `제${no}호표 ${name}`.trim() });
/** [저장] — 물음 한 번 → 고른 호표마다 서버 보기 → 체크 줄 있는 것만 만들기(체크 줄 없으면 건너뜀) */
async function save(): Promise<void> {
const nos = [...c.checked].sort((a, b) => a - b);
const fresh = nos.filter((no) => !hasDraft(draftKey(scope, key, no))).length;
const ask =
`고른 호표 ${nos.length}개를 체크한 줄로 ${L("M02_PrecomboTitle")} 생성` +
(fresh ? ` — 확인 창에서 안 고친 ${fresh}개는 서버 제안 체크 줄` : "");
if (!(await showConfirmDialog(ask, "저장"))) return;
go.disabled = true;
const ones: HopyoOne[] = [];
const skipped: HTMLElement[] = [];
showLoadingOverlay();
try {
for (const no of nos) {
if (c.closed()) return;
try {
ones.push(await readHopyoOne(scope, key, no, target));
} catch (error) {
skipped.push(el("tr", { children: [headCell(no, ""), el("td", { text: why(error) })] }));
}
}
ones.push(...(await unmadeKids(scope, key, target, ones))); // 777-1 하위 함께
} finally {
hideLoadingOverlay();
c.paintGo();
}
const ready = ones.filter((one) => readyFor(scope, key, one, nos)); // 하위 함께
for (const one of ones)
if (!ready.includes(one))
skipped.push(
el("tr", {
children: [
headCell(one.호표.번, one.호표.이름),
el("td", { text: "건너뜀 — 체크 줄 없음" }),
],
}),
);
await makeAll(ready, skipped);
}
/** 하위 호표부터 차례로 만들기 — 하위 확인 전(409)은 안내 */
async function makeAll(ones: HopyoOne[], skipped: HTMLElement[]): Promise<void> {
const by = new Map(ones.map((one) => [one.호표.번, one]));
const order = buildOrder(
ones.map((one) => {
const d = loadDraft(draftKey(scope, key, one.호표.번));
const kids = one.호표.줄.flatMap((l) =>
partsOf(l, d).flatMap((p) => (p.호표 !== undefined ? [p.호표] : [])),
);
return { 번: one.호표.번, 아래: kids };
}),
);
const rows: HTMLElement[] = [];
let ok = 0;
showLoadingOverlay();
try {
for (const no of order) {
const one = by.get(no)!;
const h = c.briefs().find((x) => x.번 === no) ?? one.호표;
const cell = headCell(no, h.이름);
try {
const from = { 층: scope.layer, 주인: scope.owner, 키: key, 번: no };
const got = await buildHopyo(target, from, ...body(one));
ok += 1;
c.onImport?.(got, h);
rows.push(
el("tr", {
attrs: { "data-built": got.참조.키 },
children: [
cell,
el("td", { text: `${got.참조.키} · ${builtWord(got)}${kidsText(got)}` }),
el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }),
el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }),
],
}),
);
} catch (error) {
const [err, kid] = leftAt(error, no); // 하위 실패 = 그 하위 남은 줄
const left = leftButton(err, { scope, target, key, 파일: c.파일, 번: kid }); // prettier-ignore
const text = `실패 — ${why(error)}`;
rows.push(el("tr", { children: [cell, el("td", { text }), ...(left ? [el("td", { children: [left] })] : [])] })); // prettier-ignore
}
}
} finally {
hideLoadingOverlay();
}
if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));
result.replaceChildren(
table(
["호표", `${L("M02_PrecomboTitle")}`, "빠진 줄", "값 차이(참고)"],
[...rows, ...skipped],
),
);
const all = order.length + skipped.length;
showToast(`만듦 ${ok} / ${all}`, ok === all ? "success" : "warning");
}
go.addEventListener("click", () => void save());
/** 들여오기 알림 글(705-3) */
const allText = (got: HopyoBuiltAll): string =>
`호표 ${got.호표} · 줄 ${got.줄} · 품셈 줄 ${got.품셈} · 고정 줄 ${got.고정} · ` +
`하위 호표 참조 ${got.호표참조} · 빠짐 ${got.빠짐} · 엑셀 합계 = 우리 합계 ${got.같음}/${got.호표}` +
(got.그대로 ? ` · 이미 만든 호표 ${got.그대로} 그대로` : "");
/** [모두 고정으로 들여오기](705-3) — 물음 한 번 · 서버가 호표 모두를 하위부터(숫자 = 서버가 파일에서) */
async function importAll(): Promise<void> {
const ask =
`호표 ${c.briefs().length}개를 엑셀 숫자 그대로(고정 줄) ${L("M02_PrecomboTitle")}으로 들여옴 — ` +
"이미 만든 호표는 그대로 · 품셈 맞춤은 뒤에 호표 [수정]에서 [품셈으로 바꾸기]";
if (!(await showConfirmDialog(ask, "들여오기"))) return;
fixAll.disabled = true;
showLoadingOverlay();
try {
const from = { 층: scope.layer, 주인: scope.owner, 키: key };
const got = await buildHopyoAll(target, from);
if (got.호표 > got.그대로) document.dispatchEvent(new Event(HOPYO_BUILT));
const next =
"다음 — 호표 [수정]에서 [품셈으로 바꾸기]" +
(got.이음후보
? ` · 이음 후보 ${got.이음후보} 줄(이름은 같고 단가가 다름)은 엑셀 단가 그대로 — 아래 [다음 — 이 공사 단가로 다시 잇기]에서 바꿈`
: "");
const rows = [
...got.다름.map((x) =>
el("tr", {
children: [
el("td", { text: `제${x.번}호표 ${x.이름}` }),
el("td", {
text: `엑셀 ${won(x.엑셀)} · 우리 ${won(x.우리)}`,
className: "m02-hopyo__num",
}),
el("td", { text: won(x.차이), className: "m02-hopyo__num" }),
],
}),
),
...got.실패.map((x) => {
const left = leftButton({ detail: x }, { scope, target, key, 파일: c.파일, 번: x.번 }); // prettier-ignore
return el("tr", { children: [el("td", { text: `제${x.번}호표` }), el("td", { text: x.까닭 }), ...(left ? [el("td", { children: [left] })] : [])] }); // prettier-ignore
}),
];
const relink = createButton({
label: "다음 — 이 공사 단가로 다시 잇기",
size: "sm",
onClick: () => void openRelink(relinkKeys((got.만듦 ?? []).map((x) => x.키))),
}); // 705-2
result.replaceChildren(
note(allText(got)),
note(next),
relink,
...(rows.length ? [table(["호표", "합계 다름 · 실패", "차이"], rows)] : []),
);
showToast(allText(got), got.빠짐 || got.실패.length ? "warning" : "success");
} catch (error) {
showToast(`실패 — ${why(error)}`, "error");
} finally {
hideLoadingOverlay();
fixAll.disabled = false;
}
}
fixAll.addEventListener("click", () => void importAll());
}
@@ -5,9 +5,9 @@
* 원본 시트 이름 · 그 행 칠 까닭 글(블록 「예상」 가름).
* · 탭 = 서버 역할 id(779-1) 시트를 같은 글(일위대가표 · 단가산출표 · 단가산출 상세)로 · 원본 이름은 막대
* · 칠 = `logic/M02_MasterTemplete_Hopyo_SheetMap` 이 셈 · 격자 옵션 `칠`(779-3) 로 보이는 칸만 · A 열 ~ 끝 열 · 띠는 A 열만
* · 다시 칠 = 목록 다시 읽음(`marks`) · 확인 창 캐시 바뀜(`HOPYO_DRAFT`) · 만들기(`HOPYO_BUILT` → 목록 · 블록 다시 읽음)
* · 다시 칠 = 목록 다시 읽음(`marks`) · 판정 화면 확인 바뀜(`repaint`) · 만들기(`HOPYO_BUILT` → 목록 · 블록 다시 읽음)
* · 블록을 여러 줄이 부르면 [▸ 부르는 줄] 이 차례로 돌림 · 뛸 때마다 그 앞 자리를 쌓고 [◂ 뒤로] 가 꺼냄 · 두 번 누름 = 첫 단추
* · 779-6 — 마지막 격자 다시 씀(`gridOf`) · 열린 창 앞으로 꺼냄 · 닫기 = 제자리로(`raise` · `lower`)
* · 779-6 — 마지막 격자 다시 씀(`gridOf`)
* · 778-5 D-6 — 머리 막대 아래 띠(부품 `createBanner`) 「엑셀 셈 어긋남 n」 + [보기 i/m] = 상세 시트 그 행으로 차례로 뛰기 ·
* 그 행 까닭은 막대 글에 덧붙임 · 칠은 안 바꿈
* · 780-2 — `role(시트)` · `blockAt(시트, 행)`(단가산출표 목록 행 · 상세 블록 범위) · `spotOf(번, 역할)` 내보냄(오른쪽 단가산출 목록)
@@ -21,9 +21,9 @@ import { t as L } from "@ui/ui_template_locale";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import { call } from "./M02_MasterTemplete_Api_Fetch";
import { draftKey, hasDraft, HOPYO_BUILT, loadDraft } from "./M02_MasterTemplete_Hopyo_Check";
import {
at,
HOPYO_BUILT,
readHopyo,
readHopyoGrid,
type HopyoGrid,
@@ -45,8 +45,6 @@ import {
type Spot,
} from "./logic/M02_MasterTemplete_Hopyo_SheetMap";
/** 확인 창 캐시를 고쳤음(document 알림 · `Hopyo_Check.saveDraft` 가 같은 글로 냄) — 엑셀 보기 창 다시 칠 */
export const HOPYO_DRAFT = "m02-hopyo-draft";
const TAB = { 일위대가표: "M02_SheetIlwi", 단가산출표: "M02_SheetList", 단가산출상세: "M02_SheetDetail" } as const; // prettier-ignore
const enc = encodeURIComponent;
@@ -69,23 +67,6 @@ export function gridOf(id: string, scope: HopyoScope, key: string): Promise<Hopy
}
export const dropGrid = (): void => void (kept = null);
/** 창(모달 바탕)을 맨 앞으로 — 원래 위에 있던 것들(제자리 표시) · 이미 맨 앞이면 [] */
export function raise(node: Element): Element[] {
const back = node.closest(".ui-modal__backdrop");
let above = back?.nextElementSibling ?? null;
while (above && !above.matches(".ui-modal__backdrop")) above = above.nextElementSibling;
if (!back || !above) return [];
document.body.append(back);
return [above];
}
/** 꺼낸 창을 제자리로 — 했으면 참(닫기 대신) */
export function lower(node: Element, above: Element[]): boolean {
const back = node.closest(".ui-modal__backdrop");
if (!back || !above[0]?.isConnected) return false;
document.body.insertBefore(back, above[0]);
return true;
}
/** 역할 시트 탭 글 통일 — 역할 → 시트 id */
export function nameTabs(book: Workbook, grid: HopyoGrid): Partial<Record<Role, string>> {
const ids = (grid as HopyoGrid & GridMore).보기 ?? {};
@@ -126,10 +107,6 @@ export function createSheetNav(opt: SheetNavOptions) {
const lastCol = new Map<string, number>();
const colOf = (sheet: string): number =>
lastCol.get(sheet) ?? lastCol.set(sheet, toCol(opt.last(sheet))).get(sheet)!;
const draftOf = (no: number) => {
const k = draftKey(opt.scope, opt.key, no);
return hasDraft(k) ? loadDraft(k) : null;
};
const go = (range: string, sheet: string): void => void opt.sheet()?.select(range, sheet);
const buttons = el("div", { className: "ui-toolbar m02-hopyo__bar" });
@@ -231,7 +208,7 @@ export function createSheetNav(opt: SheetNavOptions) {
}
function repaint(): void {
paint = paintOf(ix, briefs, blocks, draftOf, opt.judged);
paint = paintOf(ix, briefs, blocks, () => null, opt.judged); // 옛 확인 창 캐시 없음(785-7)
opt.sheet()?.repaint();
paintBar();
}
@@ -241,12 +218,10 @@ export function createSheetNav(opt: SheetNavOptions) {
paintBand();
repaint();
}
const onDraft = (): void => repaint();
const onBuilt = (): void => {
void readHopyo(opt.scope, opt.key).then((v) => (briefs = v.호표 as BriefMarks[]), () => {}).then(readBlocks); // prettier-ignore
};
const onTwice = (): void => root.querySelector<HTMLButtonElement>("[data-hopyo-jump]")?.click();
document.addEventListener(HOPYO_DRAFT, onDraft);
document.addEventListener(HOPYO_BUILT, onBuilt);
opt.host.addEventListener("dblclick", onTwice);
paintBar();
@@ -288,7 +263,6 @@ export function createSheetNav(opt: SheetNavOptions) {
return sheet && rows ? { 시트: sheet, 행: rows } : null;
},
destroy(): void {
document.removeEventListener(HOPYO_DRAFT, onDraft);
document.removeEventListener(HOPYO_BUILT, onBuilt);
opt.host.removeEventListener("dblclick", onTwice);
},
@@ -14,11 +14,10 @@ import { el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createListRow } from "@ui/ui_template_list_row";
import { createText } from "@ui/ui_template_text";
import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import { POLL_MS, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import { createJudgeBlock, type JudgeState } from "./M02_MasterTemplete_Hopyo_Block";
import { createJudgeMove } from "./M02_MasterTemplete_Hopyo_Rows";
import { readJudge, type JudgeBlockBrief } from "./M02_MasterTemplete_Hopyo_JudgeApi";
import { POLL_MS } from "./M02_MasterTemplete_Hopyo_Check";
import type { SheetNav } from "./M02_MasterTemplete_Hopyo_SheetNav";
export interface SheetSkOptions {
@@ -1,21 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_SkApi.ts
* 마법사 단가산출 길(PLAN 778-5 D-5 · 서버 D-3 `M02_Hopyo_Sangeun.build`) — `POST …/hopyo/{키}/sangeun`
* · 몸 `{확인: [{번, 고름?: {차례: 고름}}]}` — 빈 확인 = 판정만(안 만듦) · 확인한 블록만 단위 SK
* · 답 = 블록마다 줄 판정 · 만듦 · 못만듦(꼴 = `logic/M02_MasterTemplete_Hopyo_SkPick`)
* ========================================================================== */
import { call } from "./M02_MasterTemplete_Api_Fetch";
import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import type { SkJudged, SkSure } from "./logic/M02_MasterTemplete_Hopyo_SkPick";
/** 줄 판정 · 확인한 블록 옮기기 — 맞춤 셈 전 409 · 틀린 고름 400 · 없는 블록 404 */
export const judgeSangeun = (
scope: HopyoScope,
key: string,
확인: SkSure[] = [],
): Promise<SkJudged> =>
call(at(scope, `/hopyo/${encodeURIComponent(key)}/sangeun`), {
method: "POST",
body: JSON.stringify({ 확인 }),
});
@@ -1,163 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_SkLines.ts
* 마법사 단가산출 블록 펼침 = 줄 표(PLAN 778-5 D-5 · 요구 623 · 624) — 서버 줄 판정(D-3) + 캐시 고름 겹침.
* · 칸 = 묶음 · 이름 · 규격 · 매개값 · 상태 칩(이어짐 · 고를 것 · 못 이음 · 밖 · 뺌 — 엑셀 보기 칠과 한 규칙
* `SheetMap.unitTone`) + 꼬리표(「기본 — 건설업 작업반장」 · 「내가 고름」) · 방법 글 · 우리 금액 · 엑셀 금액 · 차(참고)
* · 고름(밖 줄 빼고) = 서버 후보 단추 · [찾기](`Hopyo_Lost.lineFind` = 찾아 넣기 창) · 율 = 기준 고르기 ·
* 하위 · 값 = 뺌만 · [뺌] + 까닭 · [고름 지움] — 고름은 부르는 쪽 캐시(`onPick`)
* · [보기] = 엑셀 보기 단가산출 상세 그 행(779-4 뛰기)
* 금액 · 판정은 서버 — 고른 줄 금액은 [확인한 것 옮기기] 뒤 서버가 다시 셈(그 전 「—」).
* ========================================================================== */
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createTable, type Cell } from "@ui/ui_template_table";
import { formatNumber, formatPrice } from "../common_util/common_util_money";
import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import { lineFind } from "./M02_MasterTemplete_Hopyo_Lost";
import { unitTone, type Tone } from "./logic/M02_MasterTemplete_Hopyo_SheetMap";
import {
BASES,
GROUP,
candPick,
candText,
dropOnly,
lineView,
pickable,
type SkBlock,
type SkLine,
type SkPick,
} from "./logic/M02_MasterTemplete_Hopyo_SkPick";
const BADGE: Record<Tone, BadgeTone> = {
success: "success",
warning: "warning",
info: "info",
danger: "danger",
muted: "neutral",
};
const FIND = { L: "노임", X: "기계", M: "자재" } as const;
const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "—" : formatPrice(v);
export interface SkLinesOptions {
scope: HopyoScope;
block: SkBlock;
picks: Record<string, SkPick>;
/** 고름 넣기 · 지움(null) */
onPick: (차례: number, p: SkPick | null) => void;
/** 엑셀 보기 그 행 */
onSheet?: (행: number) => void;
}
/** 상태 칩 + 꼬리표 */
function stateCell(line: SkLine, pick: SkPick | undefined): HTMLElement {
const v = lineView(line, pick);
const diff = !v.mine && line.차 !== null && line.차 !== undefined && Math.abs(line.차) >= 1;
const tone = v.상태 === "뺌" ? "muted" : unitTone(v.상태, diff);
const chip = createBadge(v.상태, BADGE[tone]);
chip.dataset.skState = v.상태;
const tags = v.꼬리표.map((t) => {
const tag = createBadge(t, t === "내가 고름" ? "accent" : "neutral");
tag.dataset.skTag = t;
return tag;
});
return el("div", { className: "ui-toolbar", children: [chip, ...tags] });
}
/** 고름 칸 — 후보 · 찾기 · 율 기준 · 뺌 + 까닭 · 지움 */
function pickCell(opt: SkLinesOptions, line: SkLine, pick: SkPick | undefined): HTMLElement {
const box = el("div", { className: "ui-toolbar", attrs: { "data-sk-pick": String(line.차례) } });
if (!pickable(line)) return box;
const put = (p: SkPick | null): void => opt.onPick(line.차례, p);
if (!dropOnly(line)) {
for (const c of line.후보.slice(0, 3)) {
const p = candPick(line.묶음, c);
if (!p) continue;
const b = createButton({ label: candText(c), size: 24, variant: "ghost", onClick: () => put(p) }); // prettier-ignore
b.dataset.skCand = String(line.차례);
b.title = c.키 ?? c.자재 ?? c.글 ?? "";
box.append(b);
}
const kind = FIND[line.묶음 as keyof typeof FIND];
if (kind) box.append(lineFind({ scope: opt.scope, 갈래: kind, 이름: line.이름 ?? "", 규격: line.규격, done: put })); // prettier-ignore
if (line.묶음 === "R") {
const base = createSelectField({
options: [{ value: "", text: L("M02_SkBasePick") }].concat(
BASES.map((b) => ({ value: b, text: b })),
),
compact: true,
size: 24,
ariaLabel: L("M02_SkBasePick"),
onChange: (v) => v && put({ 기준: v }),
});
box.append(base.root);
}
}
const why = createInputField({ compact: true, size: 24, placeholder: L("M02_SkDropWhy") });
why.input.dataset.skWhy = String(line.차례);
const drop = createButton({
label: L("M02_SkDrop"),
size: 24,
variant: "ghost",
onClick: () => put({ 뺌: why.input.value.trim() || L("M02_SkDropDefault") }),
});
drop.dataset.skDrop = String(line.차례);
box.append(why.root, drop);
if (pick) {
const clear = createButton({ label: L("M02_SkClear"), size: 24, variant: "link", onClick: () => put(null) }); // prettier-ignore
clear.dataset.skClear = String(line.차례);
box.append(clear);
}
return box;
}
/** 블록 줄 표 */
export function skLineTable(opt: SkLinesOptions): HTMLElement {
const rows: Cell[][] = opt.block.줄.map((line) => {
const pick = opt.picks[String(line.차례)];
const v = lineView(line, pick);
const param = Object.entries(line.매개값)
.map(([k, n]) => `${k} ${formatNumber(n)}`)
.join(" · ");
const how = [line.방법, v.글].filter(Boolean).join(" · ");
const sheet =
line.행 !== null && opt.onSheet
? createButton({ label: L("M02_SkSheet"), size: 24, variant: "link", onClick: () => opt.onSheet!(line.행!) }) // prettier-ignore
: "";
if (sheet) sheet.dataset.skSheet = String(line.행);
return [
GROUP[line.묶음] ?? line.묶음,
line.이름 ?? "",
line.규격,
param,
stateCell(line, pick),
how,
v.mine ? "—" : won(line.우리금액),
won(line.엑셀금액),
v.mine || line.차 === null || line.차 === undefined ? "—" : won(line.차),
pickCell(opt, line, pick),
sheet,
];
});
const table = createTable(
[
{ label: L("M02_SkColGroup"), width: "48px" },
{ label: L("M02_SangeunColName") },
{ label: L("M02_LeftSpec") },
{ label: L("M02_SkColParam") },
{ label: L("M02_SkColState") },
{ label: L("M02_SkColHow") },
{ label: L("M02_SkColOurs"), align: "end" },
{ label: L("M02_SkColExcel"), align: "end" },
{ label: L("M02_SkColDiff"), align: "end" },
{ label: L("M02_SkColPick") },
{ label: L("M02_SkSheet"), width: "52px" },
],
rows,
{ density: "data", empty: L("M02_SkNoLines") },
);
table.dataset.skLines = String(opt.block.번);
return table;
}
@@ -1,123 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_ToItem.ts
* [구조물 조합으로](PLAN 704-4 뒷부분) — 실무자료 확인 창 몸 그대로 구조물 조합 항목(SU)을 바로 만듦(사전조합 안 거침).
* 창 = 항목 이름(기본 = 호표 이름 · 규격) · 규격 묶음 제안(같은 이름 · 줄 구성 · 규격만 다른 호표 → 한 항목의 규격 열 ·
* 기본 켬 · 끄면 이 호표 하나) · [만들기] → 서버 `/hopyo-item`(이 층 쓰기 · 「실무자료」 폴더) → 나무 다시
* 숫자는 서버가 파일에서 — 화면은 체크 · 고름만
* ========================================================================== */
import {
createButton,
el,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createInputField } from "@ui/ui_template_input";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { t as L } from "@ui/ui_template_locale";
import {
buildHopyoItem,
hopyoSiblings,
type BuildLine,
type BuildTarget,
type HopyoOne,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { type SameExtra, withSameAsk } from "./M02_MasterTemplete_Library_SameAsk";
/** 항목을 만들었음(document 알림) — 구조물 조합 나무 · 옆 목록 다시 */
export const ITEM_BUILT = "m02-hopyo-item-built";
export interface ToItemOptions {
scope: HopyoScope;
target: HopyoScope;
key: string;
no: number;
hopyo: HopyoOne["호표"];
lines: BuildLine[];
whole: { 대상: BuildTarget[] } | null;
}
export async function toItem(o: ToItemOptions): Promise<void> {
let sib: Awaited<ReturnType<typeof hopyoSiblings>>;
try {
sib = await hopyoSiblings(o.scope, o.key, o.no);
} catch (error) {
showToast(
`묶음 제안 실패 — ${error instanceof Error ? error.message : String(error)}`,
"error",
);
return;
}
const h = o.hopyo;
const one = [h.이름, h.규격].filter(Boolean).join(" ");
const bundle = sib.묶음.length > 1;
const name = createInputField({ label: "구조물 조합 이름", value: bundle ? h.이름 : one });
const pack = bundle
? createCheckbox(
`규격 열로 묶음 — ${sib.이름}: ${sib.묶음.map((r) => `${r.열}(제${r.번}호표)`).join(" · ")}`,
{
checked: true,
onChange: (on) => (name.input.value = on ? h.이름 : one),
},
)
: null;
openModal({
title: `구조물 조합으로 — 제${o.no}호표`,
closeLabel: "취소",
width: "40rem",
mount: (body, close, actions) => {
const go = createButton({
label: "만들기",
onClick: async () => {
const label = name.input.value.trim();
if (!label) return name.setError("이름이 빔");
const from = { 층: o.scope.layer, 주인: o.scope.owner, 키: o.key, 번: o.no };
const with_ = pack?.input.checked
? sib.묶음.map((r) => r.번).filter((n) => n !== o.no)
: [];
// 같은 내용 항목이 있으면 물음(733-3) — 물을 때는 가림막을 걷음
const build = async (extra: SameExtra) => {
showLoadingOverlay();
try {
return await buildHopyoItem(o.target, from, o.lines, o.whole, with_, label, extra);
} finally {
hideLoadingOverlay();
}
};
try {
const got = await withSameAsk(build);
if (!got) return;
close();
document.dispatchEvent(new Event(ITEM_BUILT));
showToast(
got.씀
? `같은 내용 구조물 조합 「${got.이름}」 씀 — 새로 안 만듦`
: `구조물 조합 「${label}」 만듦 — 「실무자료」 폴더 · 줄 ${got.줄수}` +
((got.열수 ?? 0) > 1 ? ` · 규격 열 ${got.열수}` : ""),
"success",
6000,
);
} catch (error) {
showToast(
`못 만듦 — ${error instanceof Error ? error.message : String(error)}`,
"error",
);
}
},
});
actions.append(go);
body.append(
createText(
`체크한 줄 = 품셈 줄 · 나머지 = 고정 줄(엑셀 숫자 그대로) — ${L("M02_PrecomboTitle")}을 거치지 않고 구조물 조합 항목을 바로 만듦`,
"muted",
),
name.root,
...(pack ? [el("div", { children: [pack.root] })] : []),
);
},
});
}
@@ -1,352 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_WizardSk.ts
* 단가산출 목록 판(PLAN 778-5 D-5 · 780-2 · 785-4 — 엑셀 보기 오른쪽 · 요구 621 · 622 · 623 · 624 · 627) — 모두 줄로만 · 자동 없음.
* · 열면 서버 줄 판정(`POST …/sangeun` 빈 확인 = 안 만듦 · D-3) → 블록 표: 단산N · 부름 칸 · 판정(「옮길 수 있음」 ·
* 「고를 줄 n」 — 캐시 고름 겹침) · 만든 SK · [확인] 칩(고를 줄 0 일 때만 켜짐) · [줄 n] = 시트 아래 줄 판(`lines` 자리) 에
* 줄 표(`Hopyo_SkLines` · 넓은 꼴) · 금액 · 근거는 줄 title
* · 맞춤 셈 중(409)이면 3 초마다 다시 판정(끝나면 저절로 표)
* · 고름 · 확인 = 캐시 `m02-hopyo-sk:{층}:{주인}:{키}`(`SkPick` 꼴) — 누를 때 서버에 안 감 · 자동 저장 없음
* · [확인한 것 옮기기 (n)] = 몸 `{확인: [{번, 고름}]}` → 서버가 M01 지금 값으로 다시 셈 · 단위 SK(확인 안 한 블록 안 만듦) ·
* 만든 블록은 확인에서 뺌 · 못만듦 = 띠(까닭 그대로) · 「모두 확인」 단추 없음
* · `count()` = 아직 안 옮긴 블록 · 그중 일위대가 밖 · 고를 줄([끝내기] 글)
* · 부름 칸(「일위대가 n 곳」 · 「일위대가 밖」) · 거르기(전체 · 안 · 밖 · 고를 줄 있음) · 블록 줄 누름 = `onRow` ·
* `mark(번)` 강조 · `readOnly` = 옮기기 숨김 · 알림 `SK_SYNC`(같은 캐시 열쇠 · 고름 바뀜 → 다시 그림) · 옮김 뒤 `HOPYO_BUILT`
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { createToggle } from "@ui/ui_template_toggle";
import { createSegment } from "@ui/ui_template_segment";
import { createPanelHead } from "@ui/ui_template_panel_head";
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
import { HOPYO_BUILT, POLL_MS } from "./M02_MasterTemplete_Hopyo_Check";
import { formatPrice } from "../common_util/common_util_money";
import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import { judgeSangeun } from "./M02_MasterTemplete_Hopyo_SkApi";
import { skLineTable } from "./M02_MasterTemplete_Hopyo_SkLines";
import {
GOOD,
canConfirm,
filterBlocks,
outside,
picksOf,
readDraft,
setPick,
setSure,
sureBody,
tidy,
verdictOf,
waiting,
type SkBlock,
type SkDraft,
type SkFilter,
type SkJudged,
} from "./logic/M02_MasterTemplete_Hopyo_SkPick";
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "—" : formatPrice(v);
export const skDraftKey = (scope: HopyoScope, key: string): string =>
`m02-hopyo-sk:${scope.layer}:${scope.owner ?? ""}:${key}`;
export interface SkStep {
root: HTMLElement;
/** 아직 안 옮긴 블록 · 그중 일위대가 밖 · 고를 줄 — 판정 전 null */
count: () => { 블록: number; 밖: number; 줄: number } | null;
/** 그 블록 펼치고 화면에 보임(안 이은 줄 창 [단가산출 걸음에서 확인]) */
show: (번: number) => void;
/** 그 블록 줄 강조 · 보이게(펼치지 않음 · 거른 줄이면 거르기 풂) — null = 강조 지움 */
mark: (번: number | null) => void;
}
export interface SkStepOptions {
/** 줄 판 자리(시트 아래) — [줄 n] 이 여기에 그 블록 줄 표를 그림 · 없으면 [줄 n] 없음 */
lines?: HTMLElement;
/** 블록 줄 누름(단추 밖) */
onRow?: (번: number) => void;
/** 옮기기 숨김(만들 층 ≠ 파일 층 · 쓰기 권한 없음) */
readOnly?: boolean;
}
/** 맞춤 알림 — detail `{dk, from, got?}` */
export const SK_SYNC = "m02-hopyo-sk";
type SyncDetail = { dk: string; from: object; got?: SkJudged };
/** ② 단가산출 칸 — `파일` = 엑셀 보기 창 머리 · `onChange` = 판정 · 옮김 뒤(끝내기 글) */
export function sangeunStep(
scope: HopyoScope,
key: string,
파일: string,
onChange?: () => void,
o: SkStepOptions = {},
): SkStep {
const dk = skDraftKey(scope, key);
const me = {};
const load = (): SkDraft => readDraft(sessionStorage.getItem(dk));
const save = (d: SkDraft): void => sessionStorage.setItem(dk, JSON.stringify(d));
const tell = (more: Partial<SyncDetail> = {}): void =>
void document.dispatchEvent(new CustomEvent<SyncDetail>(SK_SYNC, { detail: { dk, from: me, ...more } })); // prettier-ignore
let got: SkJudged | null = null;
let fails: SkJudged["못만듦"] = [];
let current: number | null = null; // 강조 블록
let filter: SkFilter = "all";
let shown: number | null = null; // 줄 판에 보인 블록
const out = el("div", { className: "m02-hopyo", attrs: { "data-wiz-sk": "" } });
const seg = createSegment({
options: [
{ value: "all", label: L("M02_SkFilterAll") },
{ value: "in", label: L("M02_SkFilterIn") },
{ value: "out", label: L("M02_SkFilterOut") },
{ value: "open", label: L("M02_SkFilterOpen") },
],
value: filter,
size: 24,
ariaLabel: L("M02_SkFilter"),
onChange: (v) => {
filter = v as SkFilter;
draw();
},
});
seg.root.dataset.skFilter = "";
const again = createButton({ label: L("M02_SkJudge"), variant: "ghost", size: 28, onClick: () => void judge() }); // prettier-ignore
again.dataset.skJudge = "";
const move = createButton({ label: L("M02_SkMove").replace("{n}", "0"), variant: "filled", size: 28, disabled: true, onClick: () => void run() }); // prettier-ignore
move.title = L("M02_SkMoveTip");
move.dataset.skMove = "";
const root = el("div", {
className: "m02-hopyo",
children: [
seg.root,
out,
el("div", { className: "ui-toolbar", children: o.readOnly ? [again] : [move, again] }),
],
});
/** 다른 벌이 고름 · 옮김을 알림 — 떨어진 벌은 듣기 그침 */
const onSync = (e: Event): void => {
const x = (e as CustomEvent<SyncDetail>).detail;
if (!root.isConnected) return document.removeEventListener(SK_SYNC, onSync);
if (x.dk !== dk || x.from === me || !got) return;
if (x.got) {
got = x.got;
fails = [];
}
draw();
};
document.addEventListener(SK_SYNC, onSync);
const sheet = (행: number): void =>
void import("./M02_MasterTemplete_Hopyo_Sheet")
.then((m) =>
m.openHopyoSheet({ scope, key, 파일, canWrite: true, target: scope, 단산행: 행 }),
)
.catch((error: unknown) => showToast(why(error), "error"));
async function judge(): Promise<void> {
again.disabled = true;
out.replaceChildren(createText(L("M02_SkJudging"), "muted", 14));
try {
got = await judgeSangeun(scope, key);
fails = [];
draw();
} catch (error) {
out.replaceChildren(createBanner({ tone: "warning", text: why(error) }));
if (error instanceof StaleError)
window.setTimeout(() => root.isConnected && void judge(), POLL_MS); // 맞춤 셈 중 — 끝나면 저절로
} finally {
again.disabled = false;
onChange?.();
}
}
async function run(): Promise<void> {
if (!got) return;
const body = sureBody(load(), got.판정);
if (!body.length) return showToast(L("M02_SkSureFirst"), "warning");
move.disabled = true;
try {
const next = await judgeSangeun(scope, key, body);
const made = new Set(next.만듦.map((x) => x.번));
const d = load();
save({ ...d, 확인: d.확인.filter((n) => !made.has(n)) });
got = next;
fails = next.못만듦;
tell({ got: next });
if (made.size) document.dispatchEvent(new Event(HOPYO_BUILT)); // 열린 엑셀 보기 칠 · 좌측 나무 다시
showToast(
L("M02_SkMoved")
.replace("{made}", String(made.size))
.replace("{bad}", String(fails.length)),
fails.length ? "warning" : "success",
);
draw();
} catch (error) {
showToast(why(error), "error");
draw();
} finally {
onChange?.();
}
}
const cols: Column[] = [
{ label: L("M02_SkColBlock") },
{ label: L("M02_SkColCall"), width: "80px" },
{ label: L("M02_SkColVerdict"), width: "84px" },
{ label: L("M02_SkColMade"), width: "72px" },
{ label: L("M02_SkSure"), width: "56px" },
{ label: "", width: "56px" },
];
/** 부름 칸 — 「일위대가 n 곳」(title 호표 번) · 「일위대가 밖」 · 옛 서버(칸 없음) = — */
function callCell(b: SkBlock): HTMLElement | string {
if (!b.부름) return "—";
const out = outside(b);
const word = out ? L("M02_SkCallOut") : L("M02_SkCallIn").replace("{n}", String(b.부름.length)); // prettier-ignore
const badge = createBadge(word, out ? "neutral" : "info");
badge.title = b.부름.map((n) => `제${n}호표`).join(" · ");
badge.dataset.skCall = out ? "out" : "in";
return badge;
}
/** 블록 한 줄 + 펼친 줄 표 */
function blockRows(b: SkBlock, d: SkDraft): HTMLTableRowElement[] {
const picks = picksOf(d, b.번);
const word = verdictOf(b, picks);
const verdict = createBadge(word, word === GOOD ? "success" : "info");
verdict.dataset.skVerdict = String(b.번);
const basis = b.근거 ? createBadge(`${L("M02_SkBasisTag")} ${b.근거.이름 ?? b.근거.로직}`, "neutral") : "—"; // prettier-ignore
if (basis instanceof HTMLElement)
basis.title = [b.근거!.로직, b.근거!.판정].filter(Boolean).join(" · ");
const made = b.만든 ? createBadge(b.만든, "success") : "—";
const ok = canConfirm(b, picks);
const sure = createToggle({
label: L("M02_SkSure"),
kind: "chip",
size: 24,
checked: d.확인.includes(b.번),
disabled: !ok,
onChange: (on) => {
save(setSure(load(), b, on));
draw();
tell();
},
});
sure.root.dataset.skSure = String(b.번);
sure.root.title = ok
? L("M02_SkSureTip")
: L(b.줄.length ? "M02_SkSureOff" : "M02_SkSureEmpty");
const lines = createButton({
label: `${L("M02_SkOpen")} ${b.줄.length}`,
size: 24,
variant: shown === b.번 ? "filled" : "ghost",
onClick: () => showLines(shown === b.번 ? null : b.번),
});
lines.dataset.skOpen = String(b.번);
lines.hidden = !o.lines;
const name = `단산${b.번} ${b.이름 ?? ""}${b.단위 ? ` [${b.단위}]` : ""}`;
const head = tableRow(cols, [name, callCell(b), verdict, made, sure.root, lines]);
head.dataset.skBlock = String(b.번);
if (b.번 === current) head.classList.add("is-current");
head.title = `${name} · ${L("M02_SkColOurs")} ${won(b.우리금액)} · ${L("M02_SkColExcel")} ${won(b.엑셀금액)}`; // prettier-ignore
head.addEventListener("click", (e) => {
if (!(e.target as Element).closest("button, label, input")) o.onRow?.(b.번);
});
return [head];
}
/** 줄 판 — 그 블록 줄 표(넓은 꼴) · 고름은 같은 캐시 · null = 닫음 */
function showLines(번: number | null): void {
shown = 번;
draw(); // 줄 판 · 목록 단추 칠을 한 번에
}
function drawLines(): void {
const host = o.lines;
if (!host) return;
const b = shown === null ? null : got?.판정.find((x) => x.번 === shown);
host.hidden = !b;
if (!b) return host.replaceChildren();
const close = createButton({ label: L("M02_Close"), size: 24, variant: "ghost", onClick: () => showLines(null) }); // prettier-ignore
host.replaceChildren(
createPanelHead({ title: `단산${b.번} ${b.이름 ?? ""}`, level: 4, divider: true, actions: [close] }).root, // prettier-ignore
skLineTable({
scope,
block: b,
picks: picksOf(load(), b.번),
onPick: (at, p) => {
save(tidy(setPick(load(), b.번, at, p), got!.판정));
draw();
tell();
},
onSheet: sheet,
}),
);
}
function draw(): void {
if (!got) return;
const d = tidy(load(), got.판정);
save(d);
const blocks = got.판정;
const good = blocks.filter((b) => canConfirm(b, picksOf(d, b.번))).length;
const made = blocks.filter((b) => b.만든).length;
const sum = L("M02_SkSummary")
.replace("{b}", String(blocks.length))
.replace("{ok}", String(good))
.replace("{wait}", String(blocks.length - good))
.replace("{made}", String(made))
.replace("{sure}", String(d.확인.length));
const { root: table, tbody } = tableShell(cols, { density: "data" });
const shown = filterBlocks(blocks, d, filter);
for (const b of shown) tbody.append(...blockRows(b, d));
const band = fails.length
? [
createBanner({
tone: "warning",
text: L("M02_SkFailed").replace("{n}", String(fails.length)),
details: fails.map((x) => `단산${x.번} ${x.이름 ?? ""} — ${x.까닭}`).join("\n"),
}),
]
: [];
const far = blocks.filter(outside).length;
const where = blocks.some((b) => b.부름) ? ` · ${L("M02_SkSummaryIn").replace("{in}", String(blocks.length - far)).replace("{out}", String(far))}` : ""; // prettier-ignore
out.replaceChildren(
createText(sum + where, "muted", 14),
...band,
shown.length ? table : createText(L("M02_SkNone"), "muted", 14),
);
move.disabled = !d.확인.length;
move.querySelector(".ui-btn__label")!.textContent = L("M02_SkMove").replace(
"{n}",
String(d.확인.length),
);
drawLines();
onChange?.();
}
void judge();
return {
root,
count: () => (got ? waiting(load(), got.판정) : null),
show: (번) => {
showLines(번);
out.querySelector(`[data-sk-block="${번}"]`)?.scrollIntoView({ block: "center" });
},
mark: (번) => {
out.querySelector("tr.is-current")?.classList.remove("is-current");
current = 번;
if (번 === null || !got) return;
if (!out.querySelector(`[data-sk-block="${번}"]`) && filter !== "all") {
filter = "all";
seg.set("all");
draw();
}
const row = out.querySelector(`[data-sk-block="${번}"]`);
row?.classList.add("is-current");
row?.scrollIntoView({ block: "nearest" });
},
};
}
@@ -56,7 +56,7 @@ import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
import { openRowSangeun } from "./M02_MasterTemplete_Labor_SangeunModal";
import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick";
import { openLogicPicker, withSangeun } from "./M02_MasterTemplete_Labor_Pick";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks";
import { logicTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
@@ -542,7 +542,7 @@ export function mountLaborDetail(
void loadLogics().then(() => changed());
},
// 사전조합도 같은 창 · 같은 목록(51-11 · 자기 자신 · 순환은 [저장] 때 서버 422)
withHopyo(library),
withSangeun(library),
),
});
@@ -13,7 +13,7 @@ import { openModal } from "@ui/ui_template_modal";
import { createDataTable } from "@ui/ui_template_data_table";
import { createText } from "@ui/ui_template_text";
import { hideLoadingOverlay, showLoadingOverlay } from "@ui/ui_template_toast";
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check";
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Api";
import {
pageScope,
pasteHopyo,
@@ -5,12 +5,11 @@
* 프로젝트 · 카드엔 서고 줄 없음 · 거름 = 로직 품셈 분류 + 사전조합 새 분류(149-2 · 구조물군 › 공통 + 종류)
* 단가연결(251 · B08 거리이정표 · B09 설계내역서) = 종류 고르기(단가산출 · 사전조합 · 덧붙인 줄 종류) +
* 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter` · 늦게 받는 덧줄 `lib.more`
* (324-4 실무 호표 · `withHopyo`)
* 704-1 단가산출 줄 = 기준 목록 표(품명 · 규격 · 단위 · 금액 · 출처 · 대표 규격 펼침 · 공종 거름 ·
* 실무 낱말 = 서버가 별칭에 덧붙임) · 금액 = 서버 M01 엔진(`/logic-lib/standard`) · 오기 전엔 로직 줄만 ·
* 줄 누름 = 계산서 보기(704-2 · `Labor_Sheet`) → [담기] = 지금 담기 동작(`done(로직)`)
* 727-1 기초 = 로직마다 한 줄(`logicHeads` — 옵션 줄을 고르면 옵션을 버리던 흠 걷음 · 옵션은 담은 뒤 고름)
* 721-2 `lib.sangeun`(M02 일위대가 [로직 추가] · `withHopyo`) = 찾아 넣기 창(`Find_Pick` · 탭 M01 전체 · 단가산출 ·
* 721-2 `lib.sangeun`(M02 일위대가 [로직 추가] · `withSangeun`) = 찾아 넣기 창(`Find_Pick` · 탭 M01 전체 · 단가산출 ·
* 일위대가 · 실무 호표 줄 없음) · M01 인력 · 재료 = `lib.fixed`(일위대가 고정 줄 · 없으면 회색 막힘) ·
* B09 · 불러오기는 이 창 그대로
* ========================================================================== */
@@ -47,7 +46,9 @@ import {
upper,
} from "./M02_MasterTemplete_Find_Pick";
export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick";
/** M02 일위대가 [로직 추가] = 찾아 넣기 창(721-2 `sangeun` · 프로젝트 층 그대로 · 옛 「실무 호표」 덧줄은 785-7 에서 지움) */
export const withSangeun = (lib: LibraryPick): LibraryPick =>
lib.layer === "project" ? lib : { ...lib, sangeun: true };
/** 구분 보이는 글(707 낱말) — 값(저장 글)은 그대로 */
const CLASS_TEXT: Record<string, string> = { 실무자료: "실무 엑셀 일위대가" };
@@ -14,7 +14,7 @@ import { createButton, el, showToast } from "@ui/ui_template_elements";
import { askConfirm, openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check";
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Api";
import type { LaborCalc, LaborMat, LaborRow, MatMarkKey } from "./M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
@@ -38,7 +38,7 @@ import { laborBlock } from "./M02_MasterTemplete_Labor_Grid";
import { tipBorn } from "./M02_MasterTemplete_Labor_Born";
import { unlinkedBar } from "./M02_MasterTemplete_Labor_Unlinked";
import { pasteInto } from "./M02_MasterTemplete_Labor_Paste";
import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick";
import { openLogicPicker, withSangeun } from "./M02_MasterTemplete_Labor_Pick";
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
import { openMatModal, openRowModal } from "./M02_MasterTemplete_Labor_RowModal";
import { tc } from "./M02_MasterTemplete_Labor_Text";
@@ -383,7 +383,7 @@ export function mountLaborTable(
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: { ...ONE } });
void loadLogics().then(() => changed());
}, withHopyo(library)),
}, withSangeun(library)),
});
// 정보 칸 — 이름 · 구분 · 상세구분 · 단위 · 본뜬
@@ -52,7 +52,6 @@ import {
} from "./M02_MasterTemplete_TreePanel_Roots";
import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main";
import { buildSide } from "./M02_MasterTemplete_UI_Side";
import { ITEM_BUILT } from "./M02_MasterTemplete_Hopyo_ToItem";
import { precomboList, type PrecomboScope } from "./M02_MasterTemplete_UI_Side_Precombo";
import { sangeunList } from "./M02_MasterTemplete_UI_Side_Sangeun";
import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick";
@@ -203,16 +202,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
: panel;
const topHost = el("div");
const treeHost = el("div");
// 704-4 [구조물 조합으로] 만든 항목 → 나무 · 옆 목록 · 열린 표(행렬)를 다시(페이지가 내려가면 듣기 끝)
const afterItem = async (): Promise<void> => {
await Promise.all([side.refresh(), tree.refresh()]);
const now = main.current();
const shown = now?.kind === "table" && now.name === STRUCTURE_TABLE;
if (shown && (await main.settle()) !== false) await main.openTable(layer, null, true);
};
const onItem = (): void =>
main.root.isConnected ? void afterItem() : document.removeEventListener(ITEM_BUILT, onItem);
document.addEventListener(ITEM_BUILT, onItem);
const main = createMain(
() => isAdmin,
() => void Promise.all([side.refresh(), tree.refresh()]),
@@ -32,7 +32,7 @@ import {
type TemplateInfo,
} from "./M02_MasterTemplete_Api_Fetch";
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check";
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Api";
import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
import { buildPrecomboSection, type PrecomboOptions } from "./M02_MasterTemplete_UI_Side_Precombo";
@@ -1,57 +0,0 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts
* 확인 창 금액 미리 보기 꼴(PLAN 359-3) — 엑셀 줄 하나 + 지금 대상 → 일위대가 문서 한 벌(서버 `/labor/{열}/calc` 에
* 보냄 · 저장 안 함 · 금액은 서버가 셈) · 줄이 들어갈 표(로직 표 · 자재 표).
* 정본 = 서버 `M02_Hopyo_Build.Builder.build` 의 `put`([사전조합 생성] 때 다시 셈) — 같은 규칙:
* 우리 수량 = 엑셀 수량 × 배 · 로직 = {로직, 입력값, 수량 {손}} · 자재 = {코드, 수량 {손}, 별도계상 =
* 갈래 별도계상이면 「별도계상」 아니면 「직접계산」} · 같은 자재 = 수량 더함.
* 「전국」 자재(361-1 · 키 · 단가 없음) = 자재 표 · 문서엔 안 넣음(Build = 본뜬.전국자재 · 금액 없음).
* 미리 보기만 다른 것: 호표 대상(하위 사전조합 — 만든 뒤 셈) · 배 없는 대상은 뺌 ·
* 같은 로직 둘째는 뺌(Build = 한 줄 사전조합으로 감쌈 · 한 문서에 같은 로직 둘 = 서버 400).
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
import type { HopyoLine, HopyoPart } from "../M02_MasterTemplete_Hopyo_Api";
import type { LaborDoc, LaborMat, LaborRow } from "../M02_MasterTemplete_Labor_Api";
export type CheckTable = "logic" | "mat";
/** 엑셀 갈래 중 자재 표로 가는 것(대상 없을 때) */
const MAT_KINDS = ["자재", "별도계상"];
/** 줄이 들어갈 표 — 대상이 모두 자재(전국 포함)면 자재 표 · 대상 없으면 갈래(자재 · 별도계상) · 그 밖 로직 표 */
export function tableOf(line: Pick<HopyoLine, "갈래">, parts: HopyoPart[]): CheckTable {
if (parts.length) return parts.every((p) => !!p.자재 || !!p.전국) ? "mat" : "logic";
return MAT_KINDS.includes(line.갈래) ? "mat" : "logic";
}
/** 엑셀 줄 + 대상 → 미리 셈 문서 · 셀 것이 없으면 null(호표 대상뿐 · 배 없음 · 대상 없음) */
export function draftDoc(
line: Pick<HopyoLine, "수량" | "갈래">,
parts: HopyoPart[],
): LaborDoc | null {
const rows: LaborRow[] = [];
const mats = new Map<string, LaborMat>();
const apart = line.갈래 === "별도계상" ? "별도계상" : "직접계산";
for (const p of parts) {
if (p.배 === null || p.호표 !== undefined) continue;
const qty = (line.수량 ?? 0) * p.배;
if (p.자재) {
const have = mats.get(p.자재)?.수량;
const hand = (have ? have.손 : 0) + qty;
mats.set(p.자재, {
코드: p.자재,
이름: null,
규격: null,
단위: null,
수량: { 손: hand },
별도계상: apart,
});
} else if (p.로직 && !rows.some((r) => r.로직 === p.로직)) {
const values = (p.입력값 ?? {}) as LaborRow["입력값"];
rows.push({ 로직: p.로직, 별칭: null, 메모: null, 입력값: values, 수량: { 손: qty } });
}
}
if (!rows.length && !mats.size) return null;
return { 종류: "일위대가", 열: "", 단위: null, 담은로직: rows, 자재: [...mats.values()] };
}
@@ -1,11 +1,10 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts
* 엑셀 보기 창 세 보기 칠 · 뛸 곳(PLAN 779-4 · 요구서 621 3장 4 · 5) — 순수 셈.
* · 재료 = 서버 격자 `보기 · 목록 · 줄 · 호표 · 단산`(779-1 `M02_Hopyo_Grid`) · 목록 `줄상태 · 만든`(779-2 `Match.view`) ·
* · 재료 = 서버 격자 `보기 · 목록 · 줄 · 호표 · 단산`(779-1 `M02_Hopyo_Grid`) · 목록 `줄상태 · 만든`(785-7 `Judge.view` 1차 판정) ·
* 블록 `[{번, 판정, SK, 갈래, 못옮김}]`(779-2 `M02_Hopyo_Marks` — 칠 꼴 표가 그 머리) · 확인 창 캐시 고름
* · 칠 = success 이어짐 · warning 이어짐 · 엑셀과 다름 · info 고를 것 · danger 못 이음 · muted 밖 + mine(내가 고름 띠)
* · 「이어짐」 = 확인 창과 한 규칙(`Hopyo_Check` `isChecked && checkable` · 캐시 고름이 이김) — 캐시에 대상이 없으면
* 서버 체크 제안(서버가 배 확실할 때만 켬)을 그대로 믿음
* · 「이어짐」 = 1차 판정 「제안 있음」 · 판정 화면 확인함 · 만든 SC(옛 확인 창 캐시 `draftOf` 는 785-7 부터 늘 null)
* · 호표 머리 행 = 그 호표 줄 칠 중 가장 나쁜 것 · 블록 = 만든 SK 갈래 › 없으면 부르는 첫 줄 판정 「예상」 · 안 불림 = 밖
* · 블록 안 줄(779-5) = 블록 `줄 [[행, 상태, 차]]`(Marks · D-2 줄 잇기 상태) → 그 출처 행만 칠 · 머리 행 = 줄 가장 나쁜 칠 ·
* 「엑셀과 다름」 = 이어졌는데 금액 참고 차만 있을 때 · 줄 재료 없으면 옛 블록 칠(범위 통째)
@@ -63,7 +62,7 @@ export interface Miss {
줄: { 행: number | null; 이름: string; 묶음: string; 차: number | null; 까닭: string }[];
까닭: string;
}
/** 확인 창 캐시 고름(`Hopyo_Check` `CheckDraft` 중 칠에 쓰는 것) */
/** 옛 확인 창 캐시 고름 꼴(785-7 부터 부르는 쪽이 늘 null) */
export interface PickDraft {
체크: Record<number, boolean>;
대상: Record<number, { 배: number | null; 비율?: unknown }[]>;
@@ -1,242 +0,0 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SkPick.ts
* 마법사 단가산출 줄 고름(PLAN 778-5 D-5 · 요구 621 · 622 · 623 · 624) — 순수 셈.
* · 재료 = 서버 `POST …/hopyo/{키}/sangeun` 답 `판정 [{번 … 줄 […]}]`(D-3 `M02_Hopyo_Sangeun.build`)
* · 고름 캐시 `{고름 {번: {차례: 고름}}, 확인 [번]}` — 줄 고름이 서버 상태를 덮음(꼬리표 「내가 고름」)
* · 블록 [확인] = 남은 고를 줄 0 일 때만 · 몸 = `{확인: [{번, 고름?}]}`(확인한 블록만 · 자동 확인 없음)
* · 금액 · 판정 정본은 서버 — 여기는 캐시를 겹쳐 보이기 · 몸 짓기만
* · 780-2(요구 627) — 블록 `부름 [호표 번]`(빈 = 일위대가 밖 · 칸 없음 = 옛 서버 = 안) · 줄 0 = 「줄 없음」 확인 막음 ·
* 거르기(모두 · 안 · 밖 · 고를 줄 있음)
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
export type SkGroup = "L" | "X" | "M" | "R" | "S" | "E";
/** 줄 고름 — 노임 LB · 기계 EQ · 자재 MT · 율 기준 · 뺌 + 까닭 */
export type SkPick =
| { 노임: string }
| { 기계: string }
| { 자재: string; 별도계상?: boolean }
| { 기준: SkBase; 비목?: string }
| { 뺌: string };
/** 율 기준 — 묶음 · 비목 · 줄 목록(그 엑셀 행 줄) */
export type SkBase = string | { 행: number[] };
/** 서버 후보 — 노임 · 기계 = `키` · 자재 = `자재` · 율 = `기준` */
export interface SkCand {
키?: string;
자재?: string;
기준?: SkBase;
비목?: string;
갈래?: string;
이름?: string | null;
규격?: string;
단위?: string;
구분?: string;
단가?: number | null;
글?: string;
}
export interface SkLine {
차례: number;
행: number | null;
묶음: SkGroup;
이름: string | null;
규격: string;
매개값: Record<string, number>;
엑셀금액: number | null;
상태: string;
방법: string;
단위: unknown;
후보: SkCand[];
꼬리표: string[];
글: string;
우리금액?: number | null;
차?: number | null;
}
export interface SkBlock {
번: number;
이름: string | null;
단위: string | null;
판정: string;
고를줄: number;
우리금액: number | null;
엑셀금액: number | null;
근거: { 로직: string; 이름: string | null; 판정: string | null } | null;
만든: string | null;
줄: SkLine[];
/** 부르는 일위대가 호표 번(780-1) — [] = 일위대가 밖 · 없음 = 옛 서버(안) */
부름?: number[];
}
export interface SkJudged {
블록: number;
판정: SkBlock[];
만듦: { 번: number; 이름: string | null; 키: string; 갈래: string; 못옮김: unknown[] }[];
못만듦: { 번: number; 이름: string | null; 까닭: string }[];
그대로: number;
같은것씀: { 번: number; 키: string }[];
위층같음: { 번: number; 층: string; 주인: string | null; 키: string }[];
}
/** 확인 목록 한 칸(서버 몸) */
export interface SkSure {
번: number;
고름?: Record<number, SkPick>;
}
/** 캐시 꼴 — 블록 번 · 줄 차례는 JSON 열쇠라 글 */
export interface SkDraft {
고름: Record<string, Record<string, SkPick>>;
확인: number[];
}
export const MINE = "내가 고름";
export const GOOD = "옮길 수 있음";
export const NO_LINES = "줄 없음";
export const LINKED = "이어짐";
export const PICK = "고를 것";
export const OUT = "밖";
export const DROP = "뺌";
export const GROUP: Record<SkGroup, string> = {
L: "노무",
X: "기계",
M: "자재",
R: "율",
S: "하위",
E: "값",
};
/** 율 기준 고르기 칸 */
export const BASES = ["인력", "재료", "노무비", "재료비", "경비"];
const DONE = new Set([LINKED, OUT, DROP]);
export const emptyDraft = (): SkDraft => ({ 고름: {}, 확인: [] });
/** 캐시 글 → 꼴(틀리면 빈 것) */
export function readDraft(raw: string | null): SkDraft {
try {
const got = JSON.parse(raw ?? "") as Partial<SkDraft> | null;
const picks = got?.고름 && typeof got.고름 === "object" ? got.고름 : {};
const sure = Array.isArray(got?.확인) ? got.확인.filter((n) => Number.isInteger(n)) : [];
return { 고름: picks, 확인: sure };
} catch {
return emptyDraft();
}
}
/** 고른 것 글 — 키 · 율 기준 · 뺌 까닭 */
export function pickText(p: SkPick): string {
if ("뺌" in p) return `${DROP} · ${p.뺌}`;
if ("기준" in p) {
const base = typeof p.기준 === "string" ? p.기준 : `${p.기준.행.join(" · ")} 행`;
return `기준 ${base}${p.비목 ? ` · ${p.비목}` : ""}`;
}
return "노임" in p ? p.노임 : "기계" in p ? p.기계 : p.자재;
}
/** 줄 보이기 — 캐시 고름이 서버 상태를 덮음 */
export function lineView(
line: SkLine,
pick: SkPick | undefined,
): { 상태: string; 꼬리표: string[]; 글: string; mine: boolean } {
if (!pick) return { 상태: line.상태, 꼬리표: line.꼬리표, 글: line.글, mine: false };
return { 상태: "뺌" in pick ? DROP : LINKED, 꼬리표: [MINE], 글: pickText(pick), mine: true };
}
/** 그 블록 캐시 고름 `{차례: 고름}` */
export const picksOf = (d: SkDraft, 번: number): Record<string, SkPick> => d.고름[String(번)] ?? {};
/** 남은 고를 줄 수 — 이어짐 · 밖 · 뺌 아닌 줄(캐시 고름 겹침) · 줄 0 블록 = 1(서버 「줄 없음」 고를줄 1) */
export const leftLines = (b: SkBlock, picks: Record<string, SkPick>): number =>
b.줄.length ? b.줄.filter((l) => !DONE.has(lineView(l, picks[String(l.차례)]).상태)).length : 1;
/** [확인] 켜짐 — 고를 줄 0 */
export const canConfirm = (b: SkBlock, picks: Record<string, SkPick>): boolean =>
leftLines(b, picks) === 0;
/** 블록 판정 글 — 캐시 겹친 뒤 「옮길 수 있음」 · 「고를 줄 n」 · 「줄 없음」 */
export function verdictOf(b: SkBlock, picks: Record<string, SkPick>): string {
if (!b.줄.length) return NO_LINES;
const n = leftLines(b, picks);
return n ? `고를 줄 ${n}` : GOOD;
}
/** 줄 고를 수 있나 — 밖 줄 = 아님 · 하위 · 값 = 뺌만 */
export const pickable = (l: SkLine): boolean => l.상태 !== OUT;
export const dropOnly = (l: SkLine): boolean => l.묶음 === "S" || l.묶음 === "E";
/** 서버 후보 → 고름(그 줄 묶음 꼴 · 아니면 null) */
export function candPick(group: SkGroup, c: SkCand): SkPick | null {
if (group === "L" && c.키?.startsWith("LB")) return { 노임: c.키 };
if (group === "X" && c.키?.startsWith("EQ")) return { 기계: c.키 };
if (group === "M" && c.자재) return { 자재: c.자재 };
if (group === "R" && c.기준 !== undefined && c.기준 !== null)
return { 기준: c.기준, ...(c.비목 ? { 비목: c.비목 } : {}) };
return null;
}
/** 후보 글 — 「이름 규격 [단위]」 · 율 = 기준 */
export function candText(c: SkCand): string {
if (c.기준 !== undefined) return pickText({ 기준: c.기준, ...(c.비목 ? { 비목: c.비목 } : {}) });
const spec = c.규격 ? ` ${c.규격}` : "";
const unit = c.단위 ? ` [${c.단위}]` : "";
return `${c.이름 ?? c.키 ?? c.자재 ?? ""}${spec}${unit}${c.구분 ? ` · ${c.구분}` : ""}`;
}
/** 캐시 정리 — 고를 줄 남은 블록 · 이미 옮겨 판정 없는 블록은 확인에서 뺌 */
export function tidy(d: SkDraft, blocks: SkBlock[]): SkDraft {
const by = new Map(blocks.map((b) => [b.번, b]));
const sure = d.확인.filter((n) => {
const b = by.get(n);
return !!b && canConfirm(b, picksOf(d, n));
});
return { ...d, 확인: [...new Set(sure)].sort((a, b) => a - b) };
}
/** [확인한 것 옮기기] 몸 — 확인한 블록만 · 고름은 그 블록 줄에 있는 차례만 */
export function sureBody(d: SkDraft, blocks: SkBlock[]): SkSure[] {
const by = new Map(blocks.map((b) => [b.번, b]));
return tidy(d, blocks).확인.map((n) => {
const have = new Set(by.get(n)!.줄.map((l) => l.차례));
const picks = Object.entries(picksOf(d, n)).filter(([at]) => have.has(Number(at)));
return picks.length
? { 번: n, 고름: Object.fromEntries(picks.map(([at, p]) => [Number(at), p])) }
: { 번: n };
});
}
/** 일위대가 밖 블록 — `부름` 빈 배열 */
export const outside = (b: SkBlock): boolean => !!b.부름 && !b.부름.length;
/** 마법사 [끝내기] 글 재료 — 아직 안 옮긴 블록 수 · 그중 일위대가 밖 · 그 블록 고를 줄 합 */
export function waiting(d: SkDraft, blocks: SkBlock[]): { 블록: number; 밖: number; 줄: number } {
const open = blocks.filter((b) => !b.만든);
const 줄 = open.reduce((n, b) => n + leftLines(b, picksOf(d, b.번)), 0);
return { 블록: open.length, 밖: open.filter(outside).length, 줄 };
}
export type SkFilter = "all" | "in" | "out" | "open";
/** 거르기 — 모두 · 일위대가 안 · 일위대가 밖 · 고를 줄 있음(안 옮긴 블록 · 캐시 겹침) */
export function filterBlocks(blocks: SkBlock[], d: SkDraft, f: SkFilter): SkBlock[] {
if (f === "in") return blocks.filter((b) => !outside(b));
if (f === "out") return blocks.filter(outside);
if (f === "open") return blocks.filter((b) => !b.만든 && leftLines(b, picksOf(d, b.번)) > 0);
return blocks;
}
/** 줄 고름 넣기 · 지움(null) — 새 캐시 */
export function setPick(d: SkDraft, 번: number, 차례: number, p: SkPick | null): SkDraft {
const mine = { ...picksOf(d, 번) };
if (p) mine[String(차례)] = p;
else delete mine[String(차례)];
return { ...d, 고름: { ...d.고름, [String(번)]: mine } };
}
/** [확인] 켬 · 끔 — 고를 줄 남으면 안 켬 */
export function setSure(d: SkDraft, b: SkBlock, on: boolean): SkDraft {
const rest = d.확인.filter((n) => n !== b.번);
const ok = on && canConfirm(b, picksOf(d, b.번));
return { ...d, 확인: ok ? [...rest, b.번].sort((x, y) => x - y) : rest };
}