Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts
T
eomsangdonandClaude Opus 5.5 7f33ffe2bd fix(M02 실무자료): 339 덧고침 — 확인 창 표 꼴 · 만들기 뒤 사전조합 목록 갱신 · 엑셀 보기 창 제안 수 다시 읽음
- 확인 창 줄 표: 한글 낱말 단위 줄바꿈 · 품명 · 규격 최소 폭 · 옵션 · 품 비교 항목마다 한 줄 · 단추 칸 세로 쌓음(좁게)
- 별칭 장부 표: 품명 · 규격 같은 손질
- 만들기(확인 창 · 엑셀 보기 창 일괄) 뒤 document 알림 → 좌측 「일위대가 사전조합」 목록 다시 읽음
- 엑셀 보기 창: 뒤 셈 중이면 3 초마다 목록 「제안 n/m」 다시 읽음 · 펼침 표 숫자 판정 칸 = 맞음 n · 다른 점 n
- 품 비교 판정 함수 · 서버 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 01:09:47 +09:00

359 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<string, string> = { system: "마스터", company: "회사", personal: "개인" };
type Edits = Record<string, LedgerEntry | null>;
/** 화면 한 줄 — 서버 줄 위에 고친 것 */
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<LedgerEntry>): 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<void> {
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;
}