/* ============================================================================= * M02_MasterTemplete_Hopyo_Ledger.ts * 일위대가 실무자료 별칭 장부 화면(PLAN 338-3 · 설계 4-4) — 본 칸 · 좌측 판 [별칭 장부] 로 엶. * · 표 = 엑셀 품명 · 규격 · 단위 | 갈래 | 우리 로직(여럿) · 옵션 · 배 · 몫 | 누가 · 때 · 처음 파일 | 층(위 층 회색 읽기만) * · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [로직 더함] · 배 · 몫 · [옵션] · [지우기] · * 위 층 줄 [내 층으로 고쳐 씀] * · 고친 것 = sessionStorage `m02-hopyo-ledger:{층}:{주인}` → [저장] 때만 `PUT`(서버가 짝을 다시 검사) * · 안 둠 = 발주처 칸 · 자동 올림 · 자동 합침 · 쓰인 횟수 · 회사로 올리기. * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { readLedger, saveLedger, type HopyoScope, type LedgerEntry, type LedgerPair, type LedgerRow, } from "./M02_MasterTemplete_Hopyo_Api"; import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import "./M02_MasterTemplete_Hopyo.css"; export const LEDGER_TITLE = "별칭 장부"; const KINDS = ["짝", "우리에 없음", "안 씀"] as const; const LAYER_TEXT: Record = { system: "마스터", company: "회사", personal: "개인" }; type Edits = Record; /** 화면 한 줄 — 서버 줄 위에 고친 것 */ export type LedgerView = LedgerRow & { 고친?: boolean }; export const ledgerKey = (scope: HopyoScope): string => `m02-hopyo-ledger:${scope.layer}:${scope.owner ?? ""}`; function loadEdits(k: string): Edits { try { return JSON.parse(sessionStorage.getItem(k) ?? "{}") as Edits; } catch { return {}; } } function saveEdits(k: string, edits: Edits): void { try { if (Object.keys(edits).length) sessionStorage.setItem(k, JSON.stringify(edits)); else sessionStorage.removeItem(k); } catch { /* 캐시 못 씀 = 이 화면에만 남음 */ } } /** 쓰기 꼴 — 보기 칸(층 · 누가 · 때 …) 뺌 */ export const entryOf = (row: LedgerEntry): LedgerEntry => ({ 원래: row.원래, 갈래: row.갈래, 짝: row.갈래 === "짝" ? row.짝.map((p) => ({ ...p, 입력값: p.입력값 ?? {}, 몫: p.몫 ?? null })) : [], 엑셀조항: row.엑셀조항 ?? [], }); /** 서버 줄 + 고친 것 → 화면 줄 — 이 층 줄은 고친 값 · 지운 것 뺌 · 위 층에서 옮긴 줄은 이 층 줄로 더함 */ export function mergeRows(rows: LedgerRow[], edits: Edits, scope: HopyoScope): LedgerView[] { const mine = rows.filter((r) => r.고침); const out: LedgerView[] = []; for (const r of mine) { if (!(r.열쇠 in edits)) out.push(r); else if (edits[r.열쇠]) out.push({ ...r, ...edits[r.열쇠]!, 고친: true }); } for (const [key, entry] of Object.entries(edits)) { if (!entry || mine.some((r) => r.열쇠 === key)) continue; out.push({ ...entry, 열쇠: key, 누가: null, 때: null, 처음파일: null, 층: scope.layer, 주인: scope.owner, 고침: true, 고친: true, }); } return [...out, ...rows.filter((r) => !r.고침)]; } /** 짝 글 — 「GC000198 × 1 · 몫 0.1 · 소량감률=50」 */ export const pairText = (p: LedgerPair): string => [ `${p.로직 ?? p.자재 ?? ""} × ${p.배}`, p.몫 != null ? `몫 ${p.몫}` : "", Object.entries(p.입력값 ?? {}) .map(([k, v]) => `${k}=${String(v)}`) .join(", "), ] .filter(Boolean) .join(" · "); /** 찾기 · 갈래 거름 */ export const filterRows = (rows: LedgerView[], q: string, kind: string): LedgerView[] => { const want = q.trim().toLowerCase(); return rows.filter( (r) => (!kind || r.갈래 === kind) && (!want || `${r.원래.품명} ${r.원래.규격} ${r.짝.map(pairText).join(" ")}` .toLowerCase() .includes(want)), ); }; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const td = (text: string, cls = ""): HTMLElement => el("td", { text, className: cls, attrs: { title: text } }); const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); export interface LedgerOptions { scope: HopyoScope; /** 이 층 쓰기 권한 — 없으면 보기만 */ canWrite: boolean; } export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { const root = el("div", { className: "m02-hopyo m02-hopyo-ledger", attrs: { "data-hopyo-ledger": "" }, }); const k = ledgerKey(opt.scope); let edits = loadEdits(k); let rows: LedgerRow[] = []; const search = el("input", { className: "ui-input ui-input--sm m02-hopyo__search", attrs: { type: "search", placeholder: "품명 · 규격 · 로직 찾기" }, }) as HTMLInputElement; const kind = el("select", { className: "ui-input ui-input--sm", children: [ el("option", { text: "갈래 전체", attrs: { value: "" } }), ...KINDS.map((x) => el("option", { text: x, attrs: { value: x } })), ], }) as HTMLSelectElement; const save = createButton({ label: "저장", variant: "filled", size: "sm" }); const drop = createButton({ label: "고친 것 버림", variant: "ghost", size: "sm" }); const body = el("tbody"); const keep = (): void => { saveEdits(k, edits); paint(); }; const edit = (r: LedgerView, next: Partial): void => { edits[r.열쇠] = entryOf({ ...entryOf(r), ...next }); keep(); }; function pairCell(r: LedgerView, mine: boolean): HTMLElement { const pairs: HTMLElement[] = r.짝.map((p, at) => { const parts: HTMLElement[] = [el("span", { text: pairText(p) })]; if (mine) { const times = el("input", { className: "ui-input ui-input--sm m02-hopyo__times", attrs: { type: "number", step: "any", min: "0", value: String(p.배), title: "배" }, }) as HTMLInputElement; times.addEventListener("change", () => edit(r, { 짝: r.짝.map((x, i) => (i === at ? { ...x, 배: Number(times.value) } : x)) }), ); parts.push(times); if (p.로직) parts.push( createButton({ label: "옵션", variant: "ghost", size: "sm", onClick: () => void fetchLogicInputs([p.로직!]).then( (got) => openLaborInput({ title: p.로직!, logic: got.로직[0] ?? null, values: p.입력값 as never, memo: null, readOnly: false, done: (res) => edit(r, { 짝: r.짝.map((x, i) => (i === at ? { ...x, 입력값: res.입력값 } : x)), }), }), (error) => showToast(why(error), "error"), ), }), createButton({ label: "×", variant: "ghost", size: "sm", onClick: () => edit(r, { 짝: r.짝.filter((_, i) => i !== at) }), }), ); } return el("div", { className: "m02-hopyo__part", children: parts }); }); if (mine && r.갈래 === "짝") pairs.push( createButton({ label: "로직 더함", variant: "ghost", size: "sm", onClick: () => openLogicPicker((x) => edit(r, { 짝: [...r.짝, { 로직: x.키, 입력값: {}, 배: 1, 몫: null }] }), ), }), ); return el("td", { className: "m02-hopyo__parts", children: pairs }); } function rowOf(r: LedgerView): HTMLElement { const mine = opt.canWrite && r.고침; const kindCell = mine ? (() => { const s = el("select", { className: "ui-input ui-input--sm", children: KINDS.map((x) => el("option", { text: x, attrs: { value: x } })), }) as HTMLSelectElement; s.value = r.갈래; s.addEventListener("change", () => edit(r, { 갈래: s.value as LedgerEntry["갈래"], 짝: s.value === "짝" ? r.짝 : [] }), ); return el("td", { children: [s] }); })() : td(r.갈래); const acts: HTMLElement[] = []; if (mine) acts.push( createButton({ label: "지우기", variant: "danger", size: "sm", onClick: () => { edits[r.열쇠] = null; keep(); }, }), ); else if (opt.canWrite && !rows.some((x) => x.고침 && x.열쇠 === r.열쇠) && !edits[r.열쇠]) acts.push( createButton({ label: "내 층으로 고쳐 씀", variant: "ghost", size: "sm", onClick: () => { edits[r.열쇠] = entryOf(r); keep(); }, }), ); const when = [r.누가?.이름, r.때?.replace("T", " ").slice(0, 16), r.처음파일] .filter(Boolean) .join(" · "); return el("tr", { className: `m02-hopyo-ledger__row${r.고침 ? "" : " is-upper"}${r.고친 ? " is-edited" : ""}`, attrs: { "data-ledger": r.열쇠 }, children: [ td(r.원래.품명), td(r.원래.규격), td(r.원래.단위), kindCell, pairCell(r, mine), td(when), td(r.고침 ? "내 층" : (LAYER_TEXT[r.층] ?? r.층)), el("td", { className: "m02-hopyo__acts", children: acts }), ], }); } function paint(): void { const all = mergeRows(rows, edits, opt.scope); const shown = filterRows(all, search.value, kind.value); const n = Object.keys(edits).length; save.querySelector(".ui-btn__label")!.textContent = n ? `저장 (${n})` : "저장"; save.disabled = drop.disabled = !n; body.replaceChildren( ...(shown.length ? shown.map(rowOf) : [el("tr", { children: [el("td", { text: "장부 줄 없음", attrs: { colspan: "8" } })] })]), ); } search.addEventListener("input", paint); kind.addEventListener("change", paint); drop.addEventListener("click", () => { edits = {}; keep(); }); save.addEventListener("click", async () => { save.disabled = true; try { rows = await saveLedger(opt.scope, edits); edits = {}; showToast("장부 저장", "success"); keep(); } catch (error) { showToast(`저장 못 함 — ${why(error)}`, "error"); paint(); } }); async function load(): Promise { const stop = showLoading(root); try { rows = await readLedger(opt.scope); } catch (error) { root.replaceChildren(loadingError(why(error), () => void load())); return; } finally { stop(); } root.replaceChildren( el("div", { className: "m02-hopyo__bar", children: [ el("strong", { text: LEDGER_TITLE }), note("엑셀 이름 → 우리 로직 + 옵션 + 배 · 확인하고 만들 때 남음 · 아래 층이 이김"), search, kind, ...(opt.canWrite ? [save, drop] : []), ], }), el("div", { className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", children: [ el("table", { className: "ui-general-block__table m02-hopyo__table m02-hopyo-ledger__table", children: [ el("thead", { children: [ el("tr", { children: [ "엑셀 품명", "규격", "단위", "갈래", "우리 로직 · 배 · 몫 · 옵션", "누가 · 때 · 처음 파일", "층", "", ].map((t) => el("th", { text: t })), }), ], }), body, ], }), ], }), ); paint(); } void load(); return root; }