Merge remote-tracking branch 'origin/dev' into sub_laptop_3
This commit is contained in:
@@ -3,9 +3,22 @@
|
||||
* 쉬운 단가산출 Api — 서버 길 감싸기(목록 · 열기 + 자동값 · 시험 계산 뒤에서 · 새로 · 본뜨기 ·
|
||||
* 고치기 · 지우기 · 쓰는 곳) · 403 · 409 · 422 를 쉬운 말로 · 기계 기종 → 부를 기계경비 로직 · 넘길 값
|
||||
* 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 쓰기는 `/logic/*` 만(`/save` 안 씀)
|
||||
* 시험: `resources/tester/test_m01_easycalc_api.py`(서버 왕복 + 아래 「순수」 칸을 Node 로)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
ApiError,
|
||||
copyLogic,
|
||||
fetchAuto,
|
||||
fetchLogic,
|
||||
runCalc,
|
||||
type CalcAnswer,
|
||||
type LogicInput,
|
||||
type LogicRow,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
|
||||
|
||||
/** 목록 한 줄 */
|
||||
export interface EasyListItem {
|
||||
@@ -62,55 +75,396 @@ export interface EasyMachineElement {
|
||||
needInputs: LogicInput[];
|
||||
}
|
||||
|
||||
const notReady = (): never => {
|
||||
throw new Error("준비 중");
|
||||
};
|
||||
|
||||
export async function listEasy(_q: string): Promise<EasyListItem[]> {
|
||||
return notReady();
|
||||
/** 기계 시간당 경비 로직 하나 — 기종 짝 규칙의 재료 */
|
||||
export interface MachineLogic {
|
||||
key: string;
|
||||
name: string;
|
||||
resultUnit: string;
|
||||
inputs: LogicInput[];
|
||||
}
|
||||
|
||||
export async function openEasy(_key: string): Promise<EasyOpened> {
|
||||
return notReady();
|
||||
/* ── 순수 시작 — 아래 끝까지 밖 이름을 안 씀(`currentLanguageIndex` 만) · 시험이 Node 로 바로 돌림 ── */
|
||||
const TEXT = {
|
||||
Base: ["품셈 기본", "Standard"],
|
||||
Mine: ["내가 만든 것", "Mine"],
|
||||
NotMoney: [
|
||||
"금액이 아닌 값(작업량 등)을 내는 것이라 쉬운 길로 못 엶 — 지금 화면에서 봄",
|
||||
"Gives a non-money value (output etc.) — open it in the full screen",
|
||||
],
|
||||
E403: [
|
||||
"품셈 기본은 바로 못 고침 — 「이걸 바탕으로 고치기」로 내 것을 만들어 고침",
|
||||
"Standard items are read-only — use “Edit a copy”",
|
||||
],
|
||||
E404: [
|
||||
"찾을 수 없음 — 이미 지워졌을 수 있음 · 목록을 다시 열어 주세요",
|
||||
"Not found — it may be deleted; reopen the list",
|
||||
],
|
||||
E409: [
|
||||
"다른 곳에서 먼저 고쳤음 — 다시 열어 최신 것으로 고쳐 주세요",
|
||||
"Changed elsewhere first — reopen and edit again",
|
||||
],
|
||||
Net: ["서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요", "Server unreachable — try again"],
|
||||
Generic: [
|
||||
"저장 검사에 걸림 — 고친 줄을 다시 확인해 주세요",
|
||||
"Did not pass the check — review your changes",
|
||||
],
|
||||
More: ["그 밖 {n} 건", "{n} more"],
|
||||
UnknownName: [
|
||||
"「{name}」 값이 어디에도 없음 — 설계 때 정하는 값으로 더해 주세요",
|
||||
"「{name}」 is not defined — add it as a design value",
|
||||
],
|
||||
NeedCost: [
|
||||
"넣을 자리(사람 품값 · 재료값 · 기계·경비)를 골라 주세요",
|
||||
"Pick where it goes (labor · material · equipment)",
|
||||
],
|
||||
NoLines: [
|
||||
"줄이 하나도 없음 — 사람 · 재료 · 기계 줄을 하나 이상 더해 주세요",
|
||||
"No lines — add at least one labor, material or machine line",
|
||||
],
|
||||
Source: ["근거 글 모양이 맞지 않음", "The source text has a wrong shape"],
|
||||
CallNeeds: [
|
||||
"부르는 다른 일위대가가 「{name}」 값을 받아야 함 — 설계 때 정하는 값에 더해 주세요",
|
||||
"The called item needs 「{name}」 — add it as a design value",
|
||||
],
|
||||
CallExtra: [
|
||||
"부르는 다른 일위대가는 「{name}」 값을 받지 않음",
|
||||
"The called item does not take 「{name}」",
|
||||
],
|
||||
Kind: [
|
||||
"고른 항목과 줄 종류가 맞지 않음 — 다시 골라 주세요",
|
||||
"The picked item does not match the line type — pick again",
|
||||
],
|
||||
Loop: [
|
||||
"서로를 부르는 일위대가가 생김 — 한쪽을 빼 주세요",
|
||||
"Items call each other — remove one side",
|
||||
],
|
||||
Unread: ["수량 글을 읽지 못함 — 숫자로 넣어 주세요", "Cannot read the amount — enter a number"],
|
||||
Gone: [
|
||||
"이어진 항목이 없어짐 — 다른 곳에서 쓰고 있으면 지울 수 없음",
|
||||
"A linked item is missing — items still in use cannot be deleted",
|
||||
],
|
||||
NoName: ["이름을 넣어 주세요", "Enter a name"],
|
||||
Broken: ["저장 모양이 덜 갖춰짐 — 다시 열어 주세요", "The saved shape is incomplete — reopen it"],
|
||||
Cut: ["합계 자르기 설정이 맞지 않음", "The rounding setting is wrong"],
|
||||
Material: [
|
||||
"재료 찾는 말에 맞는 자료가 없음 — 다시 골라 주세요",
|
||||
"No material matches — pick again",
|
||||
],
|
||||
Stop: [
|
||||
"이 값으로는 셈하지 못함 — 설계 때 정하는 값을 확인해 주세요",
|
||||
"Cannot be calculated with these values — check the design values",
|
||||
],
|
||||
NoMachine: [
|
||||
"이 기계는 시간당 값을 셀 자료가 없음 — 다른 기계를 골라 주세요",
|
||||
"No hourly cost data for this machine — pick another",
|
||||
],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
type SayKey = keyof typeof TEXT;
|
||||
|
||||
function say(key: SayKey, fill: Record<string, string | number> = {}): string {
|
||||
const text: string = TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0];
|
||||
return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m));
|
||||
}
|
||||
|
||||
/** 검사 글 한 줄 → 쉬운 말 — 모르는 꼴은 뭉뚱그림(키 · 날 글이 화면에 안 나가게) */
|
||||
function plainCheck(line: string): string {
|
||||
const name = (re: RegExp): string | null => re.exec(line)?.[1] ?? null;
|
||||
const unknown = name(/모르는 이름 「([^」]+)」/);
|
||||
if (unknown) return say("UnknownName", { name: unknown });
|
||||
const needs = name(/입력 「([^」]+)」 안 줌/);
|
||||
if (needs) return say("CallNeeds", { name: needs });
|
||||
const extra = name(/에 입력 「([^」]+)」 없음/);
|
||||
if (extra) return say("CallExtra", { name: extra });
|
||||
if (/「호표」 도 「결과」 도 없음/.test(line)) return say("NoLines");
|
||||
if (/비목 「/.test(line)) return say("NeedCost");
|
||||
if (/출처 모양/.test(line)) return say("Source");
|
||||
if (/요소 그룹이 종류와 다름|종류 「|고르기 조건은 종류/.test(line)) return say("Kind");
|
||||
if (/돌고 도는 참조/.test(line)) return say("Loop");
|
||||
if (/식 못 읽음/.test(line)) return say("Unread");
|
||||
if (/없는 (로직|요소|키)|로직 키/.test(line)) return say("Gone");
|
||||
if (/「이름」 이 없음|이름 없음/.test(line)) return say("NoName");
|
||||
if (/끝수/.test(line)) return say("Cut");
|
||||
if (/검색어|후보 0|고르기 조건/.test(line)) return say("Material");
|
||||
if (/칸 「[^」]+」 없음/.test(line)) return say("Broken");
|
||||
return say("Generic");
|
||||
}
|
||||
|
||||
/** 상태 번호 + 서버 몸 → 쉬운 말(여러 줄은 줄바꿈 · 셋까지) · 0 = 서버에 못 닿음 */
|
||||
function plainOf(status: number, detail: unknown): string {
|
||||
if (status === 0) return say("Net");
|
||||
if (status === 403) return say("E403");
|
||||
if (status === 404) return say("E404");
|
||||
if (status === 409) return say("E409");
|
||||
const body = detail as { errors?: unknown } | null;
|
||||
const lines =
|
||||
status === 422 && body && Array.isArray(body.errors)
|
||||
? body.errors.map((x) => String(x))
|
||||
: typeof detail === "string"
|
||||
? [detail]
|
||||
: [];
|
||||
if (!lines.length) return say("Generic");
|
||||
const plain = [...new Set(lines.map(plainCheck))];
|
||||
const shown = plain.slice(0, 3);
|
||||
if (plain.length > 3) shown.push(say("More", { n: plain.length - 3 }));
|
||||
return shown.join("\n");
|
||||
}
|
||||
|
||||
/** 계산 멈춤 까닭 — 키(`GX000001`)가 섞인 글은 뭉뚱그림 */
|
||||
function plainStop(text: string): string {
|
||||
return /[A-Z]{2}\d{6}/.test(text) ? say("Stop") : text;
|
||||
}
|
||||
|
||||
const MACHINE = "기계";
|
||||
/** 부착물 아닌 입력 — 이 밖의 입력(집게 · 브레이커)을 받는 로직은 부착 장비 전용 */
|
||||
const MACHINE_PLAIN = new Set(["기계", "지역", "보정작업", "조종원직종"]);
|
||||
|
||||
/** 그 로직이 받는 기종(분류번호) 목록 */
|
||||
function machineCodes(m: MachineLogic): string[] {
|
||||
const spec = m.inputs.find((i) => i.이름 === MACHINE);
|
||||
return (spec?.고르기 ?? []).map(String);
|
||||
}
|
||||
|
||||
/** 짝 고르는 차례 — [부착 전용, 손료만, 손료만이면서 보정, 기종 수] 가 작은 것부터.
|
||||
* 손료만 = 「지역」(연료 · 노임 값 자리)을 안 받음 · 기종 수가 작을수록 그 기종 전용(굴착기 · 조종원 없음 …) */
|
||||
function machineRank(m: MachineLogic): number[] {
|
||||
const names = m.inputs.map((i) => i.이름);
|
||||
const attach = names.some((n) => !MACHINE_PLAIN.has(n)) ? 1 : 0;
|
||||
const lossOnly = names.includes("지역") ? 0 : 1;
|
||||
const boost = lossOnly && names.includes("보정작업") ? 1 : 0;
|
||||
return [attach, lossOnly, boost, machineCodes(m).length];
|
||||
}
|
||||
|
||||
/** 기종 → 부를 기계경비 로직 하나 · 받는 로직이 없으면 null */
|
||||
export function pickMachine(family: MachineLogic[], code: string): MachineLogic | null {
|
||||
const ranked = family
|
||||
.filter((m) => machineCodes(m).includes(code))
|
||||
.map((m) => ({ m, rank: machineRank(m) }))
|
||||
.sort((a, b) => {
|
||||
for (let i = 0; i < a.rank.length; i += 1) {
|
||||
if (a.rank[i] !== b.rank[i]) return a.rank[i] - b.rank[i];
|
||||
}
|
||||
return a.m.key < b.m.key ? -1 : 1;
|
||||
});
|
||||
return ranked[0]?.m ?? null;
|
||||
}
|
||||
|
||||
/** 로직 부름 글 — 기계는 고정 분류번호 · 나머지 입력은 같은 이름의 설계 때 정하는 값으로 넘김 */
|
||||
export function machineCall(m: MachineLogic, code: string): EasyMachineElement {
|
||||
const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`);
|
||||
const needInputs = m.inputs.filter((i) => i.이름 !== MACHINE);
|
||||
const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join("");
|
||||
return { element: `로직(${m.key}, ${MACHINE}='${code}'${rest})`, needInputs };
|
||||
}
|
||||
/* ── 순수 끝 ── */
|
||||
|
||||
const OWN = "로직_자체.json"; // 내가 만든 것 한 자리(서버 `Store_Make.OWN`)
|
||||
const OWNER = "현장";
|
||||
const CALC_WAIT_MS = 300;
|
||||
const MACHINE_FILE = "건설기계"; // 기계 시간당 경비 로직이 든 장 파일(「08장 건설기계」)
|
||||
|
||||
async function request<T>(path: string, body?: unknown): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}/m01${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, "");
|
||||
}
|
||||
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
const query = (params: Record<string, string>): string => new URLSearchParams(params).toString();
|
||||
|
||||
interface Summary {
|
||||
file: string;
|
||||
키: string;
|
||||
구분: string;
|
||||
상세구분: string;
|
||||
이름: string;
|
||||
결과단위: string;
|
||||
}
|
||||
|
||||
const isMoney = (unit: string): boolean => unit.startsWith("원");
|
||||
|
||||
/** 목록 — 내가 만든 것 먼저 · 돈 아닌 로직은 「쉬운 길로 못 엶」 */
|
||||
export async function listEasy(q: string): Promise<EasyListItem[]> {
|
||||
const got = await request<{ logics: Summary[] }>(`/logics?${query({ q })}`);
|
||||
const items = got.logics.map((s): EasyListItem => {
|
||||
const isBase = s.file !== OWN;
|
||||
const canOpen = isMoney(s.결과단위);
|
||||
return {
|
||||
key: s.키,
|
||||
name: s.이름,
|
||||
isBase,
|
||||
owner: say(isBase ? "Base" : "Mine"),
|
||||
resultUnit: s.결과단위,
|
||||
canOpen,
|
||||
whyNot: canOpen ? null : say("NotMoney"),
|
||||
where: isBase ? `${s.구분} ${s.상세구분}`.trim() : s.상세구분,
|
||||
};
|
||||
});
|
||||
return [...items.filter((x) => !x.isBase), ...items.filter((x) => x.isBase)];
|
||||
}
|
||||
|
||||
/** 열기 — 로직 줄 · 판본 · 견본 설계 값(자동값 · 못 받으면 빈 채로) */
|
||||
export async function openEasy(key: string): Promise<EasyOpened> {
|
||||
const [one, auto] = await Promise.all([fetchLogic(key), fetchAuto(key)]);
|
||||
return {
|
||||
row: one.logic,
|
||||
version: one.version,
|
||||
isBase: one.file !== OWN,
|
||||
trial: trialFor(one.logic, auto),
|
||||
};
|
||||
}
|
||||
|
||||
/** 로직이 받는 설계 값만 — 모르는 이름을 보내면 서버가 멈춤 */
|
||||
function trialFor(row: LogicRow, trial: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
(row.입력 ?? []).filter((i) => i.이름 in trial).map((i) => [i.이름, trial[i.이름]]),
|
||||
);
|
||||
}
|
||||
|
||||
let calcSeq = 0;
|
||||
let calcTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
/** 0.3초 뒤 서버 시험 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */
|
||||
export function calcLater(
|
||||
_row: LogicRow,
|
||||
_trial: Record<string, unknown>,
|
||||
_onDone: (ans: CalcAnswer | null, plain: string | null) => void,
|
||||
row: LogicRow,
|
||||
trial: Record<string, unknown>,
|
||||
onDone: (ans: CalcAnswer | null, plain: string | null) => void,
|
||||
): void {
|
||||
notReady();
|
||||
const mine = ++calcSeq;
|
||||
clearTimeout(calcTimer);
|
||||
calcTimer = setTimeout(() => {
|
||||
runCalc({ key: row.키 || "", inputs: trialFor(row, trial), row })
|
||||
.then((ans) => {
|
||||
if (mine === calcSeq) onDone(ans, ans.ok ? null : plainStop(safeReason(ans.reason)));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (mine === calcSeq) onDone(null, plainError(e));
|
||||
});
|
||||
}, CALC_WAIT_MS);
|
||||
}
|
||||
|
||||
export async function newEasy(_row: LogicRow): Promise<EasySaved> {
|
||||
return notReady();
|
||||
/** 새로 — 키 · 갈래는 서버가 줌 · 근거 글이 비면 「자체」(검사가 빈 글을 막음) */
|
||||
export async function newEasy(row: LogicRow): Promise<EasySaved> {
|
||||
const logic = { ...row, 출처: row.출처 || "자체" };
|
||||
const got = await request<{ version: string; key: string; logic: LogicRow }>("/logic/new", {
|
||||
logic,
|
||||
owner: OWNER,
|
||||
});
|
||||
return { key: got.key, version: got.version, row: got.logic };
|
||||
}
|
||||
|
||||
export async function copyEasy(_key: string): Promise<EasySaved> {
|
||||
return notReady();
|
||||
/** 이걸 바탕으로 고치기 — 품셈 기본 · 내 것 모두 본뜸 */
|
||||
export async function copyEasy(key: string): Promise<EasySaved> {
|
||||
const got = await copyLogic(key);
|
||||
return { key: got.key, version: got.version, row: got.logic };
|
||||
}
|
||||
|
||||
export async function editEasy(_key: string, _version: string, _row: LogicRow): Promise<EasySaved> {
|
||||
return notReady();
|
||||
/** 고치기 — 내가 만든 것만(품셈 기본은 403) · 판본이 다르면 409 */
|
||||
export async function editEasy(key: string, version: string, row: LogicRow): Promise<EasySaved> {
|
||||
const got = await request<{ version: string; logic: LogicRow }>("/logic/edit", {
|
||||
key,
|
||||
version,
|
||||
logic: row,
|
||||
});
|
||||
return { key, version: got.version, row: got.logic };
|
||||
}
|
||||
|
||||
export async function deleteEasy(_key: string, _version: string): Promise<{ version: string }> {
|
||||
return notReady();
|
||||
/** 지우기 — 내가 만든 것만 · 쓰는 곳이 남으면 422 */
|
||||
export async function deleteEasy(key: string, version: string): Promise<{ version: string }> {
|
||||
const got = await request<{ version: string }>("/logic/delete", { key, version });
|
||||
return { version: got.version };
|
||||
}
|
||||
|
||||
export async function usesOf(_key: string): Promise<EasyUse[]> {
|
||||
return notReady();
|
||||
/** 쓰는 곳 — 담은 일위대가 조합(마스터 층) + 이 로직을 부르는 내가 만든 것.
|
||||
* ponytail: 부르는 로직은 내가 만든 것만 하나씩 받아 훑음(품셈 기본은 내 것을 안 부름) —
|
||||
* 내 것이 수백이 되면 서버에 「부르는 로직」 길을 더할 것 */
|
||||
export async function usesOf(key: string): Promise<EasyUse[]> {
|
||||
const [combos, mine] = await Promise.all([
|
||||
request<{ combos: { 키: string; 이름: string }[] }>(`/logic/combos?${query({ key })}`),
|
||||
request<{ logics: Summary[] }>(`/logics?${query({ sub: "자체" })}`),
|
||||
]);
|
||||
const call = `로직(${key}`;
|
||||
const others = mine.logics.filter((s) => s.키 !== key);
|
||||
const rows = await Promise.all(others.map((s) => fetchLogic(s.키)));
|
||||
const callers = rows
|
||||
.filter((one) => JSON.stringify(one.logic).includes(call))
|
||||
.map((one): EasyUse => ({ kind: "logic", key: one.logic.키, name: one.logic.이름 }));
|
||||
return [
|
||||
...combos.combos.map((c): EasyUse => ({ kind: "combo", key: c.키, name: c.이름 })),
|
||||
...callers,
|
||||
];
|
||||
}
|
||||
|
||||
export async function listMachines(_q: string): Promise<EasyMachine[]> {
|
||||
return notReady();
|
||||
let family: Promise<MachineLogic[]> | null = null;
|
||||
|
||||
/** 기계 시간당 경비 로직 묶음 — 한 번 받아 둠(실패하면 다음에 다시) */
|
||||
function machineFamily(): Promise<MachineLogic[]> {
|
||||
family ??= request<{ logics: Summary[] }>("/logics")
|
||||
.then((got) =>
|
||||
Promise.all(
|
||||
got.logics
|
||||
.filter((s) => s.file.includes(MACHINE_FILE) && isMoney(s.결과단위))
|
||||
.map((s) => fetchLogic(s.키)),
|
||||
),
|
||||
)
|
||||
.then((all) =>
|
||||
all
|
||||
.map((one) => ({
|
||||
key: one.logic.키,
|
||||
name: one.logic.이름,
|
||||
resultUnit: one.logic.결과단위,
|
||||
inputs: one.logic.입력 ?? [],
|
||||
}))
|
||||
.filter((m) => machineCodes(m).length > 0),
|
||||
)
|
||||
.catch((e: unknown) => {
|
||||
family = null;
|
||||
throw e;
|
||||
});
|
||||
return family;
|
||||
}
|
||||
|
||||
export async function machineElement(_기종: string): Promise<EasyMachineElement> {
|
||||
return notReady();
|
||||
interface MachineRow {
|
||||
원문번호?: string;
|
||||
이름?: string;
|
||||
규격?: string;
|
||||
}
|
||||
|
||||
export function plainError(_e: unknown): string {
|
||||
return notReady();
|
||||
/** 기계 고르기 — 이름 · 분류번호 찾기 · 시간당 값을 셀 수 있는 기종만 */
|
||||
export async function listMachines(q: string): Promise<EasyMachine[]> {
|
||||
const [all, got] = await Promise.all([
|
||||
machineFamily(),
|
||||
request<{ items: MachineRow[] }>(`/elements?${query({ group: MACHINE, q, limit: "200" })}`),
|
||||
]);
|
||||
return got.items.flatMap((row): EasyMachine[] => {
|
||||
const code = String(row.원문번호 ?? "");
|
||||
const m = pickMachine(all, code);
|
||||
if (!m) return [];
|
||||
const unit = m.resultUnit.split("/")[1] ?? "";
|
||||
return [{ 분류번호: code, 이름: row.이름 ?? "", 규격: row.규격 ?? "", 단위: unit }];
|
||||
});
|
||||
}
|
||||
|
||||
/** 기종 → 호표 「요소」 글 · 받아야 할 설계 값(지역 등) — 호표 종류는 「로직」 */
|
||||
export async function machineElement(기종: string): Promise<EasyMachineElement> {
|
||||
const m = pickMachine(await machineFamily(), 기종);
|
||||
if (!m) throw new Error(say("NoMachine"));
|
||||
return machineCall(m, 기종);
|
||||
}
|
||||
|
||||
/** 어떤 실패든 화면에 찍을 쉬운 말 한두 줄 */
|
||||
export function plainError(e: unknown): string {
|
||||
if (e instanceof ApiError) return plainOf(e.status, e.detail);
|
||||
if (e instanceof Error && e.message === say("NoMachine")) return e.message;
|
||||
return say("Net");
|
||||
}
|
||||
|
||||
@@ -1,33 +1,431 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Entry.ts
|
||||
* 쉬운 단가산출 시험 길 — 들어가는 단추 + 빈 창(틀만). 이 폴더째 지워도 됨.
|
||||
* 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면 틀(위 띠 · 자리들)
|
||||
* 모양은 `docs/raw/M01_쉬운단가/설계.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄).
|
||||
* 1걸음 = 틀만 — 카드 · 설계 때 정하는 값 · 실무 일위대가표 · 저장 · 지우기 · 되돌리기는 2걸음에 이음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
createTag,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchLogic, fetchLogics, type LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
import {
|
||||
listEasy,
|
||||
openEasy,
|
||||
plainError,
|
||||
type EasyListItem,
|
||||
type EasyOpened,
|
||||
} from "./M01_EasyCalc_Api";
|
||||
import type { EasyDoc } from "./M01_EasyCalc_Model";
|
||||
import "./M01_EasyCalc_Style.css";
|
||||
|
||||
const TEXT = {
|
||||
Open: ["쉬운 단가산출(시험)", "Easy unit-price (trial)"],
|
||||
Title: ["쉬운 단가산출", "Easy unit-price"],
|
||||
Ready: ["준비 중", "Coming soon"],
|
||||
CloseWindow: ["창 닫기", "Close window"],
|
||||
Close: ["닫기", "Close"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"],
|
||||
Start_Ask: ["무엇과 비슷한가요?", "What is it similar to?"],
|
||||
Start_Search: ["이름으로 찾기 — 예: 거푸집", "Search by name — e.g. formwork"],
|
||||
Start_Blank: ["빈 종이로 새로 만들기", "Start from a blank sheet"],
|
||||
Start_Mine: ["내가 만든 것", "Made by me"],
|
||||
Start_MineNone: ["아직 없음", "None yet"],
|
||||
Start_Found: ["찾은 것", "Found"],
|
||||
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
|
||||
Start_None: ["찾은 것 없음", "Nothing found"],
|
||||
Base: ["품셈 기본", "Standard"],
|
||||
Mine: ["내가 만든 것", "Made by me"],
|
||||
CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"],
|
||||
Per: ["{u} 당", "per {u}"],
|
||||
Name: ["이름", "Name"],
|
||||
NamePlaceholder: ["예: 거푸집 설치", "e.g. formwork setup"],
|
||||
Spec: ["크기", "Size"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Total: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"],
|
||||
Undo: ["되돌리기", "Undo"],
|
||||
Save: ["저장", "Save"],
|
||||
Delete: ["지우기", "Delete"],
|
||||
Unsaved: ["저장 안 함", "Not saved"],
|
||||
BaseNote: [
|
||||
"품셈 기본은 읽기만 — 고치려면 아래 단추로 내 것을 만듦",
|
||||
"Standard items are read-only — use the button below to make your own copy",
|
||||
],
|
||||
BaseCopy: ["이걸 바탕으로 고치기", "Edit a copy of this"],
|
||||
Trial: ["설계 때 정하는 값", "Values set at design time"],
|
||||
Cards: ["무엇이 들어가나", "What goes in"],
|
||||
Sheet: ["실무 일위대가표로 보기", "View as a unit-cost sheet"],
|
||||
NotYet: ["준비 중", "Coming soon"],
|
||||
Leave_Ask: [
|
||||
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
|
||||
"There are unsaved changes — close without saving?",
|
||||
],
|
||||
Leave_Ok: ["저장 안 하고 닫기", "Close without saving"],
|
||||
} as const;
|
||||
|
||||
const te = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
type TextKey = keyof typeof TEXT;
|
||||
|
||||
/** 「식으로 새로 만들기」 옆에 놓는 단추 — 누르면 빈 창 */
|
||||
export function createEasyCalcButton(): HTMLButtonElement {
|
||||
return createButton({
|
||||
label: te("Open"),
|
||||
variant: "filled",
|
||||
onClick: () =>
|
||||
openModal({
|
||||
title: te("Title"),
|
||||
closeLabel: te("Close"),
|
||||
mount: (body) =>
|
||||
body.append(el("p", { className: "m01-easycalc__ready", text: te("Ready") })),
|
||||
const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
|
||||
Object.entries(vars).reduce(
|
||||
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
|
||||
);
|
||||
|
||||
/** 목록에 한 번에 보이는 줄 수 */
|
||||
const SHOWN = 50;
|
||||
|
||||
/* ponytail: Api(sub4) 속이 「준비 중」 인 동안만 Logic_Api 로 대신함 — Api 가 채워지면 아래 셋의 catch 를 지움 */
|
||||
const loadList = (): Promise<EasyListItem[]> =>
|
||||
listEasy("").catch(() =>
|
||||
fetchLogics().then((all) =>
|
||||
all.map((x) => {
|
||||
const isBase = !x.키.startsWith("GX");
|
||||
const canOpen = x.결과단위.startsWith("원/");
|
||||
return {
|
||||
key: x.키,
|
||||
name: x.이름,
|
||||
isBase,
|
||||
owner: isBase ? te("Base") : te("Mine"),
|
||||
resultUnit: x.결과단위,
|
||||
canOpen,
|
||||
whyNot: canOpen ? null : te("CannotOpen"),
|
||||
where: `${x.구분} ${x.상세구분}`.trim(),
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
const loadOne = (key: string): Promise<EasyOpened> =>
|
||||
openEasy(key).catch(() =>
|
||||
fetchLogic(key).then((one) => ({
|
||||
row: one.logic,
|
||||
version: one.version,
|
||||
isBase: !one.logic.키.startsWith("GX"),
|
||||
trial: {},
|
||||
})),
|
||||
);
|
||||
const plain = (error: unknown): string => {
|
||||
try {
|
||||
return plainError(error);
|
||||
} catch {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
};
|
||||
|
||||
/** 빈 종이 — 꼭 채울 칸만(검토 A 「꼭 채울 것」) · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */
|
||||
const blankRow = (resultUnit: string): LogicRow => ({
|
||||
키: "",
|
||||
원문번호: "",
|
||||
이름: "",
|
||||
결과단위: resultUnit,
|
||||
출처: "자체",
|
||||
입력: [],
|
||||
중간: [],
|
||||
호표: [],
|
||||
덧줄: [],
|
||||
끝수: null,
|
||||
});
|
||||
|
||||
const docOf = (opened: EasyOpened, key: string | null): EasyDoc => ({
|
||||
key,
|
||||
version: opened.version,
|
||||
isBase: opened.isBase,
|
||||
name: opened.row.이름,
|
||||
spec: "",
|
||||
resultUnit: opened.row.결과단위,
|
||||
inputs: opened.row.입력 ?? [],
|
||||
cards: [],
|
||||
row: opened.row,
|
||||
});
|
||||
/** 「원/㎡」 → 「㎡」 */
|
||||
const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, "");
|
||||
/** 「㎡」 → 「1㎡」 · 「100m」 은 그대로 */
|
||||
const amountOf = (resultUnit: string): string => {
|
||||
const unit = unitOf(resultUnit);
|
||||
return /^\d/.test(unit) ? unit : `1${unit}`;
|
||||
};
|
||||
|
||||
/** 「추가」 옆에 놓는 단추 — 누르면 큰 창 */
|
||||
export function createEasyCalcButton(): HTMLButtonElement {
|
||||
return createButton({ label: te("Open"), variant: "filled", onClick: openEasyCalc });
|
||||
}
|
||||
|
||||
function openEasyCalc(): void {
|
||||
let body: HTMLElement;
|
||||
let paper: EasyDoc | null = null;
|
||||
let dirty = false;
|
||||
let logics: EasyListItem[] | null = null;
|
||||
let query = "";
|
||||
|
||||
/** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */
|
||||
const mayLeave = async (): Promise<boolean> =>
|
||||
!paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
|
||||
|
||||
const notYet = (): void => showToast(te("NotYet"), "info");
|
||||
|
||||
/* ---------- 시작 화면 ---------- */
|
||||
|
||||
const listRow = (item: EasyListItem): HTMLElement => {
|
||||
const ok = item.canOpen;
|
||||
const row = el("button", {
|
||||
className: "m01-easycalc__item",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", { className: "m01-easycalc__item-name", text: item.name }),
|
||||
createTag(item.owner, item.isBase ? "neutral" : "accent"),
|
||||
el("span", {
|
||||
className: "m01-easycalc__item-unit",
|
||||
text: ok ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit,
|
||||
}),
|
||||
...(ok ? [] : [createTag(item.whyNot ?? te("CannotOpen"), "warning")]),
|
||||
],
|
||||
});
|
||||
row.disabled = !ok;
|
||||
row.title = item.where;
|
||||
row.addEventListener("click", () => void openPaper(item.key));
|
||||
return row;
|
||||
};
|
||||
|
||||
const showStart = async (): Promise<void> => {
|
||||
paper = null;
|
||||
dirty = false;
|
||||
const status = el("p", { className: "m01-easycalc__muted", text: te("Loading") });
|
||||
const mineHost = el("div", { className: "m01-easycalc__list" });
|
||||
const foundHost = el("div", { className: "m01-easycalc__list" });
|
||||
const search = createInputField({
|
||||
type: "search",
|
||||
placeholder: te("Start_Search"),
|
||||
value: query,
|
||||
onInput: (v) => {
|
||||
query = v;
|
||||
drawFound();
|
||||
},
|
||||
});
|
||||
search.root.classList.add("m01-easycalc__search");
|
||||
|
||||
const drawFound = (): void => {
|
||||
if (!logics) return;
|
||||
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const hits = words.length
|
||||
? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w)))
|
||||
: logics;
|
||||
status.textContent = !hits.length
|
||||
? te("Start_None")
|
||||
: hits.length > SHOWN
|
||||
? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN })
|
||||
: "";
|
||||
foundHost.replaceChildren(...hits.slice(0, SHOWN).map(listRow));
|
||||
};
|
||||
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-easycalc__start",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-easycalc__start-col",
|
||||
children: [
|
||||
el("h3", { className: "m01-easycalc__ask", text: te("Start_Ask") }),
|
||||
search.root,
|
||||
el("h4", { text: te("Start_Found") }),
|
||||
status,
|
||||
foundHost,
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-easycalc__start-col m01-easycalc__start-side",
|
||||
children: [
|
||||
createBigButton(te("Start_Blank"), () => openBlank()),
|
||||
el("h4", { text: te("Start_Mine") }),
|
||||
mineHost,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
search.input.focus();
|
||||
|
||||
try {
|
||||
logics ??= await loadList();
|
||||
} catch (error) {
|
||||
status.textContent = te("LoadFailed", { v: plain(error) });
|
||||
return;
|
||||
}
|
||||
const mine = logics.filter((x) => !x.isBase);
|
||||
mineHost.replaceChildren(
|
||||
...(mine.length
|
||||
? mine.map(listRow)
|
||||
: [el("p", { className: "m01-easycalc__muted", text: te("Start_MineNone") })]),
|
||||
);
|
||||
drawFound();
|
||||
};
|
||||
|
||||
/* ---------- 고치는 화면 ---------- */
|
||||
|
||||
const openBlank = (): void => {
|
||||
const row = blankRow(unitChoices()[0] ?? "원/㎡");
|
||||
paper = { ...docOf({ row, version: "", isBase: false, trial: {} }, null), version: null };
|
||||
showPaper();
|
||||
};
|
||||
|
||||
const openPaper = async (key: string): Promise<void> => {
|
||||
body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") }));
|
||||
try {
|
||||
paper = docOf(await loadOne(key), key);
|
||||
showPaper();
|
||||
} catch (error) {
|
||||
showToast(plain(error), "error");
|
||||
void showStart();
|
||||
}
|
||||
};
|
||||
|
||||
/** 단위 고르기 후보 — 목록에 많이 쓰인 「원/…」 차례 */
|
||||
const unitChoices = (): string[] => {
|
||||
const count = new Map<string, number>();
|
||||
for (const x of logics ?? []) {
|
||||
if (x.canOpen) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1);
|
||||
}
|
||||
return [...count.entries()].sort((a, b) => b[1] - a[1]).map(([u]) => u);
|
||||
};
|
||||
|
||||
const showPaper = (): void => {
|
||||
const p = paper;
|
||||
if (!p) return;
|
||||
const readOnly = p.isBase;
|
||||
const unsaved = createTag(te("Unsaved"), "warning");
|
||||
unsaved.hidden = true;
|
||||
const total = el("div", { className: "m01-easycalc__total" });
|
||||
const drawTotal = (): void => {
|
||||
total.textContent = te("Total", { u: amountOf(p.resultUnit), v: "—" });
|
||||
};
|
||||
const touched = (): void => {
|
||||
dirty = true;
|
||||
unsaved.hidden = false;
|
||||
};
|
||||
|
||||
const name = createInputField({
|
||||
label: te("Name"),
|
||||
placeholder: te("NamePlaceholder"),
|
||||
value: p.name,
|
||||
onInput: (v) => {
|
||||
p.name = v;
|
||||
touched();
|
||||
},
|
||||
});
|
||||
const spec = createInputField({
|
||||
label: te("Spec"),
|
||||
value: p.spec,
|
||||
onInput: (v) => {
|
||||
p.spec = v;
|
||||
touched();
|
||||
},
|
||||
});
|
||||
const units = unitChoices();
|
||||
if (!units.includes(p.resultUnit)) units.unshift(p.resultUnit);
|
||||
const unit = createSelectField({
|
||||
label: te("Unit"),
|
||||
options: units.map((u) => ({ value: u, text: unitOf(u) })),
|
||||
value: p.resultUnit,
|
||||
disabled: readOnly,
|
||||
onChange: (v) => {
|
||||
p.resultUnit = v;
|
||||
drawTotal();
|
||||
touched();
|
||||
},
|
||||
});
|
||||
name.input.disabled = readOnly;
|
||||
spec.input.disabled = readOnly;
|
||||
name.root.classList.add("m01-easycalc__name");
|
||||
|
||||
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet });
|
||||
undo.disabled = true;
|
||||
const close = createButton({
|
||||
label: te("Close"),
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void (async () => {
|
||||
if (await mayLeave()) void showStart();
|
||||
})(),
|
||||
});
|
||||
const actions = readOnly
|
||||
? [close]
|
||||
: [
|
||||
undo,
|
||||
createButton({ label: te("Save"), onClick: notYet }),
|
||||
...(p.key && !p.isBase
|
||||
? [createButton({ label: te("Delete"), variant: "danger", onClick: notYet })]
|
||||
: []),
|
||||
close,
|
||||
];
|
||||
|
||||
const section = (title: string, extra = ""): HTMLElement =>
|
||||
el("section", {
|
||||
className: `m01-easycalc__part ${extra}`.trim(),
|
||||
children: [
|
||||
el("h4", { text: title }),
|
||||
el("p", { className: "m01-easycalc__muted", text: te("NotYet") }),
|
||||
],
|
||||
});
|
||||
const sheet = el("details", {
|
||||
className: "m01-easycalc__part m01-easycalc__sheet",
|
||||
children: [
|
||||
el("summary", { text: te("Sheet") }),
|
||||
el("p", { className: "m01-easycalc__muted", text: te("NotYet") }),
|
||||
],
|
||||
});
|
||||
|
||||
drawTotal();
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-easycalc__strip",
|
||||
children: [
|
||||
name.root,
|
||||
spec.root,
|
||||
unit.root,
|
||||
total,
|
||||
unsaved,
|
||||
el("div", { className: "m01-easycalc__actions", children: actions }),
|
||||
],
|
||||
}),
|
||||
...(readOnly
|
||||
? [
|
||||
el("div", {
|
||||
className: "m01-easycalc__base",
|
||||
children: [
|
||||
el("p", { text: te("BaseNote") }),
|
||||
createBigButton(te("BaseCopy"), notYet),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
section(te("Trial"), "m01-easycalc__trial"),
|
||||
section(te("Cards"), "m01-easycalc__cards"),
|
||||
sheet,
|
||||
);
|
||||
if (!readOnly) name.input.focus();
|
||||
};
|
||||
|
||||
// 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름
|
||||
openModal({
|
||||
title: te("Title"),
|
||||
closeLabel: te("CloseWindow"),
|
||||
dialogClass: "m01-easycalc",
|
||||
beforeClose: mayLeave,
|
||||
mount: (host) => {
|
||||
body = host;
|
||||
void showStart();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createBigButton(label: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = createButton({ label, variant: "filled", onClick });
|
||||
button.classList.add("m01-easycalc__big");
|
||||
return button;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Model.ts
|
||||
* 쉬운 단가산출 Model — 로직 줄(LogicRow) ↔ 카드(EasyDoc · EasyCard) 바꾸기 · 카드 조작 · 검사 · 되돌리기
|
||||
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로
|
||||
* DOM 없음 · 바깥 값은 타입만 가져옴(Node 시험이 이 파일을 바로 돌림) · 모양은 `설계.md` 3절 그대로
|
||||
*
|
||||
* 호표 줄 → 카드: 인력 = person · 재료 = material · 기계경비 로직(GC000267~276) 부름 = machine · 그 밖 로직 = logic
|
||||
* 수량이 숫자 글이면 qty · 아니면 qtyFormula + lockedWhy(쉬운 말)
|
||||
* 덧줄 → 카드: 「바탕 * 숫자 / 100」 + 바탕 = 노무비 · 재료비 · 경비 또는 사람 줄 노임 합 = percent · 그 밖 = locked
|
||||
* 카드 → 줄: 카드 차례대로 호표 · 덧줄을 다시 짬 · 안 바뀐 줄은 원래 글자 그대로(왕복 같음) · 안 다룬 칸은 raw 에서 살림
|
||||
* 글 [한, 영] — 화면이 `setEasyModelLanguage(currentLanguageIndex)` 로 맞춤
|
||||
* ========================================================================== */
|
||||
|
||||
import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
import type { HoLine, LogicInput, LogicRow, NamedFormula } from "../M01_MasterData_UI_Logic_Api";
|
||||
|
||||
export type CardKind = "person" | "material" | "machine" | "logic" | "percent" | "locked";
|
||||
export type CostKind = "노무비" | "재료비" | "경비";
|
||||
@@ -66,55 +72,516 @@ export interface PickResult {
|
||||
needInputs?: LogicInput[];
|
||||
}
|
||||
|
||||
const notReady = (): never => {
|
||||
throw new Error("준비 중");
|
||||
/* ── 글 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const TEXT = {
|
||||
WhyTable: ["품셈 표에서 찾는 값", "Looked up from the standard table"],
|
||||
WhyCond: ["조건에 따라 달라지는 값", "Changes with conditions"],
|
||||
WhyInput: ["설계 때 정하는 값으로 정해짐", "Set by values chosen at design time"],
|
||||
WhyCalc: ["계산으로 정해지는 값", "Worked out by a calculation"],
|
||||
WhyKind: ["쉬운 길에서 못 고치는 줄", "This line can't be changed here"],
|
||||
NoNumber: ["얼마인지 숫자로 넣어야 함", "Enter the amount as a number"],
|
||||
Zero: ["0 은 안 됨 — 빼려면 지움", "0 is not allowed — remove the line instead"],
|
||||
Negative: ["0 보다 큰 수만 됨", "Only numbers above 0"],
|
||||
NoName: ["이름이 비어 있음", "The name is empty"],
|
||||
SameLine: ["같은 줄이 이미 있음 — 한 줄에 합쳐 넣음", "Same line already exists — merge them"],
|
||||
SameName: ["내가 만든 것에 같은 이름이 있음", "One of yours already has this name"],
|
||||
/** {per} {qty} {unit} 을 채움 */
|
||||
TooBig: ["{per} 에 {qty}{unit} — 너무 큰 수 아닌지?", "{qty}{unit} for {per} — too large?"],
|
||||
} as const;
|
||||
|
||||
let lang = 0;
|
||||
|
||||
/** 화면 말 고르기 — 0 한국어 · 1 영어 */
|
||||
export function setEasyModelLanguage(index: number): void {
|
||||
lang = index === 1 ? 1 : 0;
|
||||
}
|
||||
|
||||
const tx = (key: keyof typeof TEXT): string => TEXT[key][lang];
|
||||
|
||||
/* ── 작은 도움 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const COSTS: readonly string[] = ["노무비", "재료비", "경비"];
|
||||
const isCost = (v: unknown): v is CostKind => typeof v === "string" && COSTS.includes(v);
|
||||
/** 기계경비 로직 — `로직_건설품셈_08장_건설기계.json` GC000267~276 */
|
||||
const MACHINE_CALL = /^\s*로직\(\s*GC0002(6[7-9]|7[0-6])\s*[,)]/;
|
||||
const NUMBER_TEXT = /^\s*-?\d+(\.\d+)?\s*$/;
|
||||
const SIMPLE_TERM = /^[가-힣A-Za-z0-9_.]+$/;
|
||||
|
||||
let seq = 0;
|
||||
const newId = (): string => `c${++seq}`;
|
||||
|
||||
const clone = <T>(v: T): T => (v === undefined ? v : (JSON.parse(JSON.stringify(v)) as T));
|
||||
const isObj = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
const norm = (s: string): string => s.replace(/\s+/g, "");
|
||||
|
||||
/** 수를 글로 — 지수 꼴 없이 · 뒤 0 뗌 */
|
||||
function numText(n: number): string {
|
||||
const s = String(Number(n.toPrecision(12)));
|
||||
return s.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : s;
|
||||
}
|
||||
|
||||
/** 바깥을 통째 감싼 괄호 하나 벗김 */
|
||||
function unwrap(s: string): string {
|
||||
const t = s.trim();
|
||||
if (!t.startsWith("(") || !t.endsWith(")")) return t;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < t.length; i++) {
|
||||
if (t[i] === "(") depth++;
|
||||
else if (t[i] === ")" && --depth === 0 && i < t.length - 1) return t;
|
||||
}
|
||||
return t.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
/** 괄호 밖의 + 로 나눔 */
|
||||
function splitPlus(s: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let cur = "";
|
||||
for (const ch of s) {
|
||||
if (ch === "(") depth++;
|
||||
if (ch === ")") depth--;
|
||||
if (ch === "+" && depth === 0) {
|
||||
out.push(cur.trim());
|
||||
cur = "";
|
||||
} else cur += ch;
|
||||
}
|
||||
out.push(cur.trim());
|
||||
return out;
|
||||
}
|
||||
|
||||
const hasName = (formula: string, name: string): boolean => {
|
||||
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`(^|[^가-힣A-Za-z0-9_.'])${esc}($|[^가-힣A-Za-z0-9_'])`).test(formula);
|
||||
};
|
||||
|
||||
/** 잠금 까닭 — 표에서 찾음 > 조건 > 설계 값 > 계산 · 중간 값은 안까지 따라감 */
|
||||
function lockedWhy(formula: string, row: LogicRow): string {
|
||||
const seen = new Set<string>();
|
||||
const flags = { table: false, cond: false, input: false };
|
||||
const walk = (f: string, depth: number): void => {
|
||||
if (/찾기\s*\(/.test(f)) flags.table = true;
|
||||
if (/(만약|최소|최대|올림|버림|반올림)\s*\(|[<>]|==|!=/.test(f)) flags.cond = true;
|
||||
for (const i of row.입력 ?? []) if (hasName(f, i.이름)) flags.input = true;
|
||||
if (depth > 6) return;
|
||||
for (const m of row.중간 ?? []) {
|
||||
if (seen.has(m.이름) || !hasName(f, m.이름)) continue;
|
||||
seen.add(m.이름);
|
||||
walk(m.식, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(formula, 0);
|
||||
if (flags.table) return tx("WhyTable");
|
||||
if (flags.cond) return tx("WhyCond");
|
||||
if (flags.input) return tx("WhyInput");
|
||||
return tx("WhyCalc");
|
||||
}
|
||||
|
||||
/* ── 덧붙임(%) 꼴 ───────────────────────────────────────────────────────── */
|
||||
|
||||
/** 사람 줄 하나의 노임 조각 — 「수량 * 요소」 */
|
||||
function personTerm(qtyText: string, element: unknown): string {
|
||||
const q = SIMPLE_TERM.test(qtyText.trim()) ? qtyText.trim() : `(${qtyText.trim()})`;
|
||||
return `${q} * ${String(element)}`;
|
||||
}
|
||||
|
||||
/** 「바탕 * 숫자 / 100」 → 바탕 글 · 율 · 아니면 null */
|
||||
function parsePercent(formula: string): { base: string; rate: number } | null {
|
||||
const m = /^([\s\S]*)\*\s*(\d+(?:\.\d+)?)\s*\/\s*100\s*$/.exec(formula.trim());
|
||||
if (!m || !m[1].trim()) return null;
|
||||
return { base: unwrap(m[1]), rate: Number(m[2]) };
|
||||
}
|
||||
|
||||
/** 바탕이 사람 줄 노임 합인가 — 사람 줄 모두 · 차례대로 · 빠짐없이 */
|
||||
function isPersonSum(base: string, persons: { qtyText: string; element: unknown }[]): boolean {
|
||||
if (persons.length === 0) return false;
|
||||
const terms = splitPlus(base);
|
||||
if (terms.length !== persons.length) return false;
|
||||
return terms.every((t, i) => {
|
||||
const p = persons[i];
|
||||
const want = personTerm(p.qtyText, p.element);
|
||||
return norm(t) === norm(want) || norm(t) === norm(`${p.qtyText} * ${String(p.element)}`);
|
||||
});
|
||||
}
|
||||
|
||||
function baseOf(
|
||||
base: string,
|
||||
persons: { qtyText: string; element: unknown }[],
|
||||
): EasyCard["percentBase"] {
|
||||
if (isCost(base)) return base;
|
||||
return isPersonSum(base, persons) ? "사람줄합" : null;
|
||||
}
|
||||
|
||||
/* ── 로직 줄 → 카드 ─────────────────────────────────────────────────────── */
|
||||
|
||||
const qtyTextOf = (line: HoLine): string => String(line.수량 ?? "");
|
||||
|
||||
function hoCard(line: HoLine, index: number, row: LogicRow): EasyCard {
|
||||
const kind: CardKind =
|
||||
line.종류 === "인력"
|
||||
? "person"
|
||||
: line.종류 === "재료"
|
||||
? "material"
|
||||
: line.종류 === "로직"
|
||||
? MACHINE_CALL.test(String(line.요소))
|
||||
? "machine"
|
||||
: "logic"
|
||||
: "locked";
|
||||
const qtyText = qtyTextOf(line);
|
||||
const numeric = NUMBER_TEXT.test(qtyText);
|
||||
return {
|
||||
id: newId(),
|
||||
kind,
|
||||
name: line.이름 ?? "",
|
||||
spec: line.규격 ?? "",
|
||||
unit: line.단위 ?? "",
|
||||
qty: numeric ? Number(qtyText) : null,
|
||||
qtyFormula: numeric ? null : qtyText,
|
||||
cost: isCost(line.비목) ? line.비목 : null,
|
||||
percentBase: null,
|
||||
element: clone(line.요소),
|
||||
lockedWhy: kind === "locked" ? tx("WhyKind") : numeric ? null : lockedWhy(qtyText, row),
|
||||
source: { part: "호표", index },
|
||||
raw: clone(line),
|
||||
};
|
||||
}
|
||||
|
||||
function addCardOf(
|
||||
line: NamedFormula,
|
||||
index: number,
|
||||
row: LogicRow,
|
||||
persons: { qtyText: string; element: unknown }[],
|
||||
): EasyCard {
|
||||
const p = parsePercent(line.식);
|
||||
const base = p ? baseOf(p.base, persons) : null;
|
||||
const percent = p !== null && base !== null;
|
||||
return {
|
||||
id: newId(),
|
||||
kind: percent ? "percent" : "locked",
|
||||
name: line.이름 ?? "",
|
||||
spec: "",
|
||||
unit: percent ? "%" : "",
|
||||
qty: percent ? p.rate : null,
|
||||
qtyFormula: percent ? null : line.식,
|
||||
cost: isCost(line.비목) ? line.비목 : null,
|
||||
percentBase: percent ? base : null,
|
||||
element: null,
|
||||
lockedWhy: percent ? null : lockedWhy(line.식, row),
|
||||
source: { part: "덧줄", index },
|
||||
raw: clone(line),
|
||||
};
|
||||
}
|
||||
|
||||
export function toDoc(
|
||||
_row: LogicRow,
|
||||
_meta: { key: string | null; version: string | null; isBase: boolean },
|
||||
row: LogicRow,
|
||||
meta: { key: string | null; version: string | null; isBase: boolean },
|
||||
): EasyDoc {
|
||||
return notReady();
|
||||
const ho = row.호표 ?? [];
|
||||
const persons = ho
|
||||
.filter((h) => h.종류 === "인력")
|
||||
.map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 }));
|
||||
return {
|
||||
key: meta.key,
|
||||
version: meta.version,
|
||||
isBase: meta.isBase,
|
||||
name: row.이름 ?? "",
|
||||
spec: typeof row["규격"] === "string" ? (row["규격"] as string) : "",
|
||||
resultUnit: row.결과단위 ?? "",
|
||||
inputs: clone(row.입력 ?? []),
|
||||
cards: [
|
||||
...ho.map((h, i) => hoCard(h, i, row)),
|
||||
...(row.덧줄 ?? []).map((d, i) => addCardOf(d, i, row, persons)),
|
||||
],
|
||||
row: clone(row),
|
||||
};
|
||||
}
|
||||
|
||||
export function toRow(_doc: EasyDoc): LogicRow {
|
||||
return notReady();
|
||||
/** 새 로직의 최소 칸 줄 — 검토 A 4-1 · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */
|
||||
export function emptyRow(name = "", per = "㎡"): LogicRow {
|
||||
return {
|
||||
키: "",
|
||||
원문번호: "",
|
||||
이름: name,
|
||||
결과단위: `원/${per}`,
|
||||
출처: "자체",
|
||||
입력: [],
|
||||
중간: [],
|
||||
호표: [],
|
||||
덧줄: [],
|
||||
끝수: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function addCard(_doc: EasyDoc, _pick: PickResult): EasyDoc {
|
||||
return notReady();
|
||||
/** 빈 종이로 새로 */
|
||||
export function newDoc(name = "", per = "㎡"): EasyDoc {
|
||||
return toDoc(emptyRow(name, per), { key: null, version: null, isBase: false });
|
||||
}
|
||||
|
||||
export function updateCard(_doc: EasyDoc, _id: string, _patch: Partial<EasyCard>): EasyDoc {
|
||||
return notReady();
|
||||
/* ── 카드 → 로직 줄 ─────────────────────────────────────────────────────── */
|
||||
|
||||
const partOf = (c: EasyCard): "호표" | "덧줄" =>
|
||||
c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표";
|
||||
|
||||
const TYPE_OF: Record<string, string> = {
|
||||
person: "인력",
|
||||
material: "재료",
|
||||
machine: "로직",
|
||||
logic: "로직",
|
||||
};
|
||||
|
||||
/** 수량 글 — 식이면 식 · 숫자가 원래 글과 같으면 원래 글(「0.50」 보존) */
|
||||
function qtyTextFor(c: EasyCard, rawText: unknown): string {
|
||||
if (c.qtyFormula !== null) return c.qtyFormula;
|
||||
if (c.qty === null || !Number.isFinite(c.qty)) return "";
|
||||
const keep =
|
||||
typeof rawText === "string" && NUMBER_TEXT.test(rawText) && Number(rawText) === c.qty;
|
||||
return keep ? (rawText as string) : numText(c.qty);
|
||||
}
|
||||
|
||||
export function removeCard(_doc: EasyDoc, _id: string): EasyDoc {
|
||||
return notReady();
|
||||
/** 칸 쓰기 — 원래 있었거나 값이 있을 때만(없던 빈 칸을 만들지 않음) */
|
||||
function setField(o: Record<string, unknown>, k: string, v: string): void {
|
||||
if (v !== "" || k in o) o[k] = v;
|
||||
}
|
||||
|
||||
export function moveCard(_doc: EasyDoc, _id: string, _step: -1 | 1): EasyDoc {
|
||||
return notReady();
|
||||
function hoLineOf(c: EasyCard): HoLine {
|
||||
const raw = isObj(c.raw) ? clone(c.raw) : {};
|
||||
const line: Record<string, unknown> = raw;
|
||||
if (c.kind !== "locked") line.종류 = TYPE_OF[c.kind];
|
||||
line.요소 = clone(c.element);
|
||||
setField(line, "이름", c.name);
|
||||
setField(line, "규격", c.spec);
|
||||
setField(line, "단위", c.unit);
|
||||
line.수량 = qtyTextFor(c, raw.수량);
|
||||
if (c.cost !== null) line.비목 = c.cost;
|
||||
else if (isCost(raw.비목)) delete line.비목;
|
||||
return line as unknown as HoLine;
|
||||
}
|
||||
|
||||
export function checkCard(_card: EasyCard, _doc: EasyDoc): string | null {
|
||||
return notReady();
|
||||
function percentText(c: EasyCard, persons: { qtyText: string; element: unknown }[]): string {
|
||||
const rate = numText(c.qty ?? 0);
|
||||
if (c.percentBase === "사람줄합") {
|
||||
const terms = persons.map((p) => personTerm(p.qtyText, p.element));
|
||||
return `(${terms.length ? terms.join(" + ") : "0"}) * ${rate} / 100`;
|
||||
}
|
||||
return `${c.percentBase ?? "노무비"} * ${rate} / 100`;
|
||||
}
|
||||
|
||||
function addLineOf(c: EasyCard, persons: { qtyText: string; element: unknown }[]): NamedFormula {
|
||||
const raw = isObj(c.raw) ? clone(c.raw) : {};
|
||||
const line: Record<string, unknown> = raw;
|
||||
line.이름 = c.name;
|
||||
const rawText = typeof raw.식 === "string" ? raw.식 : "";
|
||||
if (c.kind === "percent") {
|
||||
const p = parsePercent(rawText);
|
||||
const same = p !== null && p.rate === c.qty && baseOf(p.base, persons) === c.percentBase;
|
||||
line.식 = same ? rawText : percentText(c, persons);
|
||||
} else {
|
||||
line.식 = c.qtyFormula ?? (c.qty !== null ? numText(c.qty) : rawText);
|
||||
}
|
||||
if (c.cost !== null) line.비목 = c.cost;
|
||||
else if (isCost(raw.비목)) delete line.비목;
|
||||
return line as unknown as NamedFormula;
|
||||
}
|
||||
|
||||
export function toRow(doc: EasyDoc): LogicRow {
|
||||
const row = clone(doc.row) as LogicRow & Record<string, unknown>;
|
||||
row.이름 = doc.name;
|
||||
setField(row, "규격", doc.spec);
|
||||
row.결과단위 = doc.resultUnit;
|
||||
row.입력 = clone(doc.inputs);
|
||||
const ho = doc.cards.filter((c) => partOf(c) === "호표").map(hoLineOf);
|
||||
const persons = ho
|
||||
.filter((h) => h.종류 === "인력")
|
||||
.map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 }));
|
||||
const add = doc.cards.filter((c) => partOf(c) === "덧줄").map((c) => addLineOf(c, persons));
|
||||
if (ho.length || "호표" in row) row.호표 = ho;
|
||||
if (add.length || "덧줄" in row) row.덧줄 = add;
|
||||
return row;
|
||||
}
|
||||
|
||||
/* ── 카드 조작 (모두 새 문서를 돌려줌 · 받은 문서는 안 바꿈) ─────────────── */
|
||||
|
||||
const COST_OF: Record<PickResult["kind"], CostKind | null> = {
|
||||
person: "노무비",
|
||||
material: "재료비",
|
||||
machine: null,
|
||||
logic: null,
|
||||
};
|
||||
|
||||
/** 묶음 — 화면의 묶음과 같음(사람 · 재료 · 기계 · 다른 일위대가 · 덧붙임) */
|
||||
const groupOf = (c: EasyCard): string => (partOf(c) === "덧줄" ? "덧붙임" : c.kind);
|
||||
|
||||
/** 같은 묶음 마지막 카드 뒤 · 없으면 같은 쪽(호표 · 덧붙임) 끝 · 그것도 없으면 호표는 맨 앞 · 덧붙임은 맨 뒤 */
|
||||
function insertAt(cards: EasyCard[], card: EasyCard): EasyCard[] {
|
||||
const last = (hit: (c: EasyCard) => boolean): number => {
|
||||
let at = -1;
|
||||
cards.forEach((c, i) => {
|
||||
if (hit(c)) at = i;
|
||||
});
|
||||
return at;
|
||||
};
|
||||
let at = last((c) => groupOf(c) === groupOf(card));
|
||||
if (at < 0) at = last((c) => partOf(c) === partOf(card));
|
||||
if (at < 0 && partOf(card) === "덧줄") at = cards.length - 1;
|
||||
const out = cards.slice();
|
||||
out.splice(at + 1, 0, card);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function addCard(doc: EasyDoc, pick: PickResult): EasyDoc {
|
||||
const card: EasyCard = {
|
||||
id: newId(),
|
||||
kind: pick.kind,
|
||||
name: pick.name,
|
||||
spec: pick.spec,
|
||||
unit: pick.unit,
|
||||
qty: pick.qty,
|
||||
qtyFormula: null,
|
||||
cost: COST_OF[pick.kind],
|
||||
percentBase: null,
|
||||
element: clone(pick.element),
|
||||
lockedWhy: null,
|
||||
source: null,
|
||||
raw: null,
|
||||
};
|
||||
return withInputs({ ...doc, cards: insertAt(doc.cards, card) }, pick.needInputs ?? []);
|
||||
}
|
||||
|
||||
/** 설계 때 정하는 값 더하기 — 없는 이름만(기계를 다시 골랐을 때도 씀) */
|
||||
export function withInputs(doc: EasyDoc, needs: LogicInput[]): EasyDoc {
|
||||
const inputs = doc.inputs.slice();
|
||||
for (const need of needs) if (!inputs.some((i) => i.이름 === need.이름)) inputs.push(clone(need));
|
||||
return inputs.length === doc.inputs.length ? doc : { ...doc, inputs };
|
||||
}
|
||||
|
||||
/** 덧붙임(%) 카드 더하기 — 넣을 자리 기본은 화면이 정함(설계.md 1절 9) */
|
||||
export function addPercent(
|
||||
doc: EasyDoc,
|
||||
name: string,
|
||||
base: CostKind | "사람줄합",
|
||||
rate: number | null,
|
||||
cost: CostKind | null,
|
||||
): EasyDoc {
|
||||
const card: EasyCard = {
|
||||
id: newId(),
|
||||
kind: "percent",
|
||||
name,
|
||||
spec: "",
|
||||
unit: "%",
|
||||
qty: rate,
|
||||
qtyFormula: null,
|
||||
cost,
|
||||
percentBase: base,
|
||||
element: null,
|
||||
lockedWhy: null,
|
||||
source: null,
|
||||
raw: null,
|
||||
};
|
||||
return { ...doc, cards: insertAt(doc.cards, card) };
|
||||
}
|
||||
|
||||
/** 고치기 — 숫자로 바꾸기 = `{ qty: 값, qtyFormula: null }` (잠금 까닭도 지움) */
|
||||
export function updateCard(doc: EasyDoc, id: string, patch: Partial<EasyCard>): EasyDoc {
|
||||
return {
|
||||
...doc,
|
||||
cards: doc.cards.map((c) => {
|
||||
if (c.id !== id) return c;
|
||||
const next: EasyCard = { ...c, ...patch, id: c.id };
|
||||
if (next.qtyFormula === null && next.kind !== "locked") next.lockedWhy = null;
|
||||
return next;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function removeCard(doc: EasyDoc, id: string): EasyDoc {
|
||||
return { ...doc, cards: doc.cards.filter((c) => c.id !== id) };
|
||||
}
|
||||
|
||||
/** ▲(-1) ▼(1) — 같은 묶음 안 이웃과 자리 바꿈 · 끝이면 그대로 */
|
||||
export function moveCard(doc: EasyDoc, id: string, step: -1 | 1): EasyDoc {
|
||||
const from = doc.cards.findIndex((c) => c.id === id);
|
||||
if (from < 0) return doc;
|
||||
const g = groupOf(doc.cards[from]);
|
||||
let to = from + step;
|
||||
while (to >= 0 && to < doc.cards.length && groupOf(doc.cards[to]) !== g) to += step;
|
||||
if (to < 0 || to >= doc.cards.length) return doc;
|
||||
const cards = doc.cards.slice();
|
||||
[cards[from], cards[to]] = [cards[to], cards[from]];
|
||||
return { ...doc, cards };
|
||||
}
|
||||
|
||||
/* ── 검사 (쉬운 말 한 줄 · 없으면 null) ──────────────────────────────────── */
|
||||
|
||||
/** 단위별 어림 위 끝 — 넘으면 물음 꼴 경고 */
|
||||
const TOO_BIG: Record<string, number> = { 인: 10, 일: 10, 시간: 24, "%": 100 };
|
||||
const TOO_BIG_ELSE = 10000;
|
||||
|
||||
export function checkCard(card: EasyCard, doc: EasyDoc): string | null {
|
||||
if (!card.name.trim()) return tx("NoName");
|
||||
if (card.qtyFormula !== null) return null;
|
||||
if (card.qty === null || !Number.isFinite(card.qty)) return tx("NoNumber");
|
||||
if (card.qty === 0) return tx("Zero");
|
||||
if (card.qty < 0) return tx("Negative");
|
||||
const same = doc.cards.some(
|
||||
(c) =>
|
||||
c.id !== card.id &&
|
||||
c.kind === card.kind &&
|
||||
c.name === card.name &&
|
||||
c.spec === card.spec &&
|
||||
JSON.stringify(c.element) === JSON.stringify(card.element) &&
|
||||
(card.kind !== "percent" || c.percentBase === card.percentBase),
|
||||
);
|
||||
if (same) return tx("SameLine");
|
||||
if (card.qty > (TOO_BIG[card.unit] ?? TOO_BIG_ELSE)) {
|
||||
const per = `1${doc.resultUnit.replace(/^원\//, "") || ""}`;
|
||||
return tx("TooBig")
|
||||
.replace("{per}", per)
|
||||
.replace("{qty}", numText(card.qty))
|
||||
.replace("{unit}", card.unit);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 로직 이름 검사 — mine = 내가 만든 것 이름들(자기 이름은 빼고 넘김) */
|
||||
export function checkName(name: string, mine: string[]): string | null {
|
||||
if (!name.trim()) return tx("NoName");
|
||||
return mine.some((m) => m.trim() === name.trim()) ? tx("SameName") : null;
|
||||
}
|
||||
|
||||
/* ── 되돌리기 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const HISTORY_MAX = 200;
|
||||
|
||||
/** 문서 기록 — 처음 연 문서를 push 한 뒤 바뀔 때마다 push · undo 는 앞 문서 */
|
||||
export class EasyHistory {
|
||||
push(_doc: EasyDoc): void {
|
||||
notReady();
|
||||
private stack: string[] = [];
|
||||
private saved: string | null = null;
|
||||
|
||||
push(doc: EasyDoc): void {
|
||||
const s = JSON.stringify(doc);
|
||||
if (this.stack[this.stack.length - 1] === s) return;
|
||||
this.stack.push(s);
|
||||
if (this.saved === null) this.saved = s;
|
||||
if (this.stack.length > HISTORY_MAX) this.stack.shift();
|
||||
}
|
||||
|
||||
undo(): EasyDoc | null {
|
||||
return notReady();
|
||||
if (this.stack.length < 2) return null;
|
||||
this.stack.pop();
|
||||
return JSON.parse(this.stack[this.stack.length - 1]) as EasyDoc;
|
||||
}
|
||||
|
||||
get canUndo(): boolean {
|
||||
return notReady();
|
||||
return this.stack.length > 1;
|
||||
}
|
||||
|
||||
/** 저장한 뒤(처음은 연 뒤) 바뀜 — 「저장 안 함」 표시 */
|
||||
get dirty(): boolean {
|
||||
return notReady();
|
||||
return this.stack.length > 0 && this.stack[this.stack.length - 1] !== this.saved;
|
||||
}
|
||||
|
||||
markSaved(): void {
|
||||
notReady();
|
||||
this.saved = this.stack[this.stack.length - 1] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
.m01-easysheet__title {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-easysheet__status,
|
||||
.m01-easysheet__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 좁은 폭은 표 안에서만 가로 스크롤 — 쪽 전체는 안 밀림 */
|
||||
.m01-easysheet__scroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.m01-easysheet__table {
|
||||
width: 100%;
|
||||
min-width: 920px;
|
||||
border-collapse: collapse;
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easysheet__table th,
|
||||
.m01-easysheet__table td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.m01-easysheet__table thead th {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easysheet__table .m01-easysheet__group {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.m01-easysheet__table .m01-easysheet__num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easysheet__table .m01-easysheet__note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-easysheet__table tfoot th,
|
||||
.m01-easysheet__table tfoot td {
|
||||
background: var(--color-surface);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.m01-easysheet__strip-title {
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-easysheet__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m01-easysheet__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easysheet__ctl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-easysheet__input {
|
||||
box-sizing: border-box;
|
||||
width: 160px;
|
||||
max-width: 100%;
|
||||
height: var(--control-height-sm);
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easysheet__input[aria-invalid] {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.m01-easysheet__warn {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Sheet.ts
|
||||
* 쉬운 단가산출 — 실무 일위대가표(읽기만) · 「설계 때 정하는 값」 띠(시험값만, 저장 안 됨)
|
||||
* 계약 = docs/raw/M01_쉬운단가/설계.md 3절 Sheet. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
searchElements,
|
||||
type CalcAnswer,
|
||||
type CalcLine,
|
||||
type LogicInput,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney, formatPrice } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
|
||||
import "./M01_EasyCalc_Sheet.css";
|
||||
|
||||
const TEXT = {
|
||||
Busy: ["계산 중", "Calculating"],
|
||||
Stopped: ["계산하지 못했어요", "Could not calculate"],
|
||||
Name: ["명칭", "Name"],
|
||||
Spec: ["규격", "Size"],
|
||||
Qty: ["수량", "Qty"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Total: ["합계", "Total"],
|
||||
Labor: ["노무비", "Labor"],
|
||||
Material: ["재료비", "Material"],
|
||||
Expense: ["경비", "Expense"],
|
||||
Note: ["비고", "Note"],
|
||||
Price: ["단가", "Price"],
|
||||
Amount: ["금액", "Amount"],
|
||||
Strip: ["설계 때 정하는 값", "Values set at design time"],
|
||||
Trial: ["여기 값은 시험용 — 저장 안 됨", "Trial values only — not saved"],
|
||||
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
|
||||
Pick: ["고르세요", "Choose"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
OutOfRange: ["범위 밖이에요", "Out of range"],
|
||||
} as const;
|
||||
|
||||
const ts = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
const COST_HEAD = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const;
|
||||
|
||||
const qty = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
const cell = (text: string, className = ""): HTMLElement => el("td", { className, text });
|
||||
const money = (text: string): HTMLElement => cell(text, "m01-easysheet__num");
|
||||
|
||||
/** 준용 · 산정 글에 붙은 마스터 번호는 뺌 */
|
||||
const note = (line: CalcLine): string =>
|
||||
[line.출처?.replace(/[A-Z]{2}\d{6}\./g, ""), line.단위경고].filter(Boolean).join(" · ");
|
||||
|
||||
function lineRow(line: CalcLine): HTMLElement {
|
||||
// 비목별 단가 = 줄 단가 × (그 비목 금액 ÷ 줄 금액) — 금액 반올림 오차가 단가에 안 섞임
|
||||
const per = (k: string): number =>
|
||||
line.금액 ? (line.단가 * (line.비목?.[k] ?? 0)) / line.금액 : 0;
|
||||
return el("tr", {
|
||||
children: [
|
||||
cell(line.이름),
|
||||
cell(line.품목?.규격 ?? ""),
|
||||
money(qty(line.수량)),
|
||||
cell(line.단위),
|
||||
money(formatPrice(line.단가)),
|
||||
money(formatMoney(line.금액)),
|
||||
...COSTS.flatMap((k) => [
|
||||
money(formatPrice(per(k))),
|
||||
money(formatMoney(line.비목?.[k] ?? 0)),
|
||||
]),
|
||||
cell(note(line), "m01-easysheet__note"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 실무 일위대가표 — 읽기만 · 맨 아래 합계 줄 */
|
||||
export function renderSheet(
|
||||
host: HTMLElement,
|
||||
head: { name: string; resultUnit: string },
|
||||
ans: CalcAnswer | null,
|
||||
): void {
|
||||
const title = el("p", {
|
||||
className: "m01-easysheet__title",
|
||||
text: [head.name, head.resultUnit].filter(Boolean).join(" · "),
|
||||
});
|
||||
if (!ans || !ans.ok) {
|
||||
host.replaceChildren(
|
||||
title,
|
||||
el("p", {
|
||||
className: "m01-easysheet__status",
|
||||
text: ans ? `${ts("Stopped")} — ${safeReason(ans.reason)}` : ts("Busy"),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const lines = ans.lines ?? [];
|
||||
const sums = ans.sums ?? {};
|
||||
const group = (label: string): HTMLElement =>
|
||||
el("th", { className: "m01-easysheet__group", text: label, attrs: { colspan: "2" } });
|
||||
const one = (label: string): HTMLElement => el("th", { text: label, attrs: { rowspan: "2" } });
|
||||
const pair = (): HTMLElement[] => [ts("Price"), ts("Amount")].map((h) => el("th", { text: h }));
|
||||
const table = el("table", {
|
||||
className: "m01-easysheet__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
one(ts("Name")),
|
||||
one(ts("Spec")),
|
||||
one(ts("Qty")),
|
||||
one(ts("Unit")),
|
||||
group(ts("Total")),
|
||||
...COSTS.map((k) => group(ts(COST_HEAD[k]))),
|
||||
one(ts("Note")),
|
||||
],
|
||||
}),
|
||||
el("tr", { children: [...pair(), ...pair(), ...pair(), ...pair()] }),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: lines.map(lineRow) }),
|
||||
el("tfoot", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
el("th", { text: ts("Total"), attrs: { colspan: "4" } }),
|
||||
cell(""),
|
||||
money(formatMoney(sums["계"] ?? 0)),
|
||||
...COSTS.flatMap((k) => [cell(""), money(formatMoney(sums[k] ?? 0))]),
|
||||
cell(""),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
host.replaceChildren(title, el("div", { className: "m01-easysheet__scroll", children: [table] }));
|
||||
}
|
||||
|
||||
/** 마스터 번호 꼴 고르기 값 → 이름(한 번 물은 것은 다시 안 물음) */
|
||||
const KEY_RE = /^[A-Z]{2}\d{6}$/;
|
||||
const KEY_GROUP: Record<string, string> = { MT: "재료", EQ: "기계", LB: "인력" };
|
||||
const nameCache = new Map<string, string | null>();
|
||||
|
||||
async function keyName(key: string): Promise<string | null> {
|
||||
if (nameCache.has(key)) return nameCache.get(key)!;
|
||||
const group = KEY_GROUP[key.slice(0, 2)];
|
||||
let name: string | null = null;
|
||||
if (group) {
|
||||
try {
|
||||
const hit = (await searchElements(group, key)).items.find((it) => it.ref === key);
|
||||
name = hit ? `${hit.이름 ?? ""}${hit.규격 ? ` ${hit.규격}` : ""}`.trim() || null : null;
|
||||
} catch {
|
||||
name = null;
|
||||
}
|
||||
}
|
||||
nameCache.set(key, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
/** state = 띠가 들고 있는 시험값(고칠 때마다 같은 객체를 바꿈) · emit = 바뀐 값을 밖에 알림 */
|
||||
type Trial = Record<string, unknown>;
|
||||
|
||||
function numberBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement {
|
||||
const [lo, hi] = spec.범위 ?? [];
|
||||
const hint = spec.범위 ? ts("Range").replace("{a}", String(lo)).replace("{b}", String(hi)) : "";
|
||||
const box = el("input", {
|
||||
className: "m01-easysheet__input",
|
||||
attrs: { type: "text", inputmode: "decimal", placeholder: hint },
|
||||
});
|
||||
const warn = el("span", { className: "m01-easysheet__warn" });
|
||||
box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
|
||||
box.addEventListener("change", () => {
|
||||
const raw = box.value.trim();
|
||||
const num = Number(raw);
|
||||
const bad = raw !== "" && Number.isNaN(num) ? ts("Bad") : "";
|
||||
const out = !bad && raw !== "" && spec.범위 && (num < lo! || num > hi!) ? ts("OutOfRange") : "";
|
||||
warn.textContent = bad || out;
|
||||
box.toggleAttribute("aria-invalid", !!(bad || out));
|
||||
if (bad) return;
|
||||
if (raw === "") delete state[spec.이름];
|
||||
else state[spec.이름] = num;
|
||||
emit();
|
||||
});
|
||||
return el("span", { className: "m01-easysheet__ctl", children: [box, warn] });
|
||||
}
|
||||
|
||||
function pickBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement {
|
||||
const picks = spec.고르기 ?? [];
|
||||
const optionText = (o: string | number): string =>
|
||||
(typeof o === "string" && nameCache.get(o)) || String(o);
|
||||
const options = (): { value: string; text: string }[] => [
|
||||
{ value: "", text: ts("Pick") },
|
||||
...picks.map((o) => ({ value: String(o), text: optionText(o) })),
|
||||
];
|
||||
const cur = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
|
||||
const field = createSelectField({
|
||||
options: options(),
|
||||
value: cur,
|
||||
compact: true,
|
||||
ariaLabel: spec.이름,
|
||||
onChange: (v) => {
|
||||
const hit = picks.find((o) => String(o) === v); // 원래 값(수) 그대로 보냄
|
||||
if (hit === undefined) delete state[spec.이름];
|
||||
else state[spec.이름] = hit;
|
||||
emit();
|
||||
},
|
||||
});
|
||||
const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o));
|
||||
if (keys.length) {
|
||||
void Promise.all(keys.map(keyName)).then(() => field.setOptions(options(), cur));
|
||||
}
|
||||
return field.root;
|
||||
}
|
||||
|
||||
/** 「설계 때 정하는 값」 띠 — 바꾸면 onChange(새 시험값) · 값이 없으면 띠를 숨김 */
|
||||
export function renderTrialStrip(
|
||||
host: HTMLElement,
|
||||
inputs: LogicInput[],
|
||||
trial: Record<string, unknown>,
|
||||
onChange: (trial: Record<string, unknown>) => void,
|
||||
): void {
|
||||
if (!inputs.length) {
|
||||
host.replaceChildren();
|
||||
host.hidden = true;
|
||||
return;
|
||||
}
|
||||
host.hidden = false;
|
||||
const state = { ...trial };
|
||||
const emit = (): void => onChange({ ...state });
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m01-easysheet__strip-title", text: ts("Strip") }),
|
||||
el("div", {
|
||||
className: "m01-easysheet__fields",
|
||||
children: inputs.map((spec) =>
|
||||
el("label", {
|
||||
className: "m01-easysheet__field",
|
||||
children: [
|
||||
el("span", { text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름 }),
|
||||
spec.고르기?.length ? pickBox(spec, state, emit) : numberBox(spec, state, emit),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
el("p", { className: "m01-easysheet__muted", text: ts("Trial") }),
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,159 @@
|
||||
.m01-easycalc__ready {
|
||||
/* 쉬운 단가산출 — 큰 창 틀 · 시작 화면 · 위 띠 */
|
||||
|
||||
/* 공용 모달(위 고정)을 화면 대부분으로 — 높이를 고정해 화면이 바뀌어도 창이 안 움직임 */
|
||||
.ui-modal.m01-easycalc {
|
||||
--modal-max: 90vh;
|
||||
width: min(1280px, 96vw);
|
||||
height: var(--modal-max);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easycalc .ui-modal__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easycalc__muted {
|
||||
margin: 0;
|
||||
padding: var(--spacing-16) 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easycalc__big {
|
||||
min-height: 56px;
|
||||
padding: 0 var(--spacing-24);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 시작 화면 — 왼쪽 찾기 · 오른쪽 새로 만들기 + 내가 만든 것 */
|
||||
.m01-easycalc__start {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: var(--spacing-24);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__start-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__start-col h4 {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__ask {
|
||||
margin: 0;
|
||||
font-size: var(--text-subheading);
|
||||
}
|
||||
|
||||
.m01-easycalc__search .ui-input {
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easycalc__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-easycalc__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-easycalc__item:hover:not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-easycalc__item:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.m01-easycalc__item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__item-unit {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 고치는 화면 — 위 띠 */
|
||||
.m01-easycalc__strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-12);
|
||||
padding-bottom: var(--spacing-12);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-easycalc__strip .ui-field {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.m01-easycalc__name {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.m01-easycalc__total {
|
||||
font-size: var(--text-subheading);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easycalc__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m01-easycalc__base {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easycalc__base p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__part {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.m01-easycalc__part h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__cards {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.m01-easycalc__sheet summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/* 쉬운 단가산출 2단계 — 표 보고 고르기 창 */
|
||||
.ui-modal.m01-easytable {
|
||||
width: min(1100px, 96vw);
|
||||
}
|
||||
|
||||
.m01-easytable .ui-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m01-easytable__note {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easytable__which,
|
||||
.m01-easytable__actions,
|
||||
.m01-easytable__seg {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-easytable__which:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01-easytable__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m01-easytable__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-easytable__conds {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-easytable__conds h4 {
|
||||
margin: 0;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easytable__cond {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6rem, 10rem) minmax(5rem, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easytable__cond-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-easytable__grid {
|
||||
max-height: 46vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.m01-easytable__grid table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easytable__grid th,
|
||||
.m01-easytable__grid td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easytable__grid thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-easytable__grid td.is-value {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01-easytable__grid .is-col {
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.m01-easytable__grid tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-easytable__grid tbody tr:hover {
|
||||
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m01-easytable__grid tr.is-hit {
|
||||
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m01-easytable__grid tr.is-picked td:first-child {
|
||||
box-shadow: inset 3px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-easytable__money {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easytable__money.is-busy {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.m01-easytable__warn {
|
||||
margin: 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easytable__warn:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.m01-easytable__cond {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.m01-easytable__seg {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Table.ts
|
||||
* 쉬운 단가산출 2단계 — 표 보고 고르기(설계.md 1절 7 · 3절 `openEasyTable`)
|
||||
* 잠금 카드 수량 식 속 `찾기(표, 조건=값 …).값칸` 을 읽어 그 표를 그대로 보이고,
|
||||
* 줄 · 칸을 눌러 조건 값 · 값 칸을 바꾼 새 식을 돌려줌(취소 null).
|
||||
*
|
||||
* 식 읽기 = 엔진 문법(`master_formula._Parser` 의 find · args · field) 그대로 화면에서 —
|
||||
* 서버에 찾기 마디를 돌려주는 길이 없어서. 걸린 줄 · 금액은 서버(`/text` 표 조각 `참조.줄` · `/calc`).
|
||||
* 화면 글에 식 · 키 안 보임 — 표 이름 · 칸 이름 · 값만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchTable } from "../M01_MasterData_Api_Fetch";
|
||||
import {
|
||||
fetchAuto,
|
||||
runCalc,
|
||||
runText,
|
||||
searchElements,
|
||||
type HoLine,
|
||||
type LogicRow,
|
||||
type NamedFormula,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { toRow, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model";
|
||||
import "./M01_EasyCalc_Table.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["표 보고 고르기", "Pick from the table"],
|
||||
Close: ["닫기", "Close"],
|
||||
Cannot: ["이 줄은 지금 화면에서 바꿀 수 없음", "This line can't be changed here yet"],
|
||||
NoTable: ["표를 불러오지 못함", "Couldn't load the table"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
Hint: [
|
||||
"줄을 누르면 그 줄 값으로 정함 · 노란 줄 = 지금 쓰는 줄 · 굵은 칸 = 수량을 가져오는 칸",
|
||||
"Tap a row to use it · yellow = row in use · bold = column the quantity comes from",
|
||||
],
|
||||
Conds: ["어느 줄을 쓸까", "Which row"],
|
||||
Design: ["설계 때 정함", "Set at design"],
|
||||
Fix: ["이 값으로 고정", "Fix this value"],
|
||||
AsIs: ["지금대로", "As is"],
|
||||
NotYet: ["(아직 없음)", "(none yet)"],
|
||||
Amount: ["이 줄 금액", "This line"],
|
||||
Total: ["합계", "Total"],
|
||||
Wait: ["계산 중", "Calculating"],
|
||||
Stopped: ["계산 멈춤", "Calculation stopped"],
|
||||
Mismatch: [
|
||||
"고른 줄이 이 조건으로는 안 걸림 — 조건을 다시 보세요",
|
||||
"The picked row isn't matched by these conditions",
|
||||
],
|
||||
Use: ["이걸로", "Use this"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Which: ["표 고르기", "Choose table"],
|
||||
} as const;
|
||||
|
||||
const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex as 0 | 1];
|
||||
|
||||
/* ── 식 읽기 · 짜기(DOM 없음) ─────────────────────────────────────────── */
|
||||
|
||||
export interface FindArg {
|
||||
/** 표 조건 칸 이름 */
|
||||
key: string;
|
||||
/** `=` 오른쪽 식 글 그대로 */
|
||||
rhs: string;
|
||||
}
|
||||
|
||||
export interface FindCall {
|
||||
/** 식 안 자리 — `찾기` 첫 글자부터 값 칸 끝까지 */
|
||||
start: number;
|
||||
end: number;
|
||||
table: string;
|
||||
args: FindArg[];
|
||||
col: string;
|
||||
}
|
||||
|
||||
const NAME = /[\p{L}\p{N}_]/u;
|
||||
const isName = (ch: string | undefined): boolean => !!ch && NAME.test(ch);
|
||||
|
||||
class Reader {
|
||||
i = 0;
|
||||
constructor(readonly s: string) {}
|
||||
ws(): void {
|
||||
while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
|
||||
}
|
||||
take(ch: string): boolean {
|
||||
this.ws();
|
||||
if (this.s[this.i] !== ch) return false;
|
||||
this.i++;
|
||||
return true;
|
||||
}
|
||||
name(): string | null {
|
||||
this.ws();
|
||||
const start = this.i;
|
||||
while (isName(this.s[this.i])) this.i++;
|
||||
return this.i > start ? this.s.slice(start, this.i) : null;
|
||||
}
|
||||
quoted(): string | null {
|
||||
const end = this.s.indexOf("'", this.i);
|
||||
if (end < 0) return null;
|
||||
const text = this.s.slice(this.i, end);
|
||||
this.i = end + 1;
|
||||
return text;
|
||||
}
|
||||
/** 괄호 · 따옴표 밖 `,` · `)` 앞까지 */
|
||||
until(head: boolean): string | null {
|
||||
const start = this.i;
|
||||
let depth = 0;
|
||||
while (this.i < this.s.length) {
|
||||
const ch = this.s[this.i];
|
||||
if (ch === "'" && !head) {
|
||||
const end = this.s.indexOf("'", this.i + 1);
|
||||
if (end < 0) return null;
|
||||
this.i = end + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "(" || ch === "{") depth++;
|
||||
else if (ch === ")" || ch === "}") {
|
||||
if (depth === 0) break;
|
||||
depth--;
|
||||
} else if (ch === "," && depth === 0) break;
|
||||
this.i++;
|
||||
}
|
||||
return this.i < this.s.length ? this.s.slice(start, this.i).trim() : null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 한 자리의 찾기 — 못 읽으면 null */
|
||||
function readOne(s: string, at: number): FindCall | null {
|
||||
const r = new Reader(s);
|
||||
r.i = at + 2;
|
||||
if (!r.take("(")) return null;
|
||||
r.ws();
|
||||
const table = r.until(true);
|
||||
if (!table || !/^[A-Za-z]{2}\d+$/.test(table)) return null; // {자리} 낀 키는 화면에서 안 다룸
|
||||
const args: FindArg[] = [];
|
||||
while (r.take(",")) {
|
||||
r.ws();
|
||||
const key = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name();
|
||||
if (!key || !r.take("=")) return null;
|
||||
r.ws();
|
||||
const rhs = r.until(false);
|
||||
if (!rhs) return null;
|
||||
args.push({ key, rhs });
|
||||
}
|
||||
if (!r.take(")") || !r.take(".")) return null;
|
||||
r.ws();
|
||||
const col = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name();
|
||||
if (!col) return null;
|
||||
return { start: at, end: r.i, table, args, col };
|
||||
}
|
||||
|
||||
/** 식 속 찾기 전부(바깥 것만 · 차례대로) — 하나라도 못 읽으면 null · 없으면 [] */
|
||||
export function readFinds(formula: string): FindCall[] | null {
|
||||
const out: FindCall[] = [];
|
||||
let i = 0;
|
||||
while (i < formula.length) {
|
||||
const ch = formula[i];
|
||||
if (ch === "'") {
|
||||
const end = formula.indexOf("'", i + 1);
|
||||
if (end < 0) return null;
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
if (formula.startsWith("찾기", i) && !isName(formula[i - 1]) && !isName(formula[i + 2])) {
|
||||
const call = readOne(formula, i);
|
||||
if (!call) return null;
|
||||
out.push(call);
|
||||
i = call.end;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const plain = (word: string): string => (/^[\p{L}\p{N}_]+$/u.test(word) ? word : `'${word}'`);
|
||||
|
||||
/** 찾기 하나를 글로 — 마스터 꼴 `찾기(표, 가=나, 다=라).칸` */
|
||||
export function writeFind(table: string, args: FindArg[], col: string): string {
|
||||
const parts = [table, ...args.map((a) => `${plain(a.key)}=${a.rhs}`)];
|
||||
return `찾기(${parts.join(", ")}).${plain(col)}`;
|
||||
}
|
||||
|
||||
/** 식의 찾기들을 갈아 끼움 — `swaps` 의 차례 = `readFinds` 차례 · null 은 그대로 */
|
||||
export function replaceFinds(formula: string, calls: FindCall[], swaps: (string | null)[]): string {
|
||||
let out = formula;
|
||||
for (let k = calls.length - 1; k >= 0; k--) {
|
||||
const text = swaps[k];
|
||||
if (text !== null && text !== undefined)
|
||||
out = out.slice(0, calls[k].start) + text + out.slice(calls[k].end);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 표 칸 값 → 식 글(수 · 따옴표 글) — 못 쓰면 null */
|
||||
export function literal(value: unknown, rule = ""): string | null {
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "string") return value.includes("'") ? null : `'${value}'`;
|
||||
if (Array.isArray(value)) {
|
||||
const n = rangeNumber(value as [number | null, number | null], rule);
|
||||
return n === null ? null : String(n);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 범위 칸 [아래, 위] 안에 드는 수 하나 — 엔진 `_match` 의 범위규칙대로 */
|
||||
export function rangeNumber(cell: [number | null, number | null], rule: string): number | null {
|
||||
const [low, high] = cell;
|
||||
const kind = (rule || "초과·이하").split(" · ")[0].split(" (")[0];
|
||||
const round = (n: number): number => Number(n.toFixed(6));
|
||||
if (low !== null && high !== null) return round((low + high) / 2);
|
||||
if (high !== null) {
|
||||
if (kind !== "이상·미만") return high;
|
||||
return round(high > 1 ? high - 1 : high / 2);
|
||||
}
|
||||
if (low !== null) return kind.startsWith("이상") ? low : low + 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 식 글이 따옴표 글 · 수면 그 값 */
|
||||
export function literalValue(rhs: string): string | number | null {
|
||||
const q = rhs.match(/^'([^']*)'$/);
|
||||
if (q) return q[1];
|
||||
return /^-?\d+(\.\d+)?$/.test(rhs) ? Number(rhs) : null;
|
||||
}
|
||||
|
||||
/* ── 표 · 서버 ──────────────────────────────────────────────────────── */
|
||||
|
||||
type Cell = string | number | [number | null, number | null] | null | undefined;
|
||||
interface EasyTable {
|
||||
이름?: string;
|
||||
기준?: string;
|
||||
범위규칙?: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
줄: Record<string, Cell>[];
|
||||
}
|
||||
|
||||
const tables = new Map<string, Promise<EasyTable | null>>();
|
||||
|
||||
function loadTable(key: string): Promise<EasyTable | null> {
|
||||
const hit = tables.get(key);
|
||||
if (hit) return hit;
|
||||
const groups = key[0] === "C" ? ["계수", "소요량"] : ["소요량", "계수"];
|
||||
const job = (async (): Promise<EasyTable | null> => {
|
||||
for (const group of groups) {
|
||||
const found = await searchElements(group, key).catch(() => null);
|
||||
const file = found?.items.find((x) => x.ref === key)?.file;
|
||||
if (!file) continue;
|
||||
const got = await fetchTable(file, key).catch(() => null);
|
||||
if (got?.table) return got.table as unknown as EasyTable;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
tables.set(key, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
/** 카드 줄을 새 식으로 — 호표면 `수량` · 덧붙임이면 `식` */
|
||||
function cardLine(card: EasyCard, formula: string): HoLine | NamedFormula {
|
||||
const raw = (card.raw ?? {}) as Record<string, unknown>;
|
||||
if (card.source?.part === "덧줄") return { ...raw, 이름: card.name, 식: formula } as NamedFormula;
|
||||
return {
|
||||
종류: "인력",
|
||||
요소: String(card.element ?? ""),
|
||||
이름: card.name,
|
||||
단위: card.unit,
|
||||
...raw,
|
||||
수량: formula,
|
||||
} as HoLine;
|
||||
}
|
||||
|
||||
/** 이 카드 하나만 든 시험 줄 — `/text` 답에서 그 줄 표 조각을 바로 찾으려고 */
|
||||
function probeRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow {
|
||||
const line = cardLine(card, formula);
|
||||
if (card.source?.part === "덧줄") return { ...doc.row, 키: "", 덧줄: [line as NamedFormula] };
|
||||
return { ...doc.row, 키: "", 호표: [line as HoLine], 덧줄: [] };
|
||||
}
|
||||
|
||||
/** 로직 전체 줄 — 카드 식만 바꿈 · Model 이 아직이면 원래 줄의 그 자리만 */
|
||||
function fullRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow {
|
||||
try {
|
||||
const cards = doc.cards.map((c) => (c.id === card.id ? { ...c, qtyFormula: formula } : c));
|
||||
return { ...toRow({ ...doc, cards }), 키: "" };
|
||||
} catch {
|
||||
const row: LogicRow = { ...doc.row, 키: "" };
|
||||
const at = card.source?.index ?? -1;
|
||||
if (card.source?.part === "덧줄" && row.덧줄?.[at])
|
||||
row.덧줄 = row.덧줄.map((x, i) => (i === at ? { ...x, 식: formula } : x));
|
||||
else if (row.호표?.[at])
|
||||
row.호표 = row.호표.map((x, i) => (i === at ? { ...x, 수량: formula } : x));
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
interface Preview {
|
||||
ok: boolean;
|
||||
/** 찾기 차례 → 걸린 줄 차례(못 찾으면 null) */
|
||||
hits: (number | null)[];
|
||||
line: number | null;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
async function preview(
|
||||
card: EasyCard,
|
||||
doc: EasyDoc,
|
||||
formula: string,
|
||||
calls: FindCall[],
|
||||
inputs: Record<string, unknown>,
|
||||
): Promise<Preview> {
|
||||
const [text, calc] = await Promise.all([
|
||||
runText({ key: "", inputs, row: probeRow(card, doc, formula) }).catch(() => null),
|
||||
runCalc({ key: "", inputs, row: fullRow(card, doc, formula) }).catch(() => null),
|
||||
]);
|
||||
const lines = text?.ok
|
||||
? text.groups.flatMap((g) => g.줄).filter((l) => l.이름 === card.name)
|
||||
: [];
|
||||
const pieces = (lines[0]?.조각 ?? []).filter((p) => p.kind === "표");
|
||||
const seen = new Map<string, number>();
|
||||
const hits = calls.map((c) => {
|
||||
const nth = seen.get(c.table) ?? 0;
|
||||
seen.set(c.table, nth + 1);
|
||||
const piece = pieces.filter((p) => p.참조?.키 === c.table)[nth];
|
||||
return typeof piece?.참조?.줄 === "number" ? piece.참조.줄 : null;
|
||||
});
|
||||
const sum = lines.reduce((s, l) => s + (Number(l.금액) || 0), 0);
|
||||
return {
|
||||
ok: !!text?.ok && !!calc?.ok,
|
||||
hits,
|
||||
line: lines.length ? sum : null,
|
||||
total: calc?.ok && calc.sums ? (calc.sums["계"] ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 창 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
type Mode = "design" | "fix" | "asis";
|
||||
interface CondState {
|
||||
key: string;
|
||||
/** 원래 식 글 · 새로 더한 조건은 null */
|
||||
orig: string | null;
|
||||
mode: Mode;
|
||||
/** 설계 값 이름 — 있으면 「설계 때 정함」 고를 수 있음 */
|
||||
input: string | null;
|
||||
fix: Cell;
|
||||
}
|
||||
interface CallState {
|
||||
conds: CondState[];
|
||||
col: string;
|
||||
picked: number | null;
|
||||
/** 「설계 때 정함」 조건을 고른 줄 값으로 미리 봄 */
|
||||
tryValues: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const showCell = (v: Cell): string => {
|
||||
if (v === null || v === undefined) return "";
|
||||
if (Array.isArray(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`;
|
||||
return String(v);
|
||||
};
|
||||
|
||||
function startState(call: FindCall, doc: EasyDoc): CallState {
|
||||
const names = new Set(doc.inputs.map((x) => x.이름));
|
||||
const conds = call.args.map((a): CondState => {
|
||||
const input = names.has(a.rhs) ? a.rhs : names.has(a.key) ? a.key : null;
|
||||
const lit = literalValue(a.rhs);
|
||||
const mode: Mode = names.has(a.rhs) ? "design" : lit !== null ? "fix" : "asis";
|
||||
return { key: a.key, orig: a.rhs, mode, input, fix: lit };
|
||||
});
|
||||
return { conds, col: call.col, picked: null, tryValues: {} };
|
||||
}
|
||||
|
||||
function stateArgs(st: CallState, table: EasyTable | null): FindArg[] | null {
|
||||
const out: FindArg[] = [];
|
||||
for (const c of st.conds) {
|
||||
if (c.mode === "design" && c.input) out.push({ key: c.key, rhs: c.input });
|
||||
else if (c.mode === "fix") {
|
||||
const rhs = literal(c.fix, table?.범위규칙);
|
||||
if (rhs === null) return null;
|
||||
out.push({ key: c.key, rhs });
|
||||
} else if (c.orig !== null) out.push({ key: c.key, rhs: c.orig });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 이 창 모양 그대로의 새 식 — 바뀐 것이 없으면 원래 식 */
|
||||
function compose(
|
||||
formula: string,
|
||||
calls: FindCall[],
|
||||
states: CallState[],
|
||||
tbl: (EasyTable | null)[],
|
||||
) {
|
||||
const swaps = calls.map((call, k) => {
|
||||
const args = stateArgs(states[k], tbl[k]);
|
||||
if (!args) return null;
|
||||
const same =
|
||||
states[k].col === call.col &&
|
||||
args.length === call.args.length &&
|
||||
args.every((a, i) => a.key === call.args[i].key && a.rhs === call.args[i].rhs);
|
||||
return same ? null : writeFind(call.table, args, states[k].col);
|
||||
});
|
||||
return replaceFinds(formula, calls, swaps);
|
||||
}
|
||||
|
||||
/** 잠금 카드 수량을 표에서 골라 바꿈 — 새 식 · 취소 null(설계.md 3절) · trial 없으면 자동값 */
|
||||
export function openEasyTable(
|
||||
card: EasyCard,
|
||||
doc: EasyDoc,
|
||||
trial?: Record<string, unknown>,
|
||||
): Promise<string | null> {
|
||||
const formula = card.qtyFormula ?? "";
|
||||
const calls = readFinds(formula);
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value: string | null): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve(value);
|
||||
};
|
||||
openModal({
|
||||
title: tt("Title"),
|
||||
closeLabel: tt("Close"),
|
||||
dialogClass: "m01-easytable",
|
||||
beforeClose: () => (finish(null), true),
|
||||
mount: (body, close) => {
|
||||
if (!calls || !calls.length) {
|
||||
body.append(el("p", { className: "m01-easytable__note", text: tt("Cannot") }));
|
||||
return;
|
||||
}
|
||||
void mountTable(body, { card, doc, formula, calls, trial }, (v) => {
|
||||
finish(v);
|
||||
close();
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mountTable(
|
||||
body: HTMLElement,
|
||||
ctx: {
|
||||
card: EasyCard;
|
||||
doc: EasyDoc;
|
||||
formula: string;
|
||||
calls: FindCall[];
|
||||
trial?: Record<string, unknown>;
|
||||
},
|
||||
finish: (value: string | null) => void,
|
||||
): Promise<void> {
|
||||
const { card, doc, formula, calls } = ctx;
|
||||
body.append(el("p", { className: "m01-easytable__note", text: tt("Loading") }));
|
||||
const [base, tbl] = await Promise.all([
|
||||
ctx.trial ?? (doc.key ? fetchAuto(doc.key) : Promise.resolve({})),
|
||||
Promise.all(calls.map((c) => loadTable(c.table))),
|
||||
]);
|
||||
const states = calls.map((c) => startState(c, doc));
|
||||
let now = 0;
|
||||
let last: Preview = { ok: false, hits: calls.map(() => null), line: null, total: null };
|
||||
let seq = 0;
|
||||
let busy = false;
|
||||
|
||||
const which = el("div", { className: "m01-easytable__which" });
|
||||
const head = el("div", { className: "m01-easytable__head" });
|
||||
const condBox = el("div", { className: "m01-easytable__conds" });
|
||||
const grid = el("div", { className: "m01-easytable__grid" });
|
||||
const money = el("div", { className: "m01-easytable__money" });
|
||||
const warn = el("p", { className: "m01-easytable__warn" });
|
||||
const use = createButton({ label: tt("Use"), onClick: () => finish(newFormula()) });
|
||||
const cancel = createButton({
|
||||
label: tt("Cancel"),
|
||||
variant: "ghost",
|
||||
onClick: () => finish(null),
|
||||
});
|
||||
|
||||
const newFormula = (): string => compose(formula, calls, states, tbl);
|
||||
const inputs = (): Record<string, unknown> => ({ ...base, ...states[now].tryValues });
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
busy = true;
|
||||
paint();
|
||||
const got = await preview(card, doc, newFormula(), calls, inputs());
|
||||
if (mine !== seq) return;
|
||||
last = got;
|
||||
busy = false;
|
||||
paint();
|
||||
};
|
||||
|
||||
const pickRow = (idx: number, col: string | null): void => {
|
||||
const table = tbl[now];
|
||||
const st = states[now];
|
||||
const row = table?.줄[idx];
|
||||
if (!table || !row) return;
|
||||
for (const key of Object.keys(table.조건)) {
|
||||
if (row[key] === undefined) continue;
|
||||
let c = st.conds.find((x) => x.key === key);
|
||||
if (!c) {
|
||||
const input = doc.inputs.some((x) => x.이름 === key) ? key : null;
|
||||
c = { key, orig: null, mode: "fix", input, fix: null };
|
||||
st.conds.push(c);
|
||||
}
|
||||
if (c.mode === "design" && c.input) {
|
||||
const n = Array.isArray(row[key])
|
||||
? rangeNumber(row[key] as [number | null, number | null], table.범위규칙 ?? "")
|
||||
: row[key];
|
||||
st.tryValues[c.input] = n;
|
||||
} else {
|
||||
c.mode = "fix";
|
||||
c.fix = row[key];
|
||||
}
|
||||
}
|
||||
if (col) st.col = col;
|
||||
st.picked = idx;
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const setMode = (c: CondState, mode: Mode): void => {
|
||||
if (mode === "fix" && (c.fix === null || c.fix === undefined)) {
|
||||
const hit = last.hits[now];
|
||||
const row = hit === null ? null : tbl[now]?.줄[hit];
|
||||
c.fix = row?.[c.key] ?? (c.input ? (inputs()[c.input] as Cell) : null);
|
||||
}
|
||||
c.mode = mode;
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const paintConds = (): void => {
|
||||
const st = states[now];
|
||||
condBox.replaceChildren(
|
||||
el("h4", { text: tt("Conds") }),
|
||||
...st.conds.map((c) => {
|
||||
const opts: [Mode, string][] = [];
|
||||
if (c.input) opts.push(["design", tt("Design")]);
|
||||
if (c.orig !== null && c.orig !== c.input && literalValue(c.orig) === null)
|
||||
opts.push(["asis", tt("AsIs")]);
|
||||
opts.push(["fix", tt("Fix")]);
|
||||
const hit = last.hits[now];
|
||||
const used = hit === null ? "" : showCell(tbl[now]?.줄[hit]?.[c.key]);
|
||||
const value =
|
||||
c.mode === "fix"
|
||||
? showCell(c.fix)
|
||||
: c.mode === "design" && c.input
|
||||
? showCell(inputs()[c.input] as Cell) || used
|
||||
: used;
|
||||
return el("div", {
|
||||
className: "m01-easytable__cond",
|
||||
children: [
|
||||
el("span", { className: "m01-easytable__cond-name", text: c.key }),
|
||||
el("span", { className: "m01-easytable__cond-value", text: value || tt("NotYet") }),
|
||||
el("span", {
|
||||
className: "m01-easytable__seg",
|
||||
children: opts.map(([mode, label]) => {
|
||||
const b = createButton({
|
||||
label,
|
||||
size: "sm",
|
||||
variant: c.mode === mode ? "filled" : "ghost",
|
||||
onClick: () => setMode(c, mode),
|
||||
});
|
||||
b.setAttribute("aria-pressed", String(c.mode === mode));
|
||||
return b;
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const paintGrid = (): void => {
|
||||
const table = tbl[now];
|
||||
if (!table) {
|
||||
grid.replaceChildren(el("p", { className: "m01-easytable__note", text: tt("NoTable") }));
|
||||
return;
|
||||
}
|
||||
const st = states[now];
|
||||
const conds = Object.keys(table.조건);
|
||||
const vals = Object.keys(table.값칸);
|
||||
const hit = last.hits[now];
|
||||
const th = (text: string, cls = ""): HTMLElement => el("th", { className: cls, text });
|
||||
const rows = table.줄.map((row, idx) => {
|
||||
const tr = el("tr", {
|
||||
className: [
|
||||
idx === hit && !busy ? "is-hit" : "",
|
||||
idx === st.picked ? "is-picked" : "",
|
||||
].join(" "),
|
||||
children: [
|
||||
...conds.map((k) => el("td", { text: showCell(row[k]) })),
|
||||
...vals.map((k) =>
|
||||
el("td", {
|
||||
className: `is-value${k === st.col ? " is-col" : ""}`,
|
||||
text: showCell(row[k]),
|
||||
attrs: { "data-col": k },
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", (ev) => {
|
||||
const cell = (ev.target as HTMLElement).closest("td");
|
||||
pickRow(idx, cell?.dataset.col ?? null);
|
||||
});
|
||||
return tr;
|
||||
});
|
||||
grid.replaceChildren(
|
||||
el("table", {
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
...conds.map((k) => th(k)),
|
||||
...vals.map((k) =>
|
||||
th(
|
||||
`${k}${table.값칸[k] ? ` (${table.값칸[k]})` : ""}`,
|
||||
k === st.col ? "is-col" : "",
|
||||
),
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const paint = (): void => {
|
||||
const table = tbl[now];
|
||||
which.replaceChildren(
|
||||
...(calls.length > 1
|
||||
? calls.map((_c, k) =>
|
||||
createButton({
|
||||
label: tbl[k]?.이름 ?? `${tt("Which")} ${k + 1}`,
|
||||
size: "sm",
|
||||
variant: k === now ? "filled" : "ghost",
|
||||
onClick: () => {
|
||||
now = k;
|
||||
void refresh();
|
||||
},
|
||||
}),
|
||||
)
|
||||
: []),
|
||||
);
|
||||
head.replaceChildren(
|
||||
el("strong", { text: [table?.이름, table?.기준].filter(Boolean).join(" · ") }),
|
||||
el("span", { className: "m01-easytable__note", text: tt("Hint") }),
|
||||
);
|
||||
paintConds();
|
||||
paintGrid();
|
||||
const picked = states[now].picked;
|
||||
const mismatch = !busy && picked !== null && last.hits[now] !== picked;
|
||||
warn.textContent = mismatch ? tt("Mismatch") : "";
|
||||
const fmt = (v: number | null): string => (v === null ? "—" : `${formatMoney(v)} 원`);
|
||||
money.replaceChildren(
|
||||
el("span", { text: `${tt("Amount")} ${fmt(last.line)}` }),
|
||||
el("strong", { text: `${tt("Total")} ${fmt(last.total)}` }),
|
||||
el("span", {
|
||||
className: "m01-easytable__note",
|
||||
text: busy ? tt("Wait") : last.ok ? "" : tt("Stopped"),
|
||||
}),
|
||||
);
|
||||
money.classList.toggle("is-busy", busy);
|
||||
use.disabled = busy || mismatch || !last.ok;
|
||||
};
|
||||
|
||||
body.replaceChildren(
|
||||
which,
|
||||
head,
|
||||
condBox,
|
||||
grid,
|
||||
money,
|
||||
warn,
|
||||
el("div", { className: "m01-easytable__actions", children: [cancel, use] }),
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
Reference in New Issue
Block a user