feat(view): attribute-copy dialog (시안 ⑥, plan C8)

- 속성 복사… opens the four-step dialog: source (current inputs), items,
  targets (same-type filter on by default), and a preview with changed values
  and skipped items per target. Apply changes only the visible, ticked targets.
- Items that the model does not have yet (기둥, 받침, 배근, 기초 형상) are shown
  as 예정 and disabled. Coping length and pile length are off by default.
- Option: regenerate the targets right after applying (default on); otherwise
  they show as 재생성 필요 through the normal change tracking.
- Not verified in a browser.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-22 10:01:18 +09:00
1 parent 4accf1f3be
commit f75431d898
3 files changed
+184

No files matched your search

+55
View File
@@ -246,6 +246,61 @@
</footer>
</dialog>
<dialog id="copy-dialog" aria-labelledby="cp-title">
<header class="dlg-head">
<h2 id="cp-title">속성 복사</h2>
<p class="muted small">한 교각의 설정을 여러 교각에 한 번에 적용합니다</p>
<button id="cp-close" class="btn small" type="button">닫기</button>
</header>
<div class="dlg-body">
<div class="dlg-col">
<section>
<h3>1 · 원본 교각</h3>
<p id="cp-source" class="num"></p>
<p class="muted small">현재 입력값 기준 (미반영 변경 포함)</p>
</section>
<section>
<h3>2 · 복사할 항목</h3>
<ul class="cp-items">
<li><span class="cp-group">코핑</span>
<label class="chk"><input type="checkbox" data-item="coping-width" />폭 B</label>
<label class="chk"><input type="checkbox" data-item="coping-length" />길이 L <span class="muted small">교각마다 다름 · 기본 해제</span></label>
</li>
<li><span class="cp-group">확대기초 · 말뚝</span>
<label class="chk"><input type="checkbox" data-item="pile-length" />말뚝 길이 <span class="muted small">지반마다 다름 · 기본 해제</span></label>
<label class="chk disabled"><input type="checkbox" disabled />기초 형상 · 치수 · 버림 <span class="muted small">예정</span></label>
</li>
<li><span class="cp-group">기둥 · 받침 · 철근배근</span>
<label class="chk disabled"><input type="checkbox" disabled />기둥 단면 · 지름 D, 받침·받침블럭, 배근 <span class="muted small">예정</span></label>
</li>
</ul>
<p class="muted small">복사하지 않는 값: 좌표 · IP · 받침 EL · 코핑 상면 EL · 기초 하단 EL · 지반고 · 기둥 높이 · 말뚝 선단 EL · 계단 구간</p>
</section>
<section>
<h3>3 · 대상 교각</h3>
<label class="chk"><input id="cp-same-type" type="checkbox" checked />원본과 같은 형식만 보기</label>
<ul id="cp-targets" class="cp-targets"></ul>
</section>
</div>
<div class="dlg-col">
<section>
<h3>4 · 적용 미리보기</h3>
<table id="cp-preview" class="grid-table">
<thead><tr><th>대상</th><th>형식</th><th>바뀌는 값</th><th>건너뜀</th><th>내용</th></tr></thead>
<tbody></tbody>
</table>
<label class="chk"><input id="cp-regen" type="checkbox" checked />적용 후 대상 교각 형상까지 바로 다시 생성</label>
<p class="muted small">적용하면 대상 교각마다 "변경 n건"이 표시되고, 입력 수정 카드에서 항목별로 되돌릴 수 있습니다.</p>
</section>
</div>
</div>
<footer class="dlg-foot">
<span id="cp-summary" class="muted"></span>
<button id="cp-cancel" class="btn" type="button">취소</button>
<button id="cp-apply" class="btn-p" type="button" disabled>대상 0기에 적용</button>
</footer>
</dialog>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+122
View File
@@ -7,6 +7,7 @@ import { buildPier, type PierModel } from './core/geometry';
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
import { buildTemplateWorkbook } from './core/template';
import { EDITABLE_FIELDS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes';
import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project';
import { clearBackup, readBackup, writeBackup, type Backup } from './view/backup';
import type { Issue, PierInput, PierType } from './core/types';
@@ -266,6 +267,7 @@ function renderHeader(): void {
regen.textContent = total ? `선택 교각 다시 생성 · 변경 ${total}` : '선택 교각 형상 생성';
$('bulk-count').textContent = `선택 ${checked}기`;
$<HTMLButtonElement>('bulk-regen').disabled = checked === 0;
$<HTMLButtonElement>('bulk-copy').disabled = !activePier();
}
// --- results panel (시안 ① 입력값 · 계산 결과) ----------------------------------------
@@ -369,6 +371,114 @@ function renderAll(): void {
scheduleBackup();
}
// --- 속성 복사 (시안 ⑥, plan C8) ---------------------------------------------------------
/** Names of the pier targets ticked in the copy dialog. */
let copySelection = new Set<string>();
function copyTargets(): Pier[] {
const src = activePier();
const sameType = $<HTMLInputElement>('cp-same-type').checked;
return state.piers.filter((p) => p !== src && (!sameType || p.input.type === src?.input.type));
}
/** Targets that are both visible in the dialog and ticked. Hidden targets are never changed. */
function selectedCopyTargets(): Pier[] {
return copyTargets().filter((t) => copySelection.has(t.input.name));
}
function chosenCopyItems(): CopyItem[] {
return Array.from(document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]'))
.filter((i) => i.checked)
.map((i) => i.dataset.item as CopyItem);
}
function renderCopyTargets(): void {
const list = $<HTMLUListElement>('cp-targets');
list.replaceChildren();
for (const t of copyTargets()) {
const li = el('li');
const check = el('input');
check.type = 'checkbox';
check.checked = copySelection.has(t.input.name);
check.setAttribute('aria-label', `${t.input.name} 대상`);
check.addEventListener('change', () => {
if (check.checked) copySelection.add(t.input.name);
else copySelection.delete(t.input.name);
renderCopyPreview();
});
li.append(check, el('span', `${t.input.name} · ${t.input.type} · ${t.input.foundationType}`));
list.appendChild(li);
}
}
function renderCopyPreview(): void {
const src = activePier();
const items = chosenCopyItems();
const body = $<HTMLTableElement>('cp-preview').tBodies[0];
body.replaceChildren();
let applied = 0;
let skippedTotal = 0;
const selected = selectedCopyTargets();
for (const t of selected) {
if (!src) break;
const plan = planCopy(src.draft, t.draft, items);
const labels = plan.changes.map((c) => `${EDITABLE_FIELDS.find((f) => f.key === c.key)!.label} ${c.from} → ${c.to}`);
const skipped = plan.skipped.map((k) => k.reason);
skippedTotal += skipped.length;
if (plan.changes.length) applied++;
const row = el('tr');
row.append(
el('td', t.input.name),
el('td', t.input.type),
el('td', String(plan.changes.length)),
el('td', String(skipped.length)),
el('td', [...labels, ...skipped].join(' · ') || '바뀌는 값 없음'),
);
body.appendChild(row);
}
const n = selected.length;
$('cp-summary').textContent = `${src?.input.name ?? ''} → ${n}기 · 항목 ${items.length}개 · 건너뜀 ${skippedTotal}건`;
const apply = $<HTMLButtonElement>('cp-apply');
apply.textContent = `대상 ${applied}기에 적용`;
apply.disabled = applied === 0;
}
function openCopyDialog(): void {
const src = activePier();
if (!src) return;
$('cp-source').textContent = `${src.input.name} · ${src.input.type} · ${src.input.foundationType}`;
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]')) {
i.checked = (DEFAULT_COPY_ITEMS as string[]).includes(i.dataset.item ?? '');
}
copySelection = new Set(state.piers.filter((p) => p.checked && p !== src).map((p) => p.input.name));
renderCopyTargets();
renderCopyPreview();
$<HTMLDialogElement>('copy-dialog').showModal();
}
function applyCopyDialog(): void {
const src = activePier();
if (!src) return;
const items = chosenCopyItems();
const regen = $<HTMLInputElement>('cp-regen').checked;
let applied = 0;
let changes = 0;
const targets = new Set(selectedCopyTargets().map((t) => t.input.name));
state.piers = state.piers.map((t) => {
if (t === src || !targets.has(t.input.name)) return t;
const plan = planCopy(src.draft, t.draft, items);
if (plan.changes.length === 0) return t;
applied++;
changes += plan.changes.length;
const next = { ...t, draft: applyCopy(t.draft, plan) };
return regen ? regenerateEntry(next) : next;
});
$<HTMLDialogElement>('copy-dialog').close();
setupScene();
renderAll();
setStatus(`${applied}기에 속성을 복사했습니다. 변경 ${changes}건${regen ? ' (형상 다시 생성)' : ' · 재생성 필요'}.`);
}
// --- input cards and change tracking (plan B1 · C2) -------------------------------------
function pendingTotal(): number {
return state.piers.filter((p) => p.checked).reduce((n, p) => n + pendingKeys(p).length, 0);
@@ -781,6 +891,18 @@ $<HTMLButtonElement>('dlg-cancel').addEventListener('click', () => $<HTMLDialogE
$<HTMLButtonElement>('dlg-apply').addEventListener('click', applyImport);
$<HTMLButtonElement>('regen').addEventListener('click', regenerateChecked);
$<HTMLButtonElement>('bulk-regen').addEventListener('click', regenerateChecked);
$<HTMLButtonElement>('bulk-copy').addEventListener('click', openCopyDialog);
$<HTMLInputElement>('cp-same-type').addEventListener('change', () => {
renderCopyTargets();
renderCopyPreview();
});
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]')) {
i.addEventListener('change', renderCopyPreview);
}
$<HTMLInputElement>('cp-regen').addEventListener('change', renderCopyPreview);
$<HTMLButtonElement>('cp-apply').addEventListener('click', applyCopyDialog);
$<HTMLButtonElement>('cp-cancel').addEventListener('click', () => $<HTMLDialogElement>('copy-dialog').close());
$<HTMLButtonElement>('cp-close').addEventListener('click', () => $<HTMLDialogElement>('copy-dialog').close());
$<HTMLButtonElement>('sv-save').addEventListener('click', confirmSaveDialog);
$<HTMLButtonElement>('sv-cancel').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
$<HTMLButtonElement>('sv-close').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
+7
View File
@@ -644,4 +644,11 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.views .btn[aria-pressed='true'] { background: #1f5fa8; color: #ffffff; font-weight: 700; }
.viewer-head { gap: 8px; }
.edit-card h3 { font-size: 12px; color: #55606e; }
/* 속성 복사 (시안 ⑥) */
.cp-items li { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 6px 0; border-bottom: 1px solid #e6eaef; }
.cp-group { min-width: 110px; font-size: 12px; font-weight: 700; color: #55606e; }
.cp-targets { list-style: none; padding: 0; margin: 6px 0; max-height: 240px; overflow-y: auto; }
.cp-targets li { display: flex; align-items: center; gap: 8px; min-height: 36px; }
.chk.disabled { opacity: 0.5; }
#cp-preview td, #cp-preview th { font-size: 12px; }