Merge branch 'sub_laptop_1' of C:/Aislo_wt/sub_laptop_1 into main_laptop_1

This commit is contained in:
2026-11-12 14:50:25 +09:00
19 changed files with 1255 additions and 7 deletions
+36 -4
View File
@@ -5,6 +5,8 @@
* [재생성](지금 M01 문서로 다시 찍음 · 버리는 것 알림) — 누를 때만(자동 갱신 없음)
* 흐름 = 미리 셈(`{길}/preview` {할일}) → 확인 창(계 전 → 후 · 바뀐 자원 · 버리는 것) → 저장(`{길}/save` {할일, 판})
* 층 쓰기 권한 없으면 「읽기만」 배지
* (821-3) 값 줄만 바뀜(M01 단가 · 내 기계 고침) = 「새 값 있음」(같은 자리 · 같은 꼴) · 지운 키(M01 · 내 기계) = 「지워짐 n」 ·
* [단가 갱신] 확인 창 = 바뀐 자원 줄(이름 · 바뀐 칸 전 → 후 · 지워짐 = 마지막 값 유지)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
@@ -50,9 +52,16 @@ export function isStale(m: CopyMark | undefined): boolean {
export function copyBadges(m: CopyMark | undefined, edit: boolean): HTMLElement[] {
const out: HTMLElement[] = [];
if (isStale(m)) {
const b = createBadge(ft("New_M01"), "danger");
b.dataset.copyStale = "";
const doc = !!m?.M01새판 || !!m?.문서새판?.length;
if (doc || m?.단가새값) {
const b = createBadge(ft(doc ? "New_M01" : "New_Value"), "danger");
b.dataset.copyStale = doc ? "doc" : "value";
out.push(b);
}
if (m?.지운키?.length) {
const b = createBadge(`${ft("Gone")} ${m.지운키.length}`, "warning");
b.title = `${m.지운키.join(" · ")} — ${ft("Gone_Note")}`;
b.dataset.copyGone = m.지운키.join(",");
out.push(b);
}
if (!edit) out.push(createBadge(ft("Read_Only"), "neutral"));
@@ -76,17 +85,40 @@ export function copyTools(edit: boolean, run: (job: CopyJob) => void): HTMLEleme
const sum = (m: Money | undefined): string =>
typeof m?.계 === "number" ? `${eqText(m.계)} 원` : "—";
/** 바뀐 자원 한 줄 — 「이름 칸 전 → 후」(값 묶음에서 다른 칸만) · 지워짐 = 마지막 값 유지 */
export function changedLine(x: unknown): string {
const r = (x ?? {}) as {
키?: string;
이름?: string;
전?: unknown;
후?: unknown;
지워짐?: boolean;
};
const name = r.이름 || r.키 || "";
if (r.지워짐) return `${name} ${ft("Gone")}(${ft("Gone_Note")})`;
const show = (v: unknown): string => (typeof v === "number" ? eqText(v) : String(v ?? "—"));
const a = r.전 && typeof r.전 === "object" ? (r.전 as Record<string, unknown>) : null;
const b = r.후 && typeof r.후 === "object" ? (r.후 as Record<string, unknown>) : null;
if (!a || !b) return `${name} ${show(r.전)} → ${show(r.후)}`;
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].filter(
(k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]),
);
return `${name} ${keys.map((k) => `${k} ${show(a[k])} → ${show(b[k])}`).join(", ")}`;
}
/** 할일 알림 — 계 전 → 후 · 바뀐 자원 수 · 재생성이 버리는 것(이름) → 확인이면 true */
export function askJob(job: CopyJob, got: JobReply, names: Map<string, string>): Promise<boolean> {
const bits = [
job === "단가갱신" ? ft("Price_Ask") : ft("Regen_Ask"),
`${ft("Before_After")} ${sum(got.전)} → ${sum(got.후)}`,
];
const lines = job === "단가갱신" ? (got.바뀐자원 ?? []).slice(0, 8).map(changedLine) : []; // 821-3
if (job === "단가갱신") bits.push(`${ft("Changed")} ${got.바뀐자원?.length ?? 0}`);
const lost = [
...(got.버림?.문서고침 ? [ft("Regen_Edit")] : []),
...(got.버림?.끊음 ?? []).map((k) => `${ft("Cut")} ${names.get(k) || k}`),
];
if (job === "재생성" && lost.length) bits.push(`${ft("Regen_Lost")} — ${lost.join(" · ")}`);
return showConfirmDialog(bits.join(" · "), job === "단가갱신" ? ft("Price") : ft("Regen"));
const text = [bits.join(" · "), ...lines].join("\n");
return showConfirmDialog(text, job === "단가갱신" ? ft("Price") : ft("Regen"));
}
+3
View File
@@ -63,6 +63,9 @@ const TEXT = {
"Discard edits to this basis and follow the copy again",
],
New_M01: ["M01 새 판 있음", "New M01 version"],
New_Value: ["새 값 있음", "New values"],
Gone: ["지워짐", "Deleted"],
Gone_Note: ["마지막 값 유지", "last value kept"],
Price: ["단가 갱신", "Refresh prices"],
Regen: ["재생성", "Regenerate"],
Price_Ask: ["자원 단가만 지금 M01 값으로 — 식 · 연결 그대로", "Prices only — equation unchanged"],
+10 -1
View File
@@ -46,6 +46,7 @@ import {
import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick";
import { reach } from "./formula_doc_render";
import { attachDrag } from "./line_stack_drag";
import { toMineButton } from "./line_stack_mine";
import { openStackPick, type StackTake } from "./line_stack_pick";
import { buildBlock, sourceTag } from "./line_stack_rows";
import { lt, type LsKey } from "./line_stack_text";
@@ -165,7 +166,7 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
openStackPick(
kind,
(p) => h.change((doc) => repick(doc, i, own, kind, p)),
undefined,
kind === "기계" ? h.take : undefined, // 821-3 개인 층 = 내 기계 묶음도
found?.찾기어 ?? String(mat?.검색어 ?? line.이름 ?? ""),
),
});
@@ -175,6 +176,14 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
head.push(pick);
const mark = machineMark(found, link);
if (mark) head.push(mark);
// 821-3 품셈 기계 줄 → 복사해 내 기계로(개인 층 SK)
const mine =
kind === "기계"
? toMineButton(h.take, link as { 갈래?: string; 입력?: Record<string, string> }, (p) =>
h.change((doc) => repick(doc, i, own, kind, p)),
)
: null;
if (mine) head.push(mine);
} else
head.push(
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"),
+61
View File
@@ -0,0 +1,61 @@
/* =============================================================================
* line_stack_mine.ts
* 줄 쌓기 기계 줄 「복사해 내 기계로」(PLAN 821-3 · 요구서 796 3 장) — M02 개인 층 단가산출만.
* 품셈 기계 줄(기계='원문번호') → 그 기계 값을 복사한 내 기계 양식 창 → 저장하면 줄이 그 내 기계로(기계='개인 EQP…')
* M01 정본은 그대로 · 내 기계 줄(기계='개인 …') · M01 단가산출 기초 = 단추 없음
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { showToast } from "@ui/ui_template_elements";
import type { Picked } from "../common_util/common_util_line_stack_pick";
import { findSimilar, machineArg, mineElement } from "../M02_MasterTemplete/M02_MyMachine_Api";
import { openMyMachineModal } from "../M02_MasterTemplete/M02_MyMachine_Form";
import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text";
import type { StackTake } from "./line_stack_pick";
/** 기계 줄 연결 → 품셈 기계 원문번호(내 기계 · 기계 입력 없음 = null) */
export function m01MachineNo(link: {
갈래?: string;
입력?: Record<string, string>;
}): string | null {
const arg = link.갈래 === "단가산출" ? link.입력?.기계 : undefined;
const no = typeof arg === "string" ? arg.trim().replace(/^'|'$/g, "") : "";
return no && !no.startsWith("개인 ") ? no : null;
}
/** 「복사해 내 기계로」 단추 — 개인 층 SK 품셈 기계 줄만 · 고르면 `done(새 줄 고름)` */
export function toMineButton(
take: StackTake | undefined,
link: { 갈래?: string; 입력?: Record<string, string> },
done: (p: Picked) => void,
): HTMLElement | null {
const no = m01MachineNo(link);
if (!no || take?.layer !== "personal") return null;
const b = createButton({
label: mt("To_Mine"),
variant: "ghost",
size: 24,
iconStart: "copy",
onClick: async () => {
const rows = await findSimilar(take.owner, no).catch(() => []);
const seed = rows.find((r) => r.출처 === "M01" && r.원문번호 === no);
if (!seed) return showToast(`${mt("Hourly_None")} — ${no}`, "warning");
openMyMachineModal(
take.owner,
(got) => {
const e = mineElement(got.줄, got.시간당);
if (!e.로직) return showToast(mt("Pick_Direct"), "warning");
done({
키: e.로직,
이름: e.이름,
입력: { 기계: machineArg(got.줄.원문번호) },
...(e.규격 ? { 규격: e.규격 } : {}),
});
},
seed,
);
},
});
b.dataset.toMine = no;
return b;
}
+2
View File
@@ -3,6 +3,7 @@
* 줄 쌓기 고르기 창(PLAN 804-3 · 804-4 · 요구 678-1 · 678-3 · 680-1 · 681)
* 찾아 넣기 = M02 [항목 추가] 창 그대로(`openFindPicker` · 탭 M01 전체 · 찾기 칸 · 표) — 줄 갈래마다 그 종류만
* 인력(노임 LB) · 재료(자재 MT) · 기계(EQ → 시간당 경비 로직 + 기계='원문번호') · 다른 단가산출(M01 단가산출 로직)
* 기계 + M02 개인 층(821-3) = 품셈 기계 위 「내 기계」 묶음 · 머리 [새 기계 만들기] — 고름 = 로직 + 기계='개인 EQP…'
* M01 단가산출 기초 줄 쌓기 = 「M01 전체」 탭만 — M01 은 원천 기준값이라 M02 단가산출 · 일위대가를 부르지 않음(요구 680-1)
* M02 단가산출 줄 쌓기(`take` 있음) · 다른 단가산출 = 탭 [M01 전체 | M02 단가산출 | 일위대가](위 층 · 자기 SK 뺌) —
* 고름 = 복사해 펼침(`take(src)` · src = M01 키 | `참조|층|주인|키`) · 참조 줄 없음(요구 681)
@@ -56,6 +57,7 @@ export function openStackPick(
owner: copy?.owner ?? null,
title: `${lt("Find_Title")} — ${lt(copy ? "K_복사" : (`K_${kind}` as never))}`,
kinds: copy ? ["logic", "sk", "pre"] : [want], // M01 = M01 전체만(680-1)
mine: kind === "기계" && m02?.layer === "personal" ? { owner: m02.owner } : undefined,
query: query.trim() || undefined,
ref: copy
? (r) =>
+12
View File
@@ -21,6 +21,7 @@ from M02_MasterTemplete import M02_Doc_Import as dim
from M02_MasterTemplete import M02_Doc_Screen as screen
from M02_MasterTemplete import M02_Doc_Sk as dsk
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
@@ -128,11 +129,22 @@ def edited(copy: dict, body: dict, key: str, sk: dict | None = None) -> dict:
*(c for d in out.get("끊음", {}).values() for c in dc.calls(d)),
]
if not dim.is_imported(sk):
out |= _mine(out)
out["부름"] = dc.closure(dc.over(out), calls, copy.get("부름"))
out["따로"] = True
return out
def _mine(copy: dict) -> dict:
"""줄 쌓기에서 고른 내 기계(기계='개인 EQP…') 가운데 값 사본에 없는 것만 지금 값으로 더함
(821-3) — 그 뒤 내 기계를 고쳐도 금액 그대로 + 「단가새값」 · [단가 갱신] 으로 반영 ·
지워도 마지막 값."""
keys = mym.keys_in(copy.get("문서"))
if not keys or "로직" not in copy:
return {}
return skc.grow(dc.values_of(copy), skc.snap([], keys))
def preview(key: str, body: dict) -> dict[str, Any]:
"""미리 셈(저장 없음) — {셈, 전, 후, 바뀐자원?, 버림?}."""
doc = _file(key)["문서"]
@@ -17,6 +17,8 @@
* · (728-2) 돈 아닌 기초(결과단위가 「원」 아님 · ㎥/시간 등 21 개) = 목록에서 뺌
* · (785-3) `oil` = M01 유가전력(MO · `/logic-lib/elements?group=재료` 의 MO 줄) 고름 · 고정 줄 꼴 = 이음 유가 MO ·
* 재료비 = 지금 값(셈은 M01 값) · 「M01 전체」 탭 재료 아래
* · (821-3) `mine` = 개인 층 내 기계 묶음(품셈 기계 위 · 구분 「내 기계」 · 출처 「개인 키」 · 금액 = 서버 시간당) ·
* 머리 [새 기계 만들기](양식 창 → 저장하면 그 기계로 고름) · 「시간당 값 직접」 내 기계 = 줄 쌓기 식 없음 → 회색
* ========================================================================== */
import { createDataTable } from "@ui/ui_template_data_table";
@@ -24,6 +26,9 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e
import { t as L } from "@ui/ui_template_locale";
import { aliasNote, matchLogic } from "@ui/ui_template_logic_find";
import { openNewMaterial } from "./M02_Material_Pick";
import { listMyMachines, mineElement, type MyMachineRow } from "./M02_MyMachine_Api";
import { openMyMachineModal } from "./M02_MyMachine_Form";
import { mt } from "./M02_MyMachine_Text";
import { openModal } from "@ui/ui_template_modal";
import { createTabs } from "@ui/ui_template_tabs";
import { createText } from "@ui/ui_template_text";
@@ -83,6 +88,8 @@ export interface FindPickOptions {
machine?: (e: M01Element) => void;
/** 785-3 M01 유가전력(MO) 고름 — 있을 때만 유가 줄이 보임 */
oil?: (e: M01Element) => void;
/** 821-3 개인 층 내 기계 묶음(`machine` 과 함께) — 주인(null = 자기 층) */
mine?: { owner: string | null };
}
/** 727-1 기초마다 한 줄(옛 창 `Labor_Pick` 도 씀) — 규격 칸 = 「옵션 N」 */
@@ -182,6 +189,7 @@ export function openFindPicker(opt: FindPickOptions): void {
let goods: PickItem[] = [];
let machines: PickItem[] = [];
let oils: PickItem[] = []; // 785-3 유가전력 MO
let mines: MyMachineRow[] = []; // 821-3 내 기계
const lists: Record<"sk" | "pre", SangeunRow[] | null> = { sk: null, pre: null };
let shown: Line[] = [];
let seq = 0;
@@ -280,6 +288,26 @@ export function openFindPicker(opt: FindPickOptions): void {
: take(() => put(e)),
};
});
/** 821-3 내 기계 줄 — 금액 = 서버 시간당 · 직접 = 회색(줄 쌓기 식 없음) */
const mineLines = (q: string): Line[] => {
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
return mines
.filter((r) => words.every((w) => `${r.이름} ${r.규격} ${r.키}`.toLowerCase().includes(w)))
.map((r) => {
const e = mineElement(r, r.시간당);
const off = !e.로직 || !opt.machine;
return {
품명: r.이름,
규격: r.규격,
단위: "시간",
금액: e.값,
구분: join(mt("Pick_Group"), mt(r.방식), off ? mt("Pick_Direct") : ""),
출처: r.원문번호,
dim: off,
go: off ? () => showToast(mt("Pick_Direct"), "warning") : take(() => opt.machine?.(e)),
};
});
};
const docLines = (rows: SangeunRow[], kind: string): Line[] =>
rows.map((x) => ({
품명: x.제목 || x.이름,
@@ -299,6 +327,7 @@ export function openFindPicker(opt: FindPickOptions): void {
...(ok("labor") ? elementLines("인력", people) : []),
...(ok("material") ? elementLines("재료", goods) : []),
...(opt.oil && ok("oil") ? elementLines("유가", oils) : []),
...(opt.machine && opt.mine && ok("machine") ? mineLines(q) : []),
...(opt.machine && ok("machine") ? elementLines("기계", machines) : []),
];
} else {
@@ -387,9 +416,25 @@ export function openFindPicker(opt: FindPickOptions): void {
});
newMat.dataset.materialNew = "";
const canNew = opt.layer === "personal" && !!opt.material && ok("material");
// 821-3 내 기계 묶음이면 머리 [새 기계 만들기] — 양식 창에서 저장하면 그 기계로 고름
const newMach = createButton({
label: mt("Pick_New"),
variant: "ghost",
onClick: () =>
openMyMachineModal(opt.mine?.owner ?? null, (got) => {
const e = mineElement(got.줄, got.시간당);
if (!e.로직) return showToast(mt("Pick_Direct"), "warning");
take(() => opt.machine?.(e))();
}),
});
newMach.dataset.machineNew = "";
const headBtns = [
...(canNew ? [newMat] : []),
...(opt.mine && opt.machine && ok("machine") ? [newMach] : []),
];
({ close } = openModal({
title: opt.title ?? L("M02_FindTitle"),
...(canNew ? { actions: [newMat] } : {}),
...(headBtns.length ? { actions: headBtns } : {}),
closeLabel: tc("Pick_Close"),
dialogClass: "m01-logic__pick m01-combo",
width: "960px", // 표 여섯 칸
@@ -415,6 +460,13 @@ export function openFindPicker(opt: FindPickOptions): void {
})
.catch(fail);
void findElements();
if (opt.mine && opt.machine && ok("machine"))
void listMyMachines(opt.mine.owner)
.then((rows) => {
mines = rows;
draw();
})
.catch(fail);
void upper(opt.layer, opt.owner)
.then((scopes) =>
Promise.all(
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
* 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
* 50-3 · 134-3) › 「내 기계」 · 「내 품목」(821-3 · 개인 층 · 펼치면 가운데 화면) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기).
* 구조물 옵션 정의는 M01 로 옮김(716-1).
* 도면 양식 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4).
@@ -37,6 +37,9 @@ import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
import { buildPrecomboSection, type PrecomboOptions } from "./M02_MasterTemplete_UI_Side_Precombo";
import { buildSangeunSection, type SangeunOptions } from "./M02_MasterTemplete_UI_Side_Sangeun";
import { buildMyItemsView } from "./M02_MyItems_View";
import { buildMyMachineView } from "./M02_MyMachine_View";
import { mt } from "./M02_MyMachine_Text";
import { createEmpty } from "@ui/ui_template_empty";
import { createDivider } from "@ui/ui_template_divider";
@@ -154,6 +157,35 @@ export function buildSide(opt: SideOptions): SideHandle {
getOpen: opt.getOpen,
});
const sangeunSec = section(L("M02_SangeunTitle"), sangeun.body);
// 내 기계 · 내 품목(821-3) — 자기 개인 층 · 펼치면 가운데 화면(목록 · 양식)
const hint = (key: "Side_Hint" | "Items_Side_Hint"): HTMLElement =>
createEmpty({ text: mt(key), small: true });
const myMachSec = openOnExpand(section(mt("Title"), hint("Side_Hint")), async () => {
if (!(await opt.confirmLeave())) return;
let mark = (_d: boolean): void => {};
const node = buildMyMachineView({ owner: null, setDirty: (d) => mark(d) });
mark = opt.showView(mt("Title"), node);
});
myMachSec.dataset.myMachines = "";
const myItemsSec = openOnExpand(section(mt("Items_Title"), hint("Items_Side_Hint")), async () => {
if (!(await opt.confirmLeave())) return;
const node = buildMyItemsView({
owner: null,
openSk: async (u) => {
if (!(await opt.confirmLeave())) return;
opt.onOpen({
layer: "personal",
owner: null,
canWrite: true,
projectId: null,
kind: "sangeun",
name: u.키,
label: `${u.키} ${u.이름}`.trim(),
});
},
});
opt.showView(mt("Items_Title"), node);
});
// 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝
const onBuilt = (): void =>
precombo.body.isConnected
@@ -173,6 +205,8 @@ export function buildSide(opt: SideOptions): SideHandle {
children: [
sangeunSec,
precomboSec,
myMachSec,
myItemsSec,
summary,
createDivider(),
section(hopyo.title, hopyo.body),
+95
View File
@@ -0,0 +1,95 @@
/* =============================================================================
* M02_MyItems_View.ts
* M02 「내 품목」 모아 보기(PLAN 821-3 · 요구서 796 3 장 · 읽기만) — 좌측 「내 품목」 칸을 펼치면 가운데에 뜸(개인 층).
* 줄 = 그 층 단가산출 줄에 값을 바로 적은 재료(우리 자재에 안 이어진 것 · 서버 `M02_MyMachine_Look.items`)
* 칸 = 이름 · 규격 · 단위 · 값 · 쓰는 단가산출 수 · 같은 이름 수(같은 이름 묶음 = 이름 차례로 붙어 보임)
* 줄 누름 = 아래 쓰는 단가산출 목록 → 누르면 그 단가산출로(고치기는 거기서 · 여기선 저장 없음)
* ========================================================================== */
import { createDataTable } from "@ui/ui_template_data_table";
import { createInputField, el } from "@ui/ui_template_elements";
import { createFindList } from "@ui/ui_template_find_list";
import { createText } from "@ui/ui_template_text";
import { listMyItems, type MyItem, type Use } from "./M02_MyMachine_Api";
import { mt } from "./M02_MyMachine_Text";
export interface MyItemsViewOptions {
owner: string | null;
/** 그 단가산출 열기(떠남 확인은 부르는 쪽) */
openSk: (use: Use) => void;
}
export function buildMyItemsView(opt: MyItemsViewOptions): HTMLElement {
let rows: MyItem[] = [];
let shown: MyItem[] = [];
const search = createInputField({ type: "search", placeholder: mt("Find") });
const hint = createText("");
const fail = el("div");
const uses = createFindList({ placeholder: mt("Open_Sk") });
uses.root.hidden = true;
const table = createDataTable({
caption: mt("Items_Title"),
columns: [
{ key: "이름", label: mt("Name"), width: 200 },
{ key: "규격", label: mt("Spec"), width: 140 },
{ key: "단위", label: mt("Unit"), width: 60 },
{ key: "값", label: mt("Value"), kind: "number", decimals: 2, trim: true, width: 100 },
{ key: "쓰는곳", label: mt("Uses_Count"), kind: "number", decimals: 0, width: 70 },
{ key: "같은이름", label: mt("Same_Name"), kind: "number", decimals: 0, width: 70 },
],
rows: [],
empty: mt("Items_None"),
mergeSame: ["이름"],
onRowClick: (i) => pick(shown[i]),
});
const pick = (item: MyItem | undefined): void => {
if (!item) return;
uses.root.hidden = false;
uses.hint.textContent = `${item.이름} ${item.규격}`.trim();
uses.setRows(
item.쓰는곳.map((u) => ({
key: u.키,
name: u.이름 || u.키,
sub: u.키,
onPick: () => opt.openSk(u),
})),
);
};
const draw = (): void => {
const words = search.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const count = new Map<string, number>();
for (const r of rows) count.set(r.이름, (count.get(r.이름) ?? 0) + 1);
shown = rows.filter((r) => words.every((w) => `${r.이름} ${r.규격}`.toLowerCase().includes(w)));
hint.textContent = rows.length ? `${shown.length} / ${rows.length}` : "";
table.setRows(
shown.map((r) => ({
이름: r.이름,
규격: r.규격,
단위: r.단위,
값: typeof r.값 === "number" ? r.값 : Number(r.값) || null,
쓰는곳: r.쓰는곳.length,
같은이름: count.get(r.이름) ?? 1,
})),
);
};
search.input.addEventListener("input", draw);
void listMyItems(opt.owner)
.then((got) => (rows = got))
.catch((error) =>
fail.replaceChildren(
createText(error instanceof Error ? error.message : mt("Personal_Only"), "error"),
),
)
.finally(draw);
return el("div", {
className: "m02-mymach-view",
attrs: { "data-my-items": "" },
children: [
el("div", { className: "ui-toolbar", children: [search.root, hint] }),
createText(mt("Items_Note"), "muted"),
fail,
table.root,
uses.root,
],
});
}
+16
View File
@@ -0,0 +1,16 @@
/* M02 내 기계 · 내 품목 화면(PLAN 821-3) — 차림만 · 색 · 크기는 공통 값 */
.m02-mymach,
.m02-mymach-view,
.m02-mymach-view__list {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m02-mymach-view {
padding: var(--spacing-8);
}
.m02-mymach__uses ul {
margin: var(--spacing-4) 0 0;
padding-left: var(--spacing-16);
}
+156
View File
@@ -0,0 +1,156 @@
/* =============================================================================
* M02_MyMachine_Api.ts
* M02 내 기계 · 내 품목 서버 호출(PLAN 821-3) — 개인 층만.
* 내 기계 = `/layers/personal/my-machines`(821-1 · `M02_MyMachine_Router`) — 목록 · 하나 · 만들기 · 고치기 · 지우기
* 양식 목록 · 비슷한 기계 찾기 · 내 품목 = `M02_MyMachine_Look`(읽기만)
* ========================================================================== */
import type { MachinePick, MachineValues } from "@ui/ui_template_machine_form";
import { call } from "./M02_MasterTemplete_Api_Fetch";
import type { M01Element } from "./M02_MasterTemplete_Find_Pick";
export type MachineHow = "바로" | "품셈" | "직접";
type Costs = Record<"노무비" | "재료비" | "경비", number>;
/** 서버 시간당 셈 답 — ok 아님 = 까닭 */
export interface MyHourly extends Partial<Costs> {
ok: boolean;
계?: number;
로직?: string | null;
직접?: boolean;
reason?: string;
}
/** 내 기계 줄(저장 꼴) */
export interface MyMachine {
키: string;
원문번호: string;
이름: string;
규격: string;
방식: MachineHow;
값: MachineValues;
시간당?: Costs;
비고?: string;
넣은날: string;
고친날: string;
본뜬?: string;
}
export interface MyMachineRow extends Omit<MyMachine, "시간당"> {
시간당: MyHourly;
쓰는곳수: number;
}
export interface Use {
키: string;
이름: string;
}
export interface MyMachineOne {
줄: MyMachine;
시간당: MyHourly;
같은이름: { 키: string; 이름: string; 규격: string; 출처: string }[];
쓰는곳?: Use[];
}
/** 보내는 줄 — 방식 · 값 또는 시간당(직접) */
export interface MyMachineBody {
이름: string;
규격: string;
방식: MachineHow;
값?: MachineValues;
시간당?: Partial<Costs>;
}
export interface MachineLists {
fuels: MachinePick[];
jobs: MachinePick[];
power: number;
hour: number;
}
export interface SimilarRow {
키: string;
원문번호?: string;
이름: string;
규격: string;
구분: string;
값?: MachineValues;
방식?: MachineHow;
시간당?: Costs;
출처: "개인" | "M01";
}
export interface MyItem {
이름: string;
규격: string;
단위: string;
값: unknown;
쓰는곳: Use[];
}
const at = (owner: string | null, tail = "", q: Record<string, string> = {}): string => {
const ask = new URLSearchParams({ ...q, ...(owner ? { owner } : {}) }).toString();
return `/layers/personal/my-machines${tail}${ask ? `?${ask}` : ""}`;
};
const look = (owner: string | null, path: string, q: Record<string, string> = {}): string => {
const ask = new URLSearchParams({ ...q, ...(owner ? { owner } : {}) }).toString();
return `/layers/personal/${path}${ask ? `?${ask}` : ""}`;
};
const key = (k: string): string => `/${encodeURIComponent(k)}`;
export const listMyMachines = async (owner: string | null): Promise<MyMachineRow[]> =>
(await call<{ 줄: MyMachineRow[] }>(at(owner))).줄;
export const readMyMachine = (owner: string | null, k: string): Promise<MyMachineOne> =>
call(at(owner, key(k)));
export const createMyMachine = (
owner: string | null,
body: MyMachineBody,
base?: string,
): Promise<MyMachineOne> =>
call(at(owner), { method: "POST", body: JSON.stringify({ 줄: body, 본뜬: base ?? null }) });
export const updateMyMachine = (
owner: string | null,
k: string,
body: MyMachineBody,
): Promise<MyMachineOne> =>
call(at(owner, key(k)), { method: "PUT", body: JSON.stringify({ 줄: body }) });
export const deleteMyMachine = (owner: string | null, k: string): Promise<{ 쓰는곳: Use[] }> =>
call(at(owner, key(k)), { method: "DELETE" });
let lists: Promise<MachineLists> | null = null;
/** 양식 고르기 목록 — 한 번 받아 둠 */
export const fetchMyMachineLists = (): Promise<MachineLists> =>
(lists ??= call<MachineLists>("/my-machine-lists").catch((e) => {
lists = null;
return Promise.reject(e);
}));
export const findSimilar = async (owner: string | null, q: string): Promise<SimilarRow[]> =>
(await call<{ rows: SimilarRow[] }>(look(owner, "my-machine-find", { q }))).rows;
export const listMyItems = async (owner: string | null): Promise<MyItem[]> =>
(await call<{ rows: MyItem[] }>(look(owner, "my-items"))).rows;
/** 단가산출 줄 기계 입력 글(줄 쌓기 `기계='개인 EQP…'`) */
export const machineArg = (no: string): string => `'${no}'`;
/** 내 기계 → 기계 고르기 한 줄(`M01Element` 꼴 · 줄 쌓기 = 로직 + 기계='개인 EQP…') */
export const mineElement = (
row: Pick<MyMachine, "키" | "원문번호" | "이름" | "규격">,
h: MyHourly,
): M01Element => ({
종류: "기계",
코드: row.키,
이름: row.이름,
규격: row.규격,
단위: "시간",
값: h.ok ? (h.계 ?? null) : null,
단가: { 노무비: h.노무비 ?? 0, 재료비: h.재료비 ?? 0, 경비: h.경비 ?? 0 },
로직: h.로직 ?? undefined,
원문번호: row.원문번호,
});
+296
View File
@@ -0,0 +1,296 @@
/* =============================================================================
* M02_MyMachine_Form.ts
* M02 내 기계 양식 한 장(PLAN 821-3 · 요구서 796 1 · 3 장) — 기계 양식 부품(`ui_template_machine_form` · 821-2) +
* [저장] · [지우기] · 「개인 값」 표지 · 같은 이름 · 규격 알림 · 서버 정본 시간당 · 쓰는 곳.
* 기본 = 손료계수 「바로 넣기」 · 「시간당 값 직접」 꼴 · 직종 · 연료 = M01 목록 고르기 · 옆 계산식 즉시(계수 짝 TS)
* [저장] 때만 서버(자동저장 없음) — 서버가 정본으로 다시 셈한 시간당을 받아 보임
* 새 기계 = 「비슷한 기계에서 시작」(M01 · 내 기계 · 고른 키 = 서버 `본뜬`) 또는 빈 양식 · `seed` = 품셈 기계 복사
* 지우기 = 쓰는 단가산출 이름 보이고 확인 + 「이미 만든 단가산출은 마지막 값 유지」
* 부르는 곳 = 내 기계 화면(`M02_MyMachine_View`) · 기계 고르기 창 「새 기계 만들기」 · 줄 쌓기 「복사해 내 기계로」(창 `openMyMachineModal`)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import {
createMachineForm,
HOURLY_KEY,
type MachineSimilar,
type MachineValues,
} from "@ui/ui_template_machine_form";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import {
createMyMachine,
deleteMyMachine,
fetchMyMachineLists,
findSimilar,
readMyMachine,
updateMyMachine,
type MyHourly,
type MyMachine,
type MyMachineBody,
type MyMachineOne,
type SimilarRow,
} from "./M02_MyMachine_Api";
import { mt } from "./M02_MyMachine_Text";
import "./M02_MyMachine.css";
export interface MyMachineFormOptions {
owner: string | null;
/** 고칠 내 기계 키 — 없으면 새 기계 */
key?: string;
/** 새 기계 바탕(복사해 내 기계로) — 값 · 이름 · 규격 · 본뜬 키 */
seed?: SimilarRow;
/** 새 기계면 「비슷한 기계에서 시작」 을 펼침 */
openSimilar?: boolean;
onSaved: (got: MyMachineOne) => void;
onDeleted?: () => void;
onBack?: () => void;
onDirty?: (dirty: boolean) => void;
}
export interface MyMachineFormHandle {
root: HTMLElement;
dirty: () => boolean;
}
const won = (v: number | undefined): string =>
`${(v ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 2 })}${mt("Won")}`;
/** 줄(저장 꼴) → 양식 값 묶음 — 직접 = 시간당 칸 하나 */
export function valuesOf(row: Pick<MyMachine, "방식" | "값" | "시간당">): MachineValues {
return row.방식 === "직접" ? { [HOURLY_KEY]: { ...(row.시간당 ?? {}) } } : { ...row.값 };
}
/** 서버 시간당 한 줄 글 */
export function hourlyText(h: MyHourly | undefined): string {
if (!h?.ok) return `${mt("Hourly_None")}${h?.reason ? ` — ${h.reason}` : ""}`;
return `${won(h.계)} (${mt("Labor")} ${won(h.노무비)} · ${mt("Material")} ${won(h.재료비)} · ${mt("Expense")} ${won(h.경비)})`;
}
export async function mountMyMachineForm(
host: HTMLElement,
opt: MyMachineFormOptions,
): Promise<MyMachineFormHandle> {
const [lists, one] = await Promise.all([
fetchMyMachineLists(),
opt.key ? readMyMachine(opt.owner, opt.key) : Promise.resolve(null),
]);
const row = one?.줄;
const seed = opt.seed;
let base = seed && seed.출처 === "M01" ? seed.키 : undefined; // 서버 `본뜬`
const found = new Map<string, SimilarRow>();
const notice = el("div", { className: "m02-mymach__notice" });
const server = el("div", { className: "m02-mymach__server" });
const usesBox = el("div", { className: "m02-mymach__uses" });
const form = createMachineForm({
name: row?.이름 ?? seed?.이름 ?? "",
spec: row?.규격 ?? seed?.규격 ?? "",
values: row
? valuesOf(row)
: seed
? valuesOf({ 방식: seed.방식 ?? "바로", 값: seed.값 ?? {}, 시간당: seed.시간당 })
: {},
coefMode: "direct",
allowHourly: true,
fuels: lists.fuels,
jobs: lists.jobs,
power: lists.power,
hour: lists.hour,
findSimilar: row
? undefined
: async (q): Promise<MachineSimilar[]> =>
(await findSimilar(opt.owner, q)).map((r) => {
found.set(r.키, r);
return {
key: r.키,
name: r.이름,
spec: r.규격,
tag: r.출처 === "개인" ? mt("Mine") : r.구분,
values: valuesOf({ 방식: r.방식 ?? "바로", 값: r.값 ?? {}, 시간당: r.시간당 }),
};
}),
onSimilar: (item) => {
base = item.key;
checkSame();
},
onChange: () => {
notice.replaceChildren(); // 지난 저장 막힘 글 지움(다시 [저장] 때 새로)
opt.onDirty?.(dirty());
checkSame();
},
});
let saved = !opt.key && seed ? false : true;
const dirty = (): boolean => form.state().changed || !saved;
/* --- 같은 이름 · 규격 알림(M01 · 내 기계) --- */
let timer: number | undefined;
let seq = 0;
const sameText = (hits: { 키: string; 출처: string }[]): string =>
hits.length
? mt("Same").replace("{value}", hits.map((h) => `${h.출처} ${h.키}`).join(" · "))
: "";
const checkSame = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(async () => {
const mine = ++seq;
const s = form.state();
const rows = s.name ? await findSimilar(opt.owner, s.name).catch(() => []) : [];
if (mine !== seq) return;
const hits = rows.filter(
(r) => r.키 !== opt.key && r.이름.trim() === s.name && (r.규격 ?? "").trim() === s.spec,
);
form.setNotice(sameText(hits));
}, 300);
};
/* --- 저장 --- */
const bodyOf = (): MyMachineBody | null => {
const s = form.state();
const errors = [...s.errors, ...(s.name ? [] : [mt("Name_Needed")])];
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
if (errors.length) return null;
if (s.hourly)
return {
이름: s.name,
규격: s.spec,
방식: "직접",
시간당: (s.values[HOURLY_KEY] ?? {}) as MyMachineBody["시간당"],
};
const { [HOURLY_KEY]: _drop, ...vals } = s.values;
return { 이름: s.name, 규격: s.spec, 방식: s.mode === "pumsem" ? "품셈" : "바로", 값: vals };
};
const saveBtn = createButton({ label: mt("Save"), variant: "filled", size: 28 });
saveBtn.dataset.mymachSave = "";
saveBtn.addEventListener("click", async () => {
const body = bodyOf();
if (!body) return;
saveBtn.disabled = true;
try {
const got = opt.key
? await updateMyMachine(opt.owner, opt.key, body)
: await createMyMachine(opt.owner, body, base);
saved = true;
opt.onDirty?.(false);
showToast(mt("Saved"), "success");
opt.onSaved(got);
} catch (error) {
notice.replaceChildren(
createText(error instanceof Error ? error.message : mt("Failed"), "error"),
);
} finally {
saveBtn.disabled = false;
}
});
/* --- 지우기 --- */
const delBtn = createButton({ label: mt("Delete"), variant: "danger-ghost", size: 28 });
delBtn.hidden = !opt.key;
delBtn.addEventListener("click", async () => {
if (!opt.key) return;
const uses = (await readMyMachine(opt.owner, opt.key).catch(() => one))?.쓰는곳 ?? [];
const ask = [
mt("Delete_Ask").replace("{value}", String(uses.length)),
...uses.map((u) => `${u.키} ${u.이름}`),
mt("Delete_Keep"),
].join("\n");
if (!(await showConfirmDialog(ask, mt("Delete")))) return;
try {
await deleteMyMachine(opt.owner, opt.key);
saved = true;
opt.onDirty?.(false);
showToast(mt("Deleted"), "success");
opt.onDeleted?.();
} catch (error) {
showToast(error instanceof Error ? error.message : mt("Failed"), "error");
}
});
const back = opt.onBack
? [
createButton({
label: mt("Back"),
variant: "ghost",
size: 28,
iconStart: "chevronLeft",
onClick: opt.onBack,
}),
]
: [];
const title = row ? `${row.이름} ${row.규격}`.trim() : mt("New");
server.replaceChildren(
...(one ? [createText(`${mt("Server_Hourly")} ${hourlyText(one.시간당)}`)] : []),
);
if (one?.같은이름.length) form.setNotice(sameText(one.같은이름));
const uses = one?.쓰는곳 ?? [];
usesBox.replaceChildren(
el("strong", { text: `${mt("Uses")} ${uses.length}` }),
...(uses.length
? [el("ul", { children: uses.map((u) => el("li", { text: `${u.키} ${u.이름}` })) })]
: [createText(mt("No_Uses"), "muted")]),
);
const root = el("div", {
className: "m02-mymach",
attrs: { "data-mymach-form": opt.key ?? "new" },
children: [
el("div", {
className: "ui-toolbar m02-mymach__bar",
children: [
...back,
el("strong", { text: title }),
el("span", { className: "ui-text ui-text--muted", text: opt.key ?? "" }),
createBadge(mt("Own_Value"), "info"),
saveBtn,
delBtn,
],
}),
createText(mt("Own_Note"), "muted"),
notice,
server,
form.root,
...(opt.key ? [usesBox] : []),
],
});
host.replaceChildren(root);
if (!row && (opt.openSimilar ?? !seed))
root.querySelector<HTMLDetailsElement>("details")?.setAttribute("open", "");
if (seed) {
form.setNotice(mt("Copied").replace("{value}", `${seed.이름} ${seed.규격}`.trim()));
opt.onDirty?.(true);
}
return { root, dirty };
}
/** 창으로 띄움 — 저장하면 닫고 `done(답)` · 저장 안 하고 닫으면 물음 */
export function openMyMachineModal(
owner: string | null,
done: (got: MyMachineOne) => void,
seed?: SimilarRow,
): void {
let handle: MyMachineFormHandle | null = null;
const { close } = openModal({
title: seed ? mt("Copy_Title") : mt("New"),
closeLabel: mt("Close"),
width: "1100px",
wide: true,
beforeClose: async () => !handle?.dirty() || showConfirmDialog(mt("Leave_Ask")),
mount: (body) => {
void mountMyMachineForm(body, {
owner,
seed,
onSaved: (got) => {
close();
done(got);
},
})
.then((h) => (handle = h))
.catch((error) => {
showToast(error instanceof Error ? error.message : mt("Failed"), "error");
close();
});
},
});
}
+127
View File
@@ -0,0 +1,127 @@
"""M02 내 기계 화면 읽기 길(PLAN 821-3) — 양식 목록 · 비슷한 기계 찾기 · 내 품목 · 쓰기 없음.
GET `/api/m02/my-machine-lists` = 양식 고르기(연료 · 직종 · 전력 단가 · 시간환산) —
M01 기계 화면과 같은 값(`M01_MasterData_Machine.lists` · 그 길은 시스템 관리자만).
GET `/api/m02/layers/{층}/my-machine-find?q=&owner=` = 「비슷한 기계에서 시작」 ·
「복사해 내 기계로」 — 그 층 내 기계(앞) + M01 기계(`M01_MasterData_Machine.find`) ·
q 가 M01 원문번호와 같으면 그 기계 먼저.
GET `/api/m02/layers/{층}/my-items?owner=` = 내 품목 — 그 층 단가산출 수식 문서 재료비 줄 가운데
단가가 우리 자재에 안 이어진 것(단가 변수 연결 = 숫자 · 입력) ·
(이름 · 규격 · 단위 · 값) 묶음마다 쓰는 단가산출.
권한 = 내 기계 길과 같음(`M02_MyMachine_Router._folder` — 개인 층 · 본인 · 맡은 회사 관리자 읽기).
⚠ 등록은 `main.py`(로그인만).
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Depends
from common_util.common_util_auth import verify_session
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Session, _call
from M02_MasterTemplete.M02_MyMachine_Router import _folder
router = APIRouter(prefix="/api/m02", tags=["M02 My Machine Look"])
DIRECT = ("숫자", "입력") # 단가 변수 연결 — 값을 줄에 바로 적은 것
def _m01m():
from M01_MasterData import M01_MasterData_Machine as m01m # M01 store 가 scripts 를 길에 둔 뒤
return m01m
def lists() -> dict[str, Any]:
got = _m01m().lists()
return {k: got[k] for k in ("fuels", "jobs", "power", "hour")}
def find(q: str, limit: int = 30) -> list[dict]:
"""내 기계(앞) + M01 기계 — 줄 {키, 원문번호, 이름, 규격, 구분, 값, 방식?, 시간당?, 출처}."""
words = [w for w in q.lower().split() if w]
keep = ("키", "원문번호", "이름", "규격", "값", "방식", "시간당")
out = [
{**{k: r[k] for k in keep if k in r}, "구분": "내 기계", "출처": "개인"}
for r in mym.rows()
if all(w in f"{r.get('이름')} {r.get('규격')} {r.get('키')}".lower() for w in words)
]
exact = (m01.loaded()[1].numbers.get("EQ") or {}).get(q.strip())
head = [exact] if exact is not None and not mym.mine(exact.get("키")) else []
m01_rows = [*head, *_m01m().find(q, limit)]
seen = set()
for r in m01_rows:
if r["키"] in seen:
continue
seen.add(r["키"])
brief = {k: r[k] for k in ("키", "원문번호", "이름", "규격", "값") if k in r}
out.append({**brief, "구분": r.get("상세구분") or r.get("구분") or "", "출처": "M01"})
return json.loads(json.dumps(out[:limit], default=_m01m()._plain)) # Decimal → 수(글 아님)
def _items_of(doc: dict) -> list[dict]:
"""수식 문서 재료비 줄 — 단가 = 바로 적은 값 변수(숫자 · 입력)."""
import master_doc as md
body = ((doc.get("수식") or {}).get("문서")) or {}
vars_ = {md.var_id(v): v for v in body.get("변수") or [] if isinstance(v, dict)}
out = []
for line in body.get("금액") or []:
if not isinstance(line, dict) or line.get("비목") != "재료비":
continue
var = vars_.get(str(line.get("단가") or "").strip())
link = (var or {}).get("연결") or {}
if link.get("갈래") not in DIRECT:
continue
out.append(
{
"이름": str(line.get("이름") or var.get("이름") or ""),
"규격": str(line.get("규격") or ""),
"단위": str(line.get("단위") or ""),
"값": link.get("값"),
}
)
return out
def items() -> list[dict]:
"""내 품목 — (이름 · 규격 · 단위 · 값) 묶음 · 쓰는곳 [{키, 이름}] · 이름 차례."""
from M02_MasterTemplete import M02_Sangeun_Doc as sd
groups: dict[tuple, dict] = {}
for path in sorted((store.root() / sd.KIND).glob("SK-*.json")):
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
for it in _items_of(doc):
got = groups.setdefault(
(it["이름"], it["규격"], it["단위"], json.dumps(it["값"])), {**it, "쓰는곳": []}
)
use = {"키": doc.get("키") or path.stem, "이름": doc.get("이름") or ""}
if use not in got["쓰는곳"]:
got["쓰는곳"].append(use)
return sorted(groups.values(), key=lambda g: (g["이름"], g["규격"], g["단위"]))
@router.get("/my-machine-lists")
def get_lists() -> dict:
return lists()
@router.get("/layers/{layer}/my-machine-find")
def get_find(
layer: str, q: str = "", owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
return {"rows": _call(_folder(session, layer, owner), find, q)}
@router.get("/layers/{layer}/my-items")
def get_items(
layer: str, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
return {"rows": _call(_folder(session, layer, owner), items)}
+67
View File
@@ -0,0 +1,67 @@
/* =============================================================================
* M02_MyMachine_Text.ts
* M02 내 기계 · 내 품목 화면 글(PLAN 821-3).
* ========================================================================== */
const TEXT = {
Title: "내 기계",
Items_Title: "내 품목",
New: "새 기계",
Copy_Title: "복사해 내 기계로",
Find: "이름 · 규격 · 키로 찾기",
Name: "이름",
Spec: "규격",
How: "방식",
Hourly: "시간당 경비(원)",
Uses_Count: "쓰는 곳",
Made: "넣은 날",
Edited: "고친 날",
None: "내 기계 없음 — [새 기계] 로 만듦",
Save: "저장",
Saved: "내 기계 저장 — 서버가 시간당을 다시 셈",
Delete: "지우기",
Deleted: "내 기계 지움",
Delete_Ask: "이 내 기계를 지울까? 쓰는 단가산출 {value}",
Delete_Keep: "지워도 이미 만든 단가산출은 마지막 값 유지(「지워짐」 표시)",
Back: "목록",
Close: "닫기",
Failed: "못 함",
Name_Needed: "이름 · 비어 있음",
Same: "같은 이름 · 규격 있음 — {value}",
Copied: "「{value}」 값을 복사 — 고친 뒤 [저장]",
Own_Value: "개인 값",
Own_Note:
"개인 값 = 이 기계 고유 값(가격 · 손료계수 · 연료 ℓ · 잡품 % · 조종원 수) · 단가(노임 · 유가)는 M01 값 · 단가산출엔 만들 때 복사 → 고친 뒤 「새 값」 → [단가 갱신]",
Server_Hourly: "서버 셈 시간당",
Hourly_None: "시간당 못 셈",
Uses: "쓰는 단가산출",
No_Uses: "쓰는 단가산출 없음",
Leave_Ask: "저장 안 한 고침을 버리고 닫을까?",
Mine: "내 기계",
Side_Hint: "펼치면 가운데에 목록 · 양식",
Items_Side_Hint: "펼치면 가운데에 모아 보기(읽기)",
Won: "원",
Labor: "노",
Material: "재",
Expense: "경",
바로: "바로 넣기",
품셈: "품셈 방식",
직접: "시간당 값 직접",
// 기계 고르기 창 · 줄 쌓기
Pick_Group: "내 기계",
Pick_New: "새 기계 만들기",
Pick_Direct: "시간당 값 직접 기계 — 줄 쌓기 식이 없어 못 넣음",
To_Mine: "복사해 내 기계로",
// 내 품목
Items_Note:
"단가산출 줄에 값을 바로 적은 재료(우리 자재에 안 이어진 것) — 고치기는 그 단가산출에서 · 줄 누르면 쓰는 단가산출",
Items_None: "내 품목 없음",
Value: "값(원)",
Unit: "단위",
Same_Name: "같은 이름",
Open_Sk: "쓰는 단가산출 — 누르면 그 단가산출로",
Personal_Only: "개인 층에서만 — 로그인 사용자 회사 · 개인 층이 없음",
} as const;
export type MtKey = keyof typeof TEXT;
export const mt = (key: MtKey): string => TEXT[key];
+127
View File
@@ -0,0 +1,127 @@
/* =============================================================================
* M02_MyMachine_View.ts
* M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「내 기계」 칸을 펼치면 가운데에 뜸(개인 층).
* 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기 · [새 기계]
* 줄 누름 = 양식 한 장(`M02_MyMachine_Form`) · [목록] = 저장 안 한 고침이면 물음 · 저장 · 지우기 뒤 목록 다시
* ========================================================================== */
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 { createText } from "@ui/ui_template_text";
import { listMyMachines, type MyMachineRow } from "./M02_MyMachine_Api";
import { mountMyMachineForm } from "./M02_MyMachine_Form";
import { mt, type MtKey } from "./M02_MyMachine_Text";
import "./M02_MyMachine.css";
export interface MyMachineViewOptions {
owner: string | null;
/** 저장 안 한 고침 표시(페이지 떠남 물음) */
setDirty: (dirty: boolean) => void;
}
const day = (t: string | undefined): string => (t ?? "").slice(0, 10);
export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement {
const host = el("div", { className: "m02-mymach-view" });
let rows: MyMachineRow[] = [];
let shown: MyMachineRow[] = [];
let dirty = (): boolean => false;
const mark = (d: boolean): void => opt.setDirty(d);
const search = createInputField({ type: "search", placeholder: mt("Find") });
const hint = createText("");
const fail = el("div");
const table = createDataTable({
caption: mt("Title"),
columns: [
{ key: "이름", label: mt("Name"), width: 180 },
{ key: "규격", label: mt("Spec"), width: 120 },
{ key: "방식", label: mt("How"), width: 100 },
{ key: "시간당", label: mt("Hourly"), kind: "number", decimals: 0, width: 110 },
{ key: "쓰는곳", label: mt("Uses_Count"), kind: "number", decimals: 0, width: 70 },
{ key: "넣은날", label: mt("Made"), width: 96 },
{ key: "고친날", label: mt("Edited"), width: 96 },
],
rows: [],
empty: mt("None"),
onRowClick: (i) => shown[i] && void openForm(shown[i].키),
});
const draw = (): void => {
const words = search.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
shown = rows.filter((r) => {
const text = `${r.이름} ${r.규격} ${r.키}`.toLowerCase();
return words.every((w) => text.includes(w));
});
hint.textContent = rows.length ? `${shown.length} / ${rows.length}` : "";
table.setRows(
shown.map((r) => ({
이름: r.이름,
규격: r.규격,
방식: mt(r.방식 as MtKey),
시간당: r.시간당.ok ? (r.시간당.계 ?? null) : null,
쓰는곳: r.쓰는곳수,
넣은날: day(r.넣은날),
고친날: day(r.고친날),
})),
);
};
search.input.addEventListener("input", draw);
const newBtn = createButton({
label: mt("New"),
variant: "filled",
size: 28,
onClick: () => void openForm(),
});
newBtn.dataset.mymachNew = "";
const list = el("div", {
className: "m02-mymach-view__list",
children: [
el("div", { className: "ui-toolbar", children: [newBtn, search.root, hint] }),
createText(mt("Own_Note"), "muted"),
fail,
table.root,
],
});
async function showList(): Promise<void> {
dirty = () => false;
mark(false);
host.replaceChildren(list);
fail.replaceChildren();
try {
rows = await listMyMachines(opt.owner);
} catch (error) {
rows = [];
fail.replaceChildren(
createText(error instanceof Error ? error.message : mt("Personal_Only"), "error"),
);
}
draw();
}
async function openForm(key?: string): Promise<void> {
const slot = el("div");
host.replaceChildren(slot);
try {
const h = await mountMyMachineForm(slot, {
owner: opt.owner,
key,
onSaved: (got) => void openForm(got.줄.키),
onDeleted: () => void showList(),
onBack: async () => {
if (!dirty() || (await showConfirmDialog(mt("Leave_Ask")))) await showList();
},
onDirty: mark,
});
dirty = h.dirty;
} catch (error) {
showToast(error instanceof Error ? error.message : mt("Failed"), "error");
await showList();
}
}
void showList();
return host;
}
+5
View File
@@ -583,6 +583,11 @@ app.include_router(
__import__("M02_MasterTemplete.M02_MyMachine_Router", fromlist=["router"]).router,
dependencies=protected,
)
# 내 기계 화면 읽기(PLAN 821-3) — 양식 목록 · 비슷한 기계 찾기 · 내 품목 · 로그인 · 층 권한은 길 안에서
app.include_router(
__import__("M02_MasterTemplete.M02_MyMachine_Look", fromlist=["router"]).router,
dependencies=protected,
)
# 단가산출(SK) 수식 문서 길(PLAN 802-4a) — 로그인 · 층 권한은 길 안에서
app.include_router(
__import__("M02_MasterTemplete.M02_Doc_Router", fromlist=["router"]).router,
@@ -0,0 +1,144 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""821-3 내 기계 화면 읽기 길(`M02_MyMachine_Look`) — 임시 storage · 쓰기 없음.
① 양식 목록 = M01 기계 화면 목록과 같음(연료 · 직종 · 전력 · 시간환산)
② 비슷한 기계 찾기 = 내 기계(앞) + M01 · 원문번호 = 그 M01 기계 먼저 · 남의 층 내 기계 안 보임
③ 내 품목 = 값을 바로 적은 재료비 줄만 · 같은 품목 묶음 · 쓰는 단가산출 ④ 개인 층 밖 = 404.
"""
from __future__ import annotations
import json
import pytest
from fastapi import HTTPException
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_MyMachine_Look as look
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥
NAME = "서브원임시기계"
def _at(folder, fn, *args):
with store.at(folder):
return fn(*args)
def _mine(folder):
row = {"이름": NAME, "규격": "1㎾", "방식": "바로", "값": {"가격": 1000, "손료계수": 2000}}
return _at(folder, mym.create, row)["줄"]
def test_lists_same_as_m01():
"""① 양식 목록 = M01 기계 화면 값."""
from M01_MasterData import M01_MasterData_Machine as m01m
got, want = look.lists(), m01m.lists()
assert set(got) == {"fuels", "jobs", "power", "hour"}
for k in got:
assert got[k] == want[k], k
assert got["fuels"] and got["jobs"] and got["hour"] == 0.125
def test_find_mine_first_and_number(world):
"""② 내 기계 앞 · 원문번호 = 그 M01 기계 먼저 · 남의 층 = 내 기계 안 보임."""
me, other = world["personal"], layers.personal_dir(7, 43)
key = _mine(me)["키"]
rows = _at(me, look.find, NAME)
assert rows[0]["키"] == key and rows[0]["출처"] == "개인" and rows[0]["값"]["손료계수"] == 2000
assert all(r["출처"] == "M01" for r in _at(other, look.find, NAME))
no = str(m01.loaded()[1].get(DIG)["원문번호"])
first = _at(me, look.find, no)[0]
assert first["키"] == DIG and first["원문번호"] == no and first["출처"] == "M01"
assert isinstance(first["값"]["가격"], int | float) and first["구분"] # 수(글이면 미리보기 0)
dig = [r for r in _at(me, look.find, "굴착기") if r["출처"] == "M01"]
assert dig and len({r["키"] for r in dig}) == len(dig) # 겹침 없음
def _sk(folder, key, name, lines):
"""수식 문서 재료비 줄 — (이름, 연결) · 숫자 = 바로 적은 값 · 요소 = 우리 자재."""
vars_, money = [], []
for i, (item, link) in enumerate(lines):
vars_.append({"열쇠": f"P_x{i}", "기호": "P", "아래": f"x{i}", "위": "", "연결": link})
money.append(
{"이름": item, "규격": "10㎜", "단위": "개", "비목": "재료비", "단가": f"P_x{i}"}
)
money.append({"이름": "인부", "단위": "인", "비목": "노무비", "단가": "P_x0"})
doc = {"키": key, "이름": name, "수식": {"문서": {"변수": vars_, "금액": money}}}
path = folder / "sangeun" / f"{key}.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
def test_items_direct_only(world):
"""③ 바로 적은 값 재료만 · 같은 품목 = 한 묶음 · 쓰는 단가산출 · 값 다르면 따로."""
me = world["personal"]
_sk(me, "SK-000001", "가", [("볼트", {"갈래": "숫자", "값": 500}),
("시멘트", {"갈래": "요소", "키": "MT004176"})]) # fmt: skip
_sk(me, "SK-000002", "나", [("볼트", {"갈래": "숫자", "값": 500}),
("볼트", {"갈래": "입력", "값": 700})]) # fmt: skip
got = _at(me, look.items)
assert [(r["이름"], r["값"]) for r in got] == [("볼트", 500), ("볼트", 700)]
assert [u["키"] for u in got[0]["쓰는곳"]] == ["SK-000001", "SK-000002"]
assert got[1]["쓰는곳"] == [{"키": "SK-000002", "이름": "나"}]
assert got[0]["규격"] == "10㎜" and got[0]["단위"] == "개"
assert _at(layers.personal_dir(7, 43), look.items) == []
def test_personal_only(world):
"""④ 개인 층 밖 = 404 · 남의 개인 층 = 403(일반)."""
user = {"role": "USER", "company_id": 7, "user_id": 42}
for layer, owner, code in (("company", None, 404), ("personal", "7/43", 403)):
with pytest.raises(HTTPException) as e:
look.get_items(layer, owner, user)
assert e.value.status_code == code
assert look.get_items("personal", None, user) == {"rows": []}
def _stack_doc(key, no):
"""줄 쌓기 기계 줄 하나(화면이 저장하는 꼴) — 기계='개인 …'."""
var = {"기호": "P", "아래": "새", "위": "", "이름": NAME, "단위": "",
"연결": {"갈래": "단가산출", "키": "GC000268", "입력": {"기계": f"'{no}'"}}} # fmt: skip
line = {"이름": NAME, "단위": "hr", "비목": "묶음", "수량": "1", "단가": "P_새"}
return {"꼴": "수식문서", "판": 1, "키": key, "이름": "sub1임시", "결과단위": "원",
"변수": [var], "식": [], "금액": [line]} # fmt: skip
def test_stack_line_copy(world):
"""⑤ 줄 쌓기로 고른 내 기계 — 미리 셈 됨 · 저장 때 값 사본에 박힘 →
고쳐도 금액 그대로 + 단가새값 → [단가 갱신] 바뀐 자원 1 → 지워도 마지막 값 + 지운키."""
from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_Labor_Ref as refs
me = world["personal"]
made = _at(me, mym.create, {**_mine_row(), "값": {**_mine_row()["값"], "주연료": 2,
"주연료종류": "경유", "조종원": 1, "조종원직종": "1048"}}) # fmt: skip
key, no, was = made["줄"]["키"], made["줄"]["원문번호"], made["시간당"]["계"]
sk = _at(
me,
lambda: refs.create("sangeun", {"이름": "sub1임시", "단위": "시간", "더한줄": []})["이름"],
)
doc = _stack_doc(sk, no)
look_ = _at(me, path.preview, sk, {"문서": doc})
assert look_["셈"]["계"] == was # 사본에 아직 없는 내 기계도 셈
판 = _at(me, path.read, sk)["판"]
_at(me, path.save, sk, {"문서": doc, "판": 판})
copy = _at(me, lambda: store.read("sangeun", sk)["문서"]["수식"])
assert key in [r["키"] for r in copy["단가"]] # 값 사본에 박힘
_at(me, mym.update, key, {**made["줄"], "값": {**made["줄"]["값"], "가격": 3000}})
now = _at(me, path.read, sk)
assert now["셈"]["계"] == was and now["사본"]["단가새값"] is True
job = _at(me, path.preview, sk, {"할일": "단가갱신"})
assert [x["키"] for x in job["바뀐자원"]] == [key] and job["후"]["계"] > was
_at(me, mym.delete, key)
gone = _at(me, path.read, sk)
assert gone["셈"]["계"] == was and gone["사본"]["지운키"] == [key]
def _mine_row():
return {"이름": NAME, "규격": "1㎾", "방식": "바로", "값": {"가격": 1000, "손료계수": 2000}}
+5
View File
@@ -69,6 +69,9 @@ export interface MachineFormState {
/** 숫자 아닌 칸 등 — 있으면 저장 막음 */
errors: string[];
changed: boolean;
/** 손료계수 방식 · 「시간당 값 직접」 꼴인지(내 기계 저장 방식) */
mode: "direct" | "pumsem";
hourly: boolean;
}
export interface MachineFormHandle {
@@ -507,6 +510,8 @@ export function createMachineForm(opts: MachineFormOptions): MachineFormHandle {
),
errors: [...errors.values()],
changed: changed(),
mode,
hourly: hourlyMode,
}),
setNotice,
};
+5
View File
@@ -94,6 +94,11 @@
width: min(1100px, 94vw);
height: var(--modal-max);
}
/* 확인창 글 — 줄바꿈(
) 그대로(쓰는 곳 목록 · 바뀐 자원 줄) */
.ui-confirm__message {
white-space: pre-line;
}
.ui-confirm__title {
margin: 0 0 var(--spacing-8);
font-size: var(--text-body);