feat(M02): 338-3c 확인 창 · 고르기 창 · 별칭 장부 화면 — 체크한 줄만 [확인하고 사전조합 만들기] · 옛 즉시 확정 · 바로 만들기 없앰
- 새 Hopyo_Check(확인 창 · 본 칸 · 모달) — 상태 색 · 체크 제안 · 우리 로직 여럿 · 배 · 몫 · 옵션 · 품 비교 · 뺌 · 원문 보기 · 캐시 m02-hopyo-check · 셈 중 3 초 다시 읽음 · 최신 응답만 - 새 Hopyo_Choose(고르기 창 · 후보 판정 · 여럿 고름 · 몫 · 찾기 글 미리 넣음) · 새 Hopyo_Ledger(장부 화면 · 고친 것 캐시 · [저장] 때만 PUT) - 고침 View(호표 하나 = 확인 창) · Sheet(펼침 뒤 한 번 · 하위 호표 먼저) · Pick(확인된 것 바로 참조 · 아니면 확인 창) · Api(3-3 꼴 · 만들기 몸 · 장부 길) · Side([별칭 장부]) · css · Labor_Pick(query) - 없앰 saveHopyoMemory · [확정] · [같음 모두 확정] · [근거 맞음 모두 확정] · 바로 만들기 - 시험 test_m02_hopyo_screen.py 새로 · helper_m02_hopyo_check.cjs 새 · pick · seam 도우미 손질 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
@@ -49,8 +49,8 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 줄 표 — 갈래 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 입력값 · 환산 · 단추 = 줄바꿈 없음(글자가 세로로 안
|
||||
갈라짐 · 폭 = 글 폭) · 품명 · 규격 · 대상이 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */
|
||||
/* 확인 창 줄 표(338) — 체크 · 갈래 · 수량 · 단위 · 상태 · 단추 = 줄바꿈 없음(글자가 세로로 안 갈라짐 ·
|
||||
폭 = 글 폭) · 품명 · 규격 · 우리 로직 · 옵션 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */
|
||||
.m02-hopyo__table.m02-hopyo__lines :is(th, td) {
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
@@ -58,19 +58,17 @@
|
||||
.m02-hopyo__table.m02-hopyo__lines
|
||||
:is(th, td):is(
|
||||
:nth-child(1),
|
||||
:nth-child(4),
|
||||
:nth-child(2),
|
||||
:nth-child(5),
|
||||
:nth-child(6),
|
||||
:nth-child(7),
|
||||
:nth-child(9),
|
||||
:nth-child(10),
|
||||
:nth-child(11)
|
||||
) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 입력값 — 길면 줄임표(전체 글은 칸 풍선 도움말) */
|
||||
.m02-hopyo__table.m02-hopyo__lines td:nth-child(9) {
|
||||
/* 옵션 · 품 비교 — 길면 줄임표(전체 글은 칸 풍선 도움말) */
|
||||
.m02-hopyo__table.m02-hopyo__lines td:is(:nth-child(9), :nth-child(10)) {
|
||||
max-width: 24em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -89,23 +87,56 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-hopyo__state.is-기억,
|
||||
.m02-hopyo__state.is-같음,
|
||||
/* 상태 색(338) — 맞음 초록 · 별칭 파랑 · 다른 점 주황 · 후보 회색(테두리 기본) · 없음 빨강 */
|
||||
.m02-hopyo__state.is-맞음,
|
||||
.m02-hopyo__state.is-호표 {
|
||||
border-color: var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.m02-hopyo__state.is-후보 {
|
||||
.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: 14em;
|
||||
}
|
||||
|
||||
.m02-hopyo__part {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-hopyo__part + .m02-hopyo__part {
|
||||
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;
|
||||
}
|
||||
@@ -150,6 +181,22 @@
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-hopyo__cand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */
|
||||
.m02-hopyo-ledger__row.is-upper {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m02-hopyo-ledger__row.is-edited > td:first-child {
|
||||
box-shadow: inset 3px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
/* 엑셀 보기 창(329) — 왼쪽 읽기 전용 시트 · 오른쪽 호표 목록 · 불러오기 결과 */
|
||||
.m02-hopyo-sheet__dialog > .ui-modal__body {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Api.ts
|
||||
* 일위대가 실무자료(PLAN 324) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) ·
|
||||
* 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로).
|
||||
* 일위대가 실무자료(PLAN 324 · 338) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) ·
|
||||
* 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로) ·
|
||||
* 맞춤 응답 꼴 = 설계 3-3(상태 · 체크 제안 · 대상 목록 · 품 비교) · 만들기 몸 = 확인한 줄만(3-6) · 별칭 장부(3-5).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -33,7 +34,7 @@ export interface HopyoBrief {
|
||||
규격: string;
|
||||
단위: string;
|
||||
금액: Money;
|
||||
/** [들어감 수, 줄 수] */
|
||||
/** [체크 제안 수, 줄 수] */
|
||||
맞춤: [number, number];
|
||||
/** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */
|
||||
행?: [number, number];
|
||||
@@ -75,7 +76,7 @@ export interface HopyoLineBasis {
|
||||
행: [number, number];
|
||||
}
|
||||
|
||||
/** 맞춤 대상 — 로직 · 자재 · 다른 호표 */
|
||||
/** 후보 · 고른 대상 글 — 로직 · 자재 · 다른 호표 */
|
||||
export interface HopyoTarget {
|
||||
로직?: string;
|
||||
자재?: string;
|
||||
@@ -86,17 +87,55 @@ export interface HopyoTarget {
|
||||
점수?: number;
|
||||
/** 근거 맞춤(330 · 옛 파일 · 근거 없는 줄은 없음) */
|
||||
근거?: HopyoBasisHit;
|
||||
/** 후보 판정(338) — 맞음 · 다른 점 · 후보 */
|
||||
판정?: "맞음" | "다른 점" | "후보";
|
||||
글?: string;
|
||||
}
|
||||
|
||||
/** 맞춤 대상 하나(338 · 로직 여럿 가능) — 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배 */
|
||||
export interface HopyoPart extends HopyoTarget {
|
||||
입력값: Record<string, unknown>;
|
||||
못읽음?: string[];
|
||||
/** 칸마다 값 출처 — 별칭 · 블록 · 규격 · 자동값 */
|
||||
출처?: Record<string, string>;
|
||||
환산?: { 배: number | null; 갈래: string } | null;
|
||||
몫?: number | null;
|
||||
배: number | null;
|
||||
/** 블록 절 제목(조합 덧 로직) */
|
||||
어디?: string;
|
||||
}
|
||||
|
||||
/** 품 묶음 — 직종 품 · 장비 시간 · 자재 수량 */
|
||||
export interface HopyoQty {
|
||||
인력?: Record<string, number>;
|
||||
장비?: { 갈래: string | string[]; 시간: number }[];
|
||||
자재?: { 이름: string; 수량: number }[];
|
||||
}
|
||||
|
||||
export type HopyoState =
|
||||
"맞음" | "별칭" | "다른 점" | "후보" | "없음" | "뺌" | "호표" | "%" | "노임";
|
||||
|
||||
export interface HopyoMatch {
|
||||
열쇠: string;
|
||||
상태: "기억" | "같음" | "후보" | "없음" | "빼기" | "호표" | "%";
|
||||
대상: HopyoTarget | null;
|
||||
입력값: Record<string, unknown>;
|
||||
못읽음: string[];
|
||||
환산: { 배: number | null; 갈래: string } | null;
|
||||
들어감: boolean;
|
||||
상태: HopyoState;
|
||||
/** 처음 체크 제안(확인 대기 — 들어감 아님) */
|
||||
체크: boolean;
|
||||
대상: HopyoPart[];
|
||||
후보?: HopyoTarget[];
|
||||
글?: string;
|
||||
꼬리표?: string[];
|
||||
품?: { 엑셀: HopyoQty; 우리: HopyoQty; 같음: Record<string, boolean> };
|
||||
갈리는칸?: string[];
|
||||
별칭?: { 층: Layer; 주인: string | null; 규격다름?: boolean; 근거다름?: boolean };
|
||||
뺌?: string;
|
||||
}
|
||||
|
||||
/** 숫자 판정 셈 — 끝 · 한 블록 · 모두 · 까닭(우리 로직 준비 중 등) */
|
||||
export interface HopyoCalc {
|
||||
끝: boolean;
|
||||
한: number;
|
||||
모두: number;
|
||||
까닭?: string;
|
||||
}
|
||||
|
||||
export interface HopyoLine {
|
||||
@@ -115,14 +154,38 @@ export interface HopyoLine {
|
||||
}
|
||||
|
||||
export interface HopyoOne extends HopyoFile {
|
||||
호표: HopyoBrief & { 줄: HopyoLine[] };
|
||||
호표: HopyoBrief & {
|
||||
줄: HopyoLine[];
|
||||
/** 직접 줄 호표 통째 제안(노임 · 장비를 바로 적은 호표) */
|
||||
통째?: { 대상: HopyoPart[]; 글?: string; 체크: boolean } | null;
|
||||
셈?: HopyoCalc;
|
||||
/** 만들 층에 이 호표로 만든 사전조합 */
|
||||
만든?: { 키: string; 확인: boolean } | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 미리 셈(저장 안 함) — 고른 대상 · 옵션으로 우리 품 */
|
||||
export interface HopyoGuess {
|
||||
대상: HopyoTarget;
|
||||
입력값: Record<string, unknown>;
|
||||
못읽음: string[];
|
||||
환산: { 배: number | null; 갈래: string };
|
||||
대상: HopyoPart[];
|
||||
품?: HopyoMatch["품"];
|
||||
글?: string;
|
||||
}
|
||||
|
||||
/** 만들기 몸 대상 하나 — 로직 · 자재 · 호표 중 하나(금액 · 품 칸 없음 — 서버가 셈) */
|
||||
export interface BuildTarget {
|
||||
로직?: string;
|
||||
자재?: string;
|
||||
호표?: number;
|
||||
입력값?: Record<string, unknown>;
|
||||
배: number;
|
||||
몫?: number | null;
|
||||
}
|
||||
|
||||
/** 확인한 줄 — 대상 목록 또는 뺌 까닭 */
|
||||
export interface BuildLine {
|
||||
차례: number;
|
||||
대상?: BuildTarget[];
|
||||
뺌?: "우리에 없음" | "안 씀";
|
||||
}
|
||||
|
||||
export interface HopyoMissing {
|
||||
@@ -141,22 +204,45 @@ export interface HopyoBuilt {
|
||||
다시씀: boolean;
|
||||
빠진줄: HopyoMissing[];
|
||||
비교: { 엑셀: number | null; 우리: number | null; 차이: number | null; 까닭: string | null };
|
||||
장부?: { 씀: number };
|
||||
}
|
||||
|
||||
/** [로직 추가] 「실무 호표」 줄 하나 */
|
||||
/** [로직 추가] 「실무 호표」 줄 하나 — 확인 = 문서 층에 확인된 사전조합 */
|
||||
export interface HopyoPickRow extends Omit<HopyoBrief, "금액"> {
|
||||
층: Layer;
|
||||
주인: string | null;
|
||||
키: string;
|
||||
파일: string;
|
||||
확인?: LaborRef | null;
|
||||
}
|
||||
|
||||
/** 확정 한 줄 — 로직 · 자재 · 빼기 · null(지움) */
|
||||
export type HopyoMemory =
|
||||
| { 로직: string; 입력값: Record<string, unknown>; 환산: number }
|
||||
| { 자재: string; 입력값: Record<string, unknown>; 환산: number }
|
||||
| { 빼기: true }
|
||||
| null;
|
||||
/** 별칭 장부 짝 하나 */
|
||||
export interface LedgerPair {
|
||||
로직?: string;
|
||||
자재?: string;
|
||||
입력값: Record<string, unknown>;
|
||||
배: number;
|
||||
몫: number | null;
|
||||
}
|
||||
|
||||
/** 별칭 장부 한 줄(쓰기 꼴) */
|
||||
export interface LedgerEntry {
|
||||
원래: { 품명: string; 규격: string; 단위: string };
|
||||
갈래: "짝" | "우리에 없음" | "안 씀";
|
||||
짝: LedgerPair[];
|
||||
엑셀조항?: unknown[];
|
||||
}
|
||||
|
||||
/** 별칭 장부 보기 한 줄 — 층 · 고침(이 층 · 쓰기 권한) */
|
||||
export interface LedgerRow extends LedgerEntry {
|
||||
열쇠: string;
|
||||
누가: { id: number; 이름: string | null } | null;
|
||||
때: string | null;
|
||||
처음파일: string | null;
|
||||
층: Layer;
|
||||
주인: string | null;
|
||||
고침: boolean;
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
/** 지금 화면 층(구조물 조합 층) */
|
||||
@@ -177,39 +263,58 @@ 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)}`));
|
||||
|
||||
export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise<HopyoOne> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}/${no}`));
|
||||
/** 호표 하나 — `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,
|
||||
대상: HopyoTarget,
|
||||
대상: BuildTarget[],
|
||||
): Promise<HopyoGuess> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}/${no}/guess`), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 차례, 대상 }),
|
||||
});
|
||||
|
||||
export const saveHopyoMemory = (
|
||||
export const readLedger = async (scope: HopyoScope): Promise<LedgerRow[]> =>
|
||||
(await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"))).줄;
|
||||
|
||||
/** 별칭 장부 [저장] — 고친 줄 · null(지움) · 이 층만 */
|
||||
export const saveLedger = async (
|
||||
scope: HopyoScope,
|
||||
줄: Record<string, HopyoMemory>,
|
||||
): Promise<unknown> =>
|
||||
call(at(scope, "/hopyo-memory"), { method: "POST", body: JSON.stringify({ 줄 }) });
|
||||
줄: Record<string, LedgerEntry | null>,
|
||||
): Promise<LedgerRow[]> =>
|
||||
(
|
||||
await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ 줄 }),
|
||||
})
|
||||
).줄;
|
||||
|
||||
export const deleteHopyo = (scope: HopyoScope, key: string): Promise<unknown> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE" });
|
||||
|
||||
/** 실무 호표 → `target` 층 사전조합(파일 층 = `from`) */
|
||||
/** [확인하고 사전조합 만들기] — 확인한 줄만 · 직접 줄 호표 = `통째` → `target` 층 사전조합(파일 층 = `from`) */
|
||||
export const buildHopyo = (
|
||||
target: HopyoScope,
|
||||
from: { 층: Layer; 주인: string | null; 키: string; 번: number },
|
||||
새로 = false,
|
||||
줄: BuildLine[],
|
||||
통째?: { 대상: BuildTarget[] } | null,
|
||||
): Promise<HopyoBuilt> =>
|
||||
call(at(target, "/hopyo-build"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...from, 새로 }),
|
||||
body: JSON.stringify(통째 ? { ...from, 통째 } : { ...from, 줄 }),
|
||||
});
|
||||
|
||||
export const listHopyoPicks = async (scope: HopyoScope): Promise<HopyoPickRow[]> =>
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Check.ts
|
||||
* 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 ·
|
||||
* 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [확인하고 사전조합 만들기]로 서버에 보냄.
|
||||
* · 자리 = 실무자료 본 칸(호표 누름) · [로직 추가] 「실무 호표」 모달 · 엑셀 보기 창 펼침 [확인 창 열기]
|
||||
* · 머리 = 호표 · 「제안 n / 줄 m」 · 숫자 판정 셈 중이면 3 초마다 다시 읽음(끝나면 멈춤 · 만진 줄은 그대로)
|
||||
* · 줄 = [✓] · 갈래 · 품명 · 규격 · 수량 · 단위 · 상태(맞음 · 별칭 · 다른 점 · 후보) · 우리 로직(여럿 · 몫 · 배) ·
|
||||
* 옵션(칸=값 · 출처 · 못 가름) · 품 비교(엑셀 → 우리 ✓ / ≠) · [고르기] [원문 보기] [엑셀에서 보기] · 뺌 까닭
|
||||
* · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈
|
||||
* · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음
|
||||
* · 화면 잠금은 [확인하고 사전조합 만들기] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만.
|
||||
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) — 화면은 보이기 · 체크 · 고름만.
|
||||
* ========================================================================== */
|
||||
|
||||
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 { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
|
||||
import {
|
||||
buildHopyo,
|
||||
guessHopyo,
|
||||
readHopyoOne,
|
||||
type BuildLine,
|
||||
type BuildTarget,
|
||||
type HopyoBuilt,
|
||||
type HopyoLine,
|
||||
type HopyoMatch,
|
||||
type HopyoOne,
|
||||
type HopyoPart,
|
||||
type HopyoScope,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import {
|
||||
builtWord,
|
||||
inputsText,
|
||||
openHopyoChoose,
|
||||
sourceText,
|
||||
targetText,
|
||||
} from "./M02_MasterTemplete_Hopyo_Choose";
|
||||
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
|
||||
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export const POLL_MS = 3000;
|
||||
const OUT = ["우리에 없음", "안 씀"] as const;
|
||||
|
||||
/** 확인 창 캐시 — 사용자가 만진 것만(서버 제안 위에 덮어 보임) */
|
||||
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 saveDraft(k: string, d: CheckDraft): void {
|
||||
try {
|
||||
sessionStorage.setItem(k, JSON.stringify(d));
|
||||
} catch {
|
||||
/* 캐시 못 씀 = 이 화면에만 남음 */
|
||||
}
|
||||
}
|
||||
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 (line.맞춤.상태 === "%") return false;
|
||||
if (d.뺌[line.차례]) return true;
|
||||
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 head = p.로직 ? { 로직: p.로직, 입력값: p.입력값 ?? {} } : { 자재: 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 ? "–" : String(Math.round(v * 10000) / 10000);
|
||||
|
||||
/** 품 비교 글 — 「콘크리트공 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)}`,
|
||||
);
|
||||
const name = (g: string | string[]): string => [g].flat().join("+");
|
||||
for (const x of q.엑셀.장비 ?? []) {
|
||||
const o = (q.우리.장비 ?? []).find((y) => name(y.갈래) === name(x.갈래));
|
||||
out.push(`${name(x.갈래)} ${n4(x.시간)} → ${n4(o?.시간)} ${mark([x.갈래].flat()[0])}`);
|
||||
}
|
||||
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 ? "" : v.toLocaleString("ko-KR");
|
||||
const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error");
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
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;
|
||||
/** [← 호표 목록](본 칸) */
|
||||
onBack?: () => void;
|
||||
/** 만들었음 — [로직 추가] 참조 줄 · 모달 닫기 */
|
||||
onBuilt?: (got: HopyoBuilt, hopyo: HopyoOne["호표"]) => void;
|
||||
}
|
||||
|
||||
/** 확인 창 몸 — 본 칸 · 모달이 같이 씀 */
|
||||
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 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;
|
||||
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(" — "));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function partCell(line: HopyoLine): HTMLElement {
|
||||
const parts = partsOf(line, d);
|
||||
return el("td", {
|
||||
className: "m02-hopyo__parts",
|
||||
children: parts.map((p, at) => {
|
||||
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.title = "배 — 우리 수량 = 엑셀 수량 × 배";
|
||||
times.disabled = !opt.canWrite;
|
||||
times.addEventListener("change", () => {
|
||||
const v = times.value === "" ? null : Number(times.value);
|
||||
d.대상[line.차례] = parts.map((x, i) => (i === at ? { ...x, 배: v } : x));
|
||||
keep();
|
||||
draw();
|
||||
});
|
||||
const share = p.몫 != null ? ` · 몫 ${p.몫}` : "";
|
||||
return el("div", {
|
||||
className: "m02-hopyo__part",
|
||||
children: [
|
||||
el("span", {
|
||||
text: `${targetText(p)} ${p.로직 ?? p.자재 ?? ""}${share}`.trim(),
|
||||
attrs: { title: p.어디 ?? "" },
|
||||
}),
|
||||
times,
|
||||
...(p.로직
|
||||
? [
|
||||
createButton({
|
||||
label: "옵션",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => editOptions(line, at),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function lineRow(line: HopyoLine): HTMLElement {
|
||||
const m = line.맞춤;
|
||||
const parts = partsOf(line, d);
|
||||
const box = el("input", { attrs: { type: "checkbox", "data-check": String(line.차례) } });
|
||||
const input = box as HTMLInputElement;
|
||||
input.checked = isChecked(line, d) && checkable(line, d);
|
||||
input.disabled = !opt.canWrite || !checkable(line, d);
|
||||
input.addEventListener("change", () => {
|
||||
d.체크[line.차례] = input.checked;
|
||||
keep();
|
||||
draw();
|
||||
});
|
||||
const out = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
attrs: { title: "뺌 — 사전조합에 안 넣고 장부에 남김" },
|
||||
children: [
|
||||
el("option", { text: "넣음", attrs: { value: "" } }),
|
||||
...OUT.map((x) => el("option", { text: `뺌 — ${x}`, attrs: { value: x } })),
|
||||
],
|
||||
}) as HTMLSelectElement;
|
||||
out.value = d.뺌[line.차례] ?? "";
|
||||
out.disabled = !opt.canWrite || m.상태 === "%";
|
||||
out.addEventListener("change", () => {
|
||||
if (out.value) d.뺌[line.차례] = out.value;
|
||||
else delete d.뺌[line.차례];
|
||||
d.체크[line.차례] = !!out.value || d.체크[line.차례];
|
||||
keep();
|
||||
draw();
|
||||
});
|
||||
const sheet = (행: [number, number]): void =>
|
||||
openSheet({
|
||||
scope: opt.scope,
|
||||
key: opt.key,
|
||||
파일: got?.파일 ?? "",
|
||||
canWrite: opt.canWrite,
|
||||
target,
|
||||
단산행: 행,
|
||||
});
|
||||
const acts = [
|
||||
...(opt.canWrite && m.상태 !== "%"
|
||||
? [
|
||||
createButton({
|
||||
label: "고르기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openHopyoChoose({
|
||||
line,
|
||||
openSheet: sheet,
|
||||
done: (ts) =>
|
||||
setParts(
|
||||
line,
|
||||
ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
createButton({
|
||||
label: "원문 보기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void showSource(line),
|
||||
}),
|
||||
...(line.근거
|
||||
? [
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => sheet(line.근거!.행),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
out,
|
||||
];
|
||||
const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n");
|
||||
const opts = parts
|
||||
.filter((p) => p.로직)
|
||||
.map((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처))
|
||||
.filter(Boolean);
|
||||
if (m.갈리는칸?.length) opts.push(`⚠ 못 가름: ${m.갈리는칸.join(", ")}`);
|
||||
const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : "";
|
||||
return el("tr", {
|
||||
className: `m02-hopyo__line is-${input.checked ? "in" : "out"}`,
|
||||
attrs: { "data-line": String(line.차례), "data-state": m.상태 },
|
||||
children: [
|
||||
el("td", { children: [box] }),
|
||||
td(line.갈래),
|
||||
td(line.이름),
|
||||
td(line.규격),
|
||||
td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"),
|
||||
td(line.단위),
|
||||
el("td", {
|
||||
attrs: { title: tags },
|
||||
children: [
|
||||
el("span", {
|
||||
className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`,
|
||||
text: `${m.상태}${alias}`,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
partCell(line),
|
||||
td(opts.join(" / ")),
|
||||
td(compareText(d.품[line.차례] ?? m.품)),
|
||||
el("td", { className: "m02-hopyo__acts", children: acts }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function make(h: HopyoOne["호표"]): Promise<void> {
|
||||
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
|
||||
const lines = whole ? [] : bodyOf(h.줄, d);
|
||||
const out = leftOut(h.줄, d);
|
||||
const put = lines.filter((x) => x.대상).length;
|
||||
const outs = lines.length - put;
|
||||
const ask = whole
|
||||
? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}`
|
||||
: `넣을 줄 ${put} · 뺌 ${outs} · 빠질 줄 ${Object.values(out).reduce((a, b) => a + b, 0)}` +
|
||||
(Object.keys(out).length
|
||||
? `(${Object.entries(out)
|
||||
.map(([a, b]) => `${a} ${b}`)
|
||||
.join(" · ")})`
|
||||
: "") +
|
||||
" — 확인하고 사전조합을 만듦";
|
||||
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");
|
||||
opt.onBuilt?.(res, h);
|
||||
await load(true);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
} 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 ? "" : String(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.까닭}` : ""}`,
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
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: "확인하고 사전조합 만들기", variant: "filled", size: "sm" });
|
||||
go.disabled = !whole && checked < 1;
|
||||
go.setAttribute("data-hopyo-make", "");
|
||||
go.addEventListener("click", () => void make(h));
|
||||
tools.push(all, go);
|
||||
}
|
||||
const back = opt.onBack
|
||||
? [createButton({ label: "← 호표 목록", variant: "ghost", size: "sm", onClick: opt.onBack })]
|
||||
: [];
|
||||
const head = el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
...back,
|
||||
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
|
||||
note(h.규격),
|
||||
note(h.단위),
|
||||
note(`엑셀 단가 ${won(h.금액.계)}`),
|
||||
note(`제안 ${h.맞춤[0]} / 줄 ${h.맞춤[1]} · 체크 ${checked}`),
|
||||
...(calc && !calc.끝
|
||||
? [note(`숫자 판정 셈 중 ${calc.한}/${calc.모두}${calc.까닭 ? ` · ${calc.까닭}` : ""}`)]
|
||||
: []),
|
||||
...(h.만든 ? [note(`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}`)] : []),
|
||||
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: "m02-hopyo__bar m02-hopyo__whole",
|
||||
children: [
|
||||
box,
|
||||
note(
|
||||
`통째 = ${h.통째.대상.map(targetText).join(" · ")}${h.통째.글 ? ` — ${h.통째.글}` : ""}`,
|
||||
),
|
||||
],
|
||||
}),
|
||||
];
|
||||
})()
|
||||
: [];
|
||||
const heads = [
|
||||
"",
|
||||
"갈래",
|
||||
"품명",
|
||||
"규격",
|
||||
"수량",
|
||||
"단위",
|
||||
"상태",
|
||||
"우리 로직 · 배",
|
||||
"옵션",
|
||||
"품 비교",
|
||||
"",
|
||||
];
|
||||
root.replaceChildren(
|
||||
head,
|
||||
...whole,
|
||||
h.줄.length
|
||||
? el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table m02-hopyo__lines",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
el("tbody", { children: h.줄.map(lineRow) }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
: 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();
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Choose.ts
|
||||
* 일위대가 실무자료 고르기 창(PLAN 338-3 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미.
|
||||
* · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀에서 보기](단가산출 그 행)
|
||||
* · 후보 다섯 = 이름 · 키 · 판정 꼬리표(맞음 / 다른 점 / 후보) · 글 · 근거 꼬리표(근거 맞음 초록) · 금액 비 없음
|
||||
* · 고름 = [고름](한 로직) · 또는 체크 + 몫 칸 → [고른 n개 함께](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움
|
||||
* · [다른 로직 찾기] = `openLogicPicker` 에 찾기 글 미리 넣음(엑셀 인용 조항 번호 → 없으면 품명 첫 뜻 낱말)
|
||||
* 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type {
|
||||
BuildTarget,
|
||||
HopyoBasisHit,
|
||||
HopyoLine,
|
||||
HopyoLineBasis,
|
||||
HopyoQty,
|
||||
HopyoTarget,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
const MATERIAL = new Set(["자재", "별도계상"]);
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
const num = (v: number): string => String(Math.round(v * 10000) / 10000);
|
||||
|
||||
/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제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}=${String(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(" · ")
|
||||
: "";
|
||||
|
||||
/** [다른 로직 찾기] 미리 넣을 글 — 엑셀 인용 조항 번호 → 없으면 품명 괄호 밖 첫 뜻 낱말 */
|
||||
export function searchHint(line: Pick<HopyoLine, "이름" | "근거">): string {
|
||||
const clause = line.근거?.출처?.find((x) => x[1])?.[1];
|
||||
if (clause) return clause;
|
||||
return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? "";
|
||||
}
|
||||
|
||||
/** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */
|
||||
export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] =>
|
||||
picked.map(({ c, 몫 }) => ({
|
||||
...(c.로직 ? { 로직: c.로직 } : { 자재: 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 list = (line.맞춤.후보 ?? []).map((c) => {
|
||||
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",
|
||||
attrs: { "data-cand": c.로직 ?? c.자재 ?? "" },
|
||||
children: [
|
||||
box,
|
||||
createButton({
|
||||
label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => finish(pickedTargets([{ c, 몫: null }])),
|
||||
}),
|
||||
...(c.판정
|
||||
? [
|
||||
el("span", {
|
||||
className: `m02-hopyo__state is-${c.판정.replace(" ", "")}`,
|
||||
text: c.판정,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(c.글 ? [note(c.글)] : []),
|
||||
...(tag && c.근거
|
||||
? [
|
||||
el("span", {
|
||||
className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`,
|
||||
text: tag,
|
||||
attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
share,
|
||||
],
|
||||
});
|
||||
});
|
||||
const excel = qtyText(line.맞춤.품?.엑셀);
|
||||
const basis = line.근거;
|
||||
const head = el("div", {
|
||||
className: "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.행),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
const more = createButton({
|
||||
label: material ? "다른 자재 찾기…" : "다른 로직 찾기…",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
if (material) pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }]));
|
||||
else
|
||||
openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, {
|
||||
query: searchHint(line),
|
||||
});
|
||||
},
|
||||
});
|
||||
body.append(
|
||||
...(head.childElementCount ? [head] : []),
|
||||
note(list.length ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [함께]" : "가까운 것 없음"),
|
||||
el("div", { className: "m02-hopyo__cands", children: list }),
|
||||
el("div", { className: "m02-hopyo__bar", children: [together, more] }),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Ledger.ts
|
||||
* 일위대가 실무자료 별칭 장부 화면(PLAN 338-3 · 설계 4-4) — 본 칸 · 좌측 판 [별칭 장부] 로 엶.
|
||||
* · 표 = 엑셀 품명 · 규격 · 단위 | 갈래 | 우리 로직(여럿) · 옵션 · 배 · 몫 | 누가 · 때 · 처음 파일 | 층(위 층 회색 읽기만)
|
||||
* · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [로직 더함] · 배 · 몫 · [옵션] · [지우기] ·
|
||||
* 위 층 줄 [내 층으로 고쳐 씀]
|
||||
* · 고친 것 = sessionStorage `m02-hopyo-ledger:{층}:{주인}` → [저장] 때만 `PUT`(서버가 짝을 다시 검사)
|
||||
* · 안 둠 = 발주처 칸 · 자동 올림 · 자동 합침 · 쓰인 횟수 · 회사로 올리기.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import {
|
||||
readLedger,
|
||||
saveLedger,
|
||||
type HopyoScope,
|
||||
type LedgerEntry,
|
||||
type LedgerPair,
|
||||
type LedgerRow,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export const LEDGER_TITLE = "별칭 장부";
|
||||
const KINDS = ["짝", "우리에 없음", "안 씀"] as const;
|
||||
const LAYER_TEXT: Record<string, string> = { system: "마스터", company: "회사", personal: "개인" };
|
||||
type Edits = Record<string, LedgerEntry | null>;
|
||||
/** 화면 한 줄 — 서버 줄 위에 고친 것 */
|
||||
export type LedgerView = LedgerRow & { 고친?: boolean };
|
||||
|
||||
export const ledgerKey = (scope: HopyoScope): string =>
|
||||
`m02-hopyo-ledger:${scope.layer}:${scope.owner ?? ""}`;
|
||||
function loadEdits(k: string): Edits {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(k) ?? "{}") as Edits;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function saveEdits(k: string, edits: Edits): void {
|
||||
try {
|
||||
if (Object.keys(edits).length) sessionStorage.setItem(k, JSON.stringify(edits));
|
||||
else sessionStorage.removeItem(k);
|
||||
} catch {
|
||||
/* 캐시 못 씀 = 이 화면에만 남음 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 쓰기 꼴 — 보기 칸(층 · 누가 · 때 …) 뺌 */
|
||||
export const entryOf = (row: LedgerEntry): LedgerEntry => ({
|
||||
원래: row.원래,
|
||||
갈래: row.갈래,
|
||||
짝:
|
||||
row.갈래 === "짝"
|
||||
? row.짝.map((p) => ({ ...p, 입력값: p.입력값 ?? {}, 몫: p.몫 ?? null }))
|
||||
: [],
|
||||
엑셀조항: row.엑셀조항 ?? [],
|
||||
});
|
||||
|
||||
/** 서버 줄 + 고친 것 → 화면 줄 — 이 층 줄은 고친 값 · 지운 것 뺌 · 위 층에서 옮긴 줄은 이 층 줄로 더함 */
|
||||
export function mergeRows(rows: LedgerRow[], edits: Edits, scope: HopyoScope): LedgerView[] {
|
||||
const mine = rows.filter((r) => r.고침);
|
||||
const out: LedgerView[] = [];
|
||||
for (const r of mine) {
|
||||
if (!(r.열쇠 in edits)) out.push(r);
|
||||
else if (edits[r.열쇠]) out.push({ ...r, ...edits[r.열쇠]!, 고친: true });
|
||||
}
|
||||
for (const [key, entry] of Object.entries(edits)) {
|
||||
if (!entry || mine.some((r) => r.열쇠 === key)) continue;
|
||||
out.push({
|
||||
...entry,
|
||||
열쇠: key,
|
||||
누가: null,
|
||||
때: null,
|
||||
처음파일: null,
|
||||
층: scope.layer,
|
||||
주인: scope.owner,
|
||||
고침: true,
|
||||
고친: true,
|
||||
});
|
||||
}
|
||||
return [...out, ...rows.filter((r) => !r.고침)];
|
||||
}
|
||||
|
||||
/** 짝 글 — 「GC000198 × 1 · 몫 0.1 · 소량감률=50」 */
|
||||
export const pairText = (p: LedgerPair): string =>
|
||||
[
|
||||
`${p.로직 ?? p.자재 ?? ""} × ${p.배}`,
|
||||
p.몫 != null ? `몫 ${p.몫}` : "",
|
||||
Object.entries(p.입력값 ?? {})
|
||||
.map(([k, v]) => `${k}=${String(v)}`)
|
||||
.join(", "),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
/** 찾기 · 갈래 거름 */
|
||||
export const filterRows = (rows: LedgerView[], q: string, kind: string): LedgerView[] => {
|
||||
const want = q.trim().toLowerCase();
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
(!kind || r.갈래 === kind) &&
|
||||
(!want ||
|
||||
`${r.원래.품명} ${r.원래.규격} ${r.짝.map(pairText).join(" ")}`
|
||||
.toLowerCase()
|
||||
.includes(want)),
|
||||
);
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const td = (text: string, cls = ""): HTMLElement =>
|
||||
el("td", { text, className: cls, attrs: { title: text } });
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
|
||||
export interface LedgerOptions {
|
||||
scope: HopyoScope;
|
||||
/** 이 층 쓰기 권한 — 없으면 보기만 */
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
const root = el("div", {
|
||||
className: "m02-hopyo m02-hopyo-ledger",
|
||||
attrs: { "data-hopyo-ledger": "" },
|
||||
});
|
||||
const k = ledgerKey(opt.scope);
|
||||
let edits = loadEdits(k);
|
||||
let rows: LedgerRow[] = [];
|
||||
const search = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__search",
|
||||
attrs: { type: "search", placeholder: "품명 · 규격 · 로직 찾기" },
|
||||
}) as HTMLInputElement;
|
||||
const kind = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
children: [
|
||||
el("option", { text: "갈래 전체", attrs: { value: "" } }),
|
||||
...KINDS.map((x) => el("option", { text: x, attrs: { value: x } })),
|
||||
],
|
||||
}) as HTMLSelectElement;
|
||||
const save = createButton({ label: "저장", variant: "filled", size: "sm" });
|
||||
const drop = createButton({ label: "고친 것 버림", variant: "ghost", size: "sm" });
|
||||
const body = el("tbody");
|
||||
const keep = (): void => {
|
||||
saveEdits(k, edits);
|
||||
paint();
|
||||
};
|
||||
const edit = (r: LedgerView, next: Partial<LedgerEntry>): void => {
|
||||
edits[r.열쇠] = entryOf({ ...entryOf(r), ...next });
|
||||
keep();
|
||||
};
|
||||
|
||||
function pairCell(r: LedgerView, mine: boolean): HTMLElement {
|
||||
const pairs: HTMLElement[] = r.짝.map((p, at) => {
|
||||
const parts: HTMLElement[] = [el("span", { text: pairText(p) })];
|
||||
if (mine) {
|
||||
const times = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||||
attrs: { type: "number", step: "any", min: "0", value: String(p.배), title: "배" },
|
||||
}) as HTMLInputElement;
|
||||
times.addEventListener("change", () =>
|
||||
edit(r, { 짝: r.짝.map((x, i) => (i === at ? { ...x, 배: Number(times.value) } : x)) }),
|
||||
);
|
||||
parts.push(times);
|
||||
if (p.로직)
|
||||
parts.push(
|
||||
createButton({
|
||||
label: "옵션",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
void fetchLogicInputs([p.로직!]).then(
|
||||
(got) =>
|
||||
openLaborInput({
|
||||
title: p.로직!,
|
||||
logic: got.로직[0] ?? null,
|
||||
values: p.입력값 as never,
|
||||
memo: null,
|
||||
readOnly: false,
|
||||
done: (res) =>
|
||||
edit(r, {
|
||||
짝: r.짝.map((x, i) => (i === at ? { ...x, 입력값: res.입력값 } : x)),
|
||||
}),
|
||||
}),
|
||||
(error) => showToast(why(error), "error"),
|
||||
),
|
||||
}),
|
||||
createButton({
|
||||
label: "×",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => edit(r, { 짝: r.짝.filter((_, i) => i !== at) }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return el("div", { className: "m02-hopyo__part", children: parts });
|
||||
});
|
||||
if (mine && r.갈래 === "짝")
|
||||
pairs.push(
|
||||
createButton({
|
||||
label: "로직 더함",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openLogicPicker((x) =>
|
||||
edit(r, { 짝: [...r.짝, { 로직: x.키, 입력값: {}, 배: 1, 몫: null }] }),
|
||||
),
|
||||
}),
|
||||
);
|
||||
return el("td", { className: "m02-hopyo__parts", children: pairs });
|
||||
}
|
||||
|
||||
function rowOf(r: LedgerView): HTMLElement {
|
||||
const mine = opt.canWrite && r.고침;
|
||||
const kindCell = mine
|
||||
? (() => {
|
||||
const s = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
children: KINDS.map((x) => el("option", { text: x, attrs: { value: x } })),
|
||||
}) as HTMLSelectElement;
|
||||
s.value = r.갈래;
|
||||
s.addEventListener("change", () =>
|
||||
edit(r, { 갈래: s.value as LedgerEntry["갈래"], 짝: s.value === "짝" ? r.짝 : [] }),
|
||||
);
|
||||
return el("td", { children: [s] });
|
||||
})()
|
||||
: td(r.갈래);
|
||||
const acts: HTMLElement[] = [];
|
||||
if (mine)
|
||||
acts.push(
|
||||
createButton({
|
||||
label: "지우기",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
edits[r.열쇠] = null;
|
||||
keep();
|
||||
},
|
||||
}),
|
||||
);
|
||||
else if (opt.canWrite && !rows.some((x) => x.고침 && x.열쇠 === r.열쇠) && !edits[r.열쇠])
|
||||
acts.push(
|
||||
createButton({
|
||||
label: "내 층으로 고쳐 씀",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
edits[r.열쇠] = entryOf(r);
|
||||
keep();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const when = [r.누가?.이름, r.때?.replace("T", " ").slice(0, 16), r.처음파일]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return el("tr", {
|
||||
className: `m02-hopyo-ledger__row${r.고침 ? "" : " is-upper"}${r.고친 ? " is-edited" : ""}`,
|
||||
attrs: { "data-ledger": r.열쇠 },
|
||||
children: [
|
||||
td(r.원래.품명),
|
||||
td(r.원래.규격),
|
||||
td(r.원래.단위),
|
||||
kindCell,
|
||||
pairCell(r, mine),
|
||||
td(when),
|
||||
td(r.고침 ? "내 층" : (LAYER_TEXT[r.층] ?? r.층)),
|
||||
el("td", { className: "m02-hopyo__acts", children: acts }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function paint(): void {
|
||||
const all = mergeRows(rows, edits, opt.scope);
|
||||
const shown = filterRows(all, search.value, kind.value);
|
||||
const n = Object.keys(edits).length;
|
||||
save.querySelector(".ui-btn__label")!.textContent = n ? `저장 (${n})` : "저장";
|
||||
save.disabled = drop.disabled = !n;
|
||||
body.replaceChildren(
|
||||
...(shown.length
|
||||
? shown.map(rowOf)
|
||||
: [el("tr", { children: [el("td", { text: "장부 줄 없음", attrs: { colspan: "8" } })] })]),
|
||||
);
|
||||
}
|
||||
search.addEventListener("input", paint);
|
||||
kind.addEventListener("change", paint);
|
||||
drop.addEventListener("click", () => {
|
||||
edits = {};
|
||||
keep();
|
||||
});
|
||||
save.addEventListener("click", async () => {
|
||||
save.disabled = true;
|
||||
try {
|
||||
rows = await saveLedger(opt.scope, edits);
|
||||
edits = {};
|
||||
showToast("장부 저장", "success");
|
||||
keep();
|
||||
} catch (error) {
|
||||
showToast(`저장 못 함 — ${why(error)}`, "error");
|
||||
paint();
|
||||
}
|
||||
});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const stop = showLoading(root);
|
||||
try {
|
||||
rows = await readLedger(opt.scope);
|
||||
} catch (error) {
|
||||
root.replaceChildren(loadingError(why(error), () => void load()));
|
||||
return;
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
root.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
el("strong", { text: LEDGER_TITLE }),
|
||||
note("엑셀 이름 → 우리 로직 + 옵션 + 배 · 확인하고 만들 때 남음 · 아래 층이 이김"),
|
||||
search,
|
||||
kind,
|
||||
...(opt.canWrite ? [save, drop] : []),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
"엑셀 품명",
|
||||
"규격",
|
||||
"단위",
|
||||
"갈래",
|
||||
"우리 로직 · 배 · 몫 · 옵션",
|
||||
"누가 · 때 · 처음 파일",
|
||||
"층",
|
||||
"",
|
||||
].map((t) => el("th", { text: t })),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
paint();
|
||||
}
|
||||
|
||||
void load();
|
||||
return root;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Pick.ts
|
||||
* [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(맞춤 n/m) ·
|
||||
* 고르면 서버가 그 호표를 문서 층 사전조합으로 만들고(`/hopyo-build` · 있던 것은 다시 씀) `lib.pick` 으로
|
||||
* 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림 · 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 ·
|
||||
* 고른 호표마다 참조 줄). 프로젝트 층엔 없음.
|
||||
* [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4 · 338-3) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(제안 n/m) ·
|
||||
* 고름 = 문서 층에 확인된 사전조합이 있으면 그것을 바로 참조 · 없으면 확인 창 모달(`Hopyo_Check` · 늦게 불러옴) →
|
||||
* [확인하고 사전조합 만들기] → `lib.pick` 참조 줄(별칭 = 호표 품명) · 확인 없이 만들지 않음 ·
|
||||
* 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 · 고른 호표마다 참조 줄). 프로젝트 층엔 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { buildHopyo, listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
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";
|
||||
|
||||
@@ -73,21 +73,29 @@ export function hopyoExtra(row: HopyoPickRow, lib: LibraryPick): PickExtra {
|
||||
tag: "실무 호표",
|
||||
name: [row.이름, row.규격].filter(Boolean).join(" "),
|
||||
sub: `${row.파일} · 제${row.번}호표 · ${LAYER_TEXT[row.층] ?? row.층}`,
|
||||
unit: `${row.단위} · 맞춤 ${row.맞춤[0]}/${row.맞춤[1]}`,
|
||||
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.이름}」 → 확인한 사전조합 ${row.확인.키}`, "success");
|
||||
return lib.pick(row.확인, row.이름, alias);
|
||||
}
|
||||
try {
|
||||
const from = { 층: row.층, 주인: row.주인, 키: row.키, 번: row.번 };
|
||||
const got = await buildHopyo({ layer: lib.layer, owner: lib.owner }, from);
|
||||
const miss = got.빠진줄.length;
|
||||
const head = `「${row.이름}」 → 사전조합 ${got.참조.키}${got.다시씀 ? "(있던 것)" : ""}`;
|
||||
if (miss) showToast(`${head} · 빠진 줄 ${miss} — 실무자료 화면에서 맞춤`, "warning", 6000);
|
||||
else showToast(head, "success");
|
||||
lib.pick(got.참조, got.이름, row.이름.slice(0, ALIAS_MAX).trim() || null);
|
||||
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,15 +1,23 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Sheet.ts
|
||||
* 일위대가 실무자료 엑셀 보기 창(PLAN 329) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거
|
||||
* 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 맞춤 n/m) + [고른 n개 불러오기].
|
||||
* 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거
|
||||
* 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 제안 n/m) + [고른 n개 확인하고 만들기].
|
||||
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름
|
||||
* · 불러오기 = 고른 호표마다 차례로 사전조합 만들기(`/hopyo-build`) → 결과 표(사전조합 키 · 빠진 줄 · 값 차이) ·
|
||||
* · ① [고른 n개 확인하고 만들기] = 고른 호표마다 서버 보기 → 펼침 표(넣을 줄 = 체크 제안 · 빠질 줄 · 셈 중 ·
|
||||
* [확인 창 열기]) ② [이대로 n개 만들기] = 하위 호표부터 차례로 `/hopyo-build`(몸 = 그 호표 체크 줄) —
|
||||
* 펼쳐 보이고 한 번 누름 = 사용자 확인 · 결과 표(사전조합 키 · 빠진 줄 · 값 차이) ·
|
||||
* `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
|
||||
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 불러오기는 됨
|
||||
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 확인하고 만들기는 됨
|
||||
* ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createButton,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
@@ -17,13 +25,24 @@ import {
|
||||
buildHopyo,
|
||||
readHopyo,
|
||||
readHopyoGrid,
|
||||
readHopyoOne,
|
||||
type HopyoBrief,
|
||||
type HopyoBuilt,
|
||||
type HopyoGrid,
|
||||
type HopyoOne,
|
||||
type HopyoScope,
|
||||
type HopyoSpan,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { builtWord } from "./M02_MasterTemplete_Hopyo_View";
|
||||
import {
|
||||
bodyOf,
|
||||
draftKey,
|
||||
leftOut,
|
||||
loadDraft,
|
||||
openHopyoCheck,
|
||||
partsOf,
|
||||
toTarget,
|
||||
} from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export interface HopyoSheetOptions {
|
||||
@@ -31,7 +50,7 @@ export interface HopyoSheetOptions {
|
||||
scope: HopyoScope;
|
||||
key: string;
|
||||
파일: string;
|
||||
/** 만들 층 쓰기 권한 — 없으면 체크 · 불러오기 없음(보기만) */
|
||||
/** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */
|
||||
canWrite: boolean;
|
||||
/** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */
|
||||
target?: HopyoScope;
|
||||
@@ -68,6 +87,19 @@ export function lastColumn(sheet: Pick<Sheet, "칸" | "열">): string {
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 만들 차례 — 하위 호표(호표 참조 줄이 가리키는 고른 호표)부터 · 순환은 남은 차례대로 */
|
||||
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;
|
||||
}
|
||||
|
||||
/** 단가산출 행 → 행 전체 범위 A1(`4:9`) */
|
||||
export const rowsText = (행: number | [number, number]): string =>
|
||||
typeof 행 === "number" ? `${행}:${행}` : `${행[0]}:${행[1]}`;
|
||||
@@ -94,7 +126,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
}) as HTMLInputElement;
|
||||
const list = el("div", { className: "m02-hopyo-sheet__list" });
|
||||
const count = note("");
|
||||
const go = createButton({ label: "고른 0개 불러오기", variant: "filled", size: "sm" });
|
||||
const go = createButton({ label: "고른 0개 확인하고 만들기", variant: "filled", size: "sm" });
|
||||
go.disabled = true;
|
||||
const result = el("div", { className: "m02-hopyo-sheet__result" });
|
||||
const right = el("div", {
|
||||
@@ -122,7 +154,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
});
|
||||
|
||||
const paintGo = (): void => {
|
||||
go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 불러오기`;
|
||||
go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 확인하고 만들기`;
|
||||
go.disabled = !checked.size;
|
||||
};
|
||||
|
||||
@@ -226,57 +258,147 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function importPicked(): Promise<void> {
|
||||
const nos = [...checked].sort((a, b) => a - b);
|
||||
const target = opt.target ?? opt.scope;
|
||||
const rows: HTMLElement[] = [];
|
||||
let ok = 0;
|
||||
go.disabled = true;
|
||||
for (const no of nos) {
|
||||
const h = briefs.find((x) => x.번 === no);
|
||||
if (!h || closed) continue;
|
||||
const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() });
|
||||
try {
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no };
|
||||
const got = await buildHopyo(target, from);
|
||||
ok += 1;
|
||||
opt.onImport?.(got, h);
|
||||
rows.push(
|
||||
el("tr", {
|
||||
attrs: { "data-built": got.참조.키 },
|
||||
children: [
|
||||
head,
|
||||
el("td", { text: `${got.참조.키} · ${builtWord(got, false)}` }),
|
||||
el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }),
|
||||
el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] }));
|
||||
}
|
||||
result.replaceChildren(
|
||||
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 }),
|
||||
],
|
||||
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 }),
|
||||
],
|
||||
});
|
||||
|
||||
/** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */
|
||||
function bodyFor(one: HopyoOne): Parameters<typeof buildHopyo>[2] {
|
||||
const h = one.호표;
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
|
||||
return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d);
|
||||
}
|
||||
function wholeFor(one: HopyoOne): Parameters<typeof buildHopyo>[3] {
|
||||
const h = one.호표;
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
|
||||
return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null;
|
||||
}
|
||||
|
||||
/** ① 고른 호표마다 서버 보기 → 펼침 표 */
|
||||
async function review(): Promise<void> {
|
||||
const target = opt.target ?? opt.scope;
|
||||
const nos = [...checked].sort((a, b) => a - b);
|
||||
go.disabled = true;
|
||||
const ones: HopyoOne[] = [];
|
||||
for (const no of nos) {
|
||||
if (closed) return;
|
||||
try {
|
||||
ones.push(await readHopyoOne(opt.scope, opt.key, no, target));
|
||||
} catch (error) {
|
||||
showToast(`제${no}호표 — ${why(error)}`, "error");
|
||||
}
|
||||
}
|
||||
paintGo();
|
||||
showToast(`불러옴 ${ok} / ${nos.length}`, ok === nos.length ? "success" : "warning");
|
||||
const rows = ones.map((one) => {
|
||||
const h = one.호표;
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
|
||||
const out = Object.entries(leftOut(h.줄, d));
|
||||
const calc = h.셈 && !h.셈.끝 ? `셈 중 ${h.셈.한}/${h.셈.모두}` : "";
|
||||
const brief = briefs.find((x) => x.번 === h.번) ?? h;
|
||||
return el("tr", {
|
||||
attrs: { "data-review": String(h.번) },
|
||||
children: [
|
||||
el("td", { text: `제${h.번}호표 ${h.이름}`.trim() }),
|
||||
el("td", {
|
||||
text: wholeFor(one) ? "통째" : String(bodyFor(one).filter((x) => x.대상).length),
|
||||
className: "m02-hopyo__num",
|
||||
}),
|
||||
el("td", { text: out.map(([a, b]) => `${a} ${b}`).join(" · ") }),
|
||||
el("td", { text: calc }),
|
||||
el("td", {
|
||||
children: [
|
||||
createButton({
|
||||
label: "확인 창 열기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openHopyoCheck({
|
||||
scope: opt.scope,
|
||||
key: opt.key,
|
||||
no: h.번,
|
||||
target,
|
||||
canWrite: opt.canWrite,
|
||||
onBuilt: (got) => opt.onImport?.(got, brief),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one));
|
||||
const all = createButton({
|
||||
label: `이대로 ${ready.length}개 만들기`,
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
disabled: !ready.length,
|
||||
onClick: () => void makeAll(ready),
|
||||
});
|
||||
all.setAttribute("data-hopyo-make-all", "");
|
||||
result.replaceChildren(
|
||||
table(["호표", "넣을 줄", "빠질 줄", "숫자 판정", ""], rows),
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [all, note("넣을 줄 = 체크 제안 · 확인 창에서 고친 것")],
|
||||
}),
|
||||
);
|
||||
}
|
||||
go.addEventListener("click", () => void importPicked());
|
||||
|
||||
/** ② 한 번 누름 = 사용자 확인 — 하위 호표부터 차례로 만들기 */
|
||||
async function makeAll(ones: HopyoOne[]): Promise<void> {
|
||||
const target = opt.target ?? opt.scope;
|
||||
const by = new Map(ones.map((one) => [one.호표.번, one]));
|
||||
const order = buildOrder(
|
||||
ones.map((one) => {
|
||||
const d = loadDraft(draftKey(opt.scope, opt.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 = briefs.find((x) => x.번 === no) ?? one.호표;
|
||||
const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() });
|
||||
try {
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no };
|
||||
const got = await buildHopyo(target, from, bodyFor(one), wholeFor(one));
|
||||
ok += 1;
|
||||
opt.onImport?.(got, h);
|
||||
rows.push(
|
||||
el("tr", {
|
||||
attrs: { "data-built": got.참조.키 },
|
||||
children: [
|
||||
head,
|
||||
el("td", { text: `${got.참조.키} · ${builtWord(got)}` }),
|
||||
el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }),
|
||||
el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] }));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
result.replaceChildren(table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], rows));
|
||||
showToast(`만듦 ${ok} / ${order.length}`, ok === order.length ? "success" : "warning");
|
||||
}
|
||||
go.addEventListener("click", () => void review());
|
||||
|
||||
void load();
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
|
||||
* [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면
|
||||
* (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만 ·
|
||||
* 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶.
|
||||
* 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶 ·
|
||||
* 파일 목록 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { buildHopyoLedger, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger";
|
||||
import { buildHopyoView } from "./M02_MasterTemplete_Hopyo_View";
|
||||
|
||||
export const HOPYO_TITLE = "일위대가 실무자료";
|
||||
@@ -48,9 +50,19 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
onClick: () => input.click(),
|
||||
});
|
||||
upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음";
|
||||
const ledger = el("button", {
|
||||
className: "m02-side__item ui-sidebar-row",
|
||||
text: LEDGER_TITLE,
|
||||
attrs: {
|
||||
type: "button",
|
||||
title: "엑셀 이름 → 우리 로직 짝 보기 · 고치기",
|
||||
"data-hopyo-ledger-open": "",
|
||||
},
|
||||
});
|
||||
ledger.addEventListener("click", () => void showLedger());
|
||||
const body = el("div", {
|
||||
className: "m02-hopyo-side",
|
||||
children: [el("div", { className: "m02-side__row", children: [upload, input] }), list],
|
||||
children: [el("div", { className: "m02-side__row", children: [upload, input] }), list, ledger],
|
||||
});
|
||||
let files: HopyoFile[] = [];
|
||||
let open: { key: string; view: HTMLElement } | null = null;
|
||||
@@ -75,6 +87,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
});
|
||||
|
||||
function paintList(): void {
|
||||
ledger.classList.toggle("is-active", open?.key === LEDGER_TITLE);
|
||||
list.replaceChildren(
|
||||
...(files.length
|
||||
? files.map((f) => {
|
||||
@@ -107,6 +120,16 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
paintList();
|
||||
}
|
||||
|
||||
async function showLedger(): Promise<void> {
|
||||
if (open?.key === LEDGER_TITLE && open.view.isConnected) return;
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
const view = buildHopyoLedger({ scope, canWrite: opt.canWrite });
|
||||
opt.show(`${HOPYO_TITLE} — ${LEDGER_TITLE}`, view);
|
||||
opt.onOpen();
|
||||
open = { key: LEDGER_TITLE, view };
|
||||
paintList();
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const mine = ++seq;
|
||||
try {
|
||||
|
||||
@@ -1,58 +1,31 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_View.ts
|
||||
* 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 ·
|
||||
* [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 ·
|
||||
* [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기] ·
|
||||
* [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채) ·
|
||||
* 고르기 창 후보 근거 꼬리표 · 엑셀 근거 줄 + [엑셀에서 보기](단가산출 그 행) · [근거 맞음 모두 확정](330).
|
||||
* 일위대가 실무자료 본 화면(PLAN 324-4 · 338-3) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 제안 n/m · 호표 표 + 찾기 ·
|
||||
* [지우기] · [엑셀로 보기](329 엑셀 보기 창 · 늦게 불러옴) / 호표 하나 = 확인 창(`M02_MasterTemplete_Hopyo_Check` ·
|
||||
* 체크한 줄만 [확인하고 사전조합 만들기]) — 즉시 확정 · 바로 만들기 단추 없음.
|
||||
* 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
buildHopyo,
|
||||
deleteHopyo,
|
||||
guessHopyo,
|
||||
readHopyo,
|
||||
readHopyoOne,
|
||||
saveHopyoMemory,
|
||||
type HopyoBasisHit,
|
||||
type HopyoBuilt,
|
||||
type HopyoGuess,
|
||||
type HopyoLine,
|
||||
type HopyoLineBasis,
|
||||
type HopyoMemory,
|
||||
type HopyoOne,
|
||||
type HopyoScope,
|
||||
type HopyoTarget,
|
||||
type HopyoView,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { buildHopyoCheck } from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export interface HopyoViewOptions {
|
||||
scope: HopyoScope;
|
||||
/** 층 쓰기 권한 — 없으면 확정 · 만들기 · 지우기 단추 없음 */
|
||||
/** 층 쓰기 권한 — 없으면 체크 · 만들기 · 지우기 단추 없음 */
|
||||
canWrite: boolean;
|
||||
/** 파일을 지웠음 — 좌측 목록 다시 */
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
const STATE: Record<string, string> = {
|
||||
기억: "기억",
|
||||
같음: "같음",
|
||||
후보: "후보",
|
||||
없음: "없음",
|
||||
빼기: "뺌",
|
||||
호표: "호표",
|
||||
"%": "% 줄",
|
||||
};
|
||||
const MATERIAL = new Set(["자재", "별도계상"]);
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const won = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
|
||||
@@ -61,12 +34,12 @@ const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "er
|
||||
const openSheet = (o: HopyoSheetOptions): void =>
|
||||
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail);
|
||||
|
||||
const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement =>
|
||||
const grid = (heads: string[], body: HTMLElement): 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-hopyo__table ${cls}`.trim(),
|
||||
className: "ui-general-block__table m02-hopyo__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
@@ -76,87 +49,10 @@ const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement =>
|
||||
}),
|
||||
],
|
||||
});
|
||||
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
|
||||
grid(heads, el("tbody", { children: rows }));
|
||||
const td = (text: string, cls = ""): HTMLElement =>
|
||||
el("td", { text, className: cls, attrs: { title: text } });
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
|
||||
/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제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[]): string =>
|
||||
[
|
||||
Object.entries(values)
|
||||
.map(([k, v]) => `${k}=${String(v)}`)
|
||||
.join(", "),
|
||||
unread.length ? `⚠ 못 읽음: ${unread.join(", ")}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
/** 만들기 결과 낱말 — [새로 만들기] = 「새로 만들어 덮음」 · 있던 것 = 「다시 씀」 · 그 밖 「만듦」 */
|
||||
export const builtWord = (got: Pick<HopyoBuilt, "다시씀">, fresh: boolean): string =>
|
||||
fresh ? "새로 만들어 덮음" : got.다시씀 ? "다시 씀" : "만듦";
|
||||
|
||||
/** 확정 한 줄 몸 — 대상 · 입력값 · 환산(0 넘는 수) · 없으면 null */
|
||||
export function memoryOf(
|
||||
target: HopyoTarget | null,
|
||||
values: Record<string, unknown>,
|
||||
times: number | null,
|
||||
): HopyoMemory {
|
||||
if (!target || times === null || !(times > 0)) return null;
|
||||
if (target.로직) return { 로직: target.로직, 입력값: values, 환산: times };
|
||||
if (target.자재) return { 자재: target.자재, 입력값: values, 환산: times };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 근거 맞음(330 [근거 맞음 모두 확정]) — 조항 같음(앞 마디 아님) · 단위 맞음 · 직종 겹침
|
||||
* (줄 근거 직종이 보통인부뿐이거나 없으면 — 기계 등 — 직종은 안 봄) */
|
||||
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(" · ")
|
||||
: "";
|
||||
|
||||
/** [근거 맞음 모두 확정] 대상 — 「후보」 줄의 지금 대상이 근거 맞으면 그것 · 아니면 근거 맞는 첫 후보 · 없으면 null */
|
||||
export function basisPick(line: HopyoLine): HopyoTarget | null {
|
||||
const m = line.맞춤;
|
||||
if (m.상태 !== "후보") return null;
|
||||
if (m.대상 && basisOk(m.대상.근거, line.근거)) return m.대상;
|
||||
return (m.후보 ?? []).find((c) => basisOk(c.근거, line.근거)) ?? null;
|
||||
}
|
||||
|
||||
export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement {
|
||||
const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } });
|
||||
let nav = 0; // 늦게 온 응답 버림
|
||||
@@ -223,7 +119,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
|
||||
note(`시트 ${file.시트}`),
|
||||
note(`호표 ${file.호표수}`),
|
||||
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
|
||||
note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`),
|
||||
note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`),
|
||||
createButton({
|
||||
label: "엑셀로 보기",
|
||||
size: "sm",
|
||||
@@ -235,7 +131,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
|
||||
}),
|
||||
...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []),
|
||||
search,
|
||||
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "맞춤"], body),
|
||||
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "제안 / 줄"], body),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,7 +141,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: async () => {
|
||||
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 맞춤 기억은 그대로`;
|
||||
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`;
|
||||
if (!(await showConfirmDialog(ask, "지우기"))) return;
|
||||
try {
|
||||
await deleteHopyo(opt.scope, key);
|
||||
@@ -257,358 +153,20 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */
|
||||
async function drawOne(no: number, built?: HopyoBuilt & { 새로: boolean }): Promise<void> {
|
||||
const got = await load(
|
||||
() => readHopyoOne(opt.scope, key, no),
|
||||
() => void drawOne(no),
|
||||
);
|
||||
if (!got) return;
|
||||
const h = got.호표;
|
||||
const picked = new Map<number, HopyoGuess>(); // 고른 대상(확정 전)
|
||||
const back = createButton({ label: "← 호표 목록", variant: "ghost", size: "sm" });
|
||||
back.addEventListener("click", () => void drawFile());
|
||||
const rows = el("tbody");
|
||||
const paintRows = (): void => {
|
||||
rows.replaceChildren(...h.줄.map((line) => lineRow(got, line, picked, paintRows)));
|
||||
};
|
||||
paintRows();
|
||||
const same = h.줄.filter((x) => x.맞춤.상태 === "같음" && x.맞춤.환산?.배 != null);
|
||||
const tools: HTMLElement[] = [];
|
||||
if (opt.canWrite) {
|
||||
const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" });
|
||||
all.disabled = !same.length;
|
||||
all.addEventListener("click", () => void confirmAll(no, same));
|
||||
tools.push(all);
|
||||
// 근거(330) — 옛 파일 · 근거 없는 호표면 단추 없음
|
||||
if (h.줄.some((x) => x.근거 || x.맞춤.후보?.some((c) => c.근거))) {
|
||||
const fit = h.줄.filter((x) => basisPick(x));
|
||||
const ok = createButton({ label: `근거 맞음 모두 확정 (${fit.length})`, size: "sm" });
|
||||
ok.title = "조항 · 직종 · 단위가 모두 맞는 후보만";
|
||||
ok.disabled = !fit.length;
|
||||
ok.addEventListener("click", () => void confirmBasis(no, fit));
|
||||
tools.push(ok);
|
||||
}
|
||||
const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" });
|
||||
make.addEventListener("click", () => void build(no, false));
|
||||
tools.push(make);
|
||||
}
|
||||
const missing = h.줄.filter((x) => !x.맞춤.들어감);
|
||||
/* ── 호표 하나 — 확인 창 ────────────────────────────────────── */
|
||||
function drawOne(no: number): void {
|
||||
nav += 1; // 파일 표 늦은 응답 버림
|
||||
view.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
back,
|
||||
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
|
||||
note(h.규격),
|
||||
note(h.단위),
|
||||
note(`엑셀 단가 ${won(h.금액.계)}`),
|
||||
note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`),
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openSheet({
|
||||
scope: opt.scope,
|
||||
key,
|
||||
파일: got.파일,
|
||||
canWrite: opt.canWrite,
|
||||
호표: no,
|
||||
}),
|
||||
}),
|
||||
...tools,
|
||||
],
|
||||
buildHopyoCheck({
|
||||
scope: opt.scope,
|
||||
key,
|
||||
no,
|
||||
canWrite: opt.canWrite,
|
||||
onBack: () => void drawFile(),
|
||||
}),
|
||||
h.줄.length
|
||||
? grid(
|
||||
[
|
||||
"갈래",
|
||||
"품명",
|
||||
"규격",
|
||||
"수량",
|
||||
"단위",
|
||||
"엑셀 단가",
|
||||
"맞춤",
|
||||
"대상",
|
||||
"입력값",
|
||||
"환산",
|
||||
"",
|
||||
],
|
||||
rows,
|
||||
"m02-hopyo__lines",
|
||||
)
|
||||
: el("p", { className: "m02-hopyo__note", text: "구성 줄 없음" }),
|
||||
...(missing.length ? [missingBlock(missing)] : []),
|
||||
...(built ? [builtBlock(no, built)] : []),
|
||||
);
|
||||
}
|
||||
|
||||
function lineRow(
|
||||
got: HopyoOne,
|
||||
line: HopyoLine,
|
||||
picked: Map<number, HopyoGuess>,
|
||||
repaint: () => void,
|
||||
): HTMLElement {
|
||||
const m = line.맞춤;
|
||||
const mine = picked.get(line.차례);
|
||||
const target = mine?.대상 ?? m.대상;
|
||||
const values = mine?.입력값 ?? m.입력값;
|
||||
const conv = mine?.환산 ?? m.환산;
|
||||
const fixed = ["%", "호표", "빼기"].includes(m.상태);
|
||||
const times = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||||
attrs: {
|
||||
type: "number",
|
||||
step: "any",
|
||||
min: "0",
|
||||
value: conv?.배 != null ? String(conv.배) : "",
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
times.disabled = !opt.canWrite || fixed || !target;
|
||||
const state = mine ? "고름" : (STATE[m.상태] ?? m.상태);
|
||||
const actions: HTMLElement[] = [];
|
||||
if (opt.canWrite && m.상태 === "빼기") {
|
||||
actions.push(
|
||||
createButton({
|
||||
label: "되살림",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void remember(got.호표.번, { [m.열쇠]: null }),
|
||||
}),
|
||||
);
|
||||
} else if (opt.canWrite && !["%", "호표"].includes(m.상태)) {
|
||||
actions.push(
|
||||
createButton({
|
||||
label: "고르기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
choose(line, got.파일, (t) =>
|
||||
guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then(
|
||||
(g) => (picked.set(line.차례, g), repaint()),
|
||||
fail,
|
||||
),
|
||||
),
|
||||
}),
|
||||
createButton({
|
||||
label: "확정",
|
||||
size: "sm",
|
||||
disabled: !target,
|
||||
onClick: () => {
|
||||
const body = memoryOf(target, values, times.value === "" ? null : Number(times.value));
|
||||
if (!body) return showToast("대상과 0 넘는 환산을 넣어야 확정", "error");
|
||||
void remember(got.호표.번, { [m.열쇠]: body });
|
||||
},
|
||||
}),
|
||||
createButton({
|
||||
label: "빼기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void remember(got.호표.번, { [m.열쇠]: { 빼기: true } }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return el("tr", {
|
||||
className: `m02-hopyo__line is-${m.들어감 ? "in" : "out"}`,
|
||||
attrs: { "data-line": String(line.차례), "data-state": m.상태 },
|
||||
children: [
|
||||
td(line.갈래),
|
||||
td(line.이름),
|
||||
td(line.규격),
|
||||
td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"),
|
||||
td(line.단위),
|
||||
td(won(line.단가.계), "m02-hopyo__num"),
|
||||
el("td", {
|
||||
children: [el("span", { className: `m02-hopyo__state is-${m.상태}`, text: state })],
|
||||
}),
|
||||
td(targetText(target)),
|
||||
td(inputsText(values, mine?.못읽음 ?? m.못읽음)),
|
||||
el("td", {
|
||||
children: [times, note(conv?.갈래 ? ` ${conv.갈래}` : "")],
|
||||
}),
|
||||
el("td", { className: "m02-hopyo__acts", children: actions }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 대상 고르기 — 엑셀 근거 줄 · 후보 5(근거 꼬리표) + 다른 로직 · 자재 찾기 */
|
||||
function choose(line: HopyoLine, 파일: string, done: (t: HopyoTarget) => void): void {
|
||||
const material = MATERIAL.has(line.갈래);
|
||||
openModal({
|
||||
title: `고르기 — ${line.이름} ${line.규격}`.trim(),
|
||||
closeLabel: "닫기",
|
||||
dialogClass: "m02-modal m02-hopyo__choose",
|
||||
mount: (body, close) => {
|
||||
const pick = (t: HopyoTarget): void => {
|
||||
close();
|
||||
done(t);
|
||||
};
|
||||
const list = (line.맞춤.후보 ?? []).map((c) => {
|
||||
const button = createButton({
|
||||
label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => pick(c),
|
||||
});
|
||||
const tag = basisTag(c.근거);
|
||||
if (tag && c.근거)
|
||||
button.append(
|
||||
el("span", {
|
||||
className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`,
|
||||
text: tag,
|
||||
attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` },
|
||||
}),
|
||||
);
|
||||
return button;
|
||||
});
|
||||
const basis = line.근거;
|
||||
const source = basis
|
||||
? [
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar m02-hopyo__source",
|
||||
children: [
|
||||
note(`엑셀 근거 — ${sourceText(basis)}`),
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openSheet({
|
||||
scope: opt.scope,
|
||||
key,
|
||||
파일,
|
||||
canWrite: opt.canWrite,
|
||||
단산행: basis.행,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const more = createButton({
|
||||
label: material ? "다른 자재 찾기…" : "다른 로직 찾기…",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
if (material) pickMaterial((x) => done({ 자재: x.코드 }));
|
||||
else openLogicPicker((x) => done({ 로직: x.키 }));
|
||||
},
|
||||
});
|
||||
body.append(
|
||||
...source,
|
||||
el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "가까운 것 없음" }),
|
||||
el("div", { className: "m02-hopyo__cands", children: list }),
|
||||
more,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function remember(no: number, 줄: Record<string, HopyoMemory>): Promise<void> {
|
||||
try {
|
||||
await saveHopyoMemory(opt.scope, 줄);
|
||||
await drawOne(no);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAll(no: number, lines: HopyoLine[]): Promise<void> {
|
||||
const body: Record<string, HopyoMemory> = {};
|
||||
for (const x of lines) {
|
||||
const one = memoryOf(x.맞춤.대상, x.맞춤.입력값, x.맞춤.환산?.배 ?? null);
|
||||
if (one) body[x.맞춤.열쇠] = one;
|
||||
}
|
||||
await remember(no, body);
|
||||
}
|
||||
|
||||
/** 근거 맞는 후보로 확정 — 지금 대상이 아니면 입력값 · 환산을 서버에서 다시 읽음 */
|
||||
async function confirmBasis(no: number, lines: HopyoLine[]): Promise<void> {
|
||||
const body: Record<string, HopyoMemory> = {};
|
||||
for (const x of lines) {
|
||||
const c = basisPick(x);
|
||||
const m = x.맞춤;
|
||||
if (!c) continue;
|
||||
const g =
|
||||
c === m.대상
|
||||
? { 대상: c, 입력값: m.입력값, 환산: m.환산 }
|
||||
: await guessHopyo(opt.scope, key, no, x.차례, c).catch(() => null);
|
||||
const one = g && memoryOf(g.대상, g.입력값, g.환산?.배 ?? null);
|
||||
if (one) body[m.열쇠] = one;
|
||||
}
|
||||
if (!Object.keys(body).length) return showToast("확정할 줄 없음 — 환산을 못 읽음", "warning");
|
||||
await remember(no, body);
|
||||
}
|
||||
|
||||
async function build(no: number, fresh: boolean): Promise<void> {
|
||||
try {
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no };
|
||||
const got = await buildHopyo(opt.scope, from, fresh);
|
||||
const word = builtWord(got, fresh);
|
||||
const miss = got.빠진줄.length;
|
||||
showToast(miss ? `${word} — 빠진 줄 ${miss}` : word, miss ? "warning" : "success");
|
||||
await drawOne(no, { ...got, 새로: fresh });
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
const missingBlock = (lines: HopyoLine[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-hopyo__block",
|
||||
children: [
|
||||
el("h4", { text: `빠진 줄 ${lines.length} — 사전조합에 안 들어감` }),
|
||||
table(
|
||||
["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"],
|
||||
lines.map((x) =>
|
||||
el("tr", {
|
||||
children: [
|
||||
td(x.이름),
|
||||
td(x.규격),
|
||||
td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"),
|
||||
td(x.단위),
|
||||
td(won(x.금액.계), "m02-hopyo__num"),
|
||||
td(STATE[x.맞춤.상태] ?? x.맞춤.상태),
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
function builtBlock(no: number, got: HopyoBuilt & { 새로: boolean }): HTMLElement {
|
||||
const c = got.비교;
|
||||
const again = got.다시씀
|
||||
? [
|
||||
note(
|
||||
got.새로
|
||||
? `사전조합 ${got.참조.키} 를 새로 만들어 덮음`
|
||||
: `있던 사전조합 ${got.참조.키} 를 다시 씀`,
|
||||
),
|
||||
createButton({
|
||||
label: "새로 만들기",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: async () => {
|
||||
const ask = `사전조합 ${got.참조.키} 를 지금 맞춤으로 다시 만들어 덮음`;
|
||||
if (await showConfirmDialog(ask, "새로 만들기")) await build(no, true);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [note(`새 사전조합 ${got.참조.키}`)];
|
||||
return el("div", {
|
||||
className: "m02-hopyo__block m02-hopyo__built",
|
||||
attrs: { "data-built": got.참조.키 },
|
||||
children: [
|
||||
el("div", { className: "m02-hopyo__bar", children: again }),
|
||||
note(
|
||||
`값 비교 — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` +
|
||||
` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`,
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
void drawFile();
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,12 @@ const LAYER_TEXT = {
|
||||
personal: "Lib_Personal",
|
||||
} as const;
|
||||
|
||||
export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: LibraryPick): void {
|
||||
/** `pre.query` = 찾기 칸에 미리 넣을 글(338 실무자료 고르기 창 · 인용 조항 번호 · 품명 낱말) */
|
||||
export function openLogicPicker(
|
||||
done: (logic: LogicSummary) => void,
|
||||
lib?: LibraryPick,
|
||||
pre?: { query?: string },
|
||||
): void {
|
||||
const subs: SubBrief[] = [];
|
||||
let classes: ReturnType<typeof precomboClasses> = []; // 사전조합 새 분류(149-2) — 서고 줄이 있을 때만
|
||||
let all: LogicSummary[] = [];
|
||||
@@ -112,6 +117,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
filters: [...(kindSel ? [kindSel.root] : []), sub.root, detail.root],
|
||||
extra: [layerBar],
|
||||
});
|
||||
if (pre?.query) finder.search.value = pre.query;
|
||||
|
||||
const find = (): void => {
|
||||
const q = finder.search.value.trim().toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user