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

This commit is contained in:
2026-10-04 21:46:42 +09:00
9 changed files with 96 additions and 17 deletions
+19 -8
View File
@@ -299,6 +299,8 @@ const PREFS_ENDPOINT = "/api/dashboard/me/ui-prefs";
const PREFS_PUSH_DELAY_MS = 1500;
let applyingServerPrefs = false;
let prefsPushTimer: ReturnType<typeof setTimeout> | null = null;
/** 계정 취향 받기 — 받기 전에 올리면 이 브라우저 옛 값이 계정 값을 덮으므로 올리기는 이것 뒤에 */
let prefsPull: Promise<void> | null = null;
function localPrefs(): Record<string, string> {
const out: Record<string, string> = {};
@@ -320,17 +322,26 @@ function pushUiPrefs(): void {
if (prefsPushTimer) clearTimeout(prefsPushTimer);
prefsPushTimer = setTimeout(() => {
prefsPushTimer = null;
void fetch(PREFS_ENDPOINT, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prefs: localPrefs() }),
}).catch(() => undefined);
void (prefsPull ?? Promise.resolve())
.then(() =>
fetch(PREFS_ENDPOINT, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prefs: localPrefs() }),
}),
)
.catch(() => undefined);
}, PREFS_PUSH_DELAY_MS);
}
/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번. */
export async function syncUiPrefs(): Promise<void> {
/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번(올리기는 받은 뒤로 미룸). */
export function syncUiPrefs(): Promise<void> {
prefsPull = pullUiPrefs();
return prefsPull;
}
async function pullUiPrefs(): Promise<void> {
let stored: Record<string, unknown> = {};
try {
const response = await fetch(PREFS_ENDPOINT, { credentials: "include" });
+4 -2
View File
@@ -76,7 +76,7 @@ export interface SpreadsheetHandle {
setDoc(book: Workbook): void;
/** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */
recalc(): CalcValues;
/** 알약 키 → 연결 칸 값(빈 칸 0) */
/** 알약 키 → 연결 칸 값(빈 칸 = 값 · 식 없는 칸은 빠짐 — 「수량 없음」) */
getLinkedValues(): Record<string, CalcValue>;
/** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 ·
* 주소가 틀리거나 읽기만이면 false */
@@ -289,7 +289,9 @@ export function createSpreadsheet(
for (const s of ctx.book.시트)
for (const l of s.연결 ?? []) {
const p = parseA1(l.칸);
if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
const cell = s.칸[l.칸];
const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === "");
if (p && !empty) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
}
return out;
},
@@ -15,6 +15,7 @@ const TEXT = {
close: "닫기",
now: "지금 연결",
none: "연결 없음",
empty: "빈 칸",
picked: "지금 고른 칸으로 ({value})",
address: "칸 주소",
hint: "예 B6",
@@ -51,6 +52,7 @@ function linkedText(opts: AreaLinkOptions): string | null {
.find((l) => l.키 === opts.key);
if (!link) return null;
const value = opts.sheet.getLinkedValues()[opts.key];
if (!value) return `${link.칸} = ${TEXT.empty}`;
return [link.칸, "=", cellText(value), opts.info.단위 ?? ""].join(" ").trim();
}
@@ -47,13 +47,27 @@ export interface AreaPanel {
destroy: () => void;
}
/** 시트 알약 값 → 수만(`{키: 수}`) — 수 아닌 칸은 빠짐(「수량 없음」) */
/** 칸 값 → 수(쉼표 뗌) — 빈 글 · 글 · 참거짓은 null(서버 `M02_Labor_Doc._number` 와 같음) */
function toNumber(raw: unknown): number | null {
if (raw === null || raw === undefined || typeof raw === "boolean") return null;
const text = String(raw).replace(/,/g, "").trim();
const n = Number(text);
return text !== "" && Number.isFinite(n) ? n : null;
}
/** 시트 알약 값 → `{키: 수}` — 서버 `M02_Labor_Doc.area_values` 와 같은 규칙(짝): 식 칸 = 계산 값의 수 ·
* 그 밖 = 칸 값 · 빈 칸 · 글은 빠짐(「수량 없음」 · 반영 「—」) */
function quantities(sheet: SpreadsheetHandle | null): Record<string, number> {
const out: Record<string, number> = {};
for (const [key, v] of Object.entries(sheet?.getLinkedValues() ?? {})) {
const n = "수" in v ? Number(v.수) : NaN;
if (Number.isFinite(n)) out[key] = n;
}
if (!sheet) return out;
const values = sheet.getLinkedValues();
for (const s of sheet.getDoc().시트)
for (const l of s.연결 ?? []) {
const cell = s.칸[l.칸];
const v = values[l.키];
const n = toNumber(cell?.식 !== undefined ? (v && "수" in v ? v.수 : null) : cell?.값);
if (n !== null) out[l.키] = n;
}
return out;
}
@@ -478,6 +478,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
return;
}
root.hidden = false;
paintPills(readStateRaw("m02-card-pills") === "true"); // 계정 취향을 늦게 받았어도 열 때마다 따름
if (col.id !== column) started.clear();
column = col.id;
label = col.label;
@@ -33,13 +33,18 @@ const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
const num = (v: number): string =>
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · 없으면 빈 글 */
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) ·
* 이은 칸이 비었으면 「수량 A1 = 빈 칸」(수량 없음 · 서버와 같음) · 아니면 빈 글 */
export function qtyText(
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
cells: ReadonlyMap<string, string> = new Map(),
): string {
if (!line || line.수량 === null || line.수량 === undefined) return "";
if (!line) return "";
if (line.수량 === null || line.수량 === undefined) {
const at = line.알약 ? cells.get(line.알약) : undefined;
return at ? `${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}` : "";
}
const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`;
if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`;
const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell");
@@ -75,6 +75,7 @@ const TEXT = {
In_Memo: ["메모", "Memo"],
In_Qty: ["수량", "Quantity"],
In_QtyCell: ["면적산출 칸", "Area sheet cell"],
In_QtyEmpty: ["빈 칸", "empty cell"],
In_QtyHand: ["손 입력", "Manual"],
In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"],
In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"],
+41
View File
@@ -47,3 +47,44 @@ def test_칸_지정_모달은_setLink_만():
assert "address.setError(TEXT.bad)" in link # 틀린 · 빈 주소는 모달 그대로
assert "pick.disabled = link.disabled = address.input.disabled = opts.readOnly;" in link
assert "saveTemplate" not in link # 저장은 [저장]
def test_빈_칸은_수량_없음_서버와_같음():
"""39-8 ② — 빈 칸에 이은 알약 = 수량 없음(반영 「—」 · 「수량 A1 = 빈 칸」) · 0 칸 = 0 · 값 칸 = 값."""
from M02_MasterTemplete import M02_Labor_Doc as labor
doc = {
"시트": [
{
"id": "s1",
"칸": {"A2": {"값": 0}, "A3": {"값": "1,250"}, "A4": {"값": "글"}},
"연결": [
{"키": k, "칸": c}
for k, c in (("빈", "A1"), ("영", "A2"), ("값", "A3"), ("글", "A4"))
],
}
]
}
assert {k: float(v) for k, v in labor.area_values(doc).items()} == {"영": 0, "값": 1250}
panel = _text("M02_MasterTemplete_AreaPanel.ts")
body = panel.split("function quantities")[1].split("\n}\n")[0]
assert 'toNumber(cell?.식 !== undefined ? (v && "수" in v ? v.수 : null) : cell?.값)' in body
assert 'String(raw).replace(/,/g, "").trim()' in panel and 'typeof raw === "boolean"' in panel
sheet = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8")
assert 'const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === "");' in sheet
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
assert '${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}' in rows
assert "if (!value) return `${link.칸} = ${TEXT.empty}`;" in _text(
"M02_MasterTemplete_AreaLink.ts"
)
def test_카드_알약_취향은_계정_받은_뒤_올림():
"""39-8 ③ — 화면이 뜰 때 브라우저 옛 값이 계정 값을 덮지 않게 · 카드는 열 때마다 취향을 다시 읽음."""
state = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8")
push = state.split("function pushUiPrefs")[1].split("export function syncUiPrefs")[0]
assert "void (prefsPull ?? Promise.resolve())" in push and "prefs: localPrefs()" in push
assert "prefsPull = pullUiPrefs();" in state
cards = _text("M02_MasterTemplete_ItemCards.ts")
load = cards.split("async function load(")[1]
assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in load
@@ -131,6 +131,7 @@ console.log(JSON.stringify([
m.qtyText({ 수량: 20, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡"),
m.qtyText({ 수량: 3, 수량출처: "손", 알약: "돌쌓기|㎡" }, "㎡", cells),
m.qtyText({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡", cells),
m.qtyText({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡"),
]));
"""
@@ -148,5 +149,6 @@ def test_수량_글은_이은_칸_주소를_보임() -> None:
"수량 B6 = 20 ㎡",
"수량 면적산출 칸 = 20 ㎡",
"수량 손 입력 3 ㎡",
"수량 B6 = 빈 칸", # 이은 칸이 비었음 = 수량 없음(39-8 ② · 서버 area_values 와 같음)
"",
]