Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts
T
eomsangdonandClaude Opus 5.5 01d3b0d2f2 feat(M02): 338-3c 확인 창 · 고르기 창 · 별칭 장부 화면 — 체크한 줄만 [확인하고 사전조합 만들기] · 옛 즉시 확정 · 바로 만들기 없앰
- 새 Hopyo_Check(확인 창 · 본 칸 · 모달) — 상태 색 · 체크 제안 · 우리 로직 여럿 · 배 · 몫 · 옵션 · 품 비교 · 뺌 · 원문 보기 · 캐시 m02-hopyo-check · 셈 중 3 초 다시 읽음 · 최신 응답만
- 새 Hopyo_Choose(고르기 창 · 후보 판정 · 여럿 고름 · 몫 · 찾기 글 미리 넣음) · 새 Hopyo_Ledger(장부 화면 · 고친 것 캐시 · [저장] 때만 PUT)
- 고침 View(호표 하나 = 확인 창) · Sheet(펼침 뒤 한 번 · 하위 호표 먼저) · Pick(확인된 것 바로 참조 · 아니면 확인 창) · Api(3-3 꼴 · 만들기 몸 · 장부 길) · Side([별칭 장부]) · css · Labor_Pick(query)
- 없앰 saveHopyoMemory · [확정] · [같음 모두 확정] · [근거 맞음 모두 확정] · 바로 만들기
- 시험 test_m02_hopyo_screen.py 새로 · helper_m02_hopyo_check.cjs 새 · pick · seam 도우미 손질

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 23:37:24 +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",
children: [
el("thead", {
children: [
el("tr", {
children: [
"엑셀 품명",
"규격",
"단위",
"갈래",
"우리 로직 · 배 · 몫 · 옵션",
"누가 · 때 · 처음 파일",
"층",
"",
].map((t) => el("th", { text: t })),
}),
],
}),
body,
],
}),
],
}),
);
paint();
}
void load();
return root;
}