Merge remote-tracking branch 'origin/dev' into sub_laptop_3

This commit is contained in:
2026-10-01 09:43:29 +09:00
11 changed files with 3500 additions and 71 deletions
+383 -29
View File
@@ -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");
}
+413 -15
View File
@@ -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;
}
+492 -25
View File
@@ -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") }),
);
}
+155 -2
View File
@@ -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();
}