Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_3' into sub_laptop_4
This commit is contained in:
@@ -59,7 +59,7 @@ export const STATE_REGISTRY = {
|
||||
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
|
||||
/** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */
|
||||
"drainage-visible": { bucket: "pref", scope: "global" },
|
||||
/** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */
|
||||
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
|
||||
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
|
||||
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
||||
/** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */
|
||||
|
||||
@@ -187,33 +187,6 @@ export interface StructureCombos {
|
||||
export const fetchStructureCombos = (): Promise<StructureCombos> => scoped("/structure-combos");
|
||||
|
||||
/** 조합 갈라 내기 결과 — `새로만듦` false = 같은 담은 로직의 조합이 이미 있어 그 키를 가리킴 */
|
||||
export interface BranchedCombo {
|
||||
키: string;
|
||||
이름: string;
|
||||
새로만듦: boolean;
|
||||
집계표판: string;
|
||||
}
|
||||
|
||||
/** 이 열이 가리키는 일위대가 조합을 고친 담은 로직으로 갈라 냄 — 새 UA · 집계표 그 열만 새 키 ·
|
||||
* `판`(집계표 판)을 주면 어긋날 때 StaleError · 원래 조합은 그대로 */
|
||||
export const branchCombo = (
|
||||
column: string,
|
||||
body: {
|
||||
본뜬키: string;
|
||||
담은로직: { 로직: string; 메모?: string | null }[];
|
||||
이름: string;
|
||||
판?: string;
|
||||
},
|
||||
): Promise<BranchedCombo> =>
|
||||
scoped(`/structures/${enc(column)}/combo`, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
/** 있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 키만 바꿈(조합은 그대로 · `새로만듦` false) */
|
||||
export const setColumnCombo = (
|
||||
column: string,
|
||||
body: { 종류: "일위대가"; 키: string; 판?: string },
|
||||
): Promise<BranchedCombo> =>
|
||||
scoped(`/structures/${enc(column)}/use-combo`, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
/* --- 구조물 옵션 보이기(마스터 템플릿 `구조물옵션표시.json`) — 좌측 「구조물 옵션 보이기」 줄 --- */
|
||||
export interface OptionKind {
|
||||
type_id: string;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/* M02 조합 줄(면적산출) · 조합 고치기 창 · 자재 목록 옆 판(재료산출) */
|
||||
|
||||
/* 알약 막대 위 한 줄 */
|
||||
.m02-combo {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4) var(--spacing-12);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-combo__key {
|
||||
padding: 0 var(--spacing-8);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-combo__name {
|
||||
color: var(--color-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-combo__meta,
|
||||
.m02-combo__origin {
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-combo__origin {
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-combo > .ui-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 조합 고치기 창 — 담은 로직 표(M01 조합 화면과 같은 줄) */
|
||||
.ui-modal.m02-combo-edit {
|
||||
width: min(980px, 94vw);
|
||||
}
|
||||
|
||||
.m02-combo-edit__row {
|
||||
display: grid;
|
||||
grid-template-columns: 3rem 7rem minmax(10rem, 1.4fr) 6rem minmax(8rem, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-combo-edit__row--head {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m02-combo-edit__acts {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-combo-edit__foot,
|
||||
.m02-branch__foot {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m02-branch__msg {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Combo.ts
|
||||
* 면적산출 편집 화면 — 오른쪽 조합 패널 「이 열의 조합」 칸의 조합 줄 + [조합 고치기](시스템 관리자만).
|
||||
* 일위대가 조합(UA)은 여러 열 · 모든 프로젝트가 같이 씀 → 제자리 고치지 않고 고친 내용으로 새 조합을 만들어
|
||||
* 이 열만 가리킴(서버 `branchCombo`) · 원래 조합은 그대로. 담은 로직 표는 M01 조합 화면과 같은 줄 모양.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchCombo, type ComboLine } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { branchCombo, type BranchedCombo } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api";
|
||||
import "./M02_MasterTemplete_Combo.css";
|
||||
|
||||
const TEXT = {
|
||||
combo: "일위대가",
|
||||
logics: "담은 로직 {n}",
|
||||
shared: "다른 열 {n} 이 같이 씀",
|
||||
alone: "이 열만 씀",
|
||||
branched: "본뜬 조합 {value}",
|
||||
edit: "조합 고치기",
|
||||
editTitle: "일위대가 조합 고치기 — {value}",
|
||||
no: "차례",
|
||||
key: "키",
|
||||
name: "이름",
|
||||
unit: "결과 단위",
|
||||
memo: "메모",
|
||||
up: "위",
|
||||
down: "아래",
|
||||
remove: "빼기",
|
||||
add: "로직 추가",
|
||||
apply: "적용",
|
||||
close: "닫기",
|
||||
empty: "담은 로직이 없음",
|
||||
unchanged: "고친 것이 없음",
|
||||
discard: "고친 것을 버리고 닫음",
|
||||
discardOk: "버리고 닫음",
|
||||
nameEmpty: "이름을 넣음",
|
||||
noLines: "담은 로직이 없어 적용 안 됨 — 로직을 하나 이상 담음",
|
||||
nameLong: "이름은 {n}자까지",
|
||||
loadFailed: "조합을 못 읽음 — {value}",
|
||||
askTitle: "새 조합으로 갈라 냄",
|
||||
askName: "새 조합 이름",
|
||||
askOk: "새로 만듦",
|
||||
askCancel: "취소",
|
||||
askBody:
|
||||
"이 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용의 조합이 있으면 그것을 씀 · 원래 조합은 그대로",
|
||||
askAlone:
|
||||
"이 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용의 조합이 있으면 그것을 씀 · 원래 조합은 그대로",
|
||||
made: "새 {kind} {key} 「{name}」 을 만듦 — 이 열이 가리킴",
|
||||
reused: "같은 내용의 {kind} {key} 「{name}」 을 씀 — 새로 안 만듦",
|
||||
failed: "조합을 못 고침 — {value}",
|
||||
};
|
||||
|
||||
/** 조합 이름 글자 수 한도 — 서버 `master_combo.MAX_이름` 과 같음 */
|
||||
const MAX_NAME = 60;
|
||||
|
||||
const fill = (text: string, value: string | number): string =>
|
||||
text.replace("{n}", String(value)).replace("{value}", String(value));
|
||||
|
||||
/** 갈라 내기 확인 모달 — 이름 칸을 미리 채워 두고 고칠 수 있음 · 취소면 null */
|
||||
export function askBranch(message: string, defaultName: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value: string | null): void => {
|
||||
if (!done) resolve(value);
|
||||
done = true;
|
||||
};
|
||||
openModal({
|
||||
title: TEXT.askTitle,
|
||||
closeLabel: TEXT.askCancel,
|
||||
dialogClass: "m02-modal m02-branch",
|
||||
mount: (body, close) => {
|
||||
const name = createInputField({ label: TEXT.askName, type: "text" });
|
||||
name.input.value = defaultName.slice(0, MAX_NAME);
|
||||
name.input.maxLength = MAX_NAME;
|
||||
const ok = createButton({
|
||||
label: TEXT.askOk,
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
const value = name.input.value.trim();
|
||||
if (!value || value.length > MAX_NAME) {
|
||||
name.setError(value ? fill(TEXT.nameLong, MAX_NAME) : TEXT.nameEmpty);
|
||||
return name.input.focus();
|
||||
}
|
||||
finish(value);
|
||||
close();
|
||||
},
|
||||
});
|
||||
name.input.addEventListener("input", () => name.setError(undefined));
|
||||
name.input.addEventListener("keydown", (ev) => ev.key === "Enter" && ok.click());
|
||||
body.append(
|
||||
el("p", { className: "m02-branch__msg", text: message }),
|
||||
name.root,
|
||||
el("div", { className: "m02-branch__foot", children: [ok] }),
|
||||
);
|
||||
name.input.focus();
|
||||
},
|
||||
});
|
||||
// 닫기 단추 · Esc · 바깥 누르기 — 창이 빠지면 취소
|
||||
const watch = new MutationObserver(() => {
|
||||
if (document.querySelector(".m02-branch")) return;
|
||||
watch.disconnect();
|
||||
finish(null);
|
||||
});
|
||||
watch.observe(document.body, { childList: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** 새 조합 이름 자동 — 원래 이름(끝 「 (n)」 뗌 · 서버가 새로 붙임) · 구조물 이름(한쪽이 다른 쪽을
|
||||
* 이미 품으면 긴 쪽 하나만) */
|
||||
export const branchName = (
|
||||
named: string,
|
||||
label: string,
|
||||
original = named.replace(/ \(\d+\)$/, ""),
|
||||
): string =>
|
||||
!original || label.includes(original)
|
||||
? label || original
|
||||
: !label || original.includes(label)
|
||||
? original
|
||||
: `${original} · ${label}`;
|
||||
|
||||
/** 결과 알림 — 새로 만듦 / 같은 내용의 있는 조합을 씀(`kind` = 일위대가 조합 · 자재 조합) */
|
||||
export const branchToast = (r: BranchedCombo, kind = "일위대가 조합"): void =>
|
||||
showToast(
|
||||
(r.새로만듦 ? TEXT.made : TEXT.reused)
|
||||
.replace("{kind}", kind)
|
||||
.replace("{key}", r.키)
|
||||
.replace("{name}", r.이름),
|
||||
"success",
|
||||
);
|
||||
|
||||
export interface ComboBarOptions {
|
||||
/** 집계표 열 id */
|
||||
column: string;
|
||||
/** 화면 제목에 쓰는 구조물 이름(집계표 머리 층을 이은 글) — 새 이름 자동 미리 채움에 씀 */
|
||||
label: string;
|
||||
isAdmin: boolean;
|
||||
/** 고치기 전 — 저장 안 한 시트 고침이 있으면 물음 · false 면 그만둠 */
|
||||
beforeEdit: () => Promise<boolean>;
|
||||
/** 갈라 낸 뒤 — 알약 다시 받기 */
|
||||
onApplied: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface Line {
|
||||
로직: string;
|
||||
메모: string | null;
|
||||
이름: string;
|
||||
결과단위: string;
|
||||
}
|
||||
|
||||
/** 조합 줄 — 이 열에 일위대가가 없으면 null */
|
||||
export async function buildComboBar(opts: ComboBarOptions): Promise<HTMLElement | null> {
|
||||
const base = await readColumnBase(opts.column);
|
||||
if (!base.일위대가) return null;
|
||||
const key = base.일위대가;
|
||||
let one;
|
||||
try {
|
||||
one = await fetchCombo(key);
|
||||
} catch (error) {
|
||||
showToast(fill(TEXT.loadFailed, reasonOf(error)), "error");
|
||||
return null;
|
||||
}
|
||||
const combo = one.combo as typeof one.combo & { 본뜬키?: string | null };
|
||||
const same = base.다른열일위대가;
|
||||
const parts = [
|
||||
el("span", { className: "m02-combo__key", text: `${TEXT.combo} ${key}` }),
|
||||
el("span", { className: "m02-combo__name", text: combo.이름 }),
|
||||
el("span", { className: "m02-combo__meta", text: fill(TEXT.logics, combo.담은로직.length) }),
|
||||
el("span", {
|
||||
className: "m02-combo__meta",
|
||||
text: same ? fill(TEXT.shared, same) : TEXT.alone,
|
||||
}),
|
||||
];
|
||||
if (opts.isAdmin && combo.본뜬키) {
|
||||
parts.push(
|
||||
el("span", { className: "m02-combo__origin", text: fill(TEXT.branched, combo.본뜬키) }),
|
||||
);
|
||||
}
|
||||
const bar = el("div", { className: "m02-combo", children: parts });
|
||||
if (!opts.isAdmin) return bar;
|
||||
|
||||
const edit = createButton({ label: TEXT.edit, variant: "ghost", size: "sm" });
|
||||
edit.addEventListener("click", async () => {
|
||||
if (!(await opts.beforeEdit())) return;
|
||||
openEditor(one.줄, combo.이름, same);
|
||||
});
|
||||
bar.append(edit);
|
||||
return bar;
|
||||
|
||||
function openEditor(from: ComboLine[], comboName: string, others: number): void {
|
||||
const original = JSON.stringify(from.map((l) => [l.로직, l.메모 ?? null]));
|
||||
const changed = (): boolean =>
|
||||
JSON.stringify(lines.map((l) => [l.로직, l.메모 ?? null])) !== original;
|
||||
const lines: Line[] = from.map((l) => ({
|
||||
로직: l.로직,
|
||||
메모: l.메모,
|
||||
이름: l.이름 ?? "",
|
||||
결과단위: l.결과단위 ?? "",
|
||||
}));
|
||||
openModal({
|
||||
title: fill(TEXT.editTitle, opts.label),
|
||||
closeLabel: TEXT.close,
|
||||
dialogClass: "m02-modal m02-combo-edit",
|
||||
// 고친 채 닫기 · Esc · 바깥 누르기 — 버릴지 물음
|
||||
beforeClose: () => !changed() || showConfirmDialog(TEXT.discard, TEXT.discardOk),
|
||||
mount: (body, close) => {
|
||||
const table = el("div", { className: "m02-combo-edit__table" });
|
||||
const paint = (): void => {
|
||||
const head = el("div", {
|
||||
className: "m02-combo-edit__row m02-combo-edit__row--head",
|
||||
children: [TEXT.no, TEXT.key, TEXT.name, TEXT.unit, TEXT.memo, ""].map((t) =>
|
||||
el("span", { text: t }),
|
||||
),
|
||||
});
|
||||
const rows = lines.map((line, i) => {
|
||||
const memo = el("input", {
|
||||
className: "ui-input",
|
||||
attrs: { type: "text", value: line.메모 ?? "", title: line.메모 ?? "" },
|
||||
});
|
||||
memo.addEventListener("input", () => {
|
||||
line.메모 = memo.value || null;
|
||||
memo.title = memo.value;
|
||||
});
|
||||
const move = (to: number): void => {
|
||||
if (to < 0 || to >= lines.length) return;
|
||||
[lines[i], lines[to]] = [lines[to], lines[i]];
|
||||
paint();
|
||||
};
|
||||
const up = createButton({ label: "▲", variant: "ghost", size: "sm" });
|
||||
up.title = TEXT.up;
|
||||
up.disabled = i === 0;
|
||||
up.addEventListener("click", () => move(i - 1));
|
||||
const down = createButton({ label: "▼", variant: "ghost", size: "sm" });
|
||||
down.title = TEXT.down;
|
||||
down.disabled = i === lines.length - 1;
|
||||
down.addEventListener("click", () => move(i + 1));
|
||||
const del = createButton({ label: TEXT.remove, variant: "ghost", size: "sm" });
|
||||
del.addEventListener("click", () => {
|
||||
lines.splice(i, 1);
|
||||
paint();
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-combo-edit__row",
|
||||
attrs: { "data-combo-line": line.로직 },
|
||||
children: [
|
||||
el("span", { text: String(i + 1) }),
|
||||
el("span", { text: line.로직 }),
|
||||
el("span", { text: line.이름 }),
|
||||
el("span", { text: line.결과단위 }),
|
||||
memo,
|
||||
el("span", { className: "m02-combo-edit__acts", children: [up, down, del] }),
|
||||
],
|
||||
});
|
||||
});
|
||||
table.replaceChildren(
|
||||
head,
|
||||
...(rows.length
|
||||
? rows
|
||||
: [el("p", { className: "m02-struct__empty", text: TEXT.empty })]),
|
||||
);
|
||||
};
|
||||
paint();
|
||||
const add = createButton({ label: TEXT.add, variant: "ghost", size: "sm" });
|
||||
add.addEventListener("click", () =>
|
||||
openLogicPicker((logic) => {
|
||||
lines.push({ 로직: logic.키, 메모: null, 이름: logic.이름, 결과단위: logic.결과단위 });
|
||||
paint();
|
||||
}),
|
||||
);
|
||||
const apply = createButton({ label: TEXT.apply, variant: "filled" });
|
||||
apply.addEventListener("click", async () => {
|
||||
if (!changed()) {
|
||||
return showToast(TEXT.unchanged, "info");
|
||||
}
|
||||
if (!lines.length) return showToast(TEXT.noLines, "error");
|
||||
const message = others ? fill(TEXT.askBody, others + 1) : TEXT.askAlone;
|
||||
const name = await askBranch(message, branchName(comboName, opts.label));
|
||||
if (name === null) return;
|
||||
apply.disabled = true;
|
||||
try {
|
||||
const result = await branchCombo(opts.column, {
|
||||
본뜬키: key,
|
||||
담은로직: lines.map((l) => ({ 로직: l.로직, 메모: l.메모 })),
|
||||
이름: name,
|
||||
});
|
||||
branchToast(result);
|
||||
close();
|
||||
await opts.onApplied();
|
||||
} catch (error) {
|
||||
apply.disabled = false;
|
||||
showToast(fill(TEXT.failed, reasonOf(error)), "error");
|
||||
}
|
||||
});
|
||||
body.append(
|
||||
table,
|
||||
el("div", { className: "m02-combo-edit__foot", children: [add, apply] }),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/* M02 면적산출 · 재료산출 오른쪽 조합 패널 — 틀은 공용 사이드바 카드 · 접기 · 폭 손잡이 · 접기 손잡이 */
|
||||
|
||||
/* 편집 칸 = 시트 | 패널 — 패널 폭은 손잡이가 `--m02-cpanel-width` 로 줌 */
|
||||
.m02-cpanel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
width: var(--m02-cpanel-width, 24rem);
|
||||
min-width: 280px;
|
||||
max-width: 60%;
|
||||
min-height: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-cpanel.is-resizing {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.m02-cpanel.is-collapsed {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-cpanel.is-collapsed > .m02-cpanel__body,
|
||||
.m02-cpanel.is-collapsed > .ui-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 접기 손잡이 — 패널 왼쪽 밖 세로 가운데(시트 위로) */
|
||||
.m02-cpanel > .ui-workflow-overlay__handle--side {
|
||||
z-index: 6;
|
||||
right: auto;
|
||||
left: calc(-1 * var(--spacing-24));
|
||||
border-right: 0;
|
||||
border-radius: var(--radius-buttons) 0 0 var(--radius-buttons);
|
||||
}
|
||||
|
||||
/* 안쪽만 굴림 — 페이지 스크롤 안 생김 */
|
||||
.m02-cpanel__body {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-cpanel__list {
|
||||
max-height: 24rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m02-cpanel__row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0 var(--spacing-8);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.m02-cpanel__key {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m02-cpanel__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-cpanel__meta {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 고른 줄 + 바로 아래 [이 열에 쓰기] */
|
||||
.m02-cpanel__pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-cpanel__pick > .ui-btn {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
/* 이 열이 쓰는 조합 — 테두리로 늘 보임(고름 is-active 와 따로) */
|
||||
.m02-cpanel__row.is-current {
|
||||
box-shadow: inset 3px 0 0 var(--color-primary);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_ComboPanel.ts
|
||||
* 면적산출 편집 화면 오른쪽 안쪽 패널 — 좌측 패널과 같은 공용 틀(사이드바 카드 · 접기) ·
|
||||
* 공용 폭 손잡이(`ui_template_resizer` · 폭 기억 · 최소 폭) · 공용 접기 손잡이(`ui_template_overlay`).
|
||||
* · 「이 열의 조합」 — 일위대가 조합 줄 + [조합 고치기](`M02_MasterTemplete_Combo.ts`) ·
|
||||
* 넣는 쪽이 `setMine` 으로 갈아 끼움
|
||||
* · 조합 목록 — 일위대가 조합(이 층이 쓸 수 있는 마스터 UA · 회사 UC · 개인 UP · 27-2c) ·
|
||||
* 찾기 · 이 열 조합 강조 ·
|
||||
* 하나 골라 [이 열에 쓰기] = 집계표 그 열 키만 바꿈(서버 `use-combo` · 조합은 그대로)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { getActiveLayer, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api";
|
||||
import "./M02_MasterTemplete_ComboPanel.css";
|
||||
|
||||
const TEXT = {
|
||||
handle: "조합",
|
||||
mine: "이 열의 조합",
|
||||
noneArea: "이 열에 일위대가 조합 없음",
|
||||
listArea: "일위대가 조합 목록",
|
||||
search: "키 · 이름으로 찾기",
|
||||
logics: "로직 {n}",
|
||||
origin: "본뜬 {value}",
|
||||
current: "이 열",
|
||||
empty: "맞는 조합 없음",
|
||||
none: "조합 없음",
|
||||
use: "이 열에 쓰기",
|
||||
useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물 조합에 바로 적힘 · 조합은 그대로",
|
||||
useOk: "바꿈",
|
||||
used: "이 열이 {value} 을 씀",
|
||||
loadFailed: "조합 목록을 못 받음 — {value}",
|
||||
failed: "이 열에 못 씀 — {value}",
|
||||
};
|
||||
|
||||
const fill = (text: string, value: string | number): string =>
|
||||
text.replace("{n}", String(value)).replace("{value}", String(value));
|
||||
|
||||
/** 최소 폭(px) · 최대 = 편집 칸 폭의 비율 */
|
||||
const MIN_WIDTH = 280;
|
||||
const MAX_RATIO = 0.6;
|
||||
|
||||
interface Row {
|
||||
키: string;
|
||||
이름: string;
|
||||
수: number;
|
||||
본뜬키: string | null;
|
||||
}
|
||||
|
||||
export interface ComboPanelOptions {
|
||||
kind: "area";
|
||||
/** 집계표 열 id */
|
||||
column: string;
|
||||
isAdmin: boolean;
|
||||
/** [이 열에 쓰기] 뒤 — 알약 · 「이 열의 조합」을 새 조합으로 다시 그림 */
|
||||
onUsed: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface ComboPanel {
|
||||
root: HTMLElement;
|
||||
/** 「이 열의 조합」 부분을 갈아 끼움(조합 줄) */
|
||||
setMine: (node: HTMLElement | null) => void;
|
||||
/** 목록 · 이 열 조합 키를 다시 받음(갈라 낸 뒤 등) */
|
||||
refresh: () => Promise<void>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function buildComboPanel(opts: ComboPanelOptions): ComboPanel {
|
||||
let rows: Row[] = [];
|
||||
let current: string | null = null;
|
||||
let picked: string | null = null;
|
||||
let q = "";
|
||||
let seq = 0;
|
||||
|
||||
const card = (title: string, ...children: HTMLElement[]): HTMLElement =>
|
||||
el("section", {
|
||||
className: "ui-collapsible ui-sidebar-section ui-sidebar-card",
|
||||
children: [el("h3", { className: "ui-collapsible__title", text: title }), ...children],
|
||||
});
|
||||
const mine = el("div", { className: "m02-cpanel__mine" });
|
||||
const find = createInputField({ type: "search", placeholder: TEXT.search });
|
||||
const list = el("div", { className: "ui-sidebar-list m02-cpanel__list" });
|
||||
const use = createButton({ label: TEXT.use, variant: "filled", size: "sm" });
|
||||
const body = el("div", {
|
||||
className: "m02-cpanel__body ui-sidebar-panel",
|
||||
children: [card(TEXT.mine, mine), card(TEXT.listArea, find.root, list)],
|
||||
});
|
||||
attachCollapsible(body);
|
||||
|
||||
const root = el("aside", { className: "m02-cpanel", attrs: { "data-kind": opts.kind } });
|
||||
const handle = createWorkflowPanelHandle("side", "right", TEXT.handle);
|
||||
// 왼쪽 경계를 끌어 폭 조절 — 폭은 브라우저에 기억 · 더블클릭 = 기본 폭
|
||||
const resizer = createPanelResizer({
|
||||
axis: "horizontal",
|
||||
target: root,
|
||||
cssVar: "--m02-cpanel-width",
|
||||
direction: -1,
|
||||
min: MIN_WIDTH,
|
||||
max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * MAX_RATIO,
|
||||
storageKey: stateKey("m02-combo-panel-width") ?? undefined,
|
||||
});
|
||||
root.append(handle.root, resizer.root, body);
|
||||
const setCollapsed = (collapsed: boolean): void => {
|
||||
root.classList.toggle("is-collapsed", collapsed);
|
||||
handle.setOpen(!collapsed);
|
||||
writeStateRaw("m02-combo-panel-collapsed", String(collapsed));
|
||||
};
|
||||
handle.root.addEventListener("click", () =>
|
||||
setCollapsed(!root.classList.contains("is-collapsed")),
|
||||
);
|
||||
setCollapsed(readStateRaw("m02-combo-panel-collapsed") === "true");
|
||||
|
||||
find.input.addEventListener("input", () => {
|
||||
q = find.input.value.trim().toLowerCase();
|
||||
paint();
|
||||
});
|
||||
|
||||
function paint(): void {
|
||||
const shown = rows.filter((r) => !q || `${r.키} ${r.이름}`.toLowerCase().includes(q));
|
||||
list.replaceChildren(
|
||||
...(shown.length
|
||||
? shown.map((r) => {
|
||||
const meta = [
|
||||
fill(TEXT.logics, r.수),
|
||||
r.본뜬키 ? fill(TEXT.origin, r.본뜬키) : "",
|
||||
r.키 === current ? TEXT.current : "",
|
||||
];
|
||||
const b = el("button", {
|
||||
className:
|
||||
"m02-side__item ui-sidebar-row m02-cpanel__row" +
|
||||
(r.키 === picked ? " is-active" : "") +
|
||||
(r.키 === current ? " is-current" : ""),
|
||||
attrs: { type: "button", "data-combo-key": r.키, title: `${r.키} ${r.이름}` },
|
||||
children: [
|
||||
el("span", { className: "m02-cpanel__key", text: r.키 }),
|
||||
el("span", { className: "m02-cpanel__name", text: r.이름 }),
|
||||
el("span", {
|
||||
className: "m02-cpanel__meta",
|
||||
text: meta.filter(Boolean).join(" · "),
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
picked = r.키 === picked ? null : r.키;
|
||||
paint();
|
||||
});
|
||||
// [이 열에 쓰기]는 고른 줄 바로 아래(목록 안) — 목록 밖 단추가 스크롤된 줄 글과 겹치지 않게
|
||||
return opts.isAdmin && r.키 === picked && r.키 !== current
|
||||
? el("div", { className: "m02-cpanel__pick", children: [b, use] })
|
||||
: b;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: q ? TEXT.empty : TEXT.none })]),
|
||||
);
|
||||
use.disabled = false;
|
||||
if (use.isConnected) use.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const mineSeq = ++seq;
|
||||
try {
|
||||
const [base, got] = await Promise.all([
|
||||
readColumnBase(opts.column),
|
||||
fetchUsableCombos(getActiveLayer()).then((cs) =>
|
||||
cs.map((c) => ({
|
||||
키: c.키,
|
||||
이름: c.이름,
|
||||
수: c.count, // 목록 요약엔 담은로직이 없음 — 수만 옴
|
||||
본뜬키: c.본뜬키 ?? null,
|
||||
})),
|
||||
),
|
||||
]);
|
||||
if (mineSeq !== seq) return;
|
||||
current = base.일위대가 ?? null;
|
||||
rows = got;
|
||||
} catch (error) {
|
||||
if (mineSeq !== seq) return;
|
||||
showToast(fill(TEXT.loadFailed, reasonOf(error)), "error");
|
||||
}
|
||||
if (picked === current) picked = null;
|
||||
paint();
|
||||
}
|
||||
|
||||
use.addEventListener("click", async () => {
|
||||
const row = rows.find((r) => r.키 === picked);
|
||||
if (!row || row.키 === current) return;
|
||||
if (!(await showConfirmDialog(fill(TEXT.useAsk, `${row.키} ${row.이름}`), TEXT.useOk))) return;
|
||||
use.disabled = true;
|
||||
try {
|
||||
const got = await setColumnCombo(opts.column, {
|
||||
종류: "일위대가",
|
||||
키: row.키,
|
||||
});
|
||||
showToast(fill(TEXT.used, `${got.키} ${got.이름}`), "success");
|
||||
picked = null;
|
||||
await opts.onUsed();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showToast(fill(TEXT.failed, reasonOf(error)), "error");
|
||||
paint();
|
||||
}
|
||||
});
|
||||
|
||||
paint();
|
||||
void refresh();
|
||||
return {
|
||||
root,
|
||||
setMine: (node) =>
|
||||
mine.replaceChildren(node ?? el("p", { className: "m02-side__empty", text: TEXT.noneArea })),
|
||||
refresh,
|
||||
destroy: () => {
|
||||
++seq;
|
||||
resizer.dispose();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Combo_Api.ts
|
||||
* 조합 고치기 화면이 쓰는 읽기 — 집계표 열이 가리키는 조합 키 · 같이 쓰는 다른 열 수 ·
|
||||
* 서버 까닭 글. 갈라 내기 길(`branchCombo`)은 `M02_MasterTemplete_Api_Fetch.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { getActiveLayer, readTemplate } from "./M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
/** 구조물집계표 이름 — `M02_MasterTemplete_Structure.STRUCTURE_TABLE` 과 같은 글 */
|
||||
const STRUCTURE_TABLE = "구조물집계표";
|
||||
|
||||
/** 집계표 열이 가리키는 조합 키 + 같은 조합을 쓰는 다른 열 수 */
|
||||
export interface ColumnBase {
|
||||
일위대가: string | null;
|
||||
다른열일위대가: number;
|
||||
}
|
||||
|
||||
/** 서버가 준 까닭 글 */
|
||||
export const reasonOf = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
|
||||
/** 집계표(시스템 층)에서 이 열의 일위대가 키와 같이 쓰는 다른 열 수 */
|
||||
export async function readColumnBase(column: string): Promise<ColumnBase> {
|
||||
type Col = { id: string; 일위대가?: string | null };
|
||||
const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch(
|
||||
() => null,
|
||||
);
|
||||
const cols = ((table?.문서 as { 열?: Col[] } | null)?.열 ?? []) as Col[];
|
||||
const mine = cols.find((c) => c.id === column);
|
||||
const same = (key: string | null | undefined, pick: (c: Col) => string | null | undefined) =>
|
||||
key ? cols.filter((c) => c.id !== column && pick(c) === key).length : 0;
|
||||
return {
|
||||
일위대가: mine?.일위대가 || null,
|
||||
다른열일위대가: same(mine?.일위대가, (c) => c.일위대가),
|
||||
};
|
||||
}
|
||||
@@ -1,27 +1,13 @@
|
||||
/* 일위대가 조합 화면 — 표는 칸 폭을 채움 · 미리 보기 금액은 오른쪽 맞춤 ·
|
||||
/* 일위대가 화면 — 표는 칸 폭을 채움 ·
|
||||
흐린 안내 글은 보조 글색(뮤트 글색은 밝은 테마에서 대비 3.1 로 모자람) */
|
||||
.m01-combo .m01-master__grid {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m01-combo [data-combo="preview"] th:not(:first-child),
|
||||
.m01-combo [data-combo="preview"] td:not(:first-child) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01-combo .m01-logic__muted {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 조합 목록 — 공용 표 모양(줄 선 · 머리 줄) · 줄을 누르면 상세(PLAN 30-5) */
|
||||
.m02-labor__list tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-labor__list tbody tr:hover {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
/* 일위대가 상세(39-3) — 담은 로직 · 자재목록 표 · 금액 칸 두 줄(위 기준 작게 · 아래 반영) */
|
||||
.m02-labrow {
|
||||
width: 100%;
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import {
|
||||
ApiError,
|
||||
type LogicOne,
|
||||
type LogicSummary,
|
||||
type SubBrief,
|
||||
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
@@ -126,52 +125,12 @@ export async function fetchUsableCombos(
|
||||
.flatMap((group) => group.줄.map((c) => ({ ...c, layer: group.layer })));
|
||||
}
|
||||
|
||||
/** 조합 알약 물량 하나 — 키 = 「이름|단위」 · 담은 로직 결과단위 분모(같은 이름 · 단위는 합침) · `배` = 원/10㎡ 의 10 */
|
||||
export interface ComboQuantity {
|
||||
키: string;
|
||||
이름: string;
|
||||
단위: string;
|
||||
로직: string[];
|
||||
배?: number;
|
||||
}
|
||||
|
||||
/** 조합 알약(어느 층이든 · 옛 M01 `/combo/{키}/inputs`) */
|
||||
export const fetchComboInputs = (
|
||||
key: string,
|
||||
): Promise<{ 키: string; 이름: string; 물량: ComboQuantity[]; 조건: unknown[] }> =>
|
||||
request(`/combos/key/${enc(key)}/inputs`);
|
||||
|
||||
/** 키 하나 — 어느 층이든(그 층 읽기 권한) · `version` = 그 층 조합 표 판 */
|
||||
export const fetchCombo = (key: string): Promise<ComboOne & LaborScope> =>
|
||||
request<Omit<ComboOne, "file" | "version"> & LaborScope & { 판: string }>(
|
||||
`/combos/key/${enc(key)}`,
|
||||
).then((d) => ({ ...d, file: "일위대가조합.json", version: d.판 }));
|
||||
|
||||
export const newCombo = (
|
||||
combo: Combo,
|
||||
scope: LaborScope = SYSTEM_SCOPE,
|
||||
): Promise<{ version: string; key: string }> =>
|
||||
request<{ 키: string; 판: string }>(at(scope), "POST", { combo }).then((d) => ({
|
||||
version: d.판,
|
||||
key: d.키,
|
||||
}));
|
||||
|
||||
export const editCombo = (
|
||||
key: string,
|
||||
version: string,
|
||||
combo: Combo,
|
||||
scope: LaborScope = SYSTEM_SCOPE,
|
||||
): Promise<{ version: string }> =>
|
||||
request<{ 판: string }>(at(scope, `/${enc(key)}`), "PUT", { combo, 판: version }).then((d) => ({
|
||||
version: d.판,
|
||||
}));
|
||||
|
||||
export const deleteCombo = (
|
||||
key: string,
|
||||
version: string,
|
||||
scope: LaborScope = SYSTEM_SCOPE,
|
||||
): Promise<unknown> => request(at(scope, `/${enc(key)}`, { 판: version }), "DELETE");
|
||||
|
||||
/* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */
|
||||
export const fetchLibLogics = (): Promise<LogicSummary[]> =>
|
||||
request<{ logics: LogicSummary[] }>("/logic-lib/logics").then((d) => d.logics);
|
||||
@@ -179,14 +138,6 @@ export const fetchLibLogics = (): Promise<LogicSummary[]> =>
|
||||
export const fetchLibLogicSubs = (): Promise<SubBrief[]> =>
|
||||
request<{ subs: SubBrief[] }>("/logic-lib/subs").then((d) => d.subs);
|
||||
|
||||
export const fetchLibLogic = (key: string): Promise<LogicOne> =>
|
||||
request(`/logic-lib/logic?key=${enc(key)}`);
|
||||
|
||||
export const fetchLibAuto = (key: string): Promise<Record<string, unknown>> =>
|
||||
request<{ 값?: Record<string, unknown> }>(`/logic-lib/auto?key=${enc(key)}`)
|
||||
.then((d) => d.값 ?? {})
|
||||
.catch(() => ({}));
|
||||
|
||||
/* --- 일위대가 문서(39 · 구조물 열마다 `labor/<열>.json` · 틀 = sub7 39-1a) --- */
|
||||
|
||||
/** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */
|
||||
|
||||
@@ -292,19 +292,6 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 다이어그램에서 눌러 찾아온 미리 보기 입력 칸 */
|
||||
.m02-labor__flash {
|
||||
outline: 3px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
animation: m02-labor-flash 0.4s ease-in-out 3;
|
||||
}
|
||||
|
||||
@keyframes m02-labor-flash {
|
||||
50% {
|
||||
outline-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.m02-labor__dg > summary {
|
||||
cursor: pointer;
|
||||
font-weight: var(--font-weight-bold);
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Edit.ts
|
||||
* 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표
|
||||
* (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서)). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b) · 미리 보기 ·
|
||||
* 다이어그램은 39-3 에 구조물 열 일위대가 상세(`Labor_Detail`)로 감.
|
||||
* 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import {
|
||||
deleteCombo,
|
||||
editCombo,
|
||||
fetchCombo,
|
||||
fetchLibLogics,
|
||||
newCombo,
|
||||
type Combo,
|
||||
type ComboSummary,
|
||||
type LaborScope,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { matchText } from "./M02_MasterTemplete_TreePanel_Model";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
|
||||
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
|
||||
const blankCombo = (path: string[] = []): Combo => ({
|
||||
키: "",
|
||||
이름: "",
|
||||
구분: path[0] ?? null,
|
||||
상세구분: path[1] ?? null,
|
||||
단위: null,
|
||||
담은로직: [],
|
||||
출처: "자체",
|
||||
소유: "현장",
|
||||
본뜬키: null,
|
||||
비고: null,
|
||||
});
|
||||
|
||||
/** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 · 쓰는 곳 */
|
||||
export function reason(error: unknown): string {
|
||||
if (error instanceof ApiError && typeof error.detail === "object" && error.detail) {
|
||||
const d = error.detail as { errors?: string[]; stale?: string[] };
|
||||
// 서버 검사 글 머리(「파일.json · 키 」)는 떼고 보임 — 새로 만들 때 키는 아직 없는 번호라 헷갈림
|
||||
if (d.errors)
|
||||
return d.errors
|
||||
.map((x) => x.replace(/^(\S+\.json · )?(U[AM]\d{6} |U[CP]\d+-\d{6} )?/, ""))
|
||||
.join(" / ");
|
||||
if (d.stale) return tc("Stale");
|
||||
}
|
||||
return error instanceof Error ? error.message : tc("Load_Failed");
|
||||
}
|
||||
|
||||
export interface LaborEditOptions {
|
||||
scope: LaborScope;
|
||||
canEdit: boolean;
|
||||
/** 저장 · 지움 뒤 — 나무 · 목록을 다시 받고 그 조합(`key`)을 고름 */
|
||||
onSaved: (key: string | null) => Promise<void>;
|
||||
/** 저장 안 한 고침이 있나 — 페이지가 떠날 때 물음 */
|
||||
onDirty: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export interface LaborEditor {
|
||||
root: HTMLElement;
|
||||
/** 나무가 받은 목록 — 상세가 안 열려 있으면 목록을 다시 그림 */
|
||||
setList: (items: ComboSummary[]) => void;
|
||||
open: (key: string) => Promise<void>;
|
||||
/** 나무 「조합 더하기」 — 그 폴더(구분 · 상세구분)를 채운 빈 조합 */
|
||||
startNew: (path: string[]) => void;
|
||||
showList: () => void;
|
||||
/** 「새 조합」 — 제목 줄 오른쪽 끝에 붙임(읽기 층 = null) */
|
||||
newButton: HTMLElement | null;
|
||||
isDirty: () => boolean;
|
||||
/** 열린 조합 키 — 목록이면 null · 새 조합이면 빈 글 */
|
||||
current: () => string | null;
|
||||
/** 라이브러리 찾기 칸 글 — 목록을 같은 규칙(`matchText`)으로 거름 */
|
||||
setQuery: (text: string) => void;
|
||||
/** 조합 목록을 새로 받는 동안 칸 안 「불러오는 중」(목록 화면일 때만 · 돌려받은 함수 = 끝) */
|
||||
loading: () => () => void;
|
||||
}
|
||||
|
||||
export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
|
||||
const edit = opt.canEdit;
|
||||
let items: ComboSummary[] = [];
|
||||
let logics = new Map<string, LogicSummary>();
|
||||
/** 화면을 바꾼 횟수 — 늦게 온 답은 이 값이 달라졌으면 버림 */
|
||||
let nav = 0;
|
||||
/** `saved` = 연 때의 모양 — 「저장 안 한 고침」 판정 */
|
||||
let current: { combo: Combo; version: string; saved: string } | null = null;
|
||||
let q = "";
|
||||
const view = el("div", { className: "m01-logic__editor m01-combo m02-labor__edit" });
|
||||
// 찾기 칸은 라이브러리 하나(상세 위 · 나무와 같은 글 · PLAN 29-6) — `setQuery` 로 받음
|
||||
const setQuery = (text: string): void => {
|
||||
q = text;
|
||||
if (!current) drawList();
|
||||
};
|
||||
|
||||
const logicsReady = fetchLibLogics()
|
||||
.then((all) => {
|
||||
logics = new Map(all.map((x) => [x.키, x]));
|
||||
})
|
||||
.catch((error) =>
|
||||
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
|
||||
);
|
||||
|
||||
const dirty = (): boolean => !!current && JSON.stringify(current.combo) !== current.saved;
|
||||
const markDirty = (): void => opt.onDirty(dirty());
|
||||
|
||||
/* --- 목록 --- */
|
||||
const drawList = (): void => {
|
||||
nav++;
|
||||
current = null;
|
||||
opt.onDirty(false);
|
||||
const shown = items.filter((x) => matchText([x.키, x.이름, x.구분, x.상세구분, x.단위], q));
|
||||
const heads = [
|
||||
tc("Head_Key"),
|
||||
tc("Head_Name"),
|
||||
tc("Sub"),
|
||||
tc("Detail"),
|
||||
tc("Head_Unit"),
|
||||
tc("Head_Count"),
|
||||
tc("Head_Origin"),
|
||||
];
|
||||
const rows = shown.map((x) => {
|
||||
const tr = el("tr", {
|
||||
className: "m01-master__table-row",
|
||||
attrs: { "data-combo-key": x.키 },
|
||||
children: [
|
||||
x.키,
|
||||
x.이름,
|
||||
x.구분 ?? "",
|
||||
x.상세구분 ?? "",
|
||||
x.단위 ?? "",
|
||||
String(x.count),
|
||||
x.본뜬키 ?? tc("Origin_Self"),
|
||||
].map((v) => el("td", { text: v, attrs: { title: v } })),
|
||||
});
|
||||
tr.addEventListener("click", () => void open(x.키));
|
||||
return tr;
|
||||
});
|
||||
// 「새 조합」은 제목 줄 오른쪽 끝(`newButton` · PLAN 30-5) · 목록 = 공용 표 모양(줄 선 · 머리 줄)
|
||||
view.replaceChildren(
|
||||
...(edit ? [] : [el("p", { className: "m01-logic__muted", text: tc("ReadOnly") })]),
|
||||
shown.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-labor__list",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
: el("p", { className: "m01-logic__muted", text: tc(items.length ? "NoMatch" : "Empty") }),
|
||||
);
|
||||
};
|
||||
|
||||
const open = async (key: string): Promise<void> => {
|
||||
const my = ++nav;
|
||||
const stop = showLoading(view); // 0.3초 넘으면 상세 칸 안에 「불러오는 중」(PLAN 30-6)
|
||||
try {
|
||||
const one = await fetchCombo(key);
|
||||
await logicsReady;
|
||||
if (my !== nav) return;
|
||||
stop();
|
||||
draw(one.combo, one.version);
|
||||
} catch (error) {
|
||||
if (my !== nav) return;
|
||||
stop();
|
||||
view.replaceChildren(loadingError(reason(error), () => void open(key)));
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
/** 조합 목록을 새로 받는 동안 — 목록 화면일 때만 칸 안 표시(돌려받은 함수 = 끝) */
|
||||
const loading = (): (() => void) => (current ? () => {} : showLoading(view));
|
||||
|
||||
const startNew = (path: string[]): void => draw(blankCombo(path), "");
|
||||
|
||||
/* --- 상세 --- */
|
||||
const draw = (combo: Combo, version: string, saved = JSON.stringify(combo)): void => {
|
||||
nav++;
|
||||
current = { combo, version, saved };
|
||||
opt.onDirty(dirty());
|
||||
const field = (label: string, name: "이름" | "구분" | "상세구분" | "단위" | "비고") => {
|
||||
const f = createInputField({ type: "text", label });
|
||||
f.input.value = combo[name] ?? "";
|
||||
f.input.readOnly = !edit;
|
||||
f.input.setAttribute("data-combo-field", name);
|
||||
if (name === "이름") f.input.maxLength = 60; // 서버 한도(`MAX_이름`)와 같음
|
||||
f.input.addEventListener("input", () => {
|
||||
const v = f.input.value;
|
||||
if (name === "이름") combo.이름 = v;
|
||||
else combo[name] = v || null;
|
||||
markDirty();
|
||||
});
|
||||
return f.root;
|
||||
};
|
||||
const table = el("div", { className: "m01-master__grid-wrap" });
|
||||
const drawTable = (): void => {
|
||||
const move = (i: number, d: number): void => {
|
||||
const j = i + d;
|
||||
if (j < 0 || j >= combo.담은로직.length) return;
|
||||
[combo.담은로직[i], combo.담은로직[j]] = [combo.담은로직[j], combo.담은로직[i]];
|
||||
drawTable();
|
||||
markDirty();
|
||||
};
|
||||
const trs = combo.담은로직.map((row, i) => {
|
||||
const info = logics.get(row.로직);
|
||||
const memo = createInputField({ type: "text" });
|
||||
memo.input.value = row.메모 ?? "";
|
||||
memo.input.readOnly = !edit;
|
||||
memo.input.addEventListener("input", () => {
|
||||
row.메모 = memo.input.value || null;
|
||||
markDirty();
|
||||
});
|
||||
const small = (label: string, run: () => void): HTMLElement =>
|
||||
createButton({ label, variant: "ghost", size: "sm", onClick: run });
|
||||
const btns = el("div", {
|
||||
className: "m01-logic__chips",
|
||||
children: edit
|
||||
? [
|
||||
small(tc("Up"), () => move(i, -1)),
|
||||
small(tc("Down"), () => move(i, 1)),
|
||||
small(tc("Remove"), () => {
|
||||
combo.담은로직.splice(i, 1);
|
||||
drawTable();
|
||||
markDirty();
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
});
|
||||
return el("tr", {
|
||||
attrs: { "data-combo-logic": row.로직 },
|
||||
children: [
|
||||
el("td", { text: String(i + 1) }),
|
||||
el("td", { text: row.로직 }),
|
||||
el("td", { text: info?.이름 ?? row.로직 }),
|
||||
el("td", { text: info?.결과단위 ?? "" }),
|
||||
el("td", { children: [memo.root] }),
|
||||
el("td", { children: [btns] }),
|
||||
],
|
||||
});
|
||||
});
|
||||
const heads = [
|
||||
tc("Col_Order"),
|
||||
tc("Col_Key"),
|
||||
tc("Col_Name"),
|
||||
tc("Col_Unit"),
|
||||
tc("Col_Memo"),
|
||||
tc("Col_Move"),
|
||||
];
|
||||
table.replaceChildren(
|
||||
el("table", {
|
||||
className: "m01-master__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
el("tbody", { children: trs }),
|
||||
],
|
||||
}),
|
||||
...(trs.length ? [] : [el("p", { className: "m01-logic__muted", text: tc("NoLogic") })]),
|
||||
);
|
||||
};
|
||||
|
||||
const add = createButton({
|
||||
label: tc("AddLogic"),
|
||||
variant: "filled",
|
||||
onClick: () =>
|
||||
openLogicPicker((x) => {
|
||||
if (!logics.has(x.키)) logics.set(x.키, x);
|
||||
combo.담은로직.push({ 로직: x.키, 메모: null });
|
||||
drawTable();
|
||||
markDirty();
|
||||
}),
|
||||
});
|
||||
const save = createButton({
|
||||
label: tc("Save"),
|
||||
variant: "filled",
|
||||
onClick: async () => {
|
||||
if (!combo.이름.trim()) return showToast(tc("NeedName"), "error");
|
||||
save.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
let key = combo.키;
|
||||
if (key) await editCombo(key, version, combo, opt.scope);
|
||||
else key = (await newCombo(combo, opt.scope)).key;
|
||||
showToast(tc("Saved"), "success");
|
||||
current = null; // 저장함 — 다시 여는 동안 「고침 있음」 으로 안 물음
|
||||
opt.onDirty(false);
|
||||
await opt.onSaved(key);
|
||||
await open(key);
|
||||
} catch (error) {
|
||||
showToast(reason(error), "error");
|
||||
} finally {
|
||||
save.disabled = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
const remove = createButton({
|
||||
label: tc("Delete"),
|
||||
variant: "ghost",
|
||||
onClick: async () => {
|
||||
if (
|
||||
!combo.키 ||
|
||||
!(await showConfirmDialog(`${combo.키} ${combo.이름} — ${tc("DeleteAsk")}`))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await deleteCombo(combo.키, version, opt.scope);
|
||||
showToast(tc("Deleted"), "success");
|
||||
current = null;
|
||||
await opt.onSaved(null);
|
||||
drawList();
|
||||
} catch (error) {
|
||||
showToast(reason(error), "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
const back = createButton({
|
||||
label: "←",
|
||||
variant: "ghost",
|
||||
onClick: async () => {
|
||||
if (dirty() && !(await showConfirmDialog(tc("BackAsk")))) return;
|
||||
drawList();
|
||||
},
|
||||
});
|
||||
const origin = createInputField({ type: "text", label: tc("Head_Origin") });
|
||||
origin.input.value = combo.본뜬키 ?? tc("Origin_Self");
|
||||
origin.input.readOnly = true;
|
||||
origin.input.setAttribute("data-combo-origin", combo.본뜬키 ?? "");
|
||||
view.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-logic__section-head",
|
||||
children: [
|
||||
back,
|
||||
el("h3", { text: combo.키 || tc("New") }),
|
||||
...(edit ? [remove, save] : []),
|
||||
],
|
||||
}),
|
||||
el("section", {
|
||||
className: "m01lab__box m01-logic__section",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01lab__info",
|
||||
children: [
|
||||
field(tc("Head_Name"), "이름"),
|
||||
field(tc("Sub"), "구분"),
|
||||
field(tc("Detail"), "상세구분"),
|
||||
field(tc("Head_Unit"), "단위"),
|
||||
field(tc("Head_Note"), "비고"),
|
||||
origin.root,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("section", {
|
||||
className: "m01lab__box m01-logic__section",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-logic__section-head",
|
||||
children: [el("h3", { text: tc("Logics_Title") }), ...(edit ? [add] : [])],
|
||||
}),
|
||||
table,
|
||||
],
|
||||
}),
|
||||
);
|
||||
drawTable();
|
||||
};
|
||||
|
||||
view.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Loading") }));
|
||||
return {
|
||||
root: view,
|
||||
setList: (next) => {
|
||||
items = next;
|
||||
if (!current) drawList();
|
||||
},
|
||||
open,
|
||||
startNew,
|
||||
showList: drawList,
|
||||
newButton: edit
|
||||
? createButton({ label: tc("New"), variant: "filled", onClick: () => startNew([]) })
|
||||
: null,
|
||||
isDirty: dirty,
|
||||
current: () => current?.combo.키 ?? null,
|
||||
setQuery,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -7,9 +7,6 @@ import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["일위대가 조합", "Unit-price combination"],
|
||||
Library: ["일위대가 라이브러리", "Unit-price library"],
|
||||
AddCombo: ["조합 추가", "Add combination"],
|
||||
ComboName: ["조합 이름", "Combination name"],
|
||||
ReadOnly: ["읽기만 — 이 층은 고칠 수 없음", "Read only — this layer cannot be edited"],
|
||||
Sub: ["구분", "Category"],
|
||||
Detail: ["상세구분", "Detail"],
|
||||
@@ -18,36 +15,19 @@ const TEXT = {
|
||||
New: ["새 조합", "New combination"],
|
||||
Delete: ["지우기", "Delete"],
|
||||
Save: ["저장", "Save"],
|
||||
DeleteAsk: ["이 조합을 지울까요?", "Delete this combination?"],
|
||||
Saved: ["저장함", "Saved"],
|
||||
Deleted: ["지움", "Deleted"],
|
||||
Stale: ["그 사이 조합 파일이 바뀜 — 다시 열어 주세요", "The file changed — reopen it"],
|
||||
Loading: ["불러오는 중…", "Loading…"],
|
||||
LeaveAsk: [
|
||||
"저장 안 한 고침이 있음 — 로직 상세로 가면 이 화면에서는 그대로 두고 옮겨 감. 갈까요?",
|
||||
"Unsaved edits — go to the logic anyway?",
|
||||
],
|
||||
BackAsk: [
|
||||
"저장 안 한 고침이 있음 — 버리고 목록으로 갈까요?",
|
||||
"Unsaved edits — discard and go back to the list?",
|
||||
],
|
||||
Search: ["이름 · 키로 찾기", "Search by name or key"],
|
||||
NoMatch: ["찾은 조합 없음 — 거름 · 찾기 글을 바꿔 봄", "No match — change the filter or search"],
|
||||
NeedName: ["이름을 적어야 저장됨", "A name is required"],
|
||||
Load_Failed: ["불러오지 못함", "Load failed"],
|
||||
Head_Key: ["키", "Key"],
|
||||
Head_Name: ["이름", "Name"],
|
||||
Head_Unit: ["단위", "Unit"],
|
||||
Head_Note: ["비고", "Note"],
|
||||
Head_Count: ["로직 수", "Logics"],
|
||||
Head_Origin: ["본뜬키", "Copied from"],
|
||||
Origin_Self: ["원본", "Original"],
|
||||
Logics_Title: ["담은 로직", "Logics in it"],
|
||||
Col_Order: ["차례", "No."],
|
||||
Col_Key: ["로직 키", "Logic key"],
|
||||
Col_Name: ["이름", "Name"],
|
||||
Col_Unit: ["결과 단위", "Result unit"],
|
||||
Col_Memo: ["메모", "Memo"],
|
||||
Col_Move: ["순서", "Move"],
|
||||
Up: ["올리기", "Up"],
|
||||
Down: ["내리기", "Down"],
|
||||
@@ -70,7 +50,6 @@ const TEXT = {
|
||||
Prev_Expense: ["경비", "Expense"],
|
||||
Prev_Sum: ["합계", "Total"],
|
||||
// 일위대가 상세(39-3) — 구조물 열마다 하나
|
||||
Labor_Title: ["일위대가", "Unit price"],
|
||||
Labor_Unit: ["단위", "Unit"],
|
||||
Labor_Origin: ["본뜬 곳", "Copied from"],
|
||||
Labor_Mats: ["자재목록", "Materials"],
|
||||
@@ -81,7 +60,6 @@ const TEXT = {
|
||||
Labor_Applied: ["반영", "Applied"],
|
||||
Labor_BasePrice: ["기준 단가", "Base price"],
|
||||
Labor_AppliedSum: ["반영 금액", "Applied amount"],
|
||||
Labor_Calc_Wait: ["계산 중…", "Calculating…"],
|
||||
Labor_Calc_Fail: ["계산 못 함", "Calculation failed"],
|
||||
Labor_Empty: ["빈 값 {n}", "{n} empty"],
|
||||
Labor_Lost: [
|
||||
@@ -106,7 +84,6 @@ const TEXT = {
|
||||
In_Ok: ["확인", "OK"],
|
||||
In_Cancel: ["취소", "Cancel"],
|
||||
In_Close: ["닫기", "Close"],
|
||||
In_Loading: ["입력 목록 받는 중…", "Loading inputs…"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type ComboTextKey = keyof typeof TEXT;
|
||||
|
||||
@@ -170,12 +170,16 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우
|
||||
)
|
||||
|
||||
|
||||
def test_면적_재료_오른쪽_조합_패널은_공용_틀_손잡이():
|
||||
"""좌측과 같은 사이드바 카드 · 접기 · 공용 폭 손잡이 · 접기 손잡이 — 새로 그리지 않음."""
|
||||
cpanel = _text(M02 / "M02_MasterTemplete_ComboPanel.ts")
|
||||
def test_면적_오른쪽_일위대가_패널은_공용_틀_손잡이():
|
||||
"""좌측과 같은 사이드바 카드 · 접기 · 공용 폭 손잡이 · 접기 손잡이 — 새로 그리지 않음(39-4 · 옛 조합 패널 지움)."""
|
||||
apanel = _text(M02 / "M02_MasterTemplete_AreaPanel.ts")
|
||||
for name in ("createPanelResizer", "createWorkflowPanelHandle", "attachCollapsible"):
|
||||
assert name in cpanel, name
|
||||
assert "ui-sidebar-card" in cpanel and "setColumnCombo" in cpanel
|
||||
assert name in apanel, name
|
||||
assert "ui-sidebar-card" in apanel and "setColumnCombo" not in apanel
|
||||
for gone in ("ComboPanel.ts", "ComboPanel.css", "Combo.ts", "Combo.css", "Combo_Api.ts"):
|
||||
assert not (M02 / f"M02_MasterTemplete_{gone}").exists(), gone
|
||||
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
|
||||
assert "branchCombo" not in api and "setColumnCombo" not in api
|
||||
main = _text(MAIN)
|
||||
assert "comboSlot" not in main # 조합 줄은 패널 「이 열의 조합」 칸으로
|
||||
assert "matPanel" not in main # 자재 조합 판은 없앰
|
||||
|
||||
@@ -77,12 +77,10 @@ def test_실패_글과_다시_불러오기_M02_자리():
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert "const stop = showLoading(host);" in main
|
||||
assert "loadingError(why, () => void open(next))" in main
|
||||
edit = _text(M02 / "M02_MasterTemplete_Labor_Edit.ts")
|
||||
assert "loadingError(reason(error), () => void open(key))" in edit
|
||||
assert "(current ? () => {} : showLoading(view))" in edit
|
||||
# 좌측 일위대가 조합 나무(Labor.ts)는 39-2 에 지움 — 그 목록 자리는 실무문서 화면(같은 꼴)
|
||||
# 옛 조합 나무 · 상세(Labor.ts · Labor_Edit.ts)는 39 에 지움 — 그 자리는 실무문서(같은 꼴)
|
||||
practice = _text(M02 / "M02_MasterTemplete_Practice.ts")
|
||||
assert "const stop = showLoading(view);" in practice
|
||||
assert "loadingError(why(error), () => void drawList())" in practice
|
||||
assert "loadingError(why(error), () => void drawOne(key))" in practice
|
||||
assert "cards.map((c) => c.shell.loading())" in _text(M02 / "M02_MasterTemplete_ItemCards.ts")
|
||||
assert "loading: () => showLoading(host)," in _text(M02 / "M02_MasterTemplete_CardShell.ts")
|
||||
|
||||
Reference in New Issue
Block a user