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:
1 parent
4accf1f3be
commit
f75431d898
3 files changed
+184
No files matched your search
+55
@@ -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
@@ -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());
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user