feat: timeline refactor, docking UI, location/state dropdowns, sim_time cleanup

Templates:
- mission_timeline_preview: client-side rendering with lane stacking, wheel zoom,
  drag pan, percentage-based layout, event dedup, scale labels
- asset_detail: dock/undock modals with time selection, future event warning,
  Mission Label column replacing Location/State
- asset_entry_form: all_locations expanded to 72 items (draft board hierarchy),
  all_states dynamic from DB, event point dropdown uses same list
- Remove sim_time from all template url_for links
- base.html: remove Today button

JS:
- app.js: sim_time controls use POST /api/v1/sim-time API instead of URL param

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 15:29:10 +08:00
co-authored by Claude Opus 4.7
parent cc4c460840
commit 2fef743e83
15 changed files with 1791 additions and 1966 deletions
+423 -222
View File
@@ -1,231 +1,432 @@
{% extends "base.html" %}
{% from "_datetime_picker_field.html" import datetime_picker_field %}
{% block content %}
{% include "_mission_ops_tabs.html" %}
{% include "_mission_preview_header.html" %}
<div class="ph-row">
<div class="ph">
<div class="ey">Mission Asset</div>
<h1>{{ asset.name }}</h1>
<p>{{ asset.asset_type }} · {{ 'Retired' if asset.is_retired else 'Active' }}</p>
</div>
<div style="display:flex;gap:6px">
<a class="b" href="{{ url_for('web.asset_edit', asset_id=asset.id
) }}">Edit Asset</a>
<a class="b" href="{{ url_for('web.asset_list'
) }}">← Back</a>
</div>
</div>
<section class="mission-detail-layout top-gap">
<div class="mission-detail-main">
<section class="panel mission-asset-overview">
<div class="mission-asset-head">
<div>
<p class="eyebrow">Asset Summary</p>
<h2>{{ asset.name }}</h2>
<p class="small-muted">{{ asset.asset_type }} · {{ asset.program or 'No program assigned' }}</p>
</div>
<div class="mission-asset-chip-row">
<span class="chip strong">{{ asset_summary.current_state }}</span>
<span class="chip">{{ asset_summary.current_location }}</span>
{% if asset.home_region %}
<span class="chip">{{ asset.home_region }}</span>
{% endif %}
</div>
</div>
<div class="ad-grid">
<div>
<!-- Info cards -->
<div class="mg" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
<div class="mc"><div class="l">Type</div><div class="v" style="font-size:.95rem">{{ asset.asset_type }}</div></div>
<div class="mc"><div class="l">Location</div><div class="v" style="font-size:.95rem">{{ asset_summary.current_location or '—' }}</div></div>
<div class="mc"><div class="l">Mission</div><div class="v" style="font-size:.85rem">{{ asset_summary.current_mission or '—' }}</div></div>
<div class="mc"><div class="l">Entries</div><div class="v" style="font-size:.95rem">{{ total_entry_count }}</div></div>
</div>
<div class="mission-meta-grid">
<div class="mission-meta-item">
<dt>Current Mission</dt>
<dd>{{ asset_summary.current_mission }}</dd>
</div>
<div class="mission-meta-item">
<dt>Active Window</dt>
<dd>{{ asset_summary.state_window }}</dd>
</div>
<div class="mission-meta-item field-span-two">
<dt>Next Planned Event</dt>
<dd>{{ asset_summary.next_event }}</dd>
</div>
</div>
</section>
<section class="panel mission-future-panel">
<div class="mission-log-top">
<div class="panel-heading">
<p class="eyebrow">未来事件</p>
<h2>可编辑的未来记录</h2>
<p class="small-muted">这里汇总了未来的 Event Point、State Interval,以及当前区间里尚未发生的 State Node。</p>
</div>
<div class="mission-group-count">{{ future_events|length }} future items</div>
</div>
{% if future_events %}
<div class="mission-future-list">
{% for item in future_events %}
<article class="mission-upcoming-item mission-future-item">
<div class="mission-upcoming-head">
<div>
<p class="mission-upcoming-date">{{ item.when }}</p>
<h3>{{ item.title }}</h3>
<p class="small-muted">{{ item.kind_label }} · {{ item.status_label }}</p>
</div>
<a class="table-link mission-future-action" href="{{ item.edit_url }}">{{ item.action_label }}</a>
</div>
<p class="small-muted">{{ item.scope }}</p>
<p class="small-muted mission-future-summary">{{ item.summary }}</p>
{% if item.edit_hint %}
<p class="small-muted">{{ item.edit_hint }}</p>
{% endif %}
{% if item.state_labels or item.mission_labels %}
<div class="mission-asset-chip-row mission-entry-label-row">
{% for label in item.state_labels %}<span class="chip">{{ label }}</span>{% endfor %}
{% for label in item.mission_labels %}<span class="chip">{{ label }}</span>{% endfor %}
</div>
{% endif %}
</article>
{% endfor %}
</div>
{% else %}
<section class="empty-state compact-empty">
<h2>没有未来记录</h2>
<p>当前选定的 Simulation Time 之后还没有排定的事件。</p>
</section>
{% endif %}
</section>
<section class="panel mission-log-panel">
<div class="mission-log-top">
<div class="panel-heading">
<p class="eyebrow">全部日志</p>
<h2>资产日志</h2>
</div>
<div class="mission-group-count">{{ filtered_entry_count }} / {{ total_entry_count }} entries</div>
</div>
<form method="get" class="mission-log-filter-grid">
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
{{ datetime_picker_field('Start', 'start', entry_filter_start_input, field_id='asset-log-start') }}
{{ datetime_picker_field('End', 'end', entry_filter_end_input, field_id='asset-log-end') }}
<button class="primary-button" type="submit">筛选</button>
<a class="secondary-button" href="{{ url_for('web.asset_detail', asset_id=asset.id, sim_time=simulation_time_input) }}">重置</a>
</form>
{% if entries %}
<div class="table-shell">
<table class="data-table compact-table mission-log-table">
<thead>
<tr>
<th>Time</th>
<th>Entry</th>
<th>Labels</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td class="two-line-cell">
<strong class="table-title">{{ entry.timestamp }}</strong>
{% if entry.end_timestamp %}
<div class="table-subline">→ {{ entry.end_timestamp }}</div>
{% endif %}
<div class="table-subline single-line-cell">{{ entry.location }}</div>
</td>
<td class="two-line-cell">
<strong class="table-title">{{ entry.title }}</strong>
<div class="table-subline">{{ entry.mode_label }} · {{ entry.status_label }}</div>
<div class="table-subline clamp-line">{{ entry.summary }}</div>
</td>
<td>
{% if entry.state_labels or entry.mission_labels %}
<div class="mission-asset-chip-row mission-entry-label-row">
{% for label in entry.state_labels %}<span class="chip">{{ label }}</span>{% endfor %}
{% for label in entry.mission_labels %}<span class="chip">{{ label }}</span>{% endfor %}
</div>
{% else %}
<span class="small-muted">-</span>
{% endif %}
</td>
<td>
<div class="table-actions">
<a class="table-link" href="{{ url_for('web.asset_entry_edit', asset_id=asset.id, entry_id=entry.id, sim_time=simulation_time_input) }}">编辑条目</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if entry_total_pages > 1 %}
<nav class="pagination-bar" aria-label="Asset timeline pagination">
{% if entry_has_prev %}
<a class="pagination-link" href="{{ entry_prev_url }}">上一页</a>
{% else %}
<span class="pagination-link disabled">上一页</span>
{% endif %}
<div class="pagination-pages">
{% for item in entry_page_links %}
{% if item.ellipsis %}
<span class="pagination-ellipsis">...</span>
{% elif item.current %}
<span class="pagination-link current">{{ item.number }}</span>
{% else %}
<a class="pagination-link" href="{{ item.url }}">{{ item.number }}</a>
{% endif %}
{% endfor %}
</div>
{% if entry_has_next %}
<a class="pagination-link" href="{{ entry_next_url }}">下一页</a>
{% else %}
<span class="pagination-link disabled">下一页</span>
{% endif %}
</nav>
{% endif %}
{% else %}
<section class="empty-state compact-empty">
{% if total_entry_count %}
<h2>没有命中筛选条件的日志条目</h2>
<p>调整起止时间,或者重置筛选后重试。</p>
{% else %}
<h2>还没有日志条目</h2>
<p>先为这个资产新增一个 Event Point 或 State Interval。</p>
{% endif %}
</section>
{% endif %}
</section>
<!-- Docking section -->
<div class="ad-dock">
<div>
<div class="stl" style="display:flex;align-items:center;justify-content:space-between">
<span>Docking Target</span>
<button class="b sm ac" type="button" onclick="openDockModal('dock_to_target')">+ Target</button>
</div>
<div class="ad-dock-box">
{% if asset_summary.docking_target %}
<div class="ad-dock-item">
<span>
<a href="{{ url_for('web.asset_detail', asset_id=asset_summary.docking_target.id
) }}" style="color:var(--accent)">{{ asset_summary.docking_target.name }}</a>
<span class="mt" style="margin-left:6px">{{ asset_summary.docking_target.current_location }}</span>
<span class="mt" style="margin-left:6px;color:var(--muted);font-size:.66rem">since {{ asset_summary.docking_target.docked_at }}</span>
</span>
<a class="ad-undock" onclick="openUndockModal('{{ asset_summary.docking_target.event_id }}', '{{ asset_summary.docking_target.undocked_at or '' }}')">&times; Remove</a>
</div>
{% else %}
<div style="padding:6px 10px;color:var(--mut);font-size:.72rem">Not docked to any asset</div>
{% endif %}
</div>
</div>
<div>
<div class="stl" style="display:flex;align-items:center;justify-content:space-between">
<span>Docked Vehicles</span>
<button class="b sm ac" type="button" onclick="openDockModal('dock_vehicle')">+ Dock</button>
</div>
<div class="ad-dock-box">
{% if asset_summary.docked_vehicles %}
{% for v in asset_summary.docked_vehicles %}
<div class="ad-dock-item">
<span>
{% if v.id %}
<a href="{{ url_for('web.asset_detail', asset_id=v.id
) }}" style="color:var(--accent)">{{ v.name }}</a>
{% else %}
<strong style="color:var(--txt)">{{ v.name }}</strong>
{% endif %}
<span class="mt">since {{ v.docked_at }}</span>
</span>
<a class="ad-undock" onclick="openUndockModal('{{ v.event_id }}', '{{ v.undocked_at or '' }}')">&times; Remove</a>
</div>
{% endfor %}
{% else %}
<div style="padding:6px 10px;color:var(--mut);font-size:.72rem">No vehicles docked</div>
{% endif %}
</div>
</div>
</div>
<aside class="mission-detail-side">
<section class="panel mission-current-state-panel">
<div class="panel-heading">
<p class="eyebrow">当前状态</p>
<h2>当前派生状态</h2>
</div>
<div class="mission-meta-grid">
<div class="mission-meta-item">
<dt>Status</dt>
<dd>{{ asset_summary.current_state }}</dd>
</div>
<div class="mission-meta-item">
<dt>Location</dt>
<dd>{{ asset_summary.current_location }}</dd>
</div>
<div class="mission-meta-item field-span-two">
<dt>Mission</dt>
<dd>{{ asset_summary.current_mission }}</dd>
</div>
</div>
</section>
<!-- Log entries -->
<div class="stl" style="display:flex;align-items:center;justify-content:space-between">
<span>Log Entries</span>
<div style="display:flex;gap:4px">
<button class="b sm ac" onclick="openNewLog('state')">+ State Interval</button>
<button class="b sm" onclick="openNewLog('event')">+ Event Point</button>
</div>
</div>
<div class="fb">
<input type="text" id="ad-lq" placeholder="Search logs...">
<select id="ad-lf">
<option value="">All Types</option>
<option value="state">State Interval</option>
<option value="event">Event Point</option>
</select>
</div>
<table>
<thead><tr><th>Date</th><th>Type</th><th>Title</th><th>Mission Label</th><th>Action</th></tr></thead>
<tbody>
{% for entry in entries %}
<tr data-log-row data-log-type="{{ entry.mode }}" data-log-text="{{ (entry.title ~ ' ' ~ entry.summary ~ ' ' ~ entry.mission_label)|lower|e }}">
<td class="mt">{{ entry.timestamp }}{% if entry.end_timestamp %} → {{ entry.end_timestamp }}{% endif %}</td>
<td><span style="font-size:.64rem;padding:2px 5px;border-radius:3px;background:{{ 'rgba(59,130,246,.1);color:var(--accent)' if entry.mode == 'state' else 'rgba(234,179,8,.08);color:var(--amber)' }}">{{ entry.mode_label }}</span></td>
<td>{{ entry.title }}{% for node in entry.state_nodes %}<div class="mt" style="font-size:.62rem">→ {{ node.title }} ({{ node.at }}{% if node.detail %} · {{ node.detail }}{% endif %})</div>{% endfor %}</td>
<td class="mt">{{ entry.mission_label }}</td>
<td><div class="act-btn">
<a href="{{ entry.edit_url }}">Edit</a>
<form method="post" action="{{ url_for('web.asset_entry_delete', asset_id=asset.id, entry_id=entry.source_entry_id) }}" style="display:inline" onsubmit="return confirm('Delete?')">
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
<button class="danger-link" type="submit" style="font-size:.66rem">Del</button>
</form>
</div></td>
</tr>
{% endfor %}
</tbody>
</table>
{% if entry_total_pages > 1 %}
<div class="pg-bar">
{% if entry_has_prev %}<a href="{{ entry_prev_url }}">← Prev</a>{% endif %}
{% for link in entry_page_links %}
{% if link.is_current %}<span class="cur">{{ link.page }}</span>
{% elif link.is_ellipsis %}<span>...</span>
{% else %}<a href="{{ link.url }}">{{ link.page }}</a>{% endif %}
{% endfor %}
{% if entry_has_next %}<a href="{{ entry_next_url }}">Next →</a>{% endif %}
</div>
{% endif %}
</div>
{% include "_mission_upcoming_events.html" %}
<div>
<div class="stl">Upcoming Events</div>
<div class="evl" style="margin-bottom:14px">
{% for ev in future_events %}
<div class="evr"><span class="ed">{{ ev.date }}</span>{{ ev.title }}</div>
{% endfor %}
</div>
<div class="stl">Quick Info</div>
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px;font-size:.72rem;color:var(--mut)">
<div style="margin-bottom:4px"><strong style="color:var(--txt)">Retired:</strong> {{ 'Yes' if asset.is_retired else 'No' }}</div>
<div style="margin-bottom:4px"><strong style="color:var(--txt)">Program:</strong> {{ asset.program or '—' }}</div>
<div><strong style="color:var(--txt)">Home Region:</strong> {{ asset.home_region or '—' }}</div>
</div>
</div>
</div>
<section class="panel mission-entry-actions-panel">
<div class="panel-heading">
<p class="eyebrow">操作</p>
<h2>日志操作</h2>
</div>
<div class="mission-action-stack">
<a class="primary-button" href="{{ url_for('web.asset_entry_new', asset_id=asset.id, kind='event', sim_time=simulation_time_input) }}">新增事件点</a>
<a class="primary-button" href="{{ url_for('web.asset_entry_new', asset_id=asset.id, kind='state', sim_time=simulation_time_input) }}">新增状态区间</a>
<a class="secondary-button" href="{{ url_for('web.asset_edit', asset_id=asset.id, sim_time=simulation_time_input) }}">编辑资产</a>
<a class="secondary-button" href="{{ url_for('web.asset_list', sim_time=simulation_time_input) }}">返回任务资产</a>
</div>
</section>
</aside>
</section>
{% endblock %}
<!-- ═══ DOCK MODAL ═══ -->
<div class="modal-bg" id="dock-modal">
<div class="modal">
<h2 id="dock-modal-title">Dock Vehicle</h2>
<div class="fg">
<input type="hidden" id="dock-mode" value="dock_vehicle">
<label><span id="dock-select-label">Vehicle</span><select id="dock-vehicle">
<option value="">Select existing asset...</option>
{% for a in all_assets %}{% if a.id != asset.id %}
<option value="{{ a.id }}">{{ a.name }}</option>
{% endif %}{% endfor %}
<option value="_custom">Custom name...</option>
</select></label>
<input type="text" id="dock-custom" placeholder="Enter vehicle name..." style="display:none">
<label>Dock Time<input type="datetime-local" id="dock-time" value="{{ simulation_time_input }}"></label>
<label>Note<textarea id="dock-note" rows="2" placeholder="Optional"></textarea></label>
</div>
<div class="btn-row">
<button class="b" onclick="closeModal('dock-modal')">Cancel</button>
<button class="b ac" onclick="saveDock()">Dock</button>
</div>
</div>
</div>
<!-- ═══ UNDOCK MODAL ═══ -->
<div class="modal-bg" id="undock-modal">
<div class="modal">
<h2>Remove Docking</h2>
<div class="fg">
<input type="hidden" id="undock-event-id" value="">
<input type="hidden" id="undock-has-future" value="">
<div id="undock-future-warn" style="display:none;padding:8px 12px;margin-bottom:10px;border-radius:4px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.3);color:var(--amber);font-size:.78rem">
This docking already has a scheduled undock at <strong id="undock-future-time"></strong>.
</div>
<div id="undock-normal-form">
<label>Undock Time<input type="datetime-local" id="undock-time" value="{{ simulation_time_input }}"></label>
<label>Note<textarea id="undock-note" rows="2" placeholder="Optional"></textarea></label>
</div>
</div>
<div class="btn-row" id="undock-btns-normal" style="display:none">
<button class="b" onclick="closeModal('undock-modal')">Cancel</button>
<button class="b ac" onclick="saveUndock()">Remove</button>
</div>
<div class="btn-row" id="undock-btns-future" style="display:none">
<button class="b" onclick="closeModal('undock-modal')">Cancel</button>
<button class="b" onclick="deleteFutureUndock()">Delete Future Undock</button>
<button class="b ac" onclick="editFutureUndock()">Edit</button>
</div>
</div>
</div>
<!-- ═══ LOG MODAL ═══ -->
<div class="modal-bg" id="log-modal">
<div class="modal" style="width:440px;max-height:80vh;overflow-y:auto">
<h2 id="log-modal-title">New Log Entry</h2>
<div class="fg">
<input type="hidden" id="log-type" value="state">
<label>Title<input type="text" id="log-title" placeholder="Entry title"></label>
<label id="log-start-wrap">Start At<input type="datetime-local" id="log-start" step="60"></label>
<label id="log-end-wrap">End At<input type="datetime-local" id="log-end" step="60"></label>
<div id="log-state-wrap">
<label>State<select id="log-state">
<option value=""></option>
<option>Transfer</option><option>Exploration</option><option>Maintenance</option>
<option>Docked</option><option>In Transit</option>
</select></label>
</div>
<label>Location<input type="text" id="log-location" placeholder="LEO / Mars Orbit / etc."></label>
<label>Summary<textarea id="log-summary" rows="2" placeholder="Optional"></textarea></label>
<div id="log-nodes-wrap">
<hr style="border:none;border-top:1px solid var(--border);margin:6px 0">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<span style="font-size:.7rem;color:var(--mut)">State Nodes (0-N)</span>
<button class="b sm" type="button" onclick="addNodeRow()" style="font-size:.62rem">+ Add Node</button>
</div>
<div id="nodes-list"></div>
</div>
</div>
<div class="btn-row">
<button class="b" onclick="closeModal('log-modal')">Cancel</button>
<button class="b ac" onclick="saveLog()">Save</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const ASSET_ID = '{{ asset.id }}';
const SIM_TIME = '{{ simulation_time_input }}';
// === DOCKING ===
function openDockModal(mode='dock_vehicle') {
document.getElementById('dock-mode').value = mode;
document.getElementById('dock-modal-title').textContent = mode === 'dock_to_target' ? 'Dock To Target' : 'Dock Vehicle';
document.getElementById('dock-select-label').textContent = mode === 'dock_to_target' ? 'Target Asset' : 'Vehicle';
document.getElementById('dock-vehicle').value = '';
document.getElementById('dock-custom').style.display = 'none';
document.querySelector('#dock-vehicle option[value="_custom"]').style.display = mode === 'dock_to_target' ? 'none' : '';
document.getElementById('dock-note').value = '';
document.getElementById('dock-time').value = SIM_TIME;
openModal('dock-modal');
}
document.getElementById('dock-vehicle').addEventListener('change', function() {
document.getElementById('dock-custom').style.display = this.value === '_custom' && document.getElementById('dock-mode').value !== 'dock_to_target' ? 'block' : 'none';
});
async function saveDock() {
const mode = document.getElementById('dock-mode').value;
const vehicleId = document.getElementById('dock-vehicle').value;
const customName = document.getElementById('dock-custom').value;
const note = document.getElementById('dock-note').value;
const dockTime = document.getElementById('dock-time').value;
if (!vehicleId) return;
const name = vehicleId === '_custom' ? customName : document.getElementById('dock-vehicle').selectedOptions[0]?.text;
if (!name) return;
const resp = await fetch(`/api/v1/assets/${ASSET_ID}/dock`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
mode: mode,
vehicle_id: mode === 'dock_vehicle' && vehicleId !== '_custom' ? vehicleId : null,
target_asset_id: mode === 'dock_to_target' ? vehicleId : null,
vehicle_name: name,
note: note,
dock_time: dockTime,
sim_time: SIM_TIME
})
});
if (resp.ok) { closeModal('dock-modal'); location.reload(); }
}
// === UNDOCKING ===
function openUndockModal(eventId, futureUndockAt) {
document.getElementById('undock-event-id').value = eventId;
document.getElementById('undock-time').value = SIM_TIME;
document.getElementById('undock-note').value = '';
const hasFuture = futureUndockAt && futureUndockAt.trim() !== '';
document.getElementById('undock-has-future').value = hasFuture ? futureUndockAt : '';
if (hasFuture) {
document.getElementById('undock-future-time').textContent = futureUndockAt;
document.getElementById('undock-future-warn').style.display = 'block';
document.getElementById('undock-normal-form').style.display = 'none';
document.getElementById('undock-btns-normal').style.display = 'none';
document.getElementById('undock-btns-future').style.display = 'flex';
} else {
document.getElementById('undock-future-warn').style.display = 'none';
document.getElementById('undock-normal-form').style.display = 'block';
document.getElementById('undock-btns-normal').style.display = 'flex';
document.getElementById('undock-btns-future').style.display = 'none';
}
openModal('undock-modal');
}
async function saveUndock() {
const eventId = document.getElementById('undock-event-id').value;
const undockTime = document.getElementById('undock-time').value;
const note = document.getElementById('undock-note').value;
const hasFuture = document.getElementById('undock-has-future').value;
const resp = await fetch(`/api/v1/assets/${ASSET_ID}/undock`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
event_id: eventId,
action: hasFuture ? 'edit_future' : 'undock',
undock_time: undockTime,
note: note,
sim_time: SIM_TIME
})
});
if (resp.ok) { closeModal('undock-modal'); location.reload(); }
}
async function deleteFutureUndock() {
const eventId = document.getElementById('undock-event-id').value;
const resp = await fetch(`/api/v1/assets/${ASSET_ID}/undock`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
event_id: eventId,
action: 'delete_future',
sim_time: SIM_TIME
})
});
if (resp.ok) { closeModal('undock-modal'); location.reload(); }
}
function editFutureUndock() {
const futureTime = document.getElementById('undock-has-future').value;
document.getElementById('undock-future-warn').style.display = 'none';
document.getElementById('undock-normal-form').style.display = 'block';
document.getElementById('undock-time').value = futureTime;
document.getElementById('undock-btns-future').style.display = 'none';
document.getElementById('undock-btns-normal').style.display = 'flex';
}
// === LOG MODAL (multi-node) ===
let logNodeData = [], editLogId = null;
function escapeAttr(value) {
return String(value || '').replace(/[&<>"']/g, (ch) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
}
function addNodeRow(title='', at='', detail='', stateLabel='', previousLocation='', transitLocation='', targetLocation='') {
logNodeData.push({title, at, detail, stateLabel, previousLocation, transitLocation, targetLocation});
renderNodeRows();
}
function removeNodeRow(i) { logNodeData.splice(i, 1); renderNodeRows(); }
function renderNodeRows() {
document.getElementById('nodes-list').innerHTML = logNodeData.length ? logNodeData.map((n, i) =>
`<div style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:6px 8px;margin-bottom:4px">
<div style="display:flex;gap:6px;align-items:center">
<input type="text" value="${escapeAttr(n.title)}" placeholder="Node title" oninput="logNodeData[${i}].title=this.value" style="flex:1;padding:3px 6px;border-radius:3px;background:var(--bg);color:var(--text);border:1px solid var(--border);font-size:.7rem">
<input type="datetime-local" value="${escapeAttr(n.at)}" oninput="logNodeData[${i}].at=this.value" step="60" style="width:140px;padding:3px 6px;border-radius:3px;background:var(--bg);color:var(--text);border:1px solid var(--border);font-size:.7rem">
<button class="b sm" type="button" onclick="removeNodeRow(${i})" style="color:var(--red);font-size:.6rem">✕</button>
</div>
<input type="text" value="${escapeAttr(n.detail)}" placeholder="Detail (optional)" oninput="logNodeData[${i}].detail=this.value" style="width:100%;padding:3px 6px;border-radius:3px;background:var(--bg);color:var(--text);border:1px solid var(--border);font-size:.7rem;margin-top:3px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-top:4px">
<input type="text" value="${escapeAttr(n.stateLabel)}" placeholder="State" oninput="logNodeData[${i}].stateLabel=this.value" style="padding:3px 6px;border-radius:3px;background:var(--bg);color:var(--text);border:1px solid var(--border);font-size:.7rem">
<input type="text" value="${escapeAttr(n.targetLocation)}" placeholder="Target location" oninput="logNodeData[${i}].targetLocation=this.value" style="padding:3px 6px;border-radius:3px;background:var(--bg);color:var(--text);border:1px solid var(--border);font-size:.7rem">
<input type="text" value="${escapeAttr(n.previousLocation)}" placeholder="Previous location" oninput="logNodeData[${i}].previousLocation=this.value" style="padding:3px 6px;border-radius:3px;background:var(--bg);color:var(--text);border:1px solid var(--border);font-size:.7rem">
<input type="text" value="${escapeAttr(n.transitLocation)}" placeholder="Transit location" oninput="logNodeData[${i}].transitLocation=this.value" style="padding:3px 6px;border-radius:3px;background:var(--bg);color:var(--text);border:1px solid var(--border);font-size:.7rem">
</div>
</div>`
).join('') : '<div style="font-size:.68rem;color:var(--mut);padding:4px 0">No nodes. Click "+ Add Node" to add.</div>';
}
function openNewLog(type) {
editLogId = null; logNodeData = [];
document.getElementById('log-type').value = type;
document.getElementById('log-title').value = '';
document.getElementById('log-start').value = SIM_TIME;
document.getElementById('log-end').value = '';
document.getElementById('log-state').value = '';
document.getElementById('log-location').value = '';
document.getElementById('log-summary').value = '';
document.getElementById('log-start-wrap').style.display = 'block';
document.getElementById('log-end-wrap').style.display = type === 'state' ? 'block' : 'none';
document.getElementById('log-state-wrap').style.display = type === 'state' ? 'block' : 'none';
document.getElementById('log-nodes-wrap').style.display = type === 'state' ? 'block' : 'none';
renderNodeRows();
document.getElementById('log-modal-title').textContent = 'New Log Entry';
openModal('log-modal');
}
async function saveLog() {
const type = document.getElementById('log-type').value;
const title = document.getElementById('log-title').value;
if (!title) { alert('Title required'); return; }
const stateLabel = document.getElementById('log-state').value || null;
const targetLocation = document.getElementById('log-location').value || null;
const enteredNodes = logNodeData.filter(n => n.title).map(n => ({
title: n.title,
at_time: n.at || null,
detail: n.detail || null,
state_label: n.stateLabel || stateLabel,
previous_location: n.previousLocation || null,
transit_location: n.transitLocation || null,
target_location: n.targetLocation || targetLocation
}));
const stateNodes = type === 'state' && enteredNodes.length === 0 && (stateLabel || targetLocation)
? [{title: title, at_time: document.getElementById('log-start').value || SIM_TIME, detail: null, state_label: stateLabel, target_location: targetLocation}]
: enteredNodes;
const data = {
entry_kind: type, title: title,
start_at: document.getElementById('log-start').value || null,
end_at: type === 'state' ? (document.getElementById('log-end').value || null) : null,
state_label: type === 'state' ? stateLabel : null,
location: targetLocation,
summary: document.getElementById('log-summary').value || null,
sim_time: SIM_TIME,
state_nodes: stateNodes
};
const url = editLogId ? `/assets/${ASSET_ID}/entries/${editLogId}/edit` : `/assets/${ASSET_ID}/entries/new`;
const resp = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (resp.ok) { closeModal('log-modal'); location.reload(); }
else { const err = await resp.json(); alert(err.error || 'Save failed'); }
}
function filterAssetLogs() {
const query = (document.getElementById('ad-lq')?.value || '').trim().toLowerCase();
const type = document.getElementById('ad-lf')?.value || '';
document.querySelectorAll('[data-log-row]').forEach((row) => {
const matchesType = !type || row.dataset.logType === type;
const matchesQuery = !query || (row.dataset.logText || '').includes(query);
row.style.display = matchesType && matchesQuery ? '' : 'none';
});
}
document.getElementById('ad-lq')?.addEventListener('input', filterAssetLogs);
document.getElementById('ad-lf')?.addEventListener('change', filterAssetLogs);
</script>
{% endblock %}