diff --git a/A00_Common/formula_doc_copy.ts b/A00_Common/formula_doc_copy.ts index 5dc250758..495b40aa0 100644 --- a/A00_Common/formula_doc_copy.ts +++ b/A00_Common/formula_doc_copy.ts @@ -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) : null; + const b = r.후 && typeof r.후 === "object" ? (r.후 as Record) : 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): Promise { 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")); } diff --git a/A00_Common/formula_doc_text.ts b/A00_Common/formula_doc_text.ts index d3183f97c..91c3073be 100644 --- a/A00_Common/formula_doc_text.ts +++ b/A00_Common/formula_doc_text.ts @@ -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"], diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index 3cea33fc9..bea153692 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -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 }, (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"), diff --git a/A00_Common/line_stack_mine.ts b/A00_Common/line_stack_mine.ts new file mode 100644 index 000000000..05422fcdf --- /dev/null +++ b/A00_Common/line_stack_mine.ts @@ -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 | 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 }, + 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; +} diff --git a/A00_Common/line_stack_pick.ts b/A00_Common/line_stack_pick.ts index b20e75bf3..4dbc376b0 100644 --- a/A00_Common/line_stack_pick.ts +++ b/A00_Common/line_stack_pick.ts @@ -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) => diff --git a/M02_MasterTemplete/M02_Doc_Sk_Path.py b/M02_MasterTemplete/M02_Doc_Sk_Path.py index a7cd41b01..0b0da4de6 100644 --- a/M02_MasterTemplete/M02_Doc_Sk_Path.py +++ b/M02_MasterTemplete/M02_Doc_Sk_Path.py @@ -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)["문서"] diff --git a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts index d8fcf7964..b1d684bd6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts @@ -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( diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 8e554c99c..df39ccbcc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -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), diff --git a/M02_MasterTemplete/M02_MyItems_View.ts b/M02_MasterTemplete/M02_MyItems_View.ts new file mode 100644 index 000000000..823a63507 --- /dev/null +++ b/M02_MasterTemplete/M02_MyItems_View.ts @@ -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(); + 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, + ], + }); +} diff --git a/M02_MasterTemplete/M02_MyMachine.css b/M02_MasterTemplete/M02_MyMachine.css new file mode 100644 index 000000000..97e76bde3 --- /dev/null +++ b/M02_MasterTemplete/M02_MyMachine.css @@ -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); +} diff --git a/M02_MasterTemplete/M02_MyMachine_Api.ts b/M02_MasterTemplete/M02_MyMachine_Api.ts new file mode 100644 index 000000000..cd12e8301 --- /dev/null +++ b/M02_MasterTemplete/M02_MyMachine_Api.ts @@ -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 { + 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 { + 시간당: 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; +} + +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 => { + 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 => { + 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 => + (await call<{ 줄: MyMachineRow[] }>(at(owner))).줄; + +export const readMyMachine = (owner: string | null, k: string): Promise => + call(at(owner, key(k))); + +export const createMyMachine = ( + owner: string | null, + body: MyMachineBody, + base?: string, +): Promise => + call(at(owner), { method: "POST", body: JSON.stringify({ 줄: body, 본뜬: base ?? null }) }); + +export const updateMyMachine = ( + owner: string | null, + k: string, + body: MyMachineBody, +): Promise => + 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 | null = null; +/** 양식 고르기 목록 — 한 번 받아 둠 */ +export const fetchMyMachineLists = (): Promise => + (lists ??= call("/my-machine-lists").catch((e) => { + lists = null; + return Promise.reject(e); + })); + +export const findSimilar = async (owner: string | null, q: string): Promise => + (await call<{ rows: SimilarRow[] }>(look(owner, "my-machine-find", { q }))).rows; + +export const listMyItems = async (owner: string | null): Promise => + (await call<{ rows: MyItem[] }>(look(owner, "my-items"))).rows; + +/** 단가산출 줄 기계 입력 글(줄 쌓기 `기계='개인 EQP…'`) */ +export const machineArg = (no: string): string => `'${no}'`; + +/** 내 기계 → 기계 고르기 한 줄(`M01Element` 꼴 · 줄 쌓기 = 로직 + 기계='개인 EQP…') */ +export const mineElement = ( + row: Pick, + h: MyHourly, +): M01Element => ({ + 종류: "기계", + 코드: row.키, + 이름: row.이름, + 규격: row.규격, + 단위: "시간", + 값: h.ok ? (h.계 ?? null) : null, + 단가: { 노무비: h.노무비 ?? 0, 재료비: h.재료비 ?? 0, 경비: h.경비 ?? 0 }, + 로직: h.로직 ?? undefined, + 원문번호: row.원문번호, +}); diff --git a/M02_MasterTemplete/M02_MyMachine_Form.ts b/M02_MasterTemplete/M02_MyMachine_Form.ts new file mode 100644 index 000000000..c422dc61d --- /dev/null +++ b/M02_MasterTemplete/M02_MyMachine_Form.ts @@ -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): 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 { + 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(); + 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 => + (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("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(); + }); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MyMachine_Look.py b/M02_MasterTemplete/M02_MyMachine_Look.py new file mode 100644 index 000000000..e288f4955 --- /dev/null +++ b/M02_MasterTemplete/M02_MyMachine_Look.py @@ -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)} diff --git a/M02_MasterTemplete/M02_MyMachine_Text.ts b/M02_MasterTemplete/M02_MyMachine_Text.ts new file mode 100644 index 000000000..19b1e1732 --- /dev/null +++ b/M02_MasterTemplete/M02_MyMachine_Text.ts @@ -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]; diff --git a/M02_MasterTemplete/M02_MyMachine_View.ts b/M02_MasterTemplete/M02_MyMachine_View.ts new file mode 100644 index 000000000..ffebeaa77 --- /dev/null +++ b/M02_MasterTemplete/M02_MyMachine_View.ts @@ -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 { + 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 { + 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; +} diff --git a/main.py b/main.py index 9f5954106..36756efcb 100644 --- a/main.py +++ b/main.py @@ -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, diff --git a/resources/tester/test_821_3_my_machine_screen.py b/resources/tester/test_821_3_my_machine_screen.py new file mode 100644 index 000000000..10039bce0 --- /dev/null +++ b/resources/tester/test_821_3_my_machine_screen.py @@ -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}} diff --git a/ui_template/ui_template_machine_form.ts b/ui_template/ui_template_machine_form.ts index e6b8558bb..ac1c3019c 100644 --- a/ui_template/ui_template_machine_form.ts +++ b/ui_template/ui_template_machine_form.ts @@ -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, }; diff --git a/ui_template/ui_template_modal.css b/ui_template/ui_template_modal.css index 09a0f96d7..19cc2746f 100644 --- a/ui_template/ui_template_modal.css +++ b/ui_template/ui_template_modal.css @@ -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);