Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_4' into sub_laptop_2
This commit is contained in:
@@ -29,6 +29,20 @@ export function nameMachines(list: string[], redraw: () => void): void {
|
||||
});
|
||||
}
|
||||
|
||||
let asked = false;
|
||||
/** 글 값이 기계 원문번호면 「이름 규격 (원문번호)」(838-3 · 요구 859) — 이름이 아직 없으면 한 번 받아 `redraw` · 아니면 null */
|
||||
export function machineText(x: unknown, redraw: () => void): string | null {
|
||||
if (typeof x !== "string" || !MACHINE.test(x)) return null;
|
||||
if (!(x in names) && !asked) {
|
||||
asked = true;
|
||||
void cfMachineNames().then((got) => {
|
||||
names = { ...names, ...got };
|
||||
redraw();
|
||||
});
|
||||
}
|
||||
return optionText(x);
|
||||
}
|
||||
|
||||
export function buildOptions(
|
||||
doc: FormulaDoc | null,
|
||||
given: Record<string, unknown>,
|
||||
|
||||
@@ -473,3 +473,37 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 836-1 기계 제어 구역 — 기계 줄 아래 접는 구역(값 칸 · 조종원 인력 줄 · 잡품 재료 줄) · 줄 꼴 = 단가산출 줄과 닮음 */
|
||||
.lstk-mctl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin-block-start: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.lstk-mctl > .lstk-frow {
|
||||
display: grid;
|
||||
grid-template-columns: 7em auto 11em auto auto;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.lstk-mctl > .lstk-frow > .lstk-sym {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.lstk-mctl .lstk-f-s {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.lstk-mctl .lstk-line.k-ctl > .lstk-f-s {
|
||||
flex: 0 1 9em;
|
||||
min-width: 6em;
|
||||
}
|
||||
|
||||
.lstk-mctl .lstk-line.k-ctl > :last-child {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_ut
|
||||
import { isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||||
import { lineMap, offLine, type Values } from "../common_util/common_util_formula_doc_view";
|
||||
import type { DocTable } from "../common_util/common_util_formula_doc_table";
|
||||
import type { DocRecord } from "../common_util/common_util_formula_doc_cache";
|
||||
import { coverOf } from "../common_util/common_util_formula_doc_cover";
|
||||
import {
|
||||
dropSyms,
|
||||
docOf,
|
||||
@@ -38,12 +38,7 @@ import {
|
||||
} from "../common_util/common_util_line_stack";
|
||||
import { placeNames } from "../common_util/common_util_line_stack_copy";
|
||||
import { askFileWide, materialMark, matEntry } from "./line_stack_material";
|
||||
import {
|
||||
draftsOf,
|
||||
machineNow,
|
||||
pruneDrafts,
|
||||
putDraft,
|
||||
} from "../common_util/common_util_line_stack_draft";
|
||||
import { draftsOf, machineNow, pruneDrafts } from "../common_util/common_util_line_stack_draft";
|
||||
import {
|
||||
addPicked,
|
||||
type Picked,
|
||||
@@ -57,6 +52,7 @@ import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_U
|
||||
import { reach } from "./formula_doc_render";
|
||||
import { attachDrag } from "./line_stack_drag";
|
||||
import { openMachineWindow, type MachineCtx } from "./line_stack_machine";
|
||||
import { buildMachineCtl, machineOfLine } from "./line_stack_machine_ctl";
|
||||
import { openStackPick, type StackTake } from "./line_stack_pick";
|
||||
import { buildBlock, sourceTag } from "./line_stack_rows";
|
||||
import { lt, type LsKey } from "./line_stack_text";
|
||||
@@ -78,10 +74,10 @@ export interface EditHooks {
|
||||
take?: StackTake;
|
||||
/** 저장본 식 줄 {기호: 식} — 만약 식을 고쳐 굳은 줄 표지(요구 696) */
|
||||
before?: ReadonlyMap<string, unknown>;
|
||||
/** 827-1 기계 줄 = 기계 창(조회 · 바꾸기 · 고쳐 쓰기) — 층 · 권한 · 사본 줄 */
|
||||
/** 827-1 기계 줄 = 기계 창(찾기 · 바꾸기 · 836-1) — 층 · 사본 줄 */
|
||||
machine?: MachineCtx;
|
||||
/** 827-1 초안 기계 셈 기록 → 계산기 캐시(브라우저 즉시 셈 · 서버 미리 셈과 같은 값) */
|
||||
prime?: (record: DocRecord) => void;
|
||||
/** 836-1 기계 제어 구역 — 덮음 없이 그 기계 식 셈(원본 = 기본값) */
|
||||
calc?: (doc: FormulaDoc, given: Record<string, unknown>) => DocAnswer;
|
||||
}
|
||||
|
||||
const field = (
|
||||
@@ -192,10 +188,6 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||||
now: machineNow(d, link as { 갈래?: string; 입력?: Record<string, string> }),
|
||||
query,
|
||||
pick: swap,
|
||||
draft: (item, p, record) => {
|
||||
h.prime?.(record);
|
||||
h.change((doc) => putDraft(doc, i, own, item, p));
|
||||
},
|
||||
})
|
||||
: openStackPick(kind, swap, undefined, query),
|
||||
});
|
||||
@@ -203,6 +195,24 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||||
pick.title = `${lt("Repick")}${res ? ` — ${res}` : ""}`;
|
||||
pick.dataset.repick = String(i);
|
||||
head.push(pick);
|
||||
if (kind === "기계" && h.calc) {
|
||||
// 836-1 기계 제어 구역 접기 · 고친 기계 표지
|
||||
const on = h.open.has(`mctl:${i}`);
|
||||
const t = createIconButton({
|
||||
icon: on ? "chevronDown" : "chevronRight",
|
||||
title: lt("Ctl_Open"),
|
||||
size: 24,
|
||||
onClick: () => {
|
||||
if (on) h.open.delete(`mctl:${i}`);
|
||||
else h.open.add(`mctl:${i}`);
|
||||
h.change(() => undefined);
|
||||
},
|
||||
});
|
||||
t.dataset.mctlToggle = String(i);
|
||||
head.push(t);
|
||||
const m = machineOfLine(h.answer, link);
|
||||
if (m && coverOf(d)[m]) head.push(createBadge(lt("Ctl_Changed"), "info", { size: 18 }));
|
||||
}
|
||||
const mark = machineMark(found, link);
|
||||
if (mark) head.push(mark);
|
||||
const matMark = kind === "재료" ? materialMark(matEntry(d, own), link) : null; // 827-3 판정 · 단서
|
||||
@@ -310,7 +320,9 @@ function qtyBox(
|
||||
);
|
||||
f.dataset.qty = String(i);
|
||||
const val = v ? (h.answer.값 ?? {})[text] : undefined;
|
||||
const tag = v ? sourceTag(h, text, v.연결) : null;
|
||||
// 838-3 공식 · 표 값 묶음에 이미 보이는 기호 = 줄 쪽 표 칩 걷음(묶음에서 고름) · 묶음에 없으면 줄에서 고름
|
||||
const inBlock = h.items.some((it) => it.줄.some((l) => l.꼴 === "공식" && l.기호.includes(text)));
|
||||
const tag = v && !inBlock ? sourceTag(h, text, v.연결) : null;
|
||||
if (tag && isNum(val))
|
||||
tag.title = `${eqText(val)}${shownUnit(v?.단위) ? ` ${shownUnit(v?.단위)}` : ""} — ${tag.title || tag.textContent}`;
|
||||
return el("span", { className: "lstk-qty", children: tag ? [f, tag] : [f] });
|
||||
@@ -490,6 +502,14 @@ export function buildEdit(h: EditHooks): HTMLElement {
|
||||
: [lineGrip, ...body, del],
|
||||
});
|
||||
card.dataset.loc = `${i}.${j}`;
|
||||
if (line.꼴 === "금액" && h.calc && h.open.has(`mctl:${line.i}`)) {
|
||||
const own = ownedOf(h.doc).get(line.i);
|
||||
const zone = buildMachineCtl(
|
||||
{ ...h, calc: h.calc, change: (fn) => h.change((d) => fn(d)), readOnly: !h.machine?.scope }, // prettier-ignore
|
||||
h.doc.변수?.find((x) => varId(x) === own)?.연결,
|
||||
);
|
||||
if (zone) card.append(zone);
|
||||
}
|
||||
card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`));
|
||||
attachDrag(lineGrip, {
|
||||
what: "line",
|
||||
|
||||
@@ -1,48 +1,38 @@
|
||||
/* =============================================================================
|
||||
* line_stack_machine.ts
|
||||
* 줄 쌓기 기계 창(PLAN 827-1 · 요구 810 · 811) — 기계 줄 이름 단추 · 옵션 가로 [조회]가 띄움.
|
||||
* 왼쪽 = 기계 찾기 — 맨 위 「지금 이 줄」 · 이 단가산출 초안 · 개인 층 내 기계 · M01 기계(줄마다 시간당)
|
||||
* 오른쪽 = 공통 기계 양식(`ui_template_machine_form` · 읽기 전용) — 지금 줄 = 이 단가산출 사본에 박힌 값
|
||||
* (원본 지금 값과 다르면 「새 값 있음」) · 고른 줄 = 그 기계 지금 값
|
||||
* [이 기계로 바꾸기] = `pick`(부른 쪽 `repick` · 가져온 식 823-1 규칙 그대로)
|
||||
* [고쳐 쓰기](개인 층 · 쓰기 권한 · `ctx.canEdit`) = 양식 편집 → [확인] = 서버 확인(쓰기 없음) → `draft`
|
||||
* (부른 쪽이 문서 칸 `내기계초안` 에 · 디스크는 단가산출 [저장] 때 서버) · 그 밖 = 까닭 한 줄
|
||||
* M01 기계에서 시작 = 새 내 기계(본뜬 = 그 키) · 내 기계 = 그 기계 고침 · 초안 = 그 초안 고침 · 즉시 저장 없음
|
||||
* 줄 쌓기 기계 창(PLAN 827-1 · 836-1 · 요구 810 · 846 · 853) — 기계 줄 이름 단추 · 옵션 가로 [조회]가 띄움 = 찾기 · 바꾸기만
|
||||
* 왼쪽 = 기계 찾기 — 맨 위 「지금 이 줄」 · 이 단가산출 옛 초안 · 개인 층 내 기계 · M01 기계(줄마다 시간당)
|
||||
* 오른쪽 = 고른 기계 이름 · 시간당 · 사본 표지(이 단가산출에 복사된 값 · 원본 새 값) · [이 기계로 바꾸기] = `pick`
|
||||
* 기계 내부 값 고침 = 단가산출 왼쪽 기계 제어 구역(`line_stack_machine_ctl` · 836-1) — 이 창의 양식 · [고쳐 쓰기] 걷음
|
||||
* (단가산출 안 고침은 내 기계를 만들지 않음 · 내 기계 목록 · 개인관리 화면은 그대로)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { createMachineForm, HOURLY_KEY, type MachineValues } from "@ui/ui_template_machine_form";
|
||||
import { createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { isDraftNo, newTag, type DraftItem } from "../common_util/common_util_line_stack_draft";
|
||||
import type { DocRecord } from "../common_util/common_util_formula_doc_cache";
|
||||
import { isDraftNo, type DraftItem } from "../common_util/common_util_line_stack_draft";
|
||||
import type { Picked } from "../common_util/common_util_line_stack_pick";
|
||||
import { call, type Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
checkDraft,
|
||||
fetchMyMachineLists,
|
||||
listMyMachines,
|
||||
machineArg,
|
||||
machineRows,
|
||||
readMyMachine,
|
||||
type MachineBrief,
|
||||
type MyHourly,
|
||||
type MyMachineRow,
|
||||
} from "../M02_MasterTemplete/M02_MyMachine_Api";
|
||||
import { bodyOfForm, hourlyText, valuesOf } from "../M02_MasterTemplete/M02_MyMachine_Form";
|
||||
import { hourlyText } from "../M02_MasterTemplete/M02_MyMachine_Form";
|
||||
import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text";
|
||||
import { lt } from "./line_stack_text";
|
||||
|
||||
/** 사본 줄(서버 `copy_row`) — 키 · 값 · 방식(내 기계) · 시간당(직접) */
|
||||
/** 사본 줄(서버 `copy_row`) — 키 · 이름 · 규격 */
|
||||
interface CopyRowData {
|
||||
키: string;
|
||||
원문번호?: string;
|
||||
이름?: string;
|
||||
규격?: string;
|
||||
값?: MachineValues;
|
||||
방식?: string;
|
||||
시간당?: Record<string, number>;
|
||||
}
|
||||
export interface CopyAnswer {
|
||||
사본: CopyRowData | null;
|
||||
@@ -53,11 +43,7 @@ export interface CopyAnswer {
|
||||
export interface MachineCtx {
|
||||
/** 층 · 주인 — null = M01 단가산출 기초(M01 기계만) */
|
||||
scope: { layer: Layer; owner: string | null } | null;
|
||||
/** [고쳐 쓰기] 됨(개인 층 SK · 쓰기 권한) */
|
||||
canEdit: boolean;
|
||||
/** 못 고칠 까닭(M01 · 회사 · 마스터 층) */
|
||||
why: string;
|
||||
/** 이 SK 키(쓰는 곳 셈에서 뺌) */
|
||||
/** 이 SK 키 */
|
||||
me?: string;
|
||||
/** 이 SK 사본에 박힌 기계 줄(키 있는 M02 SK) */
|
||||
copyRow?: (no: string) => Promise<CopyAnswer>;
|
||||
@@ -71,8 +57,6 @@ export interface MachineWindowOptions {
|
||||
query: string;
|
||||
/** 없으면 보기만(옵션 가로 조회) */
|
||||
pick?: (p: Picked) => void;
|
||||
/** 고쳐 쓰기 [확인] — 초안 · 줄 연결 · 그 식 셈 기록(계산기 캐시 · 즉시 셈) */
|
||||
draft?: (d: DraftItem, p: Picked, record: DocRecord) => void;
|
||||
}
|
||||
|
||||
type Kind = "now" | "draft" | "mine" | "m01";
|
||||
@@ -90,45 +74,15 @@ interface Cand {
|
||||
key?: string;
|
||||
}
|
||||
interface Shown {
|
||||
values: MachineValues;
|
||||
name: string;
|
||||
spec: string;
|
||||
lines: HTMLElement[];
|
||||
logic?: string | null;
|
||||
/** [고쳐 쓰기] 대상 */
|
||||
target: { 표지?: string; 키?: string; 본뜬?: string };
|
||||
/** 내 기계 = 쓰는 곳 셈 키 */
|
||||
mineKey?: string;
|
||||
}
|
||||
|
||||
const kindOf = (no: string): Kind =>
|
||||
isDraftNo(no) ? "draft" : no.startsWith("개인 ") ? "mine" : "m01";
|
||||
|
||||
/** 사본 · 원본 줄 → 양식 값 — 직접 = 시간당 · 바로(셈 꼴) = 계수 셋 · 빈 연료 · 조종원 0 뺌 */
|
||||
function formValues(row: CopyRowData): MachineValues {
|
||||
if (row.방식 === "직접") {
|
||||
const v = row.시간당 ?? row.값 ?? {};
|
||||
return { [HOURLY_KEY]: { 노무비: v.노무비, 재료비: v.재료비, 경비: v.경비 } };
|
||||
}
|
||||
const v: MachineValues = { ...(row.값 ?? {}) };
|
||||
if (row.방식 === "바로") {
|
||||
for (const k of ["상각비계수", "정비비계수", "관리비계수"]) delete v[k];
|
||||
for (const k of ["주연료", "잡재료율", "조종원"]) if (v[k] === 0) delete v[k];
|
||||
for (const k of ["주연료종류", "조종원직종"]) if (v[k] === "") delete v[k];
|
||||
}
|
||||
return v;
|
||||
}
|
||||
/** 내 기계 목록 줄 → 양식 값(직접 = 서버 시간당 노 · 재 · 경) */
|
||||
const mineValues = (m: MyMachineRow): MachineValues =>
|
||||
valuesOf({
|
||||
방식: m.방식,
|
||||
값: m.값,
|
||||
시간당: {
|
||||
노무비: m.시간당.노무비 ?? 0,
|
||||
재료비: m.시간당.재료비 ?? 0,
|
||||
경비: m.시간당.경비 ?? 0,
|
||||
},
|
||||
});
|
||||
const hourOf = (h: MyHourly | undefined): number | null => (h?.ok ? (h.계 ?? null) : null);
|
||||
const same = (a: Cand | undefined, b: Cand | null): boolean =>
|
||||
!!a && !!b && a.kind === b.kind && a.no === b.no && a.key === b.key;
|
||||
@@ -142,8 +96,6 @@ export function openMachineWindow(o: MachineWindowOptions): void {
|
||||
const briefs = new Map<string, MachineBrief>();
|
||||
let cands: Cand[] = [];
|
||||
let sel: Cand | null = null;
|
||||
let editing = false;
|
||||
let formDirty = (): boolean => false;
|
||||
let seq = 0;
|
||||
let showSeq = 0;
|
||||
let timer = 0;
|
||||
@@ -264,7 +216,7 @@ export function openMachineWindow(o: MachineWindowOptions): void {
|
||||
return got;
|
||||
};
|
||||
|
||||
/** 고른 줄 → 양식 값 · 글 · 바꿀 연결 · 고칠 대상 */
|
||||
/** 고른 줄 → 이름 · 글(시간당 · 사본 표지) · 바꿀 식 */
|
||||
const resolve = async (c: Cand): Promise<Shown> => {
|
||||
const k = c.kind === "now" ? kindOf(c.no) : c.kind;
|
||||
const lines: HTMLElement[] = [];
|
||||
@@ -274,24 +226,7 @@ export function openMachineWindow(o: MachineWindowOptions): void {
|
||||
lines.push(createText(mt("Win_Draft"), "muted"));
|
||||
if (d.시간당)
|
||||
lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(d.시간당 as MyHourly)}`));
|
||||
const target = {
|
||||
표지: d.표지,
|
||||
...(d.키 ? { 키: d.키 } : {}),
|
||||
...(d.본뜬 ? { 본뜬: d.본뜬 } : {}),
|
||||
};
|
||||
return {
|
||||
values: valuesOf({
|
||||
방식: d.줄.방식 as never,
|
||||
값: (d.줄.값 ?? {}) as MachineValues,
|
||||
시간당: d.줄.시간당 as never,
|
||||
}),
|
||||
name: d.줄.이름,
|
||||
spec: d.줄.규격,
|
||||
lines,
|
||||
logic: d.시간당?.로직,
|
||||
target,
|
||||
mineKey: d.키,
|
||||
};
|
||||
return { name: d.줄.이름, spec: d.줄.규격, lines, logic: d.시간당?.로직 };
|
||||
}
|
||||
// 지금 줄 = 이 단가산출 사본 값(있으면)
|
||||
const copy = c.kind === "now" && ctx.copyRow ? await ctx.copyRow(c.no).catch(() => null) : null;
|
||||
@@ -302,29 +237,22 @@ export function openMachineWindow(o: MachineWindowOptions): void {
|
||||
}
|
||||
if (k === "mine") {
|
||||
const m = c.mine ?? mines.find((r) => r.원문번호 === c.no);
|
||||
const key = m?.키 ?? c.no.replace(/^개인 /, "");
|
||||
const was = o.drafts.find((d) => d.키 === key); // 이미 고친 초안 = 그 초안을 다시 고침
|
||||
if (!row && m) lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(m.시간당)}`));
|
||||
if (m) lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(m.시간당)}`));
|
||||
return {
|
||||
values: row ? formValues(row) : m ? mineValues(m) : {},
|
||||
name: row?.이름 ?? m?.이름 ?? c.이름,
|
||||
spec: row?.규격 ?? m?.규격 ?? c.규격,
|
||||
lines,
|
||||
logic: m?.시간당.로직,
|
||||
target: { 키: key, ...(was ? { 표지: was.표지 } : {}) },
|
||||
mineKey: key,
|
||||
};
|
||||
}
|
||||
const b = await briefOf(c.no, c.key);
|
||||
if (!row && b) lines.push(createText(`${mt("Win_Live")} · ${hourlyText(b.시간당)}`));
|
||||
if (b) lines.push(createText(`${mt("Win_Live")} · ${hourlyText(b.시간당)}`));
|
||||
if (!row && !b) throw new Error(mt("Hourly_None"));
|
||||
return {
|
||||
values: row ? formValues(row) : { ...(b?.값 ?? {}) },
|
||||
name: row?.이름 ?? b?.이름 ?? c.이름,
|
||||
spec: row?.규격 ?? b?.규격 ?? c.규격,
|
||||
lines,
|
||||
logic: b?.시간당.ok ? b.시간당.로직 : null,
|
||||
target: { 본뜬: row?.키 ?? b?.키 },
|
||||
};
|
||||
};
|
||||
|
||||
@@ -335,19 +263,17 @@ export function openMachineWindow(o: MachineWindowOptions): void {
|
||||
};
|
||||
|
||||
const choose = async (c: Cand): Promise<void> => {
|
||||
if (editing && formDirty() && !(await showConfirmDialog(mt("Leave_Ask")))) return;
|
||||
editing = false;
|
||||
sel = c;
|
||||
table.setRows(cands.map((x) => ({ 품명: x.이름, 규격: x.규격, 금액: x.계, 구분: x.구분 })));
|
||||
await show(c);
|
||||
};
|
||||
|
||||
async function show(c: Cand, edit = false, blank = false): Promise<void> {
|
||||
async function show(c: Cand): Promise<void> {
|
||||
const mine = ++showSeq;
|
||||
right.replaceChildren(createText(mt("Server_Hourly"), "muted"));
|
||||
let s: Shown;
|
||||
try {
|
||||
s = blank ? { values: {}, name: "", spec: "", lines: [], target: {} } : await resolve(c);
|
||||
s = await resolve(c);
|
||||
} catch (error) {
|
||||
if (mine === showSeq)
|
||||
right.replaceChildren(
|
||||
@@ -355,151 +281,41 @@ export function openMachineWindow(o: MachineWindowOptions): void {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const lists = await fetchMyMachineLists();
|
||||
if (mine !== showSeq) return;
|
||||
editing = edit;
|
||||
const notice = el("div", { className: "lstk-mwin__notice" });
|
||||
const form = createMachineForm({
|
||||
name: s.name,
|
||||
spec: s.spec,
|
||||
values: s.values,
|
||||
coefMode: "direct",
|
||||
allowHourly: true,
|
||||
fuels: lists.fuels,
|
||||
jobs: lists.jobs,
|
||||
power: lists.power,
|
||||
hour: lists.hour,
|
||||
readOnly: !edit,
|
||||
onChange: () => notice.replaceChildren(),
|
||||
});
|
||||
formDirty = () => edit && form.state().changed;
|
||||
const bar: HTMLElement[] = [];
|
||||
if (!edit) {
|
||||
const p = pickedOf(c, s);
|
||||
if (o.pick && c.kind !== "now") {
|
||||
const b = createButton({
|
||||
label: mt("Win_Swap"),
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
disabled: !p,
|
||||
onClick: () => {
|
||||
if (!p) return;
|
||||
close();
|
||||
o.pick!(p);
|
||||
},
|
||||
});
|
||||
b.dataset.mwinSwap = "";
|
||||
if (!p) b.title = mt("Pick_Direct");
|
||||
bar.push(b);
|
||||
}
|
||||
if (o.draft && ctx.canEdit) {
|
||||
const b = createButton({
|
||||
label: mt("Win_Edit"),
|
||||
variant: c.kind === "now" ? "filled" : "ghost",
|
||||
size: 28,
|
||||
iconStart: "pencil",
|
||||
onClick: () => void show(c, true),
|
||||
});
|
||||
b.dataset.mwinEdit = "";
|
||||
bar.push(b);
|
||||
} else if (o.draft && ctx.why) bar.push(createText(ctx.why, "muted"));
|
||||
} else {
|
||||
const ok = createButton({ label: mt("Win_Ok"), variant: "filled", size: 28 });
|
||||
ok.dataset.mwinOk = "";
|
||||
ok.addEventListener("click", () => void confirm(form.state(), s, notice, ok));
|
||||
bar.push(
|
||||
ok,
|
||||
createButton({
|
||||
label: mt("Win_Cancel"),
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () => void (blank ? choose(c) : show(c)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const extra: HTMLElement[] = [];
|
||||
if (edit) extra.push(createText(mt("Win_Edit_Note"), "muted"));
|
||||
if (edit && s.target.키) {
|
||||
const uses = el("div");
|
||||
extra.push(uses);
|
||||
void readMyMachine(owner, s.target.키)
|
||||
.then((one) => {
|
||||
const n = (one.쓰는곳 ?? []).filter((u) => u.키 !== ctx.me).length;
|
||||
uses.replaceChildren(createText(mt("Win_Uses").replace("{value}", String(n)), "warning"));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
const p = pickedOf(c, s);
|
||||
if (o.pick && c.kind !== "now") {
|
||||
const b = createButton({
|
||||
label: mt("Win_Swap"),
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
disabled: !p,
|
||||
onClick: () => {
|
||||
if (!p) return;
|
||||
close();
|
||||
o.pick!(p);
|
||||
},
|
||||
});
|
||||
b.dataset.mwinSwap = "";
|
||||
if (!p) b.title = mt("Pick_Direct");
|
||||
bar.push(b);
|
||||
}
|
||||
right.replaceChildren(
|
||||
el("div", {
|
||||
className: "ui-toolbar",
|
||||
children: [el("strong", { text: `${s.name} ${s.spec}`.trim() || mt("New") }), ...bar],
|
||||
children: [el("strong", { text: `${s.name} ${s.spec}`.trim() }), ...bar],
|
||||
}),
|
||||
...s.lines,
|
||||
...extra,
|
||||
notice,
|
||||
form.root,
|
||||
createText(lt("Win_Ctl_Note"), "muted"),
|
||||
);
|
||||
right.dataset.mwinMode = edit ? "edit" : "view";
|
||||
right.dataset.mwinMode = "view";
|
||||
}
|
||||
|
||||
/** [확인] — 서버 확인(쓰기 없음) → 초안 · 줄 연결 */
|
||||
async function confirm(
|
||||
state: ReturnType<ReturnType<typeof createMachineForm>["state"]>,
|
||||
s: Shown,
|
||||
notice: HTMLElement,
|
||||
btn: HTMLButtonElement,
|
||||
): Promise<void> {
|
||||
const { body, errors } = bodyOfForm(state);
|
||||
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
|
||||
if (!body) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const tag = s.target.표지 ?? newTag();
|
||||
const got = await checkDraft(owner, body, { ...s.target, 표지: tag });
|
||||
if (!got.시간당.ok || !got.시간당.로직)
|
||||
throw new Error(hourlyText(got.시간당) || mt("Pick_Direct"));
|
||||
const item: DraftItem = {
|
||||
표지: tag,
|
||||
...(s.target.키 ? { 키: s.target.키 } : {}),
|
||||
...(!s.target.키 && s.target.본뜬 ? { 본뜬: s.target.본뜬 } : {}),
|
||||
줄: body,
|
||||
시간당: got.시간당,
|
||||
};
|
||||
const p: Picked = {
|
||||
키: got.시간당.로직,
|
||||
이름: body.이름,
|
||||
입력: { 기계: `'개인 ${tag}'` },
|
||||
...(body.규격 ? { 규격: body.규격 } : {}),
|
||||
};
|
||||
formDirty = () => false;
|
||||
close();
|
||||
o.draft!(item, p, got.연결);
|
||||
} catch (error) {
|
||||
notice.replaceChildren(
|
||||
createText(error instanceof Error ? error.message : mt("Failed"), "error"),
|
||||
);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const blankBtn = createButton({
|
||||
label: mt("Win_Blank"),
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
const c: Cand = { kind: "draft", no: "", 이름: "", 규격: "", 계: null, 구분: "" };
|
||||
sel = null;
|
||||
void show(c, true, true);
|
||||
},
|
||||
});
|
||||
blankBtn.dataset.mwinBlank = "";
|
||||
({ close } = openModal({
|
||||
title: mt("Win_Title"),
|
||||
closeLabel: mt("Close"),
|
||||
width: "1100px",
|
||||
wide: true,
|
||||
...(o.draft && ctx.canEdit ? { actions: [blankBtn] } : {}),
|
||||
beforeClose: async () => !formDirty() || showConfirmDialog(mt("Leave_Ask")),
|
||||
mount: (body) =>
|
||||
body.append(
|
||||
el("div", {
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/* =============================================================================
|
||||
* line_stack_machine_ctl.ts
|
||||
* 줄 쌓기 기계 제어 구역(PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 왼쪽 기계 줄 아래 접는 구역
|
||||
* ㉠ 값 칸 = 그 기계 식이 읽는 기계 칸(단위 · 기본 = 사본 값 · 고친 칸 = 「고침」 + [원래 값])
|
||||
* ㉡ 조종원 = 인력 줄(노임 고르기 · 인/일 · 더하기 · 빼기 · 기본 = 기계 값 조종원 · 직종)
|
||||
* ㉢ 잡품 = 재료 줄(재료 · 유가 고르기 · 수량 · 율 % · 기본 = 밑 재료 줄 · 품셈 잡재료율)
|
||||
* 고침 = 문서 칸 `기계덮음`(캐시 · [저장] 때 서버가 정본으로 다시 셈) · 원본과 같으면 지움(`putCover`)
|
||||
* 기본값 = 덮음 없이 셈한 그 기계 식 답(`defaultsOf`) · 읽기(M01 기초) = 칸 잠김 · 단추 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton, createIconButton } from "@ui/ui_template_button";
|
||||
import { eqText } from "@ui/ui_template_equation";
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { varId, type FormulaDoc, type Link } from "../common_util/common_util_formula_doc";
|
||||
import type { DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||||
import {
|
||||
coverOf,
|
||||
defaultsOf,
|
||||
machineKey,
|
||||
partsOf,
|
||||
putCover,
|
||||
type CrewRow,
|
||||
type MachineCover,
|
||||
type MiscRow,
|
||||
} from "../common_util/common_util_formula_doc_cover";
|
||||
import { subInputs, type StackDoc } from "../common_util/common_util_line_stack";
|
||||
import type { Picked } from "../common_util/common_util_line_stack_pick";
|
||||
import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
|
||||
import { openStackPick } from "./line_stack_pick";
|
||||
import { lt } from "./line_stack_text";
|
||||
|
||||
export interface CtlHooks {
|
||||
doc: StackDoc;
|
||||
answer: DocAnswer;
|
||||
subDoc: (key: string) => FormulaDoc | null;
|
||||
/** 덮음 없이 그 기계 식 셈(원본 = 기본값) */
|
||||
calc: (doc: FormulaDoc, given: Record<string, unknown>) => DocAnswer;
|
||||
change: (fn: (d: StackDoc) => void) => void;
|
||||
name: (key: string) => string;
|
||||
/** 읽기(M01 기초 · 칸 잠김) */
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
/** 기계 칸 단위(기계 표 `단위` 와 같음) */
|
||||
const UNIT: Record<string, string> = {
|
||||
가격: "천원", 상각비계수: "10⁻⁷/hr", 정비비계수: "10⁻⁷/hr", 관리비계수: "10⁻⁷/hr", 손료계수: "10⁻⁷/hr",
|
||||
주연료: "ℓ/hr", 전력: "kWh/hr", 노무비: "원/hr", 재료비: "원/hr", 경비: "원/hr",
|
||||
}; // prettier-ignore
|
||||
const FUELS = ["경유", "휘발유", "실내등유"];
|
||||
|
||||
/** 그 줄 기계 열쇠 — 부를 입력을 지금 값으로 셈(옵션 · 만약 식 기호도) · 기계 줄 아님 = "" */
|
||||
export const machineOfLine = (answer: DocAnswer, link: Link | undefined): string =>
|
||||
link?.갈래 === "단가산출"
|
||||
? machineKey(subInputs(link, (answer.값 ?? {}) as Record<string, unknown>))
|
||||
: "";
|
||||
|
||||
/** 그 기계 줄의 기계 열쇠 · 식 · 원본 기본값 — 기계 식이 아니면 null */
|
||||
export function ctlOf(h: CtlHooks, link: Link | undefined) {
|
||||
if (link?.갈래 !== "단가산출" || !link.키) return null;
|
||||
const given = subInputs(link, (h.answer.값 ?? {}) as Record<string, unknown>);
|
||||
const machine = machineKey(given);
|
||||
const sub = h.subDoc(link.키);
|
||||
if (!machine || !sub) return null;
|
||||
const orig = h.calc(sub, given);
|
||||
const vals = (orig.값 ?? {}) as Record<string, unknown>;
|
||||
return { machine, sub, vals, base: defaultsOf(sub, vals), entry: coverOf(h.doc)[machine] };
|
||||
}
|
||||
|
||||
const num = (t: string): number | null => {
|
||||
const n = Number(t.trim().replace(/,/g, ""));
|
||||
return t.trim() !== "" && Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
/** 수 칸 — 빈 글 = 원래대로(null) · 음수 · 글 = 알림 */
|
||||
function numBox(
|
||||
label: string,
|
||||
value: unknown,
|
||||
unit: string,
|
||||
ro: boolean,
|
||||
set: (n: number | null) => void,
|
||||
hint = "",
|
||||
): HTMLElement {
|
||||
const f = createInputField({
|
||||
value: value === undefined || value === null ? "" : String(value),
|
||||
placeholder: hint,
|
||||
compact: true,
|
||||
size: 24,
|
||||
...(unit ? { unit } : {}),
|
||||
});
|
||||
f.root.classList.add("lstk-f-s");
|
||||
f.input.setAttribute("aria-label", label);
|
||||
f.input.title = label;
|
||||
f.input.disabled = ro;
|
||||
f.input.addEventListener("change", () => {
|
||||
const t = f.input.value;
|
||||
const n = num(t);
|
||||
if (t.trim() === "") return set(null);
|
||||
if (n === null || n < 0) {
|
||||
f.input.value = value === undefined || value === null ? "" : String(value);
|
||||
return void showToast(lt("Ctl_Bad"), "warning");
|
||||
}
|
||||
set(n);
|
||||
});
|
||||
return f.root;
|
||||
}
|
||||
|
||||
const kind = (text: string): HTMLElement => el("span", { className: "lstk-kind", text });
|
||||
|
||||
/** 기계 제어 구역 — `link` = 그 기계 줄 단가 기호 연결 */
|
||||
export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElement | null {
|
||||
const got = ctlOf(h, link);
|
||||
if (!got) return null;
|
||||
const { machine, sub, vals, base } = got;
|
||||
const entry: MachineCover = got.entry ?? {};
|
||||
const ro = h.readOnly;
|
||||
const put = (next: MachineCover): void => h.change((d) => putCover(d, machine, next, base));
|
||||
const parts = partsOf(sub);
|
||||
const lines = sub.금액 ?? [];
|
||||
|
||||
// ㉠ 값 칸
|
||||
const values = parts.fields.map(({ 칸: col }) => {
|
||||
const was = base.값[col];
|
||||
const mine = entry.값?.[col];
|
||||
const now = mine ?? was;
|
||||
const setVal = (v: number | string | null): void => {
|
||||
const 값 = { ...(entry.값 ?? {}) };
|
||||
if (v === null || v === "") delete 값[col];
|
||||
else 값[col] = v;
|
||||
put({ ...entry, 값 });
|
||||
};
|
||||
const box =
|
||||
col === "주연료종류"
|
||||
? createSelectField({
|
||||
options: [...new Set([...FUELS, String(now ?? "")])]
|
||||
.filter(Boolean)
|
||||
.map((x) => ({ value: x, text: x })),
|
||||
value: String(now ?? ""),
|
||||
compact: true,
|
||||
size: 24,
|
||||
disabled: ro,
|
||||
ariaLabel: col,
|
||||
onChange: (x) => setVal(x),
|
||||
}).root
|
||||
: numBox(col, now, UNIT[col] ?? "", ro, setVal);
|
||||
const row = el("div", {
|
||||
className: "lstk-frow",
|
||||
children: [el("b", { className: "lstk-sym", text: col }), "=", box],
|
||||
});
|
||||
if (mine !== undefined) {
|
||||
const tag = createBadge(lt("Ctl_Changed"), "info", { size: 18 });
|
||||
tag.title = `${lt("Ctl_Was")} ${was === undefined ? "—" : eqText(was)}`;
|
||||
row.append(tag);
|
||||
if (!ro)
|
||||
row.append(
|
||||
createButton({
|
||||
label: lt("Ctl_Back"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
iconStart: "undo",
|
||||
onClick: () => setVal(null),
|
||||
}),
|
||||
);
|
||||
}
|
||||
row.dataset.mctlField = col;
|
||||
return row;
|
||||
});
|
||||
|
||||
// ㉡ 조종원 — 인력 줄
|
||||
const crew: CrewRow[] = entry.조종원 ?? base.조종원;
|
||||
const setCrew = (rows: CrewRow[]): void => put({ ...entry, 조종원: rows });
|
||||
const crewName = (r: CrewRow, i: number): string => {
|
||||
if (r.이름) return r.이름;
|
||||
const at = parts.crew[i] ?? parts.crew[0];
|
||||
const sym = at === undefined ? "" : String(lines[at]?.단가 ?? "");
|
||||
const v = (sub.변수 ?? []).find((x) => varId(x) === sym);
|
||||
return (v?.이름 ?? "").replace(/노임$/, "") || h.name(r.키) || r.키;
|
||||
};
|
||||
const crewRows = crew.map((r, i) =>
|
||||
lineRow(
|
||||
lt("K_인력"),
|
||||
crewName(r, i),
|
||||
ro,
|
||||
() => openStackPick("인력", (p) => setCrew(swapAt(crew, i, { ...r, 키: p.키, 이름: p.이름 })), undefined, crewName(r, i)), // prettier-ignore
|
||||
[numBox(lt("Ctl_Crew_Qty"), r.인, lt("Ctl_Per_Day"), ro, (n) => setCrew(swapAt(crew, i, { ...r, 인: n ?? 0 })))], // prettier-ignore
|
||||
"노무비",
|
||||
() => setCrew(crew.filter((_, k) => k !== i)),
|
||||
),
|
||||
);
|
||||
|
||||
// ㉢ 잡품 — 재료 줄
|
||||
const misc: MiscRow[] = entry.잡품 ?? base.잡품;
|
||||
const setMisc = (rows: MiscRow[]): void => put({ ...entry, 잡품: rows });
|
||||
const baseQty = (r: MiscRow): number | null => {
|
||||
const b = lines.find((l) => String(l.이름 ?? "") === r.줄);
|
||||
const q = b?.수량 ? (vals[String(b.수량)] ?? Number(b.수량)) : null;
|
||||
return typeof q === "number" && Number.isFinite(q) ? q : null;
|
||||
};
|
||||
const miscName = (r: MiscRow): string => {
|
||||
if (r.이름) return r.이름;
|
||||
if (r.키) return h.name(r.키) || r.키;
|
||||
const fuel = r.줄 === "주연료" ? vals.S_연료 : undefined;
|
||||
return [r.줄, fuel].filter(Boolean).join(" · ");
|
||||
};
|
||||
const pickMat = (done: (p: Picked) => void, query: string): void =>
|
||||
openFindPicker({
|
||||
layer: "system",
|
||||
owner: null,
|
||||
title: `${lt("Find_Title")} — ${lt("Ctl_Misc")}`,
|
||||
kinds: ["material", "oil"],
|
||||
query: query || undefined,
|
||||
element: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위, ...(e.규격 ? { 규격: e.규격 } : {}) }), // prettier-ignore
|
||||
oil: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위 }),
|
||||
logic: () => undefined,
|
||||
});
|
||||
const asMat = (r: MiscRow, p: Picked): MiscRow => ({
|
||||
키: p.키,
|
||||
이름: [p.이름, p.규격].filter(Boolean).join(" "),
|
||||
...(p.단위 ? { 단위: p.단위 } : {}),
|
||||
수량: r.수량 ?? baseQty(r) ?? 1,
|
||||
율: r.율,
|
||||
});
|
||||
const miscRows = misc.map((r, i) =>
|
||||
lineRow(
|
||||
lt("K_재료"),
|
||||
miscName(r),
|
||||
ro,
|
||||
() => pickMat((p) => setMisc(swapAt(misc, i, asMat(r, p))), r.키 ? miscName(r) : ""),
|
||||
[
|
||||
numBox(lt("Qty"), r.수량, r.단위 ?? "", ro, (n) => {
|
||||
const { 수량: _, ...rest } = r;
|
||||
setMisc(swapAt(misc, i, n === null ? rest : { ...r, 수량: n }));
|
||||
}, r.줄 && r.수량 === undefined ? `${baseQty(r) ?? ""} ${lt("Ctl_Follow")}` : ""), // prettier-ignore
|
||||
numBox(lt("Ctl_Rate"), r.율, "%", ro, (n) => setMisc(swapAt(misc, i, { ...r, 율: n ?? 0 }))), // prettier-ignore
|
||||
],
|
||||
"재료비",
|
||||
() => setMisc(misc.filter((_, k) => k !== i)),
|
||||
),
|
||||
);
|
||||
|
||||
const head = (label: string, covered: boolean, add: () => void, reset: () => void) =>
|
||||
el("div", {
|
||||
className: "lstk-fhead",
|
||||
children: [
|
||||
el("span", { className: "lstk-kind", text: label }),
|
||||
...(covered ? [createBadge(lt("Ctl_Changed"), "info", { size: 18 })] : []),
|
||||
...(ro
|
||||
? []
|
||||
: [
|
||||
createIconButton({ icon: "plus", title: `${label} ${lt("Ctl_Add")}`, size: 24, onClick: add }), // prettier-ignore
|
||||
...(covered
|
||||
? [createButton({ label: lt("Ctl_Back"), variant: "ghost", size: 24, iconStart: "undo", onClick: reset })] // prettier-ignore
|
||||
: []),
|
||||
]),
|
||||
],
|
||||
});
|
||||
const box = el("div", {
|
||||
className: "lstk-mctl lstk-wide",
|
||||
attrs: { "data-mctl": machine },
|
||||
children: [
|
||||
...(values.length
|
||||
? [el("div", { className: "lstk-fhead", children: [kind(lt("Ctl_Values"))] }), ...values]
|
||||
: []),
|
||||
head(
|
||||
lt("Ctl_Crew"),
|
||||
entry.조종원 !== undefined,
|
||||
() => openStackPick("인력", (p) => setCrew([...crew, { 키: p.키, 이름: p.이름, 인: 1 }])),
|
||||
() => put({ ...entry, 조종원: undefined }),
|
||||
),
|
||||
...(crewRows.length ? crewRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore
|
||||
head(
|
||||
lt("Ctl_Misc"),
|
||||
entry.잡품 !== undefined,
|
||||
() => pickMat((p) => setMisc([...misc, asMat({ 율: 100 }, p)]), ""),
|
||||
() => put({ ...entry, 잡품: undefined }),
|
||||
),
|
||||
...(miscRows.length ? miscRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore
|
||||
],
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
const swapAt = <T>(rows: T[], i: number, one: T): T[] => rows.map((r, k) => (k === i ? one : r));
|
||||
|
||||
/** 줄 꼴(단가산출 줄과 닮음) — [종류][이름 단추][칸들][비목][X] */
|
||||
function lineRow(
|
||||
label: string,
|
||||
name: string,
|
||||
ro: boolean,
|
||||
pick: () => void,
|
||||
boxes: HTMLElement[],
|
||||
cost: string,
|
||||
drop: () => void,
|
||||
): HTMLElement {
|
||||
const b = createButton({
|
||||
label: name || "—",
|
||||
variant: "ghost",
|
||||
tone: "link",
|
||||
size: 24,
|
||||
iconStart: "search",
|
||||
disabled: ro,
|
||||
onClick: pick,
|
||||
});
|
||||
b.classList.add("lstk-res");
|
||||
b.title = `${lt("Repick")} — ${name}`;
|
||||
return el("div", {
|
||||
className: "lstk-line k-ctl",
|
||||
children: [
|
||||
kind(label),
|
||||
b,
|
||||
...boxes,
|
||||
el("span", { className: "lstk-hint", text: cost }),
|
||||
...(ro ? [] : [createIconButton({ icon: "x", title: lt("Del"), size: 24, onClick: drop })]),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -15,12 +15,14 @@ import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
|
||||
import { COSTS, varId, type FormulaDoc } from "../common_util/common_util_formula_doc";
|
||||
import { calcDoc, isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||||
import type { DocCache } from "../common_util/common_util_formula_doc_cache";
|
||||
import { coverDoc, coverOf, type Cover } from "../common_util/common_util_formula_doc_cover";
|
||||
import {
|
||||
lineMap,
|
||||
moneyScope,
|
||||
offLine,
|
||||
type Values,
|
||||
} from "../common_util/common_util_formula_doc_view";
|
||||
import { machineText } from "./formula_doc_options";
|
||||
import { reach } from "./formula_doc_render";
|
||||
import {
|
||||
kindOf,
|
||||
@@ -56,6 +58,10 @@ export interface OutHooks {
|
||||
toggle: (at: string) => void;
|
||||
/** 캐시에 없는 연결 — 뒤에서 서버에 물음 */
|
||||
miss: () => void;
|
||||
/** 836-1 기계 덮음(맨 위 문서 칸) — 펼친 기계 식에 얹음(계산기와 같음) */
|
||||
cover?: Cover;
|
||||
/** 838-3 기계 이름을 받은 뒤 다시 그림 */
|
||||
redraw?: () => void;
|
||||
}
|
||||
|
||||
const COST_SHOW: Record<string, string> = { 노무비: "노무비", 재료비: "재료비", 경비: "경 비" };
|
||||
@@ -145,9 +151,11 @@ function rows(
|
||||
};
|
||||
const expand = (id: string): void => {
|
||||
const path = `${at}/${id}`;
|
||||
const sub = subOf(id);
|
||||
if (!h.open.has(path) || !sub || depth >= MAX_DEPTH) return;
|
||||
const got = calcDoc(sub, h.cache.links, subInputs(vars.get(id)?.연결, vals));
|
||||
const raw = subOf(id);
|
||||
if (!h.open.has(path) || !raw || depth >= MAX_DEPTH) return;
|
||||
const given = subInputs(vars.get(id)?.연결, vals);
|
||||
const sub = coverDoc(raw, given, h.cover);
|
||||
const got = calcDoc(sub, h.cache.links, given, [], new Map(), h.cover);
|
||||
if (h.cache.takeMissing()) h.miss();
|
||||
out.push({
|
||||
depth: depth + 1,
|
||||
@@ -175,7 +183,9 @@ function rows(
|
||||
flush();
|
||||
const res = isNum(v)
|
||||
? katexNode(`${valTex(v)}\\ ${unit(id) ? `\\text{${unit(id)}}` : ""}`)
|
||||
: missing(a, id);
|
||||
: typeof v === "string" // 글 값(기계 원문번호 등 · 838-3) = 그 글 · 기계면 이름
|
||||
? el("span", { text: machineText(v, () => h.redraw?.()) ?? v })
|
||||
: missing(a, id);
|
||||
// 기호식 = 값식 = 결과 — 같은 꼴이 이어지면 한 번만(만약이 수 하나를 탄 줄)
|
||||
const n = nodeOf(t)!;
|
||||
const sym = tex(n, { side: "sym", vals, alias });
|
||||
@@ -217,7 +227,11 @@ function rows(
|
||||
head,
|
||||
...expr,
|
||||
" = ",
|
||||
isNum(v) ? `${eqText(v)} ${unit(id)}` : typeof v === "string" ? v : missing(a, id),
|
||||
isNum(v)
|
||||
? `${eqText(v)} ${unit(id)}`
|
||||
: typeof v === "string"
|
||||
? (machineText(v, () => h.redraw?.()) ?? v)
|
||||
: missing(a, id),
|
||||
],
|
||||
});
|
||||
pack.push(cell);
|
||||
@@ -408,7 +422,7 @@ export function buildOut(doc: StackDoc, a: DocAnswer, h: OutHooks): HTMLElement
|
||||
depth: 0,
|
||||
lv: 0,
|
||||
};
|
||||
for (const r of [head, ...rows(doc, a, h, 0, "")]) {
|
||||
for (const r of [head, ...rows(doc, a, { ...h, cover: coverOf(doc) }, 0, "")]) {
|
||||
const cell = (k: "sum" | (typeof COSTS)[number]): string => {
|
||||
const v = r.cols?.[k];
|
||||
if (v === undefined || (r.won && !v && k !== "sum")) return "";
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "../common_util/common_util_formula_doc";
|
||||
import { isNum } from "../common_util/common_util_formula_doc_calc";
|
||||
import { decide, hasIf, tryParse } from "../common_util/common_util_formula_doc_view";
|
||||
import { machineText } from "./formula_doc_options";
|
||||
import {
|
||||
dropSyms,
|
||||
freshId,
|
||||
@@ -157,6 +158,8 @@ export function buildBlock(
|
||||
const realText = (t: string): string => aliasText(t, alias, true);
|
||||
const valText = (id: string): string => {
|
||||
const v = vals[id];
|
||||
const mach = machineText(v, () => h.change(() => undefined)); // 838-3 기계 원문번호 = 이름
|
||||
if (mach) return mach;
|
||||
if (isNum(v) || typeof v === "string")
|
||||
return `${eqText(v)} ${shownUnit(vars.get(id)?.단위)}`.trim();
|
||||
if (v && typeof v === "object")
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
* 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음)
|
||||
* 고침 = 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음
|
||||
* M02 사본(`copy`) — 저장 몸 {문서, 끊음(받은 그대로), 판} · 판 바뀜(409)은 문서가 그대로면 새 판으로 한 번 더
|
||||
* 기계 줄 · 옵션 가로 기계 = 기계 창(827-1 · `line_stack_machine`) — 초안은 문서 칸(캐시) · [저장] 때 서버가 내 기계로
|
||||
* 기계 줄 · 옵션 가로 기계 = 기계 창(827-1 · 836-1 찾기 · 바꾸기만) · 기계 내부 값 = 왼쪽 기계 제어 구역(836-1 ·
|
||||
* `line_stack_machine_ctl` — 문서 칸 `기계덮음` · 캐시 · [저장] 때 서버가 다시 셈)
|
||||
* [단가 갱신] · [재생성] = 미리 셈 → 알림 → 저장(`formula_doc_copy.ts` · 고친 것 먼저 저장)
|
||||
* 저장 전 문서(`memory` · 836-5 · 불러오기 미리 보기) = 빈 키 + 서버에 문서째(`본`) 읽음 · 왼쪽 줄 쌓기는 보기만(inert) · 디스크 쓰기 0
|
||||
* ========================================================================== */
|
||||
@@ -57,7 +58,6 @@ import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy
|
||||
import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { buildEdit } from "./line_stack_edit";
|
||||
import { openMachineWindow, type CopyAnswer, type MachineCtx } from "./line_stack_machine";
|
||||
import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text";
|
||||
import { buildOut } from "./line_stack_out";
|
||||
import { lt } from "./line_stack_text";
|
||||
import "./line_stack.css";
|
||||
@@ -283,18 +283,20 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
|
||||
// ── 그림 ──
|
||||
const edit = (): boolean => opt.admin && (!!key || !cp);
|
||||
/** 827-1 기계 창 — 개인 층 M02 SK 만 [고쳐 쓰기] · M01 기초 = M01 기계만 · 사본 줄 = 수식 길 `/machine` */
|
||||
/** 827-1 기계 창(찾기 · 바꾸기) — M01 기초 = M01 기계만 · 사본 줄 = 수식 길 `/machine` */
|
||||
const machineCtx = (): MachineCtx => ({
|
||||
scope: cp && opt.scope ? opt.scope : null,
|
||||
canEdit: !!cp && !!key && opt.scope?.layer === "personal" && edit(),
|
||||
why: !cp ? mt("Why_M01") : opt.scope?.layer === "personal" ? "" : mt("Why_Company"),
|
||||
...(key ? { me: key } : {}),
|
||||
...(cp && key
|
||||
? { copyRow: (no: string) => call<CopyAnswer>(`/machine?no=${encodeURIComponent(no)}`) }
|
||||
: {}),
|
||||
});
|
||||
/** 초안 기계 셈 기록(서버 확인 길 · 요소 · 표 · 식 문서) → 계산기 캐시 — [확인] 직후 브라우저가 바로 셈 */
|
||||
const prime = (record: DocRecord): void => cache.merge(record);
|
||||
/** 836-1 기계 제어 구역 기본값 — 덮음 없이 그 기계 식 셈(캐시에 없는 연결 = 뒤에서 서버) */
|
||||
const calcSub = (d: FormulaDoc, g: Record<string, unknown>): DocAnswer => {
|
||||
const got = calcDoc(d, cache.links, g, [], new Map(), {});
|
||||
if (cache.takeMissing()) ask();
|
||||
return got;
|
||||
};
|
||||
const draw = (): void => {
|
||||
if (!doc || !answer) {
|
||||
[head, left, right, foot].forEach((x) => x.replaceChildren());
|
||||
@@ -372,7 +374,7 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
pair: (loc) => pair(loc, "left"),
|
||||
take: takeCtx(),
|
||||
machine: machineCtx(),
|
||||
prime,
|
||||
calc: calcSub,
|
||||
before: saved ? lineMap(JSON.parse(saved) as StackDoc) : undefined,
|
||||
}),
|
||||
]
|
||||
@@ -391,6 +393,7 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
},
|
||||
miss: ask,
|
||||
pair: (loc) => pair(loc, "right"),
|
||||
redraw: () => draw(),
|
||||
})
|
||||
: el("p", { className: "ui-text ui-text--muted", text: lt("Empty") }),
|
||||
);
|
||||
|
||||
@@ -90,6 +90,24 @@ const TEXT = {
|
||||
"이 값의 줄은 위 옵션이 고름(다른 줄은 옵션에서 바꿈 · 여기선 칸만)",
|
||||
"Row follows the option above (change it there; pick a column here)",
|
||||
],
|
||||
Ctl_Open: ["기계 값 · 조종원 · 잡품 고치기", "Edit machine values · operator · sundries"],
|
||||
Ctl_Values: ["기계 값", "Machine values"],
|
||||
Ctl_Crew: ["조종원", "Operator"],
|
||||
Ctl_Misc: ["잡품", "Sundries"],
|
||||
Ctl_Add: ["더함", "add"],
|
||||
Ctl_Changed: ["고침", "Edited"],
|
||||
Ctl_Was: ["원래 값", "Original"],
|
||||
Ctl_Back: ["원래 값", "Original"],
|
||||
Ctl_Bad: ["0 이상 수만 넣을 수 있음", "Enter a number 0 or more"],
|
||||
Ctl_Crew_Qty: ["인원", "Persons"],
|
||||
Ctl_Per_Day: ["인/일", "per day"],
|
||||
Ctl_Rate: ["율", "Rate"],
|
||||
Ctl_Follow: ["(따름)", "(follows)"],
|
||||
Ctl_None: ["없음", "None"],
|
||||
Win_Ctl_Note: [
|
||||
"기계 내부 값(가격 · 연료 · 조종원 · 잡품)은 단가산출 왼쪽 기계 줄의 ▸ 에서 고침",
|
||||
"Edit machine internals from the ▸ on the left machine line",
|
||||
],
|
||||
} as const;
|
||||
|
||||
export type LsKey = keyof typeof TEXT;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
그대로 쓰는 길(목록 · 올림 · 엑셀 보기 머리 · 격자 · 지우기 · 모두 고정 · 붙여넣기 · 다시 잇기 ·
|
||||
구조물 조합 · 칠 = B09 · M01 이 씀) · 옛 맞춤 길(호표 하나 맞춤 · 미리 셈 · 실무 호표 · 확인 만들기 · 안 이은 줄 ·
|
||||
단가산출 만들기)은 785-7 에서 지움.
|
||||
층 권한 = `Router_Items._root` · ⚠ 등록은 `main.py`(로그인만).
|
||||
층 = 늘 개인(요구 855 · 위 `_root` 가 고정) · ⚠ 등록은 `main.py`(로그인만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,11 +33,17 @@ from M02_MasterTemplete import M02_Hopyo_View as hopyo_view_
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_San_MatPick as mat_pick
|
||||
from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import _root as _layer_root
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 Hopyo"])
|
||||
|
||||
|
||||
def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path:
|
||||
"""불러오기 자리 = 늘 자기 개인 층(요구 855) — 경로 층 · 주인 글 · 몸 층은 받되 무시(회사 · 마스터 · 남의 층 불가)."""
|
||||
return _layer_root(session, "personal", None, write=write)
|
||||
|
||||
|
||||
# ── [수정] 보기(809-1) — 경쟁사 로직 인정 · 요소만 연결 · 읽기만 ─────────────────
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Import.ts
|
||||
* 일위대가 불러오기 본 칸 보기(PLAN 785-4 · 요구 642-11 — 마법사 `Hopyo_Wizard` 갈음 · 가운데 상세 페이지 없음) —
|
||||
* 왼쪽 [불러오기] → 층 · 파일 고름 → 올림(`Hopyo_Side`) → 바로 이 보기:
|
||||
* · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 연결)의 머리에 층 · [끝내기]를 덧붙임
|
||||
* 왼쪽 [불러오기] → 파일 고름 → 올림(`Hopyo_Side` · 층 가르기 없음 — 누가 하든 자기 개인 층 · 요구 855) → 바로 이 보기:
|
||||
* · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 연결)의 머리에 [끝내기]를 덧붙임
|
||||
* · 아래 = 엑셀(탭 셋) + 오른쪽 호표 · 단가산출 목록(`Hopyo_Sheet` · `Hopyo_SheetSk`) · 만들기 = 호표 줄 [내 일위대가로](807-1)
|
||||
* · [끝내기] = 물음(안 만든 단가산출 알림) → 서버가 이번 HP · 격자 저장본 지움 ·
|
||||
* 만든 일위대가 · 단가산출(엑셀 사본 걷고 출처만) · 별칭 장부 남음 · 옛 「안 이은 줄」 띠는 785-7 에서 지움(판정 「고를 것」)
|
||||
@@ -10,34 +10,19 @@
|
||||
* 하루 지난 것은 서버가 지움 · 엑셀 자료는 보관하지 않음(필요하면 다시 올림)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import { deleteHopyo, type HopyoFile, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import type { HopyoSheetBody } from "./M02_MasterTemplete_Hopyo_Sheet";
|
||||
import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export const LAYERS: { layer: ManageLayer; text: string }[] = [
|
||||
{ layer: "personal", text: "개인" },
|
||||
{ layer: "company", text: "회사" },
|
||||
{ layer: "system", text: "마스터" },
|
||||
];
|
||||
export const layerText = (layer: string): string =>
|
||||
LAYERS.find((x) => x.layer === layer)?.text ?? layer;
|
||||
/** 불러오기 자리 = 늘 자기 개인 층(요구 855 — 층 가르기 없음) */
|
||||
export const MINE: HopyoScope = { layer: "personal", owner: null };
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
/** 쓸 수 있는 자기 층(개인 · 회사 · 마스터 차례) */
|
||||
export async function writableLayers(): Promise<ManageLayer[]> {
|
||||
const user = await fetchSessionUser().catch(() => null);
|
||||
return LAYERS.map((x) => x.layer).filter((layer) => accessFor(layer, null, user).canWrite);
|
||||
}
|
||||
|
||||
export interface HopyoImportArgs {
|
||||
layer: ManageLayer;
|
||||
file: HopyoFile;
|
||||
/** [끝내기] 로 올린 파일을 지웠음 */
|
||||
onEnded: () => void;
|
||||
@@ -52,8 +37,8 @@ export interface HopyoImport {
|
||||
}
|
||||
|
||||
export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
|
||||
const { layer, file } = a;
|
||||
const scope: HopyoScope = { layer, owner: null };
|
||||
const { file } = a;
|
||||
const scope = MINE;
|
||||
let held = true;
|
||||
let body: HopyoSheetBody | null = null;
|
||||
|
||||
@@ -74,7 +59,6 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
|
||||
// ── 머리 막대에 덧붙일 것 ──
|
||||
const finish = createButton({ label: "끝내기", variant: "filled", size: 28, onClick: () => void end() }); // prettier-ignore
|
||||
finish.dataset.hopyoFinish = "";
|
||||
const layerNote = createBadge(`${layerText(layer)} 층`, "neutral");
|
||||
|
||||
async function end(): Promise<void> {
|
||||
finish.disabled = true;
|
||||
@@ -111,7 +95,7 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
|
||||
파일: file.파일,
|
||||
target: scope,
|
||||
canWrite: true,
|
||||
headExtra: [layerNote, finish],
|
||||
headExtra: [finish],
|
||||
});
|
||||
body = view;
|
||||
root.replaceChildren(...view.parts);
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시 · 785-4) —
|
||||
* · [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) = 바로 파일 하나 고르기 창(엑셀 · 경쟁사 .estx · .STC · 814-1) → 올림 → 본 칸 불러오기 보기
|
||||
* (`M02_MasterTemplete_Hopyo_Import` · 엑셀 + 오른쪽 호표 목록 · 가운데 상세 페이지 없음 · 누름 하나)
|
||||
* · 결과 층 고름 = 작은 고름(쓸 수 있는 층 둘 이상일 때만 · 일반 = 개인 하나라 안 보임)
|
||||
* · 「작업 중」 줄 = 층마다 서버 목록(같은 엑셀 작업 중 이어서) · 누르면 본 칸 불러오기 보기
|
||||
* · 층 가르기 없음 — 누가 하든 올린 것 · 만든 것 = 자기 개인 층(요구 855 · 공유는 만든 뒤 본인이)
|
||||
* · 「작업 중」 줄 = 자기 개인 층 서버 목록(같은 엑셀 작업 중 이어서) · 누르면 본 칸 불러오기 보기
|
||||
* · 다른 엑셀 [불러오기] · 다른 화면으로 = 지금 보기 떠남(올린 파일 지움 · 743-1) — 고르고 안 만든 확인 캐시가 있으면 물음 한 번
|
||||
* 옛 [별칭 장부](338-3)는 걷음(815-2) — 별칭은 M01 요소 「별칭」 칸 · M01 별칭 모아 보기.
|
||||
* ========================================================================== */
|
||||
@@ -12,22 +12,9 @@
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { createListRow } from "@ui/ui_template_list_row";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
listHopyo,
|
||||
uploadHopyo,
|
||||
type HopyoFile,
|
||||
type HopyoScope,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import {
|
||||
buildHopyoImport,
|
||||
LAYERS,
|
||||
layerText,
|
||||
writableLayers,
|
||||
type HopyoImport,
|
||||
} from "./M02_MasterTemplete_Hopyo_Import";
|
||||
import type { ManageLayer } from "./M02_MasterTemplete_Layer";
|
||||
import { listHopyo, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { buildHopyoImport, MINE, type HopyoImport } from "./M02_MasterTemplete_Hopyo_Import";
|
||||
|
||||
export interface HopyoSectionOptions {
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
@@ -46,12 +33,9 @@ export interface HopyoSection {
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null });
|
||||
|
||||
export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
const title = L("M02_HopyoImport");
|
||||
let layers: ManageLayer[] = [];
|
||||
let pick: ManageLayer = "personal";
|
||||
const input = el("input", {
|
||||
attrs: { type: "file", accept: ".xls,.xlsx,.estx,.stc", "data-hopyo-file": "" },
|
||||
}) as HTMLInputElement;
|
||||
@@ -65,50 +49,25 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
start.title =
|
||||
"파일 하나 — 경쟁사 엑셀(.xls · .xlsx) 또는 경쟁사 파일(.estx · .STC) → 우리 정보로 다시 만듦(한시 · 끝내거나 떠나면 올린 파일 지움)";
|
||||
start.dataset.hopyoWizardOpen = "";
|
||||
const seg = el("div", { className: "m02-side__row" });
|
||||
seg.hidden = true;
|
||||
const jobs = el("div", { className: "m02-hopyo-side", attrs: { "data-hopyo-jobs": "" } });
|
||||
const body = el("div", {
|
||||
className: "m02-hopyo-side",
|
||||
children: [el("div", { className: "m02-side__row", children: [start, input] }), seg, jobs],
|
||||
children: [el("div", { className: "m02-side__row", children: [start, input] }), jobs],
|
||||
});
|
||||
let open: { key: string; view: HTMLElement; imp?: HopyoImport } | null = null;
|
||||
|
||||
/** 층 고름 · 작업 중 줄 — 쓸 수 있는 층을 처음 한 번 읽음 */
|
||||
/** 작업 중 줄 — 자기 개인 층 서버 목록 */
|
||||
async function refresh(): Promise<void> {
|
||||
if (!layers.length) {
|
||||
layers = await writableLayers();
|
||||
if (!layers.includes(pick)) pick = layers[0] ?? "personal";
|
||||
if (layers.length > 1) {
|
||||
const s = createSegment({
|
||||
options: LAYERS.filter((x) => layers.includes(x.layer)).map((x) => ({ value: x.layer, label: x.text })), // prettier-ignore
|
||||
value: pick,
|
||||
size: 24,
|
||||
onChange: (v) => (pick = v as ManageLayer),
|
||||
});
|
||||
s.root.dataset.hopyoLayer = "";
|
||||
seg.replaceChildren(s.root);
|
||||
seg.hidden = false;
|
||||
}
|
||||
}
|
||||
const rows = (
|
||||
await Promise.all(
|
||||
layers.map((layer) =>
|
||||
listHopyo(scopeOf(layer))
|
||||
.then((list) => list.map((f) => ({ layer, f })))
|
||||
.catch(() => []),
|
||||
),
|
||||
)
|
||||
).flat();
|
||||
const rows = await listHopyo(MINE).catch(() => []);
|
||||
jobs.replaceChildren(
|
||||
...(rows.length
|
||||
? rows.map(({ layer, f }) => {
|
||||
? rows.map((f) => {
|
||||
const row = createListRow({
|
||||
label: f.파일,
|
||||
sub: `호표 ${f.호표수} · ${layerText(layer)} — 이어서`,
|
||||
sub: `호표 ${f.호표수} — 이어서`,
|
||||
size: 28,
|
||||
active: open?.imp !== undefined && open.key === f.키,
|
||||
onClick: () => void show(layer, f),
|
||||
onClick: () => void show(f),
|
||||
});
|
||||
row.dataset.hopyoKey = f.키;
|
||||
return row;
|
||||
@@ -119,8 +78,6 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
|
||||
/** [불러오기] — 지금 보기를 떠날 수 있는지 묻고 바로 파일 고르기 창 */
|
||||
async function choose(): Promise<void> {
|
||||
if (!layers.length) await refresh().catch(() => {});
|
||||
if (!layers.length) return void showToast("쓸 수 있는 층 없음", "warning");
|
||||
if (!(await leaveOk())) return;
|
||||
input.click();
|
||||
}
|
||||
@@ -129,14 +86,14 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
start.disabled = true;
|
||||
uploadHopyo(scopeOf(pick), file)
|
||||
uploadHopyo(MINE, file)
|
||||
.then((got: HopyoFile & { 이어서?: boolean; 원파일?: boolean }) => {
|
||||
const tool = /\.estx$/i.test(file.name) ? "EST" : "STmate"; // 810-3 원파일 올림 안내
|
||||
if (got.이어서) showToast(`같은 파일 작업 중 — ${got.키} 이어서`, "info");
|
||||
else if (got.원파일) showToast(`${tool} 파일 — 단가산출표 · 산출근거`, "success");
|
||||
else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success");
|
||||
if (got.읽기불가) showToast(`가져온 식으로 열 수 없음 — ${got.읽기불가}`, "warning");
|
||||
return show(pick, got, true);
|
||||
return show(got, true);
|
||||
})
|
||||
.catch((error) => showToast(why(error), "error"))
|
||||
.finally(() => (start.disabled = false));
|
||||
@@ -153,12 +110,11 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
}
|
||||
|
||||
/** 본 칸에 불러오기 보기 — 같은 파일이면 그대로 */
|
||||
async function show(layer: ManageLayer, file: HopyoFile, checked = false): Promise<void> {
|
||||
async function show(file: HopyoFile, checked = false): Promise<void> {
|
||||
if (open?.imp && open.key === file.키 && open.view.isConnected) return;
|
||||
if (!checked && !(await leaveOk())) return;
|
||||
open?.imp?.leave(); // 불러오기 떠남 = 올린 파일 지움(743-1)
|
||||
const imp = buildHopyoImport({
|
||||
layer,
|
||||
file,
|
||||
onEnded: () => {
|
||||
open = null;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
가져온 식(793-2 · `M02_Sangeun_Imported`) `…/sangeun/imported`(만들기 · 층 쓰기) · `/price` ·
|
||||
`/link`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기 · 기초로 바꾸기 없음 · 809-1) ·
|
||||
불러오기 단산 블록 → 가져온 식(793-3b · `M02_Sangeun_FromHopyo`) `…/sangeun/from-hopyo`
|
||||
(만들 층 쓰기 · 몸 `문서` = 미리 보기에서 고친 문서) · 미리 보기 `…/from-hopyo/preview`(층 읽기 · 안 씀 · 835-1).
|
||||
(늘 개인 층 쓰기 · 몸 `문서` = 미리 보기에서 고친 문서) · 미리 보기 `…/from-hopyo/preview`(층 읽기 · 안 씀 · 835-1).
|
||||
로직 사본(795-1 · `M02_Sangeun_Copy`) `…/sangeun/copy/price` [단가 갱신] · `/regen` [재생성]
|
||||
(화면 캐시 문서에 할일 얹음 · 저장 없음 · 층 읽기).
|
||||
수식 탭(796-2b · `M02_Sangeun_Equation`) `…/sangeun/equation`(층 읽기) · `/preview`(고침 미리 셈 ·
|
||||
@@ -176,8 +176,8 @@ def sangeun_from_hopyo(
|
||||
) -> dict:
|
||||
"""불러오기 단산 블록 → 가져온 식 SK(793-3b) — 경로 층 = 만들 층 · 몸 층 = 파일 층 ·
|
||||
답 = {이름, 검산, 단가기준, 다시씀}(같은 내용 = 그 SK)."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
source = _root(session, body.층, body.주인)
|
||||
folder = _root(session, "personal", None, write=True) # 불러오기 = 늘 개인 층(요구 855)
|
||||
source = _root(session, "personal", None)
|
||||
mine = _root(session, "personal", None, write=body.고름 is not None)
|
||||
fp, name = _call(source, mat_pick.fingerprint, source, body.키) # 827-3 그 파일 재료 고름
|
||||
if body.고름 is not None and fp:
|
||||
@@ -200,8 +200,8 @@ def sangeun_from_hopyo_preview(
|
||||
) -> dict:
|
||||
"""[가져온 식으로 열기] 미리 보기(835-1 · 요구 852-1) — 층에 안 씀(재료 고름도 메모리) ·
|
||||
답 = {문서, 검산, 같은것(같은 내용 SK 키 | None)} · [저장] = `from-hopyo` 몸 `문서`."""
|
||||
folder = _root(session, layer, owner)
|
||||
source = _root(session, body.층, body.주인)
|
||||
folder = _root(session, "personal", None) # 불러오기 = 늘 개인 층(요구 855)
|
||||
source = _root(session, "personal", None)
|
||||
mine = _root(session, "personal", None)
|
||||
fp, _ = _call(source, mat_pick.fingerprint, source, body.키)
|
||||
picks = mat_pick.merge(mat_pick.read(mine, fp)["고름"], body.고름) if fp else {}
|
||||
|
||||
@@ -41,6 +41,8 @@ const HOUR_LINE = { 기호: HOUR, 식: "Q_분자^표 ÷ Q_분모^표" };
|
||||
export interface CrewRow {
|
||||
키: string;
|
||||
인: number;
|
||||
/** 화면 이름(직종) — 셈 · 원본 대조 안 봄 */
|
||||
이름?: string;
|
||||
}
|
||||
export interface MiscRow {
|
||||
줄?: string;
|
||||
@@ -166,12 +168,14 @@ export function defaultsOf(doc: FormulaDoc, vals: Vals): Required<MachineCover>
|
||||
return { 값, 조종원, 잡품 };
|
||||
}
|
||||
|
||||
/** 원본 대조 꼴 — 이름 · 단위(화면 글) · 빈 칸 뺌 · 수 12 자리 */
|
||||
const LABEL = ["이름", "단위"];
|
||||
const canon = (v: unknown): string =>
|
||||
JSON.stringify(v, (_, x: unknown) =>
|
||||
x && typeof x === "object" && !Array.isArray(x)
|
||||
? Object.fromEntries(
|
||||
Object.entries(x as Record<string, unknown>)
|
||||
.filter(([, y]) => y !== undefined && y !== null && y !== "")
|
||||
.filter(([k, y]) => y !== undefined && y !== null && y !== "" && !LABEL.includes(k))
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
||||
)
|
||||
: typeof x === "number"
|
||||
@@ -238,6 +242,7 @@ function crewLines(doc: FormulaDoc, rows: CrewRow[]): MoneyLine[] {
|
||||
}
|
||||
return rows.map((r) => ({
|
||||
이름: "조종원",
|
||||
...(r.이름 ? { 규격: r.이름 } : {}),
|
||||
단위: "인",
|
||||
비목: "노무비",
|
||||
수량: `${plain(r.인)} × ${HOUR}`,
|
||||
|
||||
@@ -145,10 +145,15 @@ def defaults_of(doc: dict, vals: dict) -> dict:
|
||||
return {"값": values, "조종원": crew, "잡품": misc}
|
||||
|
||||
|
||||
_LABEL = ("이름", "단위") # 화면 글 — 원본 대조 안 봄
|
||||
|
||||
|
||||
def _canon(v) -> str:
|
||||
def norm(x):
|
||||
if isinstance(x, dict):
|
||||
return {k: norm(y) for k, y in sorted(x.items()) if y not in (None, "")}
|
||||
return {
|
||||
k: norm(y) for k, y in sorted(x.items()) if y not in (None, "") and k not in _LABEL
|
||||
}
|
||||
if isinstance(x, list):
|
||||
return [norm(y) for y in x]
|
||||
if _num(x) is not None:
|
||||
@@ -209,7 +214,8 @@ def _crew_lines(doc: dict, rows: list) -> list[dict]:
|
||||
)
|
||||
doc.setdefault("식", []).append(dict(_HOUR_LINE))
|
||||
return [
|
||||
{"이름": "조종원", "단위": "인", "비목": "노무비", "수량": f"{_plain(r['인'])} × {HOUR}",
|
||||
{"이름": "조종원", **({"규격": r["이름"]} if r.get("이름") else {}), "단위": "인",
|
||||
"비목": "노무비", "수량": f"{_plain(r['인'])} × {HOUR}",
|
||||
"단가": _add_var(doc, "w", "조종원", "조종원 노임", r["키"])}
|
||||
for r in rows
|
||||
] # fmt: skip
|
||||
|
||||
@@ -352,10 +352,11 @@ def test_구조물_조합으로_규격_묶음_울진_돌기슭막이(client):
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
def test_구조물_조합으로_남의_층_403(client):
|
||||
def test_구조물_조합으로_경로_층_무시_늘_개인(client): # 837-1 · 요구 855 — 옛 「남의 층 403」 갈음
|
||||
key = _upload(client, "대흥")
|
||||
body = {"층": "personal", "키": key, "번": 17}
|
||||
assert client.post("/api/m02/layers/company/hopyo-item", json=body).status_code == 403
|
||||
assert client.post("/api/m02/layers/company/hopyo-item", json=body).status_code == 200
|
||||
assert list((layers_dir() / "labor").glob("*.json")) # 쓴 곳 = 개인 층(`layers_dir`)
|
||||
|
||||
|
||||
def test_구조물_조합으로_같은_내용_409_씀_새로(client):
|
||||
|
||||
@@ -27,20 +27,20 @@ def test_누름_하나_바로_엑셀():
|
||||
choose = side.split("async function choose()")[1].split("input.addEventListener")[0]
|
||||
assert "input.click();" in choose # [불러오기] = 바로 파일 고르기 창
|
||||
change = side.split('input.addEventListener("change"')[1].split("async function leaveOk")[0]
|
||||
assert (
|
||||
"uploadHopyo(scopeOf(pick), file)" in change and "return show(pick, got, true);" in change
|
||||
)
|
||||
assert "uploadHopyo(MINE, file)" in change and "return show(got, true);" in change
|
||||
show = side.split("async function show(")[1].split("async function showLedger")[0]
|
||||
assert "buildHopyoImport({" in show and "opt.show(" in show # 올림 뒤 본 칸 바로
|
||||
imp = _src("Import")
|
||||
assert "m.buildHopyoSheet({" in imp and "headExtra: [layerNote, finish]" in imp
|
||||
assert "m.buildHopyoSheet({" in imp and "headExtra: [finish]" in imp
|
||||
|
||||
|
||||
def test_결과_층_고름_둘_이상일_때만():
|
||||
side = _src("Side")
|
||||
assert "layers.length > 1" in side and "seg.hidden = false;" in side
|
||||
assert "seg.hidden = true;" in side # 처음 숨김 — 일반(개인 하나)은 안 보임
|
||||
assert 'let pick: ManageLayer = "personal";' in side
|
||||
def test_층_가르기_없음_늘_개인(): # 837-1 · 요구 855 — 예전 「결과 층 고름」 걷음
|
||||
side, imp = _src("Side"), _src("Import")
|
||||
for gone in ("createSegment", "writableLayers", "LAYERS", "layerText", "seg.hidden", "pick"):
|
||||
assert gone not in side, gone
|
||||
for gone in ("LAYERS", "layerText", "writableLayers", "layerNote", "createBadge"):
|
||||
assert gone not in imp, gone
|
||||
assert 'export const MINE: HopyoScope = { layer: "personal", owner: null };' in imp
|
||||
|
||||
|
||||
def test_끝내기_머리_막대_끝냄_뒤_빈_본_칸():
|
||||
@@ -71,5 +71,5 @@ def test_부품_상자만_700줄_늘리지_않음():
|
||||
def test_해시_캐시_열쇠_없음():
|
||||
# 809-1 — 판정 고름 캐시(`Hopyo_Picks`) 걷음 · 옮기기 = [내 일위대가로]
|
||||
assert not (M02 / "logic" / "M02_MasterTemplete_Hopyo_Picks.ts").exists()
|
||||
assert "draftKey" not in _src("Import") and "dirty: () => false" in _src("Import")
|
||||
assert "draftKey" not in _src("Import") and "dirty: () => body?.matDirty()" in _src("Import")
|
||||
assert "location.hash" not in _src("Import") + _src("Side") # 새 해시 열쇠 없음
|
||||
|
||||
@@ -229,7 +229,7 @@ def _same(a, b, where=""):
|
||||
COVERS = [
|
||||
None,
|
||||
{"값": {"가격": 150000, "주연료종류": "휘발유"}},
|
||||
{"조종원": [{"키": "LB:1048", "인": 2}, {"키": "LB:1002", "인": 0.5}]},
|
||||
{"조종원": [{"키": "LB:1048", "인": 2, "이름": "운전사"}, {"키": "LB:1002", "인": 0.5}]},
|
||||
{"조종원": []},
|
||||
{"잡품": [{"줄": "주연료", "율": 30}, {"키": "LB:1048", "이름": "시험", "수량": 2, "율": 5}]},
|
||||
{"잡품": [{"줄": "주연료", "수량": 10, "율": 22}]},
|
||||
@@ -334,3 +334,8 @@ def test_저장_다시_열기_되돌리면_덮음_없음(world):
|
||||
doc[mcv.COVER] = {NO: {"값": {"가격": -5}}}
|
||||
with pytest.raises(store.StoreError):
|
||||
at(path.save, key, {"문서": doc, "판": back["판"]})
|
||||
|
||||
|
||||
def test_이름_단위는_원본_대조_안_봄():
|
||||
base = {"값": {}, "조종원": [{"키": "LB:1048", "인": 1}], "잡품": []}
|
||||
assert mcv.prune({"조종원": [{"키": "LB:1048", "인": 1, "이름": "운전사"}]}, base) is None
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""837-1 일위대가 불러오기 = 늘 자기 개인 층(요구 855) — 서버 · 화면 없이 돎.
|
||||
불러오기 길(`M02_Hopyo_Router` · `Sangeun_Router` from-hopyo)은 경로 층 · 주인 · 몸 층을 무시하고 개인 층만 씀."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Router as hopyo # noqa: E402
|
||||
|
||||
|
||||
def test_불러오기_길_층_고정(monkeypatch):
|
||||
seen: list[tuple] = []
|
||||
monkeypatch.setattr(
|
||||
hopyo,
|
||||
"_layer_root",
|
||||
lambda s, layer, owner=None, write=False: seen.append((layer, owner, write)),
|
||||
)
|
||||
for layer, owner in (("company", "3"), ("system", None), ("personal", "3/9")):
|
||||
hopyo._root({}, layer, owner, write=True)
|
||||
hopyo._root({}, layer, owner)
|
||||
assert seen == [("personal", None, True), ("personal", None, False)] * 3
|
||||
|
||||
|
||||
def test_라우터_루트_모두_고정_함수():
|
||||
src = (ROOT / "M02_MasterTemplete" / "M02_Hopyo_Router.py").read_text(encoding="utf-8")
|
||||
assert "import _root as _layer_root" in src
|
||||
assert "_layer_root(" in src.split("def _root(")[1].split("\n\n")[0]
|
||||
assert src.count("_layer_root(") == 1 # 층 고르는 길은 이 한 곳뿐
|
||||
|
||||
|
||||
def test_단산_블록_만들기_개인_층만():
|
||||
src = (ROOT / "M02_MasterTemplete" / "M02_Sangeun_Router.py").read_text(encoding="utf-8")
|
||||
for fn in ("def sangeun_from_hopyo(", "def sangeun_from_hopyo_preview("):
|
||||
body = src.split(fn)[1].split("\n@router")[0]
|
||||
assert "body.층" not in body and "_root(session, layer" not in body, fn
|
||||
assert '_root(session, "personal", None' in body, fn
|
||||
|
||||
|
||||
def test_화면_층_고름_없음():
|
||||
m02 = ROOT / "M02_MasterTemplete"
|
||||
side = (m02 / "M02_MasterTemplete_Hopyo_Side.ts").read_text(encoding="utf-8")
|
||||
imp = (m02 / "M02_MasterTemplete_Hopyo_Import.ts").read_text(encoding="utf-8")
|
||||
assert "createSegment" not in side and "writableLayers" not in side + imp
|
||||
assert '"회사"' not in side + imp and '"마스터"' not in side + imp
|
||||
Reference in New Issue
Block a user