- 확인 창 줄 표: 한글 낱말 단위 줄바꿈 · 품명 · 규격 최소 폭 · 옵션 · 품 비교 항목마다 한 줄 · 단추 칸 세로 쌓음(좁게) - 별칭 장부 표: 품명 · 규격 같은 손질 - 만들기(확인 창 · 엑셀 보기 창 일괄) 뒤 document 알림 → 좌측 「일위대가 사전조합」 목록 다시 읽음 - 엑셀 보기 창: 뒤 셈 중이면 3 초마다 목록 「제안 n/m」 다시 읽음 · 펼침 표 숫자 판정 칸 = 맞음 n · 다른 점 n - 품 비교 판정 함수 · 서버 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|