Force clean rebuild (recover from BUILD_ERROR)
Browse files- templates//admin.html +219 -0
- templates//analysis.html +608 -0
- templates//batch.html +193 -0
- templates//combined_analysis.html +418 -0
- templates//dashboard.html +141 -0
- templates//dsa_analysis.html +382 -0
- templates//dsa_batch.html +179 -0
- templates//dsa_history.html +110 -0
- templates//dsa_index.html +620 -0
- templates//dsa_patient.html +434 -0
- templates//history.html +178 -0
- templates//index.html +927 -0
- templates//login.html +63 -0
- templates//patient.html +486 -0
- templates//register.html +50 -0
templates//admin.html
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>後台管理</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div class="container py-4" style="max-width: 1000px;">
|
| 11 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 12 |
+
<h2 class="mb-0">後台管理</h2>
|
| 13 |
+
<div>
|
| 14 |
+
<a href="/" class="btn btn-dark btn-sm me-1">首頁</a>
|
| 15 |
+
<a href="/logout" class="btn btn-outline-secondary btn-sm">登出</a>
|
| 16 |
+
</div>
|
| 17 |
+
</div>
|
| 18 |
+
|
| 19 |
+
<h5>使用者管理</h5>
|
| 20 |
+
<table class="table table-bordered bg-white" style="font-size:.9rem;">
|
| 21 |
+
<thead class="table-light">
|
| 22 |
+
<tr>
|
| 23 |
+
<th>ID</th>
|
| 24 |
+
<th>姓名</th>
|
| 25 |
+
<th>Email</th>
|
| 26 |
+
<th>帳號</th>
|
| 27 |
+
<th>密碼</th>
|
| 28 |
+
<th>權限</th>
|
| 29 |
+
<th>註冊時間</th>
|
| 30 |
+
<th></th>
|
| 31 |
+
</tr>
|
| 32 |
+
</thead>
|
| 33 |
+
<tbody>
|
| 34 |
+
{% for u in users %}
|
| 35 |
+
<tr id="user-{{ u.id }}">
|
| 36 |
+
<td>{{ u.id }}</td>
|
| 37 |
+
<td id="name-{{ u.id }}">{{ u.display_name or '' }}</td>
|
| 38 |
+
<td id="email-{{ u.id }}">{{ u.username }}</td>
|
| 39 |
+
<td>{{ u.username }}</td>
|
| 40 |
+
<td>{{ u.password_plain or '***' }}</td>
|
| 41 |
+
<td>
|
| 42 |
+
<select class="form-select form-select-sm" style="width:120px;" id="role-{{ u.id }}">
|
| 43 |
+
<option value="user" {{ 'selected' if u.role == 'user' else '' }}>一般使用者</option>
|
| 44 |
+
<option value="admin" {{ 'selected' if u.role == 'admin' else '' }}>管理員</option>
|
| 45 |
+
</select>
|
| 46 |
+
</td>
|
| 47 |
+
<td>{{ u.created_at or '-' }}</td>
|
| 48 |
+
<td class="text-nowrap">
|
| 49 |
+
<button class="btn btn-sm btn-outline-primary me-1" onclick="editUser({{ u.id }})">Edit</button>
|
| 50 |
+
{% if u.username != username %}
|
| 51 |
+
<button class="btn btn-sm btn-outline-danger" onclick="deleteUser({{ u.id }}, '{{ u.username }}')">Del</button>
|
| 52 |
+
{% endif %}
|
| 53 |
+
</td>
|
| 54 |
+
</tr>
|
| 55 |
+
{% endfor %}
|
| 56 |
+
</tbody>
|
| 57 |
+
</table>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<!-- ============ Storage ============ -->
|
| 61 |
+
{% set pra_uploads = uploads | selectattr('source', 'equalto', 'PRA') | list %}
|
| 62 |
+
{% set dsa_uploads = uploads | selectattr('source', 'equalto', 'DSA') | list %}
|
| 63 |
+
{% set pra_size = (pra_uploads | sum(attribute='size_kb') / 1024) | round(2) %}
|
| 64 |
+
{% set dsa_size = (dsa_uploads | sum(attribute='size_kb') / 1024) | round(2) %}
|
| 65 |
+
<div class="d-flex justify-content-between align-items-center mt-4 mb-2">
|
| 66 |
+
<h5 class="mb-0">檔案儲存空間
|
| 67 |
+
<small class="text-muted">總計 {{ storage.count }} 個檔案,{{ storage.size_mb }} MB</small>
|
| 68 |
+
</h5>
|
| 69 |
+
<div>
|
| 70 |
+
<a href="/admin/download_all_uploads" class="btn btn-sm btn-outline-success me-1">下載全部(PRA/DSA 分資料夾)</a>
|
| 71 |
+
<button class="btn btn-sm btn-outline-danger" onclick="cleanupUploads()">清理舊檔案</button>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
<div class="row g-3 mb-4">
|
| 75 |
+
{% for grp in [
|
| 76 |
+
{'label': 'PRA', 'badge': 'bg-primary', 'list': pra_uploads, 'size': pra_size, 'idprefix': 'pra'},
|
| 77 |
+
{'label': 'DSA', 'badge': 'bg-danger', 'list': dsa_uploads, 'size': dsa_size, 'idprefix': 'dsa'}
|
| 78 |
+
] %}
|
| 79 |
+
<div class="col-md-6">
|
| 80 |
+
<div class="card h-100">
|
| 81 |
+
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
| 82 |
+
<div>
|
| 83 |
+
<span class="badge {{ grp.badge }} me-1">{{ grp.label }}</span>
|
| 84 |
+
<strong>{{ grp.list | length }}</strong> 個檔案,共 <strong>{{ grp.size }} MB</strong>
|
| 85 |
+
</div>
|
| 86 |
+
{% if grp.list %}
|
| 87 |
+
<a href="/admin/download_all_uploads?source={{ grp.label }}" class="btn btn-sm btn-outline-primary">下載 {{ grp.label }} 全部</a>
|
| 88 |
+
{% endif %}
|
| 89 |
+
</div>
|
| 90 |
+
<div class="card-body p-2">
|
| 91 |
+
{% if grp.list %}
|
| 92 |
+
<div style="max-height: 360px; overflow-y: auto;">
|
| 93 |
+
<table class="table table-sm table-bordered mb-0" style="font-size:.82rem;">
|
| 94 |
+
<thead class="table-light" style="position:sticky; top:0;">
|
| 95 |
+
<tr><th>檔名</th><th style="width:80px;">大小</th><th style="width:130px;">上傳時間</th><th style="width:120px;">操作</th></tr>
|
| 96 |
+
</thead>
|
| 97 |
+
<tbody>
|
| 98 |
+
{% for u in grp.list %}
|
| 99 |
+
<tr id="upload-row-{{ grp.idprefix }}-{{ loop.index }}">
|
| 100 |
+
<td class="small">{{ u.name }}</td>
|
| 101 |
+
<td class="text-end">{{ u.size_kb }} KB</td>
|
| 102 |
+
<td class="small">{{ u.mtime }}</td>
|
| 103 |
+
<td class="text-nowrap">
|
| 104 |
+
<a href="/download_upload/{{ u.name }}" class="btn btn-sm btn-outline-primary py-0 px-2">下載</a>
|
| 105 |
+
<button class="btn btn-sm btn-outline-danger py-0 px-2" onclick="deleteUpload('{{ u.name }}', '{{ grp.idprefix }}-{{ loop.index }}')">刪除</button>
|
| 106 |
+
</td>
|
| 107 |
+
</tr>
|
| 108 |
+
{% endfor %}
|
| 109 |
+
</tbody>
|
| 110 |
+
</table>
|
| 111 |
+
</div>
|
| 112 |
+
{% else %}
|
| 113 |
+
<div class="text-muted small">無 {{ grp.label }} 上傳檔案</div>
|
| 114 |
+
{% endif %}
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
{% endfor %}
|
| 119 |
+
</div>
|
| 120 |
+
<script>
|
| 121 |
+
function deleteUpload(name, rowKey) {
|
| 122 |
+
if (!confirm('確定刪除 ' + name + '?')) return;
|
| 123 |
+
fetch('/admin/delete_upload', {
|
| 124 |
+
method: 'POST',
|
| 125 |
+
headers: {'Content-Type': 'application/json'},
|
| 126 |
+
body: JSON.stringify({ filename: name })
|
| 127 |
+
}).then(r => r.json()).then(d => {
|
| 128 |
+
if (d.ok) {
|
| 129 |
+
const row = document.getElementById('upload-row-' + rowKey);
|
| 130 |
+
if (row) row.remove();
|
| 131 |
+
} else {
|
| 132 |
+
alert('刪除失敗:' + (d.error || ''));
|
| 133 |
+
}
|
| 134 |
+
});
|
| 135 |
+
}
|
| 136 |
+
</script>
|
| 137 |
+
|
| 138 |
+
<!-- Edit Modal -->
|
| 139 |
+
<div class="modal fade" id="editModal" tabindex="-1">
|
| 140 |
+
<div class="modal-dialog modal-sm">
|
| 141 |
+
<div class="modal-content">
|
| 142 |
+
<div class="modal-header">
|
| 143 |
+
<h6 class="modal-title">編輯使用者</h6>
|
| 144 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
| 145 |
+
</div>
|
| 146 |
+
<div class="modal-body">
|
| 147 |
+
<input type="hidden" id="editId">
|
| 148 |
+
<div class="mb-2">
|
| 149 |
+
<label class="form-label small">姓名</label>
|
| 150 |
+
<input type="text" class="form-control form-control-sm" id="editName">
|
| 151 |
+
</div>
|
| 152 |
+
<div class="mb-2">
|
| 153 |
+
<label class="form-label small">Email / 帳號</label>
|
| 154 |
+
<input type="text" class="form-control form-control-sm" id="editEmail">
|
| 155 |
+
</div>
|
| 156 |
+
<div class="mb-2">
|
| 157 |
+
<label class="form-label small">新密碼(空白=不修改)</label>
|
| 158 |
+
<input type="text" class="form-control form-control-sm" id="editPassword" placeholder="不修改請留空">
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
<div class="modal-footer">
|
| 162 |
+
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">取消</button>
|
| 163 |
+
<button type="button" class="btn btn-sm btn-primary" onclick="saveUser()">儲存</button>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 170 |
+
<script>
|
| 171 |
+
function cleanupUploads() {
|
| 172 |
+
const months = prompt('清理幾個月前的檔案?', '6');
|
| 173 |
+
if (!months) return;
|
| 174 |
+
fetch('/admin/cleanup_uploads', {
|
| 175 |
+
method: 'POST',
|
| 176 |
+
headers: {'Content-Type': 'application/json'},
|
| 177 |
+
body: JSON.stringify({ months: parseInt(months) })
|
| 178 |
+
})
|
| 179 |
+
.then(r => r.json())
|
| 180 |
+
.then(d => {
|
| 181 |
+
if (d.ok) { alert('已刪除 ' + d.deleted + ' 個檔案'); location.reload(); }
|
| 182 |
+
else alert('失敗: ' + (d.error || ''));
|
| 183 |
+
});
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
function deleteUser(id, name) {
|
| 187 |
+
if (!confirm('確定刪除帳號 ' + name + ' ?')) return;
|
| 188 |
+
fetch('/admin/delete_user/' + id, { method: 'POST' })
|
| 189 |
+
.then(r => r.json())
|
| 190 |
+
.then(d => { if (d.ok) document.getElementById('user-' + id).remove(); else alert('刪除失敗'); });
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
function editUser(id) {
|
| 194 |
+
document.getElementById('editId').value = id;
|
| 195 |
+
document.getElementById('editName').value = document.getElementById('name-' + id).textContent.trim();
|
| 196 |
+
document.getElementById('editEmail').value = document.getElementById('email-' + id).textContent.trim();
|
| 197 |
+
document.getElementById('editPassword').value = '';
|
| 198 |
+
new bootstrap.Modal(document.getElementById('editModal')).show();
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function saveUser() {
|
| 202 |
+
const id = document.getElementById('editId').value;
|
| 203 |
+
const role = document.getElementById('role-' + id)?.value || 'user';
|
| 204 |
+
fetch('/admin/update_user/' + id, {
|
| 205 |
+
method: 'POST',
|
| 206 |
+
headers: {'Content-Type': 'application/json'},
|
| 207 |
+
body: JSON.stringify({
|
| 208 |
+
display_name: document.getElementById('editName').value.trim(),
|
| 209 |
+
username: document.getElementById('editEmail').value.trim(),
|
| 210 |
+
password: document.getElementById('editPassword').value,
|
| 211 |
+
role: role
|
| 212 |
+
})
|
| 213 |
+
})
|
| 214 |
+
.then(r => r.json())
|
| 215 |
+
.then(d => { if (d.ok) location.reload(); else alert('更新失敗: ' + (d.error || '')); });
|
| 216 |
+
}
|
| 217 |
+
</script>
|
| 218 |
+
</body>
|
| 219 |
+
</html>
|
templates//analysis.html
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>PRA 統計分析</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
| 9 |
+
<style>
|
| 10 |
+
.chart-container { position: relative; height: 640px; background: #fff; border: 1px solid #dee2e6; border-radius: 6px; padding: 1rem; margin-bottom: 1.5rem; }
|
| 11 |
+
.ab-table { font-size: .82rem; }
|
| 12 |
+
.mfi-up { color: #dc3545; }
|
| 13 |
+
.mfi-down { color: #198754; }
|
| 14 |
+
.mfi-new { color: #0d6efd; font-weight: bold; }
|
| 15 |
+
.mfi-gone { color: #000; font-weight: bold; }
|
| 16 |
+
</style>
|
| 17 |
+
</head>
|
| 18 |
+
<body>
|
| 19 |
+
<div class="container py-4" style="max-width: 1200px;">
|
| 20 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 21 |
+
<div>
|
| 22 |
+
<h2 class="mb-0">PRA 統計分析</h2>
|
| 23 |
+
</div>
|
| 24 |
+
<div>
|
| 25 |
+
<a href="/" class="btn btn-dark btn-sm me-1">首頁</a>
|
| 26 |
+
<a href="/history" class="btn btn-outline-secondary btn-sm me-1">歷史紀錄</a>
|
| 27 |
+
{% if chart_no and patient %}
|
| 28 |
+
<a href="/history/{{ chart_no }}" class="btn btn-outline-info btn-sm me-1">此病患歷史</a>
|
| 29 |
+
{% endif %}
|
| 30 |
+
<a href="/new" class="btn btn-outline-primary btn-sm">新增資料</a>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<!-- Patient selector -->
|
| 35 |
+
<form class="row g-2 mb-4" method="get" action="/analysis">
|
| 36 |
+
<div class="col-sm-4">
|
| 37 |
+
<select class="form-select" id="patientSelect" onchange="selectPatient(this.value)">
|
| 38 |
+
<option value="">-- 選擇病人 --</option>
|
| 39 |
+
{% for p in patients %}
|
| 40 |
+
<option value="{{ p.chart_no }}" {{ 'selected' if p.chart_no == chart_no else '' }}>
|
| 41 |
+
{{ p.patient_name }} ({{ p.chart_no }})
|
| 42 |
+
</option>
|
| 43 |
+
{% endfor %}
|
| 44 |
+
</select>
|
| 45 |
+
</div>
|
| 46 |
+
<div class="col-sm-5">
|
| 47 |
+
<input type="text" class="form-control" id="patientSearch" list="patientList" autocomplete="off"
|
| 48 |
+
onchange="selectPatient(this.value)">
|
| 49 |
+
<datalist id="patientList">
|
| 50 |
+
{% for p in patients %}
|
| 51 |
+
<option value="{{ p.chart_no }}" label="{{ p.patient_name }} ({{ p.chart_no }})">
|
| 52 |
+
{% endfor %}
|
| 53 |
+
</datalist>
|
| 54 |
+
<input type="hidden" name="chart_no" id="chartNoInput" value="{{ chart_no }}">
|
| 55 |
+
</div>
|
| 56 |
+
</form>
|
| 57 |
+
<script>
|
| 58 |
+
function selectPatient(val) {
|
| 59 |
+
if (!val) return;
|
| 60 |
+
document.getElementById('chartNoInput').value = val;
|
| 61 |
+
document.getElementById('chartNoInput').form.submit();
|
| 62 |
+
}
|
| 63 |
+
{% if chart_no %}
|
| 64 |
+
document.getElementById('patientSearch').value = '{{ chart_no }}';
|
| 65 |
+
{% endif %}
|
| 66 |
+
</script>
|
| 67 |
+
|
| 68 |
+
{% if patient %}
|
| 69 |
+
<!-- Donor HLA Input -->
|
| 70 |
+
<div class="card mb-3">
|
| 71 |
+
<div class="card-header fw-bold">Donor HLA</div>
|
| 72 |
+
<div class="card-body" style="font-size:.85rem;">
|
| 73 |
+
<style>.donor-hla { width: 65px !important; padding: 2px 4px !important; text-align: center; }</style>
|
| 74 |
+
<div class="row">
|
| 75 |
+
<!-- Serology -->
|
| 76 |
+
<div class="col-md-5">
|
| 77 |
+
<div class="fw-bold mb-1">Serology</div>
|
| 78 |
+
<table class="table table-sm table-bordered mb-1">
|
| 79 |
+
<tbody>
|
| 80 |
+
<tr><td class="fw-bold">A</td>
|
| 81 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-1" oninput="highlightDSA()"></td>
|
| 82 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-2" oninput="highlightDSA()"></td>
|
| 83 |
+
<td class="fw-bold">DR</td>
|
| 84 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-1" oninput="highlightDSA()"></td>
|
| 85 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-2" oninput="highlightDSA()"></td></tr>
|
| 86 |
+
<tr><td class="fw-bold">B</td>
|
| 87 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-1" oninput="highlightDSA()"></td>
|
| 88 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-2" oninput="highlightDSA()"></td>
|
| 89 |
+
<td class="fw-bold">DQ</td>
|
| 90 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-1" oninput="highlightDSA()"></td>
|
| 91 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-2" oninput="highlightDSA()"></td></tr>
|
| 92 |
+
<tr><td class="fw-bold">Cw</td>
|
| 93 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-1" oninput="highlightDSA()"></td>
|
| 94 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-2" oninput="highlightDSA()"></td>
|
| 95 |
+
<td class="fw-bold">DP</td>
|
| 96 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-1" oninput="highlightDSA()"></td>
|
| 97 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-2" oninput="highlightDSA()"></td></tr>
|
| 98 |
+
</tbody>
|
| 99 |
+
</table>
|
| 100 |
+
</div>
|
| 101 |
+
<!-- DNA Typing -->
|
| 102 |
+
<div class="col-md-7">
|
| 103 |
+
<div class="fw-bold mb-1">DNA Typing</div>
|
| 104 |
+
<table class="table table-sm table-bordered mb-1">
|
| 105 |
+
<tbody>
|
| 106 |
+
<tr><td class="fw-bold">A*</td>
|
| 107 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-1" oninput="highlightDSA()"></td>
|
| 108 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-2" oninput="highlightDSA()"></td>
|
| 109 |
+
<td class="fw-bold">DRB1*</td>
|
| 110 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-1" oninput="highlightDSA()"></td>
|
| 111 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-2" oninput="highlightDSA()"></td></tr>
|
| 112 |
+
<tr><td class="fw-bold">B*</td>
|
| 113 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-1" oninput="highlightDSA()"></td>
|
| 114 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-2" oninput="highlightDSA()"></td>
|
| 115 |
+
<td class="fw-bold">DQB1*</td>
|
| 116 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-1" oninput="highlightDSA()"></td>
|
| 117 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-2" oninput="highlightDSA()"></td></tr>
|
| 118 |
+
<tr><td class="fw-bold">C*</td>
|
| 119 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-1" oninput="highlightDSA()"></td>
|
| 120 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-2" oninput="highlightDSA()"></td>
|
| 121 |
+
<td class="fw-bold">DQA1*</td>
|
| 122 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-1" oninput="highlightDSA()"></td>
|
| 123 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-2" oninput="highlightDSA()"></td></tr>
|
| 124 |
+
<tr><td></td><td></td><td></td>
|
| 125 |
+
<td class="fw-bold">DPB1*</td>
|
| 126 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-1" oninput="highlightDSA()"></td>
|
| 127 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-2" oninput="highlightDSA()"></td></tr>
|
| 128 |
+
<tr><td></td><td></td><td></td>
|
| 129 |
+
<td class="fw-bold">DPA1*</td>
|
| 130 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-1" oninput="highlightDSA()"></td>
|
| 131 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-2" oninput="highlightDSA()"></td></tr>
|
| 132 |
+
</tbody>
|
| 133 |
+
</table>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
<div class="text-end">
|
| 137 |
+
<button class="btn btn-sm btn-outline-danger" onclick="dsaCheckAndSave()">存檔</button>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
<div class="mb-3">
|
| 142 |
+
<h4>{{ patient.patient_name }} <span class="text-muted fs-6">({{ patient.chart_no }})</span></h4>
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<!-- Reports summary -->
|
| 146 |
+
<table class="table table-bordered table-sm bg-white mb-4" style="font-size:.9rem;">
|
| 147 |
+
<thead class="table-light">
|
| 148 |
+
<tr><th style="width:32px;"><input type="checkbox" id="chk-all" checked onchange="toggleAllReports(this)"></th>
|
| 149 |
+
<th style="width:72px;">順序</th>
|
| 150 |
+
<th>報告日期</th><th>Class</th><th>PRA%</th><th>Overall</th><th>Specificity</th></tr>
|
| 151 |
+
</thead>
|
| 152 |
+
<tbody id="reports-tbody">
|
| 153 |
+
{% for r in reports %}
|
| 154 |
+
<tr data-chart="{{ 'chart1' if r.pra_class == 'PRA Class I' else 'chart2' }}">
|
| 155 |
+
<td><input type="checkbox" class="chk-report" checked
|
| 156 |
+
data-chart="{{ 'chart1' if r.pra_class == 'PRA Class I' else 'chart2' }}"
|
| 157 |
+
data-label="{{ r.chart_label }}" onchange="onReportChk()"></td>
|
| 158 |
+
<td class="text-nowrap">
|
| 159 |
+
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1" onclick="moveRow(this, -1)" title="向上">↑</button>
|
| 160 |
+
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1" onclick="moveRow(this, 1)" title="向下">↓</button>
|
| 161 |
+
</td>
|
| 162 |
+
<td>{{ r.chart_label }}</td>
|
| 163 |
+
<td>{{ r.pra_class }}</td>
|
| 164 |
+
<td class="fw-bold">{{ r.pra_percent }}%</td>
|
| 165 |
+
<td><span class="badge {{ 'bg-danger' if r.overall == 'Positive' else 'bg-success' }}">{{ r.overall }}</span></td>
|
| 166 |
+
<td class="small spec-cell" style="max-width:300px;">{{ r.specificity or '(-)' }}</td>
|
| 167 |
+
</tr>
|
| 168 |
+
{% endfor %}
|
| 169 |
+
</tbody>
|
| 170 |
+
</table>
|
| 171 |
+
<script>
|
| 172 |
+
function toggleAllReports(el) {
|
| 173 |
+
document.querySelectorAll('.chk-report').forEach(c => { c.checked = el.checked; });
|
| 174 |
+
onReportChk();
|
| 175 |
+
}
|
| 176 |
+
function onReportChk() {
|
| 177 |
+
['chart1', 'chart2'].forEach(cid => {
|
| 178 |
+
const fn = window['rebuildChart_' + cid];
|
| 179 |
+
if (fn) fn();
|
| 180 |
+
rebuildCompTable(cid);
|
| 181 |
+
});
|
| 182 |
+
const all = document.querySelectorAll('.chk-report');
|
| 183 |
+
const chkd = document.querySelectorAll('.chk-report:checked');
|
| 184 |
+
const chkAll = document.getElementById('chk-all');
|
| 185 |
+
if (chkAll) chkAll.checked = all.length === chkd.length;
|
| 186 |
+
}
|
| 187 |
+
function rebuildCompTable(chartId) {
|
| 188 |
+
const tbl = document.querySelector('.comp-table[data-chart-id="' + chartId + '"]');
|
| 189 |
+
if (!tbl) return;
|
| 190 |
+
const orderedLabels = (typeof getSelectedLabels === 'function') ? getSelectedLabels(chartId) : [];
|
| 191 |
+
const selected = new Set(orderedLabels);
|
| 192 |
+
// 1. show/hide cells by selection
|
| 193 |
+
tbl.querySelectorAll('[data-date]').forEach(el => {
|
| 194 |
+
el.style.display = selected.has(el.dataset.date) ? '' : 'none';
|
| 195 |
+
});
|
| 196 |
+
// 2. 依 orderedLabels 實際重排每列的日期 cells
|
| 197 |
+
// 用 row.lastElementChild 當 anchor(最後一格是趨勢/空 th)
|
| 198 |
+
const allRows = [...tbl.querySelectorAll('thead tr'), ...tbl.querySelectorAll('tbody tr')];
|
| 199 |
+
allRows.forEach(row => {
|
| 200 |
+
const dateCells = Array.from(row.querySelectorAll('[data-date]'));
|
| 201 |
+
if (!dateCells.length) return;
|
| 202 |
+
const byDate = {};
|
| 203 |
+
dateCells.forEach(c => {
|
| 204 |
+
(byDate[c.dataset.date] = byDate[c.dataset.date] || []).push(c);
|
| 205 |
+
});
|
| 206 |
+
const anchor = row.lastElementChild; // 趨勢欄
|
| 207 |
+
dateCells.forEach(c => c.remove());
|
| 208 |
+
const placed = new Set();
|
| 209 |
+
orderedLabels.forEach(d => {
|
| 210 |
+
(byDate[d] || []).forEach(c => { row.insertBefore(c, anchor); placed.add(c); });
|
| 211 |
+
});
|
| 212 |
+
// 未在 orderedLabels 的(被隱藏)放回尾端保留
|
| 213 |
+
dateCells.forEach(c => { if (!placed.has(c)) row.insertBefore(c, anchor); });
|
| 214 |
+
});
|
| 215 |
+
// 3. 以新 DOM 順序重算 trend
|
| 216 |
+
tbl.querySelectorAll('tbody tr').forEach(tr => {
|
| 217 |
+
const trendTd = tr.querySelector('.trend-cell');
|
| 218 |
+
if (!trendTd) return;
|
| 219 |
+
const visible = Array.from(tr.querySelectorAll('[data-kind="max"]'))
|
| 220 |
+
.filter(td => td.style.display !== 'none');
|
| 221 |
+
const parseMax = td => {
|
| 222 |
+
const v = td.dataset.max;
|
| 223 |
+
return (v === undefined || v === '') ? null : parseInt(v);
|
| 224 |
+
};
|
| 225 |
+
if (visible.length < 2) { trendTd.innerHTML = ''; return; }
|
| 226 |
+
const first = parseMax(visible[0]);
|
| 227 |
+
const last = parseMax(visible[visible.length - 1]);
|
| 228 |
+
if (first !== null && last !== null) {
|
| 229 |
+
const diff = last - first;
|
| 230 |
+
if (diff > 500) trendTd.innerHTML = '<span class="mfi-up">▲ +' + diff + '</span>';
|
| 231 |
+
else if (diff < -500) trendTd.innerHTML = '<span class="mfi-down">▼ ' + diff + '</span>';
|
| 232 |
+
else trendTd.innerHTML = '<span class="text-muted">–</span>';
|
| 233 |
+
} else if (last !== null && first === null) {
|
| 234 |
+
trendTd.innerHTML = '<span class="mfi-new">新增</span>';
|
| 235 |
+
} else if (first !== null && last === null) {
|
| 236 |
+
trendTd.innerHTML = '<span class="mfi-gone">\u2716 消失</span>';
|
| 237 |
+
} else {
|
| 238 |
+
trendTd.innerHTML = '';
|
| 239 |
+
}
|
| 240 |
+
});
|
| 241 |
+
}
|
| 242 |
+
function getSelectedLabels(chartId) {
|
| 243 |
+
// 依 tbody DOM 順序回傳,允許使用者重新排序
|
| 244 |
+
const rows = document.querySelectorAll('#reports-tbody tr[data-chart="' + chartId + '"]');
|
| 245 |
+
const labels = [];
|
| 246 |
+
rows.forEach(tr => {
|
| 247 |
+
const chk = tr.querySelector('.chk-report');
|
| 248 |
+
if (chk && chk.checked) labels.push(chk.dataset.label);
|
| 249 |
+
});
|
| 250 |
+
return labels;
|
| 251 |
+
}
|
| 252 |
+
function moveRow(btn, dir) {
|
| 253 |
+
const tr = btn.closest('tr');
|
| 254 |
+
if (!tr) return;
|
| 255 |
+
const sibling = dir < 0 ? tr.previousElementSibling : tr.nextElementSibling;
|
| 256 |
+
if (!sibling) return;
|
| 257 |
+
if (dir < 0) tr.parentNode.insertBefore(tr, sibling);
|
| 258 |
+
else tr.parentNode.insertBefore(sibling, tr);
|
| 259 |
+
onReportChk();
|
| 260 |
+
}
|
| 261 |
+
</script>
|
| 262 |
+
|
| 263 |
+
<!-- MFI Trend Charts -->
|
| 264 |
+
{% for comp_label, comp_id, comp_dates, comp_antigens, comp_pra in [
|
| 265 |
+
('PRA Class I', 'chart1', comparison_class1.dates if comparison_class1 else [],
|
| 266 |
+
comparison_class1.antigens if comparison_class1 else [],
|
| 267 |
+
comparison_class1.pra_by_date if comparison_class1 else {}),
|
| 268 |
+
('PRA Class II', 'chart2', comparison_class2.dates if comparison_class2 else [],
|
| 269 |
+
comparison_class2.antigens if comparison_class2 else [],
|
| 270 |
+
comparison_class2.pra_by_date if comparison_class2 else {})
|
| 271 |
+
] %}
|
| 272 |
+
{% if comp_dates and comp_dates | length >= 1 %}
|
| 273 |
+
<h5>{{ comp_label }} — MFI 趨勢圖</h5>
|
| 274 |
+
<div class="mb-2">
|
| 275 |
+
<small class="text-muted me-2">點圖例可顯示/隱藏抗體</small>
|
| 276 |
+
<div class="btn-group btn-group-sm" id="filter-{{ comp_id }}">
|
| 277 |
+
<button type="button" class="btn btn-outline-secondary" onclick="setFilter('{{ comp_id }}',this,'all')">全部</button>
|
| 278 |
+
<button type="button" class="btn btn-outline-danger" onclick="setFilter('{{ comp_id }}',this,'dsa')">DSA</button>
|
| 279 |
+
<button type="button" class="btn btn-outline-secondary" onclick="setFilter('{{ comp_id }}',this,'10')">Top 10</button>
|
| 280 |
+
<button type="button" class="btn btn-outline-secondary" onclick="setFilter('{{ comp_id }}',this,'20')">Top 20</button>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
<div class="chart-container">
|
| 284 |
+
<canvas id="{{ comp_id }}"></canvas>
|
| 285 |
+
</div>
|
| 286 |
+
|
| 287 |
+
<script>
|
| 288 |
+
(function() {
|
| 289 |
+
const dates = {{ comp_dates | tojson }};
|
| 290 |
+
const allData = [
|
| 291 |
+
{% for ag in comp_antigens %}
|
| 292 |
+
{ antigen: '{{ ag.antigen }}', allele: '{{ ag.allele }}',
|
| 293 |
+
values: [{% for d in comp_dates %}{{ ag.mfi_by_date.get(d, {}).get('mean_mfi', 0) }}{% if not loop.last %},{% endif %}{% endfor %}] },
|
| 294 |
+
{% endfor %}
|
| 295 |
+
];
|
| 296 |
+
|
| 297 |
+
function getColor(ag, i) {
|
| 298 |
+
const ca = ['#e53935','#d81b60','#8e24aa','#5e35b1','#3949ab','#1e88e5','#ef5350','#ec407a'];
|
| 299 |
+
const cb = ['#00897b','#43a047','#7cb342','#c0ca33','#26a69a','#66bb6a','#9ccc65','#d4e157','#2e7d32','#558b2f'];
|
| 300 |
+
const cc = ['#ff8f00','#ff6f00','#f57f17','#ffb300','#ffa000'];
|
| 301 |
+
if (ag.startsWith('A') || ag.startsWith('DR')) return ca[i % ca.length];
|
| 302 |
+
if (ag.startsWith('B') || ag.startsWith('DQ')) return cb[i % cb.length];
|
| 303 |
+
return cc[i % cc.length];
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
allData.sort((a, b) => Math.max(...b.values.filter(v=>v!==null)) - Math.max(...a.values.filter(v=>v!==null)));
|
| 307 |
+
let chartInst = null;
|
| 308 |
+
|
| 309 |
+
function getDonorSet() {
|
| 310 |
+
const s = new Set();
|
| 311 |
+
// Serology: each locus has -1 and -2 fields
|
| 312 |
+
['A','B','Cw','DR','DQ','DP'].forEach(l => {
|
| 313 |
+
[1,2].forEach(n => {
|
| 314 |
+
const el = document.getElementById('donor-' + l + '-' + n);
|
| 315 |
+
if (!el || !el.value.trim()) return;
|
| 316 |
+
s.add(l + el.value.trim());
|
| 317 |
+
});
|
| 318 |
+
});
|
| 319 |
+
// DNA Typing Class I: A*, B*, C*
|
| 320 |
+
['A','B','C'].forEach(l => {
|
| 321 |
+
[1,2].forEach(n => {
|
| 322 |
+
const el = document.getElementById('donor-dna-' + l + '-' + n);
|
| 323 |
+
if (!el || !el.value.trim()) return;
|
| 324 |
+
s.add(l + '*' + el.value.trim());
|
| 325 |
+
});
|
| 326 |
+
});
|
| 327 |
+
// DNA Typing Class II: DRB1*, DQA1*, DQB1*, DPA1*, DPB1*
|
| 328 |
+
['DRB1','DQA1','DQB1','DPA1','DPB1'].forEach(l => {
|
| 329 |
+
[1,2].forEach(n => {
|
| 330 |
+
const el = document.getElementById('donor-dna-' + l + '-' + n);
|
| 331 |
+
if (!el || !el.value.trim()) return;
|
| 332 |
+
s.add(l + '*' + el.value.trim());
|
| 333 |
+
});
|
| 334 |
+
});
|
| 335 |
+
return s;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
function matchesDonor(ag, ds) {
|
| 339 |
+
// 資料已以 allele 為主展開,直接比對 antigen(sero) 或 allele
|
| 340 |
+
if (ag.antigen && ds.has(ag.antigen)) return true;
|
| 341 |
+
if (ag.allele && ds.has(ag.allele)) return true;
|
| 342 |
+
return false;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
let currentFilter = 'all';
|
| 346 |
+
function build(limit) {
|
| 347 |
+
if (limit) currentFilter = limit;
|
| 348 |
+
let data;
|
| 349 |
+
if (currentFilter === 'dsa') {
|
| 350 |
+
const ds = getDonorSet();
|
| 351 |
+
data = allData.filter(ag => matchesDonor(ag, ds));
|
| 352 |
+
} else {
|
| 353 |
+
data = currentFilter === 'all' ? allData : allData.slice(0, parseInt(currentFilter));
|
| 354 |
+
}
|
| 355 |
+
// 套用「報告日期」checkbox 篩選,並依表格 DOM 順序排列 x 軸
|
| 356 |
+
const orderedLabels = (typeof getSelectedLabels === 'function')
|
| 357 |
+
? getSelectedLabels('{{ comp_id }}') : dates.slice();
|
| 358 |
+
const keepIdx = [];
|
| 359 |
+
const visibleDates = [];
|
| 360 |
+
orderedLabels.forEach(lbl => {
|
| 361 |
+
const idx = dates.indexOf(lbl);
|
| 362 |
+
if (idx >= 0) { keepIdx.push(idx); visibleDates.push(lbl); }
|
| 363 |
+
});
|
| 364 |
+
const datasets = data.map((ag, i) => ({
|
| 365 |
+
label: ag.antigen + (ag.allele && ag.allele !== ag.antigen ? ' (' + ag.allele + ')' : ''),
|
| 366 |
+
antigen: ag.antigen,
|
| 367 |
+
allele: ag.allele,
|
| 368 |
+
data: keepIdx.map(j => ag.values[j]),
|
| 369 |
+
borderColor: getColor(ag.antigen, i),
|
| 370 |
+
backgroundColor: getColor(ag.antigen, i) + '33',
|
| 371 |
+
borderWidth: 2, pointRadius: 5, pointHoverRadius: 7, tension: 0.3, spanGaps: true,
|
| 372 |
+
}));
|
| 373 |
+
if (chartInst) chartInst.destroy();
|
| 374 |
+
chartInst = new Chart(document.getElementById('{{ comp_id }}'), {
|
| 375 |
+
type: 'line',
|
| 376 |
+
data: { labels: visibleDates, datasets },
|
| 377 |
+
options: {
|
| 378 |
+
responsive: true, maintainAspectRatio: false,
|
| 379 |
+
interaction: { mode: 'nearest', intersect: false },
|
| 380 |
+
plugins: {
|
| 381 |
+
legend: { position: 'right', maxWidth: 520, labels: { font: { size: 10 }, boxWidth: 10, padding: 3 } },
|
| 382 |
+
tooltip: { callbacks: { label: c => c.dataset.label + ': ' + (c.parsed.y !== null ? c.parsed.y.toLocaleString() : '-') } }
|
| 383 |
+
},
|
| 384 |
+
scales: {
|
| 385 |
+
y: { title: { display: true, text: 'Mean MFI' }, beginAtZero: true },
|
| 386 |
+
x: { title: { display: true, text: '報告日期' } }
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
});
|
| 390 |
+
}
|
| 391 |
+
window['updateChart_{{ comp_id }}'] = function(val) { build(val || 'all'); highlightDSA(); };
|
| 392 |
+
window['rebuildChart_{{ comp_id }}'] = function() { build(); };
|
| 393 |
+
|
| 394 |
+
// Default: DSA if donor HLA exists, otherwise all
|
| 395 |
+
var defaultFilter = 'all';
|
| 396 |
+
if (getDonorSet().size > 0) {
|
| 397 |
+
var _ds = getDonorSet();
|
| 398 |
+
var dsaData = allData.filter(ag => matchesDonor(ag, _ds));
|
| 399 |
+
if (dsaData.length > 0) defaultFilter = 'dsa';
|
| 400 |
+
}
|
| 401 |
+
build(defaultFilter);
|
| 402 |
+
// 注意:rebuildCompTable 的初始呼叫放到 DOMContentLoaded,
|
| 403 |
+
// 因為此 IIFE 執行時 .comp-table 還在後面還沒 parse 到 DOM
|
| 404 |
+
// Set active button
|
| 405 |
+
document.querySelectorAll('#filter-{{ comp_id }} .btn').forEach(b => {
|
| 406 |
+
b.classList.remove('active');
|
| 407 |
+
if ((defaultFilter === 'dsa' && b.textContent === 'DSA') ||
|
| 408 |
+
(defaultFilter === 'all' && b.textContent === '全部'))
|
| 409 |
+
b.classList.add('active');
|
| 410 |
+
});
|
| 411 |
+
})();
|
| 412 |
+
</script>
|
| 413 |
+
|
| 414 |
+
<!-- MFI Comparison Table -->
|
| 415 |
+
{% if comp_dates | length >= 2 %}
|
| 416 |
+
<h6 class="mt-3">MFI 比較表</h6>
|
| 417 |
+
<div class="small text-muted mb-1">
|
| 418 |
+
<span class="mfi-up">▲ 上升</span>
|
| 419 |
+
<span class="mfi-down ms-2">▼ 下降</span>
|
| 420 |
+
<span class="mfi-new ms-2">新增</span>
|
| 421 |
+
<span class="mfi-gone ms-2">✖ 消失</span>
|
| 422 |
+
</div>
|
| 423 |
+
<div style="overflow-x:auto;">
|
| 424 |
+
<table class="table table-bordered table-sm ab-table bg-white mb-4 comp-table" data-chart-id="{{ comp_id }}">
|
| 425 |
+
<thead class="table-light">
|
| 426 |
+
<tr>
|
| 427 |
+
<th>Antigen</th><th>Allele</th>
|
| 428 |
+
{% for d in comp_dates %}
|
| 429 |
+
<th class="text-center" colspan="2" data-date="{{ d }}">{{ d }}<br><small>PRA {{ comp_pra.get(d,'?') }}%</small></th>
|
| 430 |
+
{% endfor %}
|
| 431 |
+
<th class="text-center">趨勢</th>
|
| 432 |
+
</tr>
|
| 433 |
+
<tr><th></th><th></th>
|
| 434 |
+
{% for d in comp_dates %}<th class="text-end small" data-date="{{ d }}" data-kind="max">Max</th><th class="text-end small" data-date="{{ d }}" data-kind="mean">Mean</th>{% endfor %}
|
| 435 |
+
<th></th>
|
| 436 |
+
</tr>
|
| 437 |
+
</thead>
|
| 438 |
+
<tbody>
|
| 439 |
+
{% for ag in comp_antigens %}
|
| 440 |
+
<tr>
|
| 441 |
+
<td class="fw-bold ag-cell" data-ag="{{ ag.antigen }}">{{ ag.antigen }}</td><td class="fw-bold allele-cell" data-allele="{{ ag.allele }}">{{ ag.allele }}</td>
|
| 442 |
+
{% for d in comp_dates %}
|
| 443 |
+
{% set val = ag.mfi_by_date.get(d) %}
|
| 444 |
+
{% if val %}
|
| 445 |
+
<td class="text-end" data-date="{{ d }}" data-kind="max" data-max="{{ val.max_mfi|int }}">{{ val.max_mfi|int }}</td>
|
| 446 |
+
<td class="text-end" data-date="{{ d }}" data-kind="mean">{{ val.mean_mfi|int }}</td>
|
| 447 |
+
{% else %}<td class="text-center text-muted" data-date="{{ d }}" data-kind="max">-</td><td class="text-center text-muted" data-date="{{ d }}" data-kind="mean">-</td>{% endif %}
|
| 448 |
+
{% endfor %}
|
| 449 |
+
<td class="text-center trend-cell">
|
| 450 |
+
{% set first_val = ag.mfi_by_date.get(comp_dates[0]) %}
|
| 451 |
+
{% set last_val = ag.mfi_by_date.get(comp_dates[-1]) %}
|
| 452 |
+
{% if first_val and last_val %}
|
| 453 |
+
{% set diff = last_val.max_mfi - first_val.max_mfi %}
|
| 454 |
+
{% if diff > 500 %}<span class="mfi-up">▲ +{{ diff|int }}</span>
|
| 455 |
+
{% elif diff < -500 %}<span class="mfi-down">▼ {{ diff|int }}</span>
|
| 456 |
+
{% else %}<span class="text-muted">–</span>{% endif %}
|
| 457 |
+
{% elif last_val and not first_val %}<span class="mfi-new">新增</span>
|
| 458 |
+
{% elif first_val and not last_val %}<span class="mfi-gone">✖ 消失</span>
|
| 459 |
+
{% endif %}
|
| 460 |
+
</td>
|
| 461 |
+
</tr>
|
| 462 |
+
{% endfor %}
|
| 463 |
+
</tbody>
|
| 464 |
+
</table>
|
| 465 |
+
</div>
|
| 466 |
+
{% endif %}
|
| 467 |
+
{% endif %}
|
| 468 |
+
{% endfor %}
|
| 469 |
+
|
| 470 |
+
{% elif chart_no %}
|
| 471 |
+
<div class="alert alert-warning">找不到病歷號 {{ chart_no }}</div>
|
| 472 |
+
{% else %}
|
| 473 |
+
<div class="text-center text-muted py-5">請選擇病人查看統計分析</div>
|
| 474 |
+
{% endif %}
|
| 475 |
+
</div>
|
| 476 |
+
<script>
|
| 477 |
+
// Load saved donor HLA on page load
|
| 478 |
+
window.addEventListener('DOMContentLoaded', function() {
|
| 479 |
+
// MFI 比較表:依 reports DOM 順序(= chart x 軸順序)重排欄位 + 重算趨勢
|
| 480 |
+
if (typeof rebuildCompTable === 'function') {
|
| 481 |
+
['chart1', 'chart2'].forEach(cid => rebuildCompTable(cid));
|
| 482 |
+
}
|
| 483 |
+
const saved = {{ donor_hla | default("", true) | tojson }};
|
| 484 |
+
if (saved) {
|
| 485 |
+
try {
|
| 486 |
+
const obj = JSON.parse(saved);
|
| 487 |
+
Object.keys(obj).forEach(k => {
|
| 488 |
+
const el = document.getElementById(k);
|
| 489 |
+
if (el) el.value = obj[k];
|
| 490 |
+
});
|
| 491 |
+
} catch(e) { console.log('donor_hla parse error', e); }
|
| 492 |
+
}
|
| 493 |
+
// 一律跑(沒 donor 也要套藍/灰預設色)
|
| 494 |
+
setTimeout(highlightDSA, 1000);
|
| 495 |
+
});
|
| 496 |
+
|
| 497 |
+
function setFilter(chartId, btn, val) {
|
| 498 |
+
// Toggle active state
|
| 499 |
+
btn.parentElement.querySelectorAll('.btn').forEach(b => b.classList.remove('active'));
|
| 500 |
+
btn.classList.add('active');
|
| 501 |
+
window['updateChart_' + chartId](val);
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
function dsaCheckAndSave() {
|
| 505 |
+
highlightDSA();
|
| 506 |
+
// Save to DB
|
| 507 |
+
const obj = {};
|
| 508 |
+
document.querySelectorAll('.donor-hla').forEach(el => {
|
| 509 |
+
if (el.value.trim()) obj[el.id] = el.value.trim();
|
| 510 |
+
});
|
| 511 |
+
fetch('/save_donor_hla', {
|
| 512 |
+
method: 'POST',
|
| 513 |
+
headers: {'Content-Type': 'application/json'},
|
| 514 |
+
body: JSON.stringify({ chart_no: '{{ chart_no }}', donor_hla: JSON.stringify(obj) })
|
| 515 |
+
}).then(r => r.json()).then(d => {
|
| 516 |
+
if (d.ok) {
|
| 517 |
+
const btn = document.querySelector('[onclick="dsaCheckAndSave()"]');
|
| 518 |
+
btn.textContent = '已存檔';
|
| 519 |
+
}
|
| 520 |
+
});
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
function colorizeSpecCells(donorSet) {
|
| 524 |
+
// Specificity tokens: sero 藍、allele 灰、DSA 紅。即使 donorSet 為空也跑藍/灰。
|
| 525 |
+
document.querySelectorAll('.spec-cell').forEach(td => {
|
| 526 |
+
const orig = td.dataset.orig || td.textContent;
|
| 527 |
+
td.dataset.orig = orig;
|
| 528 |
+
const tokens = orig.match(/\S+?\([^)]*\)|\S+/g) || [];
|
| 529 |
+
td.innerHTML = tokens.map(seg => {
|
| 530 |
+
const m = seg.match(/^([^(]+)(\([^)]*\))?$/);
|
| 531 |
+
if (!m) return seg;
|
| 532 |
+
const sero = m[1];
|
| 533 |
+
const paren = m[2] || '';
|
| 534 |
+
let isDSA = false;
|
| 535 |
+
for (const ag of donorSet) {
|
| 536 |
+
if (!ag) continue;
|
| 537 |
+
if (sero === ag || (paren && paren.indexOf(ag) >= 0)) { isDSA = true; break; }
|
| 538 |
+
}
|
| 539 |
+
if (isDSA) {
|
| 540 |
+
return '<span style="color:#dc3545;font-weight:bold;background:#fff0f0;padding:0 2px;border-radius:2px;">' + seg + '</span>';
|
| 541 |
+
}
|
| 542 |
+
const seroHtml = '<span style="color:#1E40AF;font-weight:bold;">' + sero + '</span>';
|
| 543 |
+
if (!paren) return seroHtml;
|
| 544 |
+
const inner = paren.slice(1, -1);
|
| 545 |
+
const alleleHtml = inner.split(/\s+/).map(a =>
|
| 546 |
+
a ? '<span style="color:#374151;">' + a + '</span>' : a
|
| 547 |
+
).join(' ');
|
| 548 |
+
return seroHtml + '(' + alleleHtml + ')';
|
| 549 |
+
}).join(' ');
|
| 550 |
+
});
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
function highlightDSA() {
|
| 554 |
+
// Build donor HLA set from both sero and DNA inputs
|
| 555 |
+
const donorSet = new Set();
|
| 556 |
+
['A','B','Cw','DR','DQ','DP'].forEach(locus => {
|
| 557 |
+
[1,2].forEach(n => {
|
| 558 |
+
const el = document.getElementById('donor-' + locus + '-' + n);
|
| 559 |
+
if (el && el.value.trim()) donorSet.add(locus + el.value.trim());
|
| 560 |
+
});
|
| 561 |
+
});
|
| 562 |
+
['A','B','C','DRB1','DQB1','DQA1','DPB1','DPA1'].forEach(locus => {
|
| 563 |
+
[1,2].forEach(n => {
|
| 564 |
+
const el = document.getElementById('donor-dna-' + locus + '-' + n);
|
| 565 |
+
if (el && el.value.trim()) donorSet.add(locus + '*' + el.value.trim());
|
| 566 |
+
});
|
| 567 |
+
});
|
| 568 |
+
|
| 569 |
+
// 1. Spec-cell 藍/灰/紅(無條件跑,沒 donor 也要藍/灰)
|
| 570 |
+
colorizeSpecCells(donorSet);
|
| 571 |
+
|
| 572 |
+
if (donorSet.size === 0) return;
|
| 573 |
+
|
| 574 |
+
// 2. Highlight ag-cell + allele-cell (MFI comparison/antibody strength tables)
|
| 575 |
+
document.querySelectorAll('.ag-cell').forEach(td => {
|
| 576 |
+
const ag = td.dataset.ag;
|
| 577 |
+
const row = td.parentElement;
|
| 578 |
+
const alleleCell = row.querySelector('.allele-cell') || row.querySelector('td:nth-child(2)');
|
| 579 |
+
const alleleVal = alleleCell ? (alleleCell.dataset.allele || alleleCell.textContent) : '';
|
| 580 |
+
const alleles = alleleVal.split(',').map(a => a.trim()).filter(a => a);
|
| 581 |
+
const agMatch = donorSet.has(ag);
|
| 582 |
+
const alleleMatch = alleles.some(a => donorSet.has(a));
|
| 583 |
+
const isMatch = agMatch || alleleMatch;
|
| 584 |
+
td.style.color = isMatch ? '#dc3545' : '';
|
| 585 |
+
td.style.background = isMatch ? '#fff0f0' : '';
|
| 586 |
+
row.style.background = isMatch ? '#fff0f0' : '';
|
| 587 |
+
if (alleleCell) {
|
| 588 |
+
alleleCell.style.color = alleleMatch ? '#dc3545' : (isMatch ? '' : '');
|
| 589 |
+
alleleCell.style.background = isMatch ? '#fff0f0' : '';
|
| 590 |
+
}
|
| 591 |
+
});
|
| 592 |
+
|
| 593 |
+
// 3. Highlight chart legend labels
|
| 594 |
+
document.querySelectorAll('canvas').forEach(canvas => {
|
| 595 |
+
const chart = Chart.getChart(canvas);
|
| 596 |
+
if (!chart) return;
|
| 597 |
+
chart.data.datasets.forEach(ds => {
|
| 598 |
+
const cleanLabel = ds.label.replace(/ ★$/, '');
|
| 599 |
+
const labelMatch = (ds.antigen && donorSet.has(ds.antigen)) ||
|
| 600 |
+
(ds.allele && donorSet.has(ds.allele));
|
| 601 |
+
ds.label = labelMatch ? cleanLabel + ' ★' : cleanLabel;
|
| 602 |
+
});
|
| 603 |
+
chart.update();
|
| 604 |
+
});
|
| 605 |
+
}
|
| 606 |
+
</script>
|
| 607 |
+
</body>
|
| 608 |
+
</html>
|
templates//batch.html
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>批次新增資料</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
body { background: #f5f7fa; }
|
| 10 |
+
.drop-zone { border: 2px dashed #adb5bd; border-radius: 12px; padding: 2rem; text-align: center; cursor: pointer; background: #fff; }
|
| 11 |
+
.drop-zone:hover, .drop-zone.dragover { border-color: #0d6efd; background: #e8f0fe; }
|
| 12 |
+
table.preview td, table.preview th { font-size: .88rem; }
|
| 13 |
+
.status-ok { color: #198754; font-weight: bold; }
|
| 14 |
+
.status-err { color: #dc3545; font-weight: bold; }
|
| 15 |
+
.status-dup { color: #fd7e14; font-weight: bold; }
|
| 16 |
+
</style>
|
| 17 |
+
</head>
|
| 18 |
+
<body>
|
| 19 |
+
<div class="container py-4" style="max-width: 1100px;">
|
| 20 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 21 |
+
<h2 class="mb-0">批次新增資料</h2>
|
| 22 |
+
<div>
|
| 23 |
+
<a href="/" class="btn btn-dark btn-sm me-1">首頁</a>
|
| 24 |
+
<a href="/history" class="btn btn-outline-secondary btn-sm me-1">歷史紀錄</a>
|
| 25 |
+
<a href="/new" class="btn btn-outline-primary btn-sm">單筆新增</a>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<div class="alert alert-info">
|
| 30 |
+
<strong>檔名格式</strong>:必須包含<strong>病歷號</strong>與<strong>姓名</strong>,用底線 <code>_</code> 或空格分隔。<br>
|
| 31 |
+
範例:<code>40091041_王小明_PRA2_20240605.xls</code>、<code>王小明_40091041 PRA1.xls</code>。<br>
|
| 32 |
+
系統會自動抓取(姓名在前或病歷號在前都可以),日期與 PRA1 / PRA2 之類描述會被忽略。
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<div class="card mb-3">
|
| 36 |
+
<div class="card-body">
|
| 37 |
+
<div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
|
| 38 |
+
<div style="font-size:2rem;">📁</div>
|
| 39 |
+
<div class="fw-bold">點擊或拖曳選取多個 XLS 檔</div>
|
| 40 |
+
<input type="file" id="fileInput" multiple accept=".xls,.xlsx" style="display:none" onchange="onPick(this)">
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<div id="previewWrap" style="display:none;">
|
| 46 |
+
<h5>預覽 <span class="text-muted small">(可手動修正病歷號 / 姓名)</span></h5>
|
| 47 |
+
<table class="table table-bordered table-sm bg-white preview">
|
| 48 |
+
<thead class="table-light">
|
| 49 |
+
<tr><th>檔名</th><th style="width:140px;">病歷號</th><th style="width:140px;">姓名</th><th style="width:100px;">狀態</th><th style="width:72px;"></th></tr>
|
| 50 |
+
</thead>
|
| 51 |
+
<tbody id="previewBody"></tbody>
|
| 52 |
+
</table>
|
| 53 |
+
<div class="mb-3">
|
| 54 |
+
<div class="form-check form-check-inline">
|
| 55 |
+
<input class="form-check-input" type="radio" name="dupmode" id="mode-overwrite" value="overwrite">
|
| 56 |
+
<label class="form-check-label" for="mode-overwrite">覆寫</label>
|
| 57 |
+
</div>
|
| 58 |
+
<div class="form-check form-check-inline">
|
| 59 |
+
<input class="form-check-input" type="radio" name="dupmode" id="mode-new" value="new" checked>
|
| 60 |
+
<label class="form-check-label" for="mode-new">可多筆存在</label>
|
| 61 |
+
</div>
|
| 62 |
+
<div class="form-check form-check-inline">
|
| 63 |
+
<input class="form-check-input" type="radio" name="dupmode" id="mode-skip" value="skip">
|
| 64 |
+
<label class="form-check-label" for="mode-skip">跳過</label>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
<button class="btn btn-primary" id="submitBtn" onclick="submitBatch()">全部上傳</button>
|
| 68 |
+
<div id="results" class="mt-3"></div>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
<script>
|
| 72 |
+
let selectedFiles = [];
|
| 73 |
+
|
| 74 |
+
function parseFilename(fname) {
|
| 75 |
+
const stem = fname.replace(/\.(xls|xlsx)$/i, '');
|
| 76 |
+
// 切 token:底線、空格、連字號、點號
|
| 77 |
+
const tokens = stem.split(/[_\-. ]+/).filter(x => x);
|
| 78 |
+
|
| 79 |
+
// 8 位純數字且符合 YYYYMMDD(年 1900-2099、月 1-12、日 1-31)→ 視為日期略過
|
| 80 |
+
function isDate(t) {
|
| 81 |
+
if (!/^\d{8}$/.test(t)) return false;
|
| 82 |
+
const y = +t.slice(0,4), m = +t.slice(4,6), d = +t.slice(6,8);
|
| 83 |
+
return y >= 1900 && y <= 2099 && m >= 1 && m <= 12 && d >= 1 && d <= 31;
|
| 84 |
+
}
|
| 85 |
+
// PRA / PRA1 / PRA2 / PRA-II / Class1 / ClassII → 視為描述略過
|
| 86 |
+
function isDesc(t) {
|
| 87 |
+
return /^(PRA\d*|Class\d*|LS\w*|I|II|III)$/i.test(t);
|
| 88 |
+
}
|
| 89 |
+
// 7-8 位連續數字(且不是日期)→ 病歷號候選
|
| 90 |
+
function isChartNo(t) {
|
| 91 |
+
return /^\d{6,8}$/.test(t) && !isDate(t);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
let chart = '', name = '';
|
| 95 |
+
for (const t of tokens) {
|
| 96 |
+
if (isDate(t) || isDesc(t)) continue;
|
| 97 |
+
if (isChartNo(t) && !chart) { chart = t; continue; }
|
| 98 |
+
// 非純數字且非描述,且還沒抓到姓名
|
| 99 |
+
if (!/^\d+$/.test(t) && !name) { name = t; }
|
| 100 |
+
}
|
| 101 |
+
return { name, chart };
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function onPick(input) {
|
| 105 |
+
selectedFiles = Array.from(input.files);
|
| 106 |
+
if (!selectedFiles.length) return;
|
| 107 |
+
const tbody = document.getElementById('previewBody');
|
| 108 |
+
tbody.innerHTML = '';
|
| 109 |
+
selectedFiles.forEach((f, i) => {
|
| 110 |
+
const p = parseFilename(f.name);
|
| 111 |
+
const tr = document.createElement('tr');
|
| 112 |
+
tr.dataset.idx = i;
|
| 113 |
+
const missing = !p.chart || !p.name;
|
| 114 |
+
tr.innerHTML = `
|
| 115 |
+
<td class="small">${f.name}</td>
|
| 116 |
+
<td><input type="text" class="form-control form-control-sm" data-field="chart" value="${p.chart}"></td>
|
| 117 |
+
<td><input type="text" class="form-control form-control-sm" data-field="name" value="${p.name}"></td>
|
| 118 |
+
<td class="${missing ? 'status-err' : 'status-ok'}">${missing ? '缺欄位' : '待上傳'}</td>
|
| 119 |
+
<td><button type="button" class="btn btn-sm btn-outline-danger" onclick="removeRow(this)">刪除</button></td>
|
| 120 |
+
`;
|
| 121 |
+
tbody.appendChild(tr);
|
| 122 |
+
});
|
| 123 |
+
document.getElementById('previewWrap').style.display = 'block';
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function removeRow(btn) {
|
| 127 |
+
const tr = btn.closest('tr');
|
| 128 |
+
if (tr) tr.remove();
|
| 129 |
+
// 若表格已空,隱藏預覽區
|
| 130 |
+
if (!document.querySelectorAll('#previewBody tr').length) {
|
| 131 |
+
document.getElementById('previewWrap').style.display = 'none';
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function submitBatch() {
|
| 136 |
+
const btn = document.getElementById('submitBtn');
|
| 137 |
+
const mode = document.querySelector('input[name="dupmode"]:checked').value;
|
| 138 |
+
const rows = document.querySelectorAll('#previewBody tr');
|
| 139 |
+
btn.disabled = true;
|
| 140 |
+
btn.textContent = '上傳中...';
|
| 141 |
+
const results = document.getElementById('results');
|
| 142 |
+
results.innerHTML = '<div class="text-muted small">處理中...</div>';
|
| 143 |
+
|
| 144 |
+
const fd = new FormData();
|
| 145 |
+
fd.append('mode', mode);
|
| 146 |
+
rows.forEach(tr => {
|
| 147 |
+
const idx = parseInt(tr.dataset.idx);
|
| 148 |
+
const chart = tr.querySelector('[data-field="chart"]').value.trim();
|
| 149 |
+
const name = tr.querySelector('[data-field="name"]').value.trim();
|
| 150 |
+
const f = selectedFiles[idx];
|
| 151 |
+
fd.append('file', f);
|
| 152 |
+
fd.append('chart', chart);
|
| 153 |
+
fd.append('name', name);
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
fetch('/batch_upload', { method: 'POST', body: fd })
|
| 157 |
+
.then(r => r.json())
|
| 158 |
+
.then(d => {
|
| 159 |
+
btn.disabled = false;
|
| 160 |
+
btn.textContent = '全部上傳';
|
| 161 |
+
let html = '<table class="table table-sm table-bordered"><thead><tr><th>檔名</th><th>結果</th></tr></thead><tbody>';
|
| 162 |
+
(d.results || []).forEach(r => {
|
| 163 |
+
let cls = 'status-ok', msg = '成功新增';
|
| 164 |
+
if (r.status === 'error') { cls = 'status-err'; msg = r.msg || '失敗'; }
|
| 165 |
+
else if (r.status === 'duplicate') { cls = 'status-dup'; msg = '重複,已複寫'; }
|
| 166 |
+
else if (r.status === 'skipped') { cls = 'status-dup'; msg = '重複,跳過不新增'; }
|
| 167 |
+
html += `<tr><td class="small">${r.file}</td><td class="${cls}">${msg}</td></tr>`;
|
| 168 |
+
});
|
| 169 |
+
html += '</tbody></table>';
|
| 170 |
+
html += `<div class="mt-2">成功 ${d.summary.ok}、重複 ${d.summary.dup || 0}、失敗 ${d.summary.err}(共 ${d.summary.total})</div>`;
|
| 171 |
+
if (d.summary.ok > 0) html += '<a href="/history" class="btn btn-sm btn-outline-primary mt-2">查看歷史紀錄</a>';
|
| 172 |
+
results.innerHTML = html;
|
| 173 |
+
})
|
| 174 |
+
.catch(e => {
|
| 175 |
+
btn.disabled = false;
|
| 176 |
+
btn.textContent = '全部上傳';
|
| 177 |
+
results.innerHTML = '<div class="alert alert-danger">上傳失敗:' + e + '</div>';
|
| 178 |
+
});
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// Drag & drop
|
| 182 |
+
const dz = document.getElementById('dropZone');
|
| 183 |
+
['dragenter', 'dragover'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('dragover'); }));
|
| 184 |
+
['dragleave', 'drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('dragover'); }));
|
| 185 |
+
dz.addEventListener('drop', e => {
|
| 186 |
+
const dt = new DataTransfer();
|
| 187 |
+
Array.from(e.dataTransfer.files).forEach(f => dt.items.add(f));
|
| 188 |
+
document.getElementById('fileInput').files = dt.files;
|
| 189 |
+
onPick(document.getElementById('fileInput'));
|
| 190 |
+
});
|
| 191 |
+
</script>
|
| 192 |
+
</body>
|
| 193 |
+
</html>
|
templates//combined_analysis.html
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{% if patient %}{{ patient.patient_name }} — {% endif %}綜合分析 (PRA + DSA)</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
| 9 |
+
<style>
|
| 10 |
+
.chart-container { position: relative; height: 540px; background: #fff; border: 1px solid #dee2e6; border-radius: 6px; padding: 1rem; margin-bottom: 1.5rem; }
|
| 11 |
+
.ab-table { font-size: .82rem; }
|
| 12 |
+
.strength-strong { --bs-table-bg: #f8d7da; background-color: #f8d7da !important; font-weight: bold; }
|
| 13 |
+
.strength-strong > td { background-color: #f8d7da !important; }
|
| 14 |
+
.strength-weak { --bs-table-bg: #fff9c4; background-color: #fff9c4 !important; }
|
| 15 |
+
.strength-weak > td { background-color: #fff9c4 !important; }
|
| 16 |
+
.spec-strong-bg { background-color: #f8d7da; padding: 3px 6px; border-radius: 3px; margin-bottom: 2px; display: block; }
|
| 17 |
+
.spec-weak-bg { background-color: #fff9c4; padding: 3px 6px; border-radius: 3px; display: block; }
|
| 18 |
+
.badge-pra { background: #1E40AF; }
|
| 19 |
+
.badge-dsa { background: #dc3545; }
|
| 20 |
+
</style>
|
| 21 |
+
</head>
|
| 22 |
+
<body>
|
| 23 |
+
<div class="container py-4" style="max-width: 1200px;">
|
| 24 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 25 |
+
<div><h2 class="mb-0">綜合分析 <span class="text-muted fs-5">PRA + DSA</span></h2></div>
|
| 26 |
+
<div>
|
| 27 |
+
<a href="/" class="btn btn-dark btn-sm me-1">首頁</a>
|
| 28 |
+
<a href="/history" class="btn btn-outline-primary btn-sm me-1">PRA 歷史</a>
|
| 29 |
+
<a href="/dsa/history" class="btn btn-outline-danger btn-sm">DSA 歷史</a>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<form class="row g-2 mb-4" method="get" action="/combined">
|
| 34 |
+
<div class="col-sm-6">
|
| 35 |
+
<select class="form-select" id="patientSelect" onchange="selectPatient(this.value)">
|
| 36 |
+
<option value="">-- 選擇病人(同時有 PRA + DSA) --</option>
|
| 37 |
+
{% for p in patients %}
|
| 38 |
+
<option value="{{ p.chart_no }}" {{ 'selected' if p.chart_no == chart_no else '' }}>
|
| 39 |
+
{{ p.patient_name }} ({{ p.chart_no }}) — PRA {{ p.pra_count }} 筆 / DSA {{ p.dsa_count }} 筆
|
| 40 |
+
</option>
|
| 41 |
+
{% endfor %}
|
| 42 |
+
</select>
|
| 43 |
+
<input type="hidden" name="chart_no" id="chartNoInput" value="{{ chart_no }}">
|
| 44 |
+
</div>
|
| 45 |
+
</form>
|
| 46 |
+
<script>
|
| 47 |
+
function selectPatient(val) {
|
| 48 |
+
if (!val) return;
|
| 49 |
+
document.getElementById('chartNoInput').value = val;
|
| 50 |
+
document.getElementById('chartNoInput').form.submit();
|
| 51 |
+
}
|
| 52 |
+
</script>
|
| 53 |
+
|
| 54 |
+
{% if not patients %}
|
| 55 |
+
<div class="alert alert-secondary">尚無同時測過 PRA 與 DSA 的病人。</div>
|
| 56 |
+
{% endif %}
|
| 57 |
+
|
| 58 |
+
{% if patient %}
|
| 59 |
+
<div class="card mb-3">
|
| 60 |
+
<div class="card-header fw-bold">{{ patient.patient_name }} ({{ patient.chart_no }}) — Donor HLA</div>
|
| 61 |
+
<div class="card-body" style="font-size:.85rem;">
|
| 62 |
+
<style>.donor-hla { width: 80px !important; padding: 2px 4px !important; text-align: center; }</style>
|
| 63 |
+
<div class="row">
|
| 64 |
+
<div class="col-md-5">
|
| 65 |
+
<div class="fw-bold mb-1">Serology</div>
|
| 66 |
+
<table class="table table-sm table-bordered mb-1"><tbody>
|
| 67 |
+
<tr><td class="fw-bold">A</td>
|
| 68 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-1" oninput="highlightDSA()"></td>
|
| 69 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-2" oninput="highlightDSA()"></td>
|
| 70 |
+
<td class="fw-bold">DR</td>
|
| 71 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-1" oninput="highlightDSA()"></td>
|
| 72 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-2" oninput="highlightDSA()"></td></tr>
|
| 73 |
+
<tr><td class="fw-bold">B</td>
|
| 74 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-1" oninput="highlightDSA()"></td>
|
| 75 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-2" oninput="highlightDSA()"></td>
|
| 76 |
+
<td class="fw-bold">DQ</td>
|
| 77 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-1" oninput="highlightDSA()"></td>
|
| 78 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-2" oninput="highlightDSA()"></td></tr>
|
| 79 |
+
<tr><td class="fw-bold">Cw</td>
|
| 80 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-1" oninput="highlightDSA()"></td>
|
| 81 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-2" oninput="highlightDSA()"></td>
|
| 82 |
+
<td class="fw-bold">DP</td>
|
| 83 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-1" oninput="highlightDSA()"></td>
|
| 84 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-2" oninput="highlightDSA()"></td></tr>
|
| 85 |
+
</tbody></table>
|
| 86 |
+
</div>
|
| 87 |
+
<div class="col-md-7">
|
| 88 |
+
<div class="fw-bold mb-1">DNA Typing</div>
|
| 89 |
+
<table class="table table-sm table-bordered mb-1"><tbody>
|
| 90 |
+
<tr><td class="fw-bold">A*</td>
|
| 91 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-1" oninput="highlightDSA()"></td>
|
| 92 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-2" oninput="highlightDSA()"></td>
|
| 93 |
+
<td class="fw-bold">DRB1*</td>
|
| 94 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-1" oninput="highlightDSA()"></td>
|
| 95 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-2" oninput="highlightDSA()"></td></tr>
|
| 96 |
+
<tr><td class="fw-bold">B*</td>
|
| 97 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-1" oninput="highlightDSA()"></td>
|
| 98 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-2" oninput="highlightDSA()"></td>
|
| 99 |
+
<td class="fw-bold">DQB1*</td>
|
| 100 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-1" oninput="highlightDSA()"></td>
|
| 101 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-2" oninput="highlightDSA()"></td></tr>
|
| 102 |
+
<tr><td class="fw-bold">C*</td>
|
| 103 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-1" oninput="highlightDSA()"></td>
|
| 104 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-2" oninput="highlightDSA()"></td>
|
| 105 |
+
<td class="fw-bold">DQA1*</td>
|
| 106 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-1" oninput="highlightDSA()"></td>
|
| 107 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-2" oninput="highlightDSA()"></td></tr>
|
| 108 |
+
<tr><td></td><td></td><td></td>
|
| 109 |
+
<td class="fw-bold">DPB1*</td>
|
| 110 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-1" oninput="highlightDSA()"></td>
|
| 111 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-2" oninput="highlightDSA()"></td></tr>
|
| 112 |
+
<tr><td></td><td></td><td></td>
|
| 113 |
+
<td class="fw-bold">DPA1*</td>
|
| 114 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-1" oninput="highlightDSA()"></td>
|
| 115 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-2" oninput="highlightDSA()"></td></tr>
|
| 116 |
+
</tbody></table>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
<div class="text-end">
|
| 120 |
+
<button class="btn btn-sm btn-outline-danger" onclick="saveDonorHLA(this)">存檔</button>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<script>
|
| 126 |
+
function getDonorSet() {
|
| 127 |
+
const s = new Set();
|
| 128 |
+
['A','B','Cw','DR','DQ','DP'].forEach(l => {
|
| 129 |
+
[1,2].forEach(n => {
|
| 130 |
+
const el = document.getElementById('donor-' + l + '-' + n);
|
| 131 |
+
if (el && el.value.trim()) s.add(l + el.value.trim());
|
| 132 |
+
});
|
| 133 |
+
});
|
| 134 |
+
['A','B','C','DRB1','DQB1','DQA1','DPB1','DPA1'].forEach(l => {
|
| 135 |
+
[1,2].forEach(n => {
|
| 136 |
+
const el = document.getElementById('donor-dna-' + l + '-' + n);
|
| 137 |
+
if (el && el.value.trim()) s.add(l + '*' + el.value.trim());
|
| 138 |
+
});
|
| 139 |
+
});
|
| 140 |
+
return s;
|
| 141 |
+
}
|
| 142 |
+
function highlightTokensInCell(cell, donorSet) {
|
| 143 |
+
if (!cell) return;
|
| 144 |
+
const text = cell.dataset.orig || cell.textContent;
|
| 145 |
+
cell.dataset.orig = text;
|
| 146 |
+
const tokens = text.split(/,\s*/);
|
| 147 |
+
cell.innerHTML = tokens.map(tok => {
|
| 148 |
+
const t = tok.trim();
|
| 149 |
+
if (!t) return tok;
|
| 150 |
+
for (const ag of donorSet) {
|
| 151 |
+
if (t.includes(ag)) return '<span style="color:#dc3545;font-weight:bold;">' + tok + '</span>';
|
| 152 |
+
}
|
| 153 |
+
return tok;
|
| 154 |
+
}).join(', ');
|
| 155 |
+
}
|
| 156 |
+
function highlightSpecText(specEl, donorSet) {
|
| 157 |
+
if (!specEl) return;
|
| 158 |
+
const orig = specEl.dataset.orig || specEl.textContent;
|
| 159 |
+
specEl.dataset.orig = orig;
|
| 160 |
+
const tokens = orig.match(/\S+?\([^)]*\)|\S+/g) || [];
|
| 161 |
+
specEl.innerHTML = tokens.map(seg => {
|
| 162 |
+
const m = seg.match(/^([^(]+)(\([^)]*\))?$/);
|
| 163 |
+
if (!m) return seg;
|
| 164 |
+
const sero = m[1];
|
| 165 |
+
const paren = m[2] || '';
|
| 166 |
+
if (!/^[A-Za-z]/.test(sero)) return seg;
|
| 167 |
+
let isDSA = false;
|
| 168 |
+
for (const ag of donorSet) {
|
| 169 |
+
if (!ag) continue;
|
| 170 |
+
if (sero === ag || (paren && paren.indexOf(ag) >= 0)) { isDSA = true; break; }
|
| 171 |
+
}
|
| 172 |
+
if (isDSA) return '<span style="color:#dc3545;font-weight:bold;">' + seg + '</span>';
|
| 173 |
+
const seroHtml = '<span style="color:#1E40AF;font-weight:bold;">' + sero + '</span>';
|
| 174 |
+
if (!paren) return seroHtml;
|
| 175 |
+
const inner = paren.slice(1, -1);
|
| 176 |
+
const alleleHtml = inner.split(/\s+/).map(a =>
|
| 177 |
+
a ? '<span style="color:#374151;">' + a + '</span>' : a
|
| 178 |
+
).join(' ');
|
| 179 |
+
return seroHtml + '(' + alleleHtml + ')';
|
| 180 |
+
}).join(' ');
|
| 181 |
+
}
|
| 182 |
+
function highlightDSA() {
|
| 183 |
+
const ds = getDonorSet();
|
| 184 |
+
document.querySelectorAll('.spec-cell').forEach(el => highlightSpecText(el, ds));
|
| 185 |
+
document.querySelectorAll('.ab-table tbody tr').forEach(tr => {
|
| 186 |
+
highlightTokensInCell(tr.cells[0], ds);
|
| 187 |
+
highlightTokensInCell(tr.cells[1], ds);
|
| 188 |
+
});
|
| 189 |
+
['chart1', 'chart2'].forEach(cid => {
|
| 190 |
+
const fn = window['rebuildCombinedChart_' + cid];
|
| 191 |
+
if (typeof fn === 'function') fn();
|
| 192 |
+
});
|
| 193 |
+
}
|
| 194 |
+
function setCombinedFilter(chartId, btn, val) {
|
| 195 |
+
btn.parentElement.querySelectorAll('.btn').forEach(b => b.classList.remove('active'));
|
| 196 |
+
btn.classList.add('active');
|
| 197 |
+
const fn = window['updateCombinedFilter_' + chartId];
|
| 198 |
+
if (typeof fn === 'function') fn(val);
|
| 199 |
+
}
|
| 200 |
+
function saveDonorHLA(btn) {
|
| 201 |
+
const obj = {};
|
| 202 |
+
document.querySelectorAll('.donor-hla').forEach(el => {
|
| 203 |
+
if (el.value.trim()) obj[el.id] = el.value.trim();
|
| 204 |
+
});
|
| 205 |
+
btn.disabled = true;
|
| 206 |
+
fetch('/save_donor_hla', {
|
| 207 |
+
method: 'POST', headers: {'Content-Type': 'application/json'},
|
| 208 |
+
body: JSON.stringify({ chart_no: '{{ patient.chart_no }}', donor_hla: JSON.stringify(obj) })
|
| 209 |
+
}).then(r => r.json()).then(d => {
|
| 210 |
+
btn.disabled = false;
|
| 211 |
+
if (d.ok) { btn.classList.remove('btn-outline-danger'); btn.classList.add('btn-danger'); btn.textContent = '已存檔'; }
|
| 212 |
+
});
|
| 213 |
+
highlightDSA();
|
| 214 |
+
}
|
| 215 |
+
window.addEventListener('DOMContentLoaded', function() {
|
| 216 |
+
const saved = {{ donor_hla | default('', true) | tojson }};
|
| 217 |
+
if (saved) {
|
| 218 |
+
try {
|
| 219 |
+
const obj = JSON.parse(saved);
|
| 220 |
+
Object.keys(obj).forEach(k => {
|
| 221 |
+
const el = document.getElementById(k);
|
| 222 |
+
if (el) el.value = obj[k];
|
| 223 |
+
});
|
| 224 |
+
} catch(e) {}
|
| 225 |
+
}
|
| 226 |
+
['chart1', 'chart2'].forEach(cid => {
|
| 227 |
+
const fn = window['applyCombinedDefault_' + cid];
|
| 228 |
+
if (typeof fn === 'function') fn();
|
| 229 |
+
});
|
| 230 |
+
highlightDSA();
|
| 231 |
+
});
|
| 232 |
+
</script>
|
| 233 |
+
|
| 234 |
+
<h5>Reports Timeline</h5>
|
| 235 |
+
<table class="table table-bordered table-sm bg-white mb-4" style="font-size:.9rem;">
|
| 236 |
+
<thead class="table-light"><tr><th>Source</th><th>Date</th><th>Class</th><th>%</th><th>Overall</th><th>Specificity</th></tr></thead>
|
| 237 |
+
<tbody>
|
| 238 |
+
{% for r in reports %}
|
| 239 |
+
<tr>
|
| 240 |
+
<td><span class="badge {{ 'badge-pra' if r.source == 'PRA' else 'badge-dsa' }}">{{ r.source }}</span></td>
|
| 241 |
+
<td>{{ r.report_date }}</td>
|
| 242 |
+
<td>{{ r.class_label }}</td>
|
| 243 |
+
<td class="fw-bold">{{ r.pct }}%</td>
|
| 244 |
+
<td><span class="badge {{ 'bg-danger' if r.overall == 'Positive' else 'bg-success' }}">{{ r.overall }}</span></td>
|
| 245 |
+
<td class="small" style="max-width:400px;">
|
| 246 |
+
{%- set spec_text = (r.specificity or '') | replace('>', '>') -%}
|
| 247 |
+
{%- if 'Weak (MFI' in spec_text -%}
|
| 248 |
+
{%- set parts = spec_text.split('Weak (MFI', 1) -%}
|
| 249 |
+
<div class="spec-cell spec-strong-bg">{{ parts[0]|trim }}</div>
|
| 250 |
+
<div class="spec-cell spec-weak-bg">Weak (MFI{{ parts[1] }}</div>
|
| 251 |
+
{%- else -%}
|
| 252 |
+
<div class="spec-cell">{{ spec_text or '(-)' }}</div>
|
| 253 |
+
{%- endif -%}
|
| 254 |
+
</td>
|
| 255 |
+
</tr>
|
| 256 |
+
{% endfor %}
|
| 257 |
+
</tbody>
|
| 258 |
+
</table>
|
| 259 |
+
|
| 260 |
+
{% for comp_label, comp_id, comp_dates, comp_antigens in [
|
| 261 |
+
('Class I', 'chart1', comparison_class1.dates if comparison_class1 else [], comparison_class1.antigens if comparison_class1 else []),
|
| 262 |
+
('Class II', 'chart2', comparison_class2.dates if comparison_class2 else [], comparison_class2.antigens if comparison_class2 else [])
|
| 263 |
+
] %}
|
| 264 |
+
{% if comp_dates and comp_dates | length >= 1 %}
|
| 265 |
+
<h5>{{ comp_label }} — Combined MFI Trend
|
| 266 |
+
<small class="text-muted ms-2">DSA 實線▲ / PRA 虛線●</small>
|
| 267 |
+
</h5>
|
| 268 |
+
<div class="mb-2">
|
| 269 |
+
<small class="text-muted me-2">點圖例可顯示/隱藏抗體</small>
|
| 270 |
+
<div class="btn-group btn-group-sm" id="filter-{{ comp_id }}">
|
| 271 |
+
<button type="button" class="btn btn-outline-secondary" onclick="setCombinedFilter('{{ comp_id }}',this,'all')">全部</button>
|
| 272 |
+
<button type="button" class="btn btn-outline-danger" onclick="setCombinedFilter('{{ comp_id }}',this,'dsa')">DSA</button>
|
| 273 |
+
<button type="button" class="btn btn-outline-secondary" onclick="setCombinedFilter('{{ comp_id }}',this,'10')">Top 10</button>
|
| 274 |
+
<button type="button" class="btn btn-outline-secondary" onclick="setCombinedFilter('{{ comp_id }}',this,'20')">Top 20</button>
|
| 275 |
+
</div>
|
| 276 |
+
</div>
|
| 277 |
+
<div class="chart-container"><canvas id="{{ comp_id }}"></canvas></div>
|
| 278 |
+
<script>
|
| 279 |
+
(function() {
|
| 280 |
+
const dates = {{ comp_dates | tojson }};
|
| 281 |
+
const allData = [
|
| 282 |
+
{% for ag in comp_antigens %}
|
| 283 |
+
{ allele: '{{ ag.allele }}', antigen: '{{ ag.antigen }}',
|
| 284 |
+
pra: [{% for d in comp_dates %}{% set v = ag.pra_mfi.get(d) %}{{ v if v is not none else 'null' }}{% if not loop.last %},{% endif %}{% endfor %}],
|
| 285 |
+
dsa: [{% for d in comp_dates %}{% set v = ag.dsa_mfi.get(d) %}{{ v if v is not none else 'null' }}{% if not loop.last %},{% endif %}{% endfor %}] },
|
| 286 |
+
{% endfor %}
|
| 287 |
+
];
|
| 288 |
+
function getColor(ag, i) {
|
| 289 |
+
const a = ['#e53935','#d81b60','#8e24aa','#5e35b1','#3949ab','#1e88e5','#ef5350','#ec407a'];
|
| 290 |
+
const b = ['#00897b','#43a047','#7cb342','#c0ca33','#26a69a','#66bb6a','#9ccc65','#d4e157'];
|
| 291 |
+
const c = ['#ff8f00','#ff6f00','#f57f17','#ffb300','#ffa000'];
|
| 292 |
+
if (ag.startsWith('A') || ag.startsWith('DR')) return a[i % a.length];
|
| 293 |
+
if (ag.startsWith('B') || ag.startsWith('DQ')) return b[i % b.length];
|
| 294 |
+
return c[i % c.length];
|
| 295 |
+
}
|
| 296 |
+
// Sort by combined max MFI desc
|
| 297 |
+
allData.forEach(ag => {
|
| 298 |
+
const all_vals = ag.pra.concat(ag.dsa).filter(v => v !== null);
|
| 299 |
+
ag._max = all_vals.length ? Math.max(...all_vals) : 0;
|
| 300 |
+
});
|
| 301 |
+
allData.sort((x, y) => y._max - x._max);
|
| 302 |
+
let chartInstance = null;
|
| 303 |
+
let currentFilter = '20';
|
| 304 |
+
function matchesDonor(ag, ds) {
|
| 305 |
+
if (ag.antigen && ds.has(ag.antigen)) return true;
|
| 306 |
+
if (ag.allele && ds.has(ag.allele)) return true;
|
| 307 |
+
return false;
|
| 308 |
+
}
|
| 309 |
+
function buildChart(limit) {
|
| 310 |
+
if (limit) currentFilter = limit;
|
| 311 |
+
let data;
|
| 312 |
+
if (currentFilter === 'dsa') {
|
| 313 |
+
const ds = (typeof getDonorSet === 'function') ? getDonorSet() : new Set();
|
| 314 |
+
data = allData.filter(ag => matchesDonor(ag, ds));
|
| 315 |
+
} else if (currentFilter === 'all') {
|
| 316 |
+
data = allData;
|
| 317 |
+
} else {
|
| 318 |
+
data = allData.slice(0, parseInt(currentFilter));
|
| 319 |
+
}
|
| 320 |
+
const datasets = [];
|
| 321 |
+
data.forEach((ag, i) => {
|
| 322 |
+
const col = getColor(ag.antigen || ag.allele, i);
|
| 323 |
+
const hasPra = ag.pra.some(v => v !== null);
|
| 324 |
+
const hasDsa = ag.dsa.some(v => v !== null);
|
| 325 |
+
const labelBase = ag.antigen + (ag.allele && ag.allele !== ag.antigen ? '(' + ag.allele + ')' : '');
|
| 326 |
+
if (hasPra) datasets.push({
|
| 327 |
+
label: labelBase + ' PRA', data: ag.pra,
|
| 328 |
+
borderColor: col, backgroundColor: col + '33',
|
| 329 |
+
borderWidth: 2, borderDash: [6, 4], pointRadius: 4, pointHoverRadius: 6,
|
| 330 |
+
pointStyle: 'circle', tension: 0.3, spanGaps: true,
|
| 331 |
+
});
|
| 332 |
+
if (hasDsa) datasets.push({
|
| 333 |
+
label: labelBase + ' DSA', data: ag.dsa,
|
| 334 |
+
borderColor: col, backgroundColor: col + '33',
|
| 335 |
+
borderWidth: 2.5, pointRadius: 5, pointHoverRadius: 7,
|
| 336 |
+
pointStyle: 'triangle', tension: 0.3, spanGaps: true,
|
| 337 |
+
});
|
| 338 |
+
});
|
| 339 |
+
if (chartInstance) chartInstance.destroy();
|
| 340 |
+
chartInstance = new Chart(document.getElementById('{{ comp_id }}'), {
|
| 341 |
+
type: 'line', data: { labels: dates, datasets: datasets },
|
| 342 |
+
options: { responsive: true, maintainAspectRatio: false,
|
| 343 |
+
interaction: { mode: 'nearest', intersect: false },
|
| 344 |
+
plugins: {
|
| 345 |
+
legend: { position: 'right', labels: { font: { size: 11 }, boxWidth: 12, padding: 4, usePointStyle: true } },
|
| 346 |
+
tooltip: { callbacks: { label: c => c.dataset.label + ': ' + (c.parsed.y !== null ? c.parsed.y.toLocaleString() : '-') } }
|
| 347 |
+
},
|
| 348 |
+
scales: { y: { title: { display: true, text: 'Max MFI' }, beginAtZero: true },
|
| 349 |
+
x: { title: { display: true, text: 'Report Date' } } } }
|
| 350 |
+
});
|
| 351 |
+
}
|
| 352 |
+
window['rebuildCombinedChart_{{ comp_id }}'] = function() { buildChart(); };
|
| 353 |
+
window['updateCombinedFilter_{{ comp_id }}'] = function(val) { buildChart(val); };
|
| 354 |
+
function applyDefault() {
|
| 355 |
+
let defaultFilter = '20';
|
| 356 |
+
try {
|
| 357 |
+
const ds = (typeof getDonorSet === 'function') ? getDonorSet() : new Set();
|
| 358 |
+
if (ds.size > 0 && allData.some(ag => matchesDonor(ag, ds))) defaultFilter = 'dsa';
|
| 359 |
+
} catch (e) {}
|
| 360 |
+
buildChart(defaultFilter);
|
| 361 |
+
document.querySelectorAll('#filter-{{ comp_id }} .btn').forEach(b => {
|
| 362 |
+
b.classList.remove('active');
|
| 363 |
+
if ((defaultFilter === 'dsa' && b.textContent === 'DSA') ||
|
| 364 |
+
(defaultFilter === '20' && b.textContent === 'Top 20'))
|
| 365 |
+
b.classList.add('active');
|
| 366 |
+
});
|
| 367 |
+
}
|
| 368 |
+
window['applyCombinedDefault_{{ comp_id }}'] = applyDefault;
|
| 369 |
+
applyDefault();
|
| 370 |
+
})();
|
| 371 |
+
</script>
|
| 372 |
+
{% endif %}
|
| 373 |
+
{% endfor %}
|
| 374 |
+
|
| 375 |
+
<h5>Antibody Strength <small class="text-muted">(每筆報告獨立)</small></h5>
|
| 376 |
+
{% for r in reports %}
|
| 377 |
+
{% if r.antibodies %}
|
| 378 |
+
<div class="card mb-3">
|
| 379 |
+
<div class="card-header">
|
| 380 |
+
<span class="badge {{ 'badge-pra' if r.source == 'PRA' else 'badge-dsa' }} me-1">{{ r.source }}</span>
|
| 381 |
+
<strong>{{ r.class_label }}</strong> — {{ r.report_date }}
|
| 382 |
+
<span class="badge ms-1 {{ 'bg-danger' if r.overall == 'Positive' else 'bg-success' }}">{{ r.overall }}</span>
|
| 383 |
+
<span class="fw-bold ms-1">{{ r.pct }}%</span>
|
| 384 |
+
</div>
|
| 385 |
+
<div class="card-body p-2">
|
| 386 |
+
<table class="table table-sm table-bordered ab-table mb-0">
|
| 387 |
+
<thead class="table-light"><tr>
|
| 388 |
+
<th>Antigen</th><th>Allele</th>
|
| 389 |
+
{% if r.source == 'DSA' %}<th>Strength</th>{% endif %}
|
| 390 |
+
<th class="text-end">Max MFI</th>
|
| 391 |
+
<th class="text-end">Mean MFI</th><th class="text-center">No of Beads</th><th>Bead IDs</th>
|
| 392 |
+
</tr></thead>
|
| 393 |
+
<tbody>
|
| 394 |
+
{% for a in r.antibodies %}
|
| 395 |
+
<tr class="{% if r.source == 'DSA' and a.strength == 'Strong' %}strength-strong{% elif r.source == 'DSA' and a.strength == 'Weak' %}strength-weak{% endif %}">
|
| 396 |
+
<td style="color:#1E40AF;font-weight:bold;">{{ a.antigen }}</td>
|
| 397 |
+
<td class="small" style="color:#374151;">{{ a.allele }}</td>
|
| 398 |
+
{% if r.source == 'DSA' %}<td>{{ a.strength or '' }}</td>{% endif %}
|
| 399 |
+
<td class="text-end">{{ a.max_mfi|int }}</td>
|
| 400 |
+
<td class="text-end">{{ a.mean_mfi|int }}</td>
|
| 401 |
+
<td class="text-center">{{ a.no_of_beads }}</td>
|
| 402 |
+
<td class="small text-muted">{{ a.bead_ids }}</td>
|
| 403 |
+
</tr>
|
| 404 |
+
{% endfor %}
|
| 405 |
+
</tbody>
|
| 406 |
+
</table>
|
| 407 |
+
</div>
|
| 408 |
+
</div>
|
| 409 |
+
{% endif %}
|
| 410 |
+
{% endfor %}
|
| 411 |
+
{% else %}
|
| 412 |
+
{% if patients %}
|
| 413 |
+
<div class="alert alert-info">請先選擇病人。</div>
|
| 414 |
+
{% endif %}
|
| 415 |
+
{% endif %}
|
| 416 |
+
</div>
|
| 417 |
+
</body>
|
| 418 |
+
</html>
|
templates//dashboard.html
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>腎臟免疫學分析</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
body { background: #f5f7fa; }
|
| 10 |
+
.dash-card {
|
| 11 |
+
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,.08);
|
| 12 |
+
padding: 2rem 1rem; text-align: center; transition: all .2s; cursor: pointer;
|
| 13 |
+
text-decoration: none; color: inherit; display: block; height: 100%;
|
| 14 |
+
}
|
| 15 |
+
.dash-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,.15); transform: translateY(-2px); }
|
| 16 |
+
.dash-card.disabled { cursor: not-allowed; opacity: .55; }
|
| 17 |
+
.dash-card.disabled:hover { box-shadow: 0 2px 12px rgba(0,0,0,.08); transform: none; }
|
| 18 |
+
.dash-icon { font-size: 2.3rem; margin-bottom: .6rem; }
|
| 19 |
+
.dash-label { font-size: 1.15rem; font-weight: 600; }
|
| 20 |
+
.dash-desc { font-size: .82rem; color: #6c757d; margin-top: .3rem; }
|
| 21 |
+
.section-title {
|
| 22 |
+
font-size: .9rem; font-weight: 600; color: #6c757d; text-transform: uppercase;
|
| 23 |
+
letter-spacing: .08em; margin: 1.8rem 0 .8rem; padding-bottom: .4rem;
|
| 24 |
+
border-bottom: 1px solid #dee2e6;
|
| 25 |
+
}
|
| 26 |
+
.section-title.pra { color: #1E40AF; border-bottom-color: #bfdbfe; }
|
| 27 |
+
.section-title.dsa { color: #dc3545; border-bottom-color: #f5c2c7; }
|
| 28 |
+
.section-title.combined { color: #6f42c1; border-bottom-color: #d6c2f2; }
|
| 29 |
+
.section-title.admin { color: #198754; border-bottom-color: #badbcc; }
|
| 30 |
+
</style>
|
| 31 |
+
</head>
|
| 32 |
+
<body>
|
| 33 |
+
<div class="container py-5" style="max-width: 1100px;">
|
| 34 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 35 |
+
<div>
|
| 36 |
+
<h2 class="mb-0">腎臟免疫學分析</h2>
|
| 37 |
+
<p class="text-muted mb-0">LABScreen PRA Class I / II & Donor-Specific Antibody</p>
|
| 38 |
+
</div>
|
| 39 |
+
<div>
|
| 40 |
+
<span class="text-muted me-2">{{ username }}</span>
|
| 41 |
+
{% if is_admin %}
|
| 42 |
+
<a href="/admin" class="btn btn-outline-danger btn-sm me-1">後台管理</a>
|
| 43 |
+
{% endif %}
|
| 44 |
+
<a href="/logout" class="btn btn-outline-secondary btn-sm">登出</a>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<!-- 第一排:PRA -->
|
| 49 |
+
<div class="section-title pra">PRA Analysis</div>
|
| 50 |
+
<div class="row g-3">
|
| 51 |
+
<div class="col-md-3 col-sm-6">
|
| 52 |
+
<a href="/new" class="dash-card">
|
| 53 |
+
<div class="dash-icon">📄</div>
|
| 54 |
+
<div class="dash-label">新增資料</div>
|
| 55 |
+
<div class="dash-desc">上傳單筆XLS進行分析</div>
|
| 56 |
+
</a>
|
| 57 |
+
</div>
|
| 58 |
+
<div class="col-md-3 col-sm-6">
|
| 59 |
+
<a href="/batch_upload" class="dash-card">
|
| 60 |
+
<div class="dash-icon">📚</div>
|
| 61 |
+
<div class="dash-label">批次新增資料</div>
|
| 62 |
+
<div class="dash-desc">上傳多筆XLS自動抓取</div>
|
| 63 |
+
</a>
|
| 64 |
+
</div>
|
| 65 |
+
<div class="col-md-3 col-sm-6">
|
| 66 |
+
<a href="/history" class="dash-card">
|
| 67 |
+
<div class="dash-icon">🔍</div>
|
| 68 |
+
<div class="dash-label">查詢紀錄</div>
|
| 69 |
+
<div class="dash-desc">搜尋病人歷史報告</div>
|
| 70 |
+
</a>
|
| 71 |
+
</div>
|
| 72 |
+
<div class="col-md-3 col-sm-6">
|
| 73 |
+
<a href="/analysis" class="dash-card">
|
| 74 |
+
<div class="dash-icon">📈</div>
|
| 75 |
+
<div class="dash-label">統計分析</div>
|
| 76 |
+
<div class="dash-desc">MFI 趨勢圖與比較</div>
|
| 77 |
+
</a>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<!-- 第二排:DSA -->
|
| 82 |
+
<div class="section-title dsa">DSA Analysis</div>
|
| 83 |
+
<div class="row g-3">
|
| 84 |
+
<div class="col-md-3 col-sm-6">
|
| 85 |
+
<a href="/dsa/new" class="dash-card">
|
| 86 |
+
<div class="dash-icon">📄</div>
|
| 87 |
+
<div class="dash-label">新增資料</div>
|
| 88 |
+
<div class="dash-desc">上傳單筆XLS進行分析</div>
|
| 89 |
+
</a>
|
| 90 |
+
</div>
|
| 91 |
+
<div class="col-md-3 col-sm-6">
|
| 92 |
+
<a href="/dsa/batch_upload" class="dash-card">
|
| 93 |
+
<div class="dash-icon">📚</div>
|
| 94 |
+
<div class="dash-label">批次新增資料</div>
|
| 95 |
+
<div class="dash-desc">上傳多筆XLS自動抓取</div>
|
| 96 |
+
</a>
|
| 97 |
+
</div>
|
| 98 |
+
<div class="col-md-3 col-sm-6">
|
| 99 |
+
<a href="/dsa/history" class="dash-card">
|
| 100 |
+
<div class="dash-icon">🔍</div>
|
| 101 |
+
<div class="dash-label">查詢紀錄</div>
|
| 102 |
+
<div class="dash-desc">搜尋病人歷史報告</div>
|
| 103 |
+
</a>
|
| 104 |
+
</div>
|
| 105 |
+
<div class="col-md-3 col-sm-6">
|
| 106 |
+
<a href="/dsa/analysis" class="dash-card">
|
| 107 |
+
<div class="dash-icon">📈</div>
|
| 108 |
+
<div class="dash-label">統計分析</div>
|
| 109 |
+
<div class="dash-desc">MFI 趨勢圖與比較</div>
|
| 110 |
+
</a>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<!-- 第三排:綜合分析(PRA + DSA 對照) -->
|
| 115 |
+
<div class="section-title combined">綜合分析</div>
|
| 116 |
+
<div class="row g-3">
|
| 117 |
+
<div class="col-md-3 col-sm-6">
|
| 118 |
+
<a href="/combined" class="dash-card" style="border: 2px solid #6f42c1;">
|
| 119 |
+
<div class="dash-icon">📊</div>
|
| 120 |
+
<div class="dash-label">PRA + DSA 綜合分析</div>
|
| 121 |
+
<div class="dash-desc">同病人 PRA / DSA 同表對照</div>
|
| 122 |
+
</a>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<!-- 第四排:後台管理(僅 admin) -->
|
| 127 |
+
{% if is_admin %}
|
| 128 |
+
<div class="section-title admin">後台管理</div>
|
| 129 |
+
<div class="row g-3">
|
| 130 |
+
<div class="col-md-3 col-sm-6">
|
| 131 |
+
<a href="/admin" class="dash-card" style="border: 2px solid #dc3545;">
|
| 132 |
+
<div class="dash-icon">⚙</div>
|
| 133 |
+
<div class="dash-label">後台管理</div>
|
| 134 |
+
<div class="dash-desc">使用者帳號與權限管理</div>
|
| 135 |
+
</a>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
{% endif %}
|
| 139 |
+
</div>
|
| 140 |
+
</body>
|
| 141 |
+
</html>
|
templates//dsa_analysis.html
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>DSA 統計分析</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
| 9 |
+
<style>
|
| 10 |
+
.chart-container { position: relative; height: 540px; background: #fff; border: 1px solid #dee2e6; border-radius: 6px; padding: 1rem; margin-bottom: 1.5rem; }
|
| 11 |
+
.ab-table { font-size: .82rem; }
|
| 12 |
+
.strength-strong { --bs-table-bg: #f8d7da; background-color: #f8d7da !important; font-weight: bold; }
|
| 13 |
+
.strength-strong > td { background-color: #f8d7da !important; }
|
| 14 |
+
.strength-weak { --bs-table-bg: #fff9c4; background-color: #fff9c4 !important; }
|
| 15 |
+
.strength-weak > td { background-color: #fff9c4 !important; }
|
| 16 |
+
.spec-strong-bg { background-color: #f8d7da; padding: 3px 6px; border-radius: 3px; margin-bottom: 2px; display: block; }
|
| 17 |
+
.spec-weak-bg { background-color: #fff9c4; padding: 3px 6px; border-radius: 3px; display: block; }
|
| 18 |
+
</style>
|
| 19 |
+
</head>
|
| 20 |
+
<body>
|
| 21 |
+
<div class="container py-4" style="max-width: 1200px;">
|
| 22 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 23 |
+
<div><h2 class="mb-0">DSA 統計分析</h2></div>
|
| 24 |
+
<div>
|
| 25 |
+
<a href="/" class="btn btn-dark btn-sm me-1">首頁</a>
|
| 26 |
+
<a href="/dsa/history" class="btn btn-outline-secondary btn-sm me-1">歷史紀錄</a>
|
| 27 |
+
{% if chart_no and patient %}
|
| 28 |
+
<a href="/dsa/history/{{ chart_no }}" class="btn btn-outline-info btn-sm me-1">此病患歷史</a>
|
| 29 |
+
{% endif %}
|
| 30 |
+
<a href="/dsa/new" class="btn btn-outline-primary btn-sm">新增資料</a>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<form class="row g-2 mb-4" method="get" action="/dsa/analysis">
|
| 35 |
+
<div class="col-sm-6">
|
| 36 |
+
<select class="form-select" id="patientSelect" onchange="selectPatient(this.value)">
|
| 37 |
+
<option value="">-- 選擇病人 --</option>
|
| 38 |
+
{% for p in patients %}
|
| 39 |
+
<option value="{{ p.chart_no }}" {{ 'selected' if p.chart_no == chart_no else '' }}>
|
| 40 |
+
{{ p.patient_name }} ({{ p.chart_no }})
|
| 41 |
+
</option>
|
| 42 |
+
{% endfor %}
|
| 43 |
+
</select>
|
| 44 |
+
<input type="hidden" name="chart_no" id="chartNoInput" value="{{ chart_no }}">
|
| 45 |
+
</div>
|
| 46 |
+
</form>
|
| 47 |
+
<script>
|
| 48 |
+
function selectPatient(val) {
|
| 49 |
+
if (!val) return;
|
| 50 |
+
document.getElementById('chartNoInput').value = val;
|
| 51 |
+
document.getElementById('chartNoInput').form.submit();
|
| 52 |
+
}
|
| 53 |
+
</script>
|
| 54 |
+
|
| 55 |
+
{% if patient %}
|
| 56 |
+
<div class="card mb-3">
|
| 57 |
+
<div class="card-header fw-bold">{{ patient.patient_name }} ({{ patient.chart_no }}) — Donor HLA</div>
|
| 58 |
+
<div class="card-body" style="font-size:.85rem;">
|
| 59 |
+
<style>.donor-hla { width: 80px !important; padding: 2px 4px !important; text-align: center; }</style>
|
| 60 |
+
<div class="row">
|
| 61 |
+
<div class="col-md-5">
|
| 62 |
+
<div class="fw-bold mb-1">Serology</div>
|
| 63 |
+
<table class="table table-sm table-bordered mb-1"><tbody>
|
| 64 |
+
<tr><td class="fw-bold">A</td>
|
| 65 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-1" oninput="highlightDSA()"></td>
|
| 66 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-2" oninput="highlightDSA()"></td>
|
| 67 |
+
<td class="fw-bold">DR</td>
|
| 68 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-1" oninput="highlightDSA()"></td>
|
| 69 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-2" oninput="highlightDSA()"></td></tr>
|
| 70 |
+
<tr><td class="fw-bold">B</td>
|
| 71 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-1" oninput="highlightDSA()"></td>
|
| 72 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-2" oninput="highlightDSA()"></td>
|
| 73 |
+
<td class="fw-bold">DQ</td>
|
| 74 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-1" oninput="highlightDSA()"></td>
|
| 75 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-2" oninput="highlightDSA()"></td></tr>
|
| 76 |
+
<tr><td class="fw-bold">Cw</td>
|
| 77 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-1" oninput="highlightDSA()"></td>
|
| 78 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-2" oninput="highlightDSA()"></td>
|
| 79 |
+
<td class="fw-bold">DP</td>
|
| 80 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-1" oninput="highlightDSA()"></td>
|
| 81 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-2" oninput="highlightDSA()"></td></tr>
|
| 82 |
+
</tbody></table>
|
| 83 |
+
</div>
|
| 84 |
+
<div class="col-md-7">
|
| 85 |
+
<div class="fw-bold mb-1">DNA Typing</div>
|
| 86 |
+
<table class="table table-sm table-bordered mb-1"><tbody>
|
| 87 |
+
<tr><td class="fw-bold">A*</td>
|
| 88 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-1" oninput="highlightDSA()"></td>
|
| 89 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-2" oninput="highlightDSA()"></td>
|
| 90 |
+
<td class="fw-bold">DRB1*</td>
|
| 91 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-1" oninput="highlightDSA()"></td>
|
| 92 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-2" oninput="highlightDSA()"></td></tr>
|
| 93 |
+
<tr><td class="fw-bold">B*</td>
|
| 94 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-1" oninput="highlightDSA()"></td>
|
| 95 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-2" oninput="highlightDSA()"></td>
|
| 96 |
+
<td class="fw-bold">DQB1*</td>
|
| 97 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-1" oninput="highlightDSA()"></td>
|
| 98 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-2" oninput="highlightDSA()"></td></tr>
|
| 99 |
+
<tr><td class="fw-bold">C*</td>
|
| 100 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-1" oninput="highlightDSA()"></td>
|
| 101 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-2" oninput="highlightDSA()"></td>
|
| 102 |
+
<td class="fw-bold">DQA1*</td>
|
| 103 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-1" oninput="highlightDSA()"></td>
|
| 104 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-2" oninput="highlightDSA()"></td></tr>
|
| 105 |
+
<tr><td></td><td></td><td></td>
|
| 106 |
+
<td class="fw-bold">DPB1*</td>
|
| 107 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-1" oninput="highlightDSA()"></td>
|
| 108 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-2" oninput="highlightDSA()"></td></tr>
|
| 109 |
+
<tr><td></td><td></td><td></td>
|
| 110 |
+
<td class="fw-bold">DPA1*</td>
|
| 111 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-1" oninput="highlightDSA()"></td>
|
| 112 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-2" oninput="highlightDSA()"></td></tr>
|
| 113 |
+
</tbody></table>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
<div class="text-end">
|
| 117 |
+
<button class="btn btn-sm btn-outline-danger" onclick="saveDonorHLA(this)">存檔</button>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<script>
|
| 123 |
+
function getDonorSet() {
|
| 124 |
+
const s = new Set();
|
| 125 |
+
['A','B','Cw','DR','DQ','DP'].forEach(l => {
|
| 126 |
+
[1,2].forEach(n => {
|
| 127 |
+
const el = document.getElementById('donor-' + l + '-' + n);
|
| 128 |
+
if (el && el.value.trim()) s.add(l + el.value.trim());
|
| 129 |
+
});
|
| 130 |
+
});
|
| 131 |
+
['A','B','C','DRB1','DQB1','DQA1','DPB1','DPA1'].forEach(l => {
|
| 132 |
+
[1,2].forEach(n => {
|
| 133 |
+
const el = document.getElementById('donor-dna-' + l + '-' + n);
|
| 134 |
+
if (el && el.value.trim()) s.add(l + '*' + el.value.trim());
|
| 135 |
+
});
|
| 136 |
+
});
|
| 137 |
+
return s;
|
| 138 |
+
}
|
| 139 |
+
function highlightTokensInCell(cell, donorSet) {
|
| 140 |
+
if (!cell) return;
|
| 141 |
+
const text = cell.dataset.orig || cell.textContent;
|
| 142 |
+
cell.dataset.orig = text;
|
| 143 |
+
const tokens = text.split(/,\s*/);
|
| 144 |
+
cell.innerHTML = tokens.map(tok => {
|
| 145 |
+
const t = tok.trim();
|
| 146 |
+
if (!t) return tok;
|
| 147 |
+
for (const ag of donorSet) {
|
| 148 |
+
if (t.includes(ag)) return '<span style="color:#dc3545;font-weight:bold;">' + tok + '</span>';
|
| 149 |
+
}
|
| 150 |
+
return tok;
|
| 151 |
+
}).join(', ');
|
| 152 |
+
}
|
| 153 |
+
function highlightSpecText(specEl, donorSet) {
|
| 154 |
+
if (!specEl) return;
|
| 155 |
+
const orig = specEl.dataset.orig || specEl.textContent;
|
| 156 |
+
specEl.dataset.orig = orig;
|
| 157 |
+
const tokens = orig.match(/\S+?\([^)]*\)|\S+/g) || [];
|
| 158 |
+
specEl.innerHTML = tokens.map(seg => {
|
| 159 |
+
const m = seg.match(/^([^(]+)(\([^)]*\))?$/);
|
| 160 |
+
if (!m) return seg;
|
| 161 |
+
const sero = m[1];
|
| 162 |
+
const paren = m[2] || '';
|
| 163 |
+
if (!/^[A-Za-z]/.test(sero)) return seg;
|
| 164 |
+
let isDSA = false;
|
| 165 |
+
for (const ag of donorSet) {
|
| 166 |
+
if (!ag) continue;
|
| 167 |
+
if (sero === ag || (paren && paren.indexOf(ag) >= 0)) { isDSA = true; break; }
|
| 168 |
+
}
|
| 169 |
+
if (isDSA) return '<span style="color:#dc3545;font-weight:bold;">' + seg + '</span>';
|
| 170 |
+
const seroHtml = '<span style="color:#1E40AF;font-weight:bold;">' + sero + '</span>';
|
| 171 |
+
if (!paren) return seroHtml;
|
| 172 |
+
const inner = paren.slice(1, -1);
|
| 173 |
+
const alleleHtml = inner.split(/\s+/).map(a =>
|
| 174 |
+
a ? '<span style="color:#374151;">' + a + '</span>' : a
|
| 175 |
+
).join(' ');
|
| 176 |
+
return seroHtml + '(' + alleleHtml + ')';
|
| 177 |
+
}).join(' ');
|
| 178 |
+
}
|
| 179 |
+
function highlightDSA() {
|
| 180 |
+
const ds = getDonorSet();
|
| 181 |
+
document.querySelectorAll('.spec-cell').forEach(el => highlightSpecText(el, ds));
|
| 182 |
+
document.querySelectorAll('.ab-table tbody tr').forEach(tr => {
|
| 183 |
+
highlightTokensInCell(tr.cells[0], ds);
|
| 184 |
+
highlightTokensInCell(tr.cells[1], ds);
|
| 185 |
+
});
|
| 186 |
+
['chart1', 'chart2'].forEach(cid => {
|
| 187 |
+
const fn = window['rebuildDsaChart_' + cid];
|
| 188 |
+
if (typeof fn === 'function') fn();
|
| 189 |
+
});
|
| 190 |
+
}
|
| 191 |
+
function setDsaFilter(chartId, btn, val) {
|
| 192 |
+
btn.parentElement.querySelectorAll('.btn').forEach(b => b.classList.remove('active'));
|
| 193 |
+
btn.classList.add('active');
|
| 194 |
+
const fn = window['updateDsaFilter_' + chartId];
|
| 195 |
+
if (typeof fn === 'function') fn(val);
|
| 196 |
+
}
|
| 197 |
+
function saveDonorHLA(btn) {
|
| 198 |
+
const obj = {};
|
| 199 |
+
document.querySelectorAll('.donor-hla').forEach(el => {
|
| 200 |
+
if (el.value.trim()) obj[el.id] = el.value.trim();
|
| 201 |
+
});
|
| 202 |
+
btn.disabled = true;
|
| 203 |
+
fetch('/save_donor_hla', {
|
| 204 |
+
method: 'POST', headers: {'Content-Type': 'application/json'},
|
| 205 |
+
body: JSON.stringify({ chart_no: '{{ patient.chart_no }}', donor_hla: JSON.stringify(obj) })
|
| 206 |
+
}).then(r => r.json()).then(d => {
|
| 207 |
+
btn.disabled = false;
|
| 208 |
+
if (d.ok) { btn.classList.remove('btn-outline-danger'); btn.classList.add('btn-danger'); btn.textContent = '已存檔'; }
|
| 209 |
+
});
|
| 210 |
+
highlightDSA();
|
| 211 |
+
}
|
| 212 |
+
window.addEventListener('DOMContentLoaded', function() {
|
| 213 |
+
const saved = {{ donor_hla | default('', true) | tojson }};
|
| 214 |
+
if (saved) {
|
| 215 |
+
try {
|
| 216 |
+
const obj = JSON.parse(saved);
|
| 217 |
+
Object.keys(obj).forEach(k => {
|
| 218 |
+
const el = document.getElementById(k);
|
| 219 |
+
if (el) el.value = obj[k];
|
| 220 |
+
});
|
| 221 |
+
} catch(e) {}
|
| 222 |
+
}
|
| 223 |
+
// After donor inputs are populated, re-apply default (may switch to DSA mode)
|
| 224 |
+
['chart1', 'chart2'].forEach(cid => {
|
| 225 |
+
const fn = window['applyDsaDefault_' + cid];
|
| 226 |
+
if (typeof fn === 'function') fn();
|
| 227 |
+
});
|
| 228 |
+
highlightDSA();
|
| 229 |
+
});
|
| 230 |
+
</script>
|
| 231 |
+
|
| 232 |
+
<h5>Reports</h5>
|
| 233 |
+
<table class="table table-bordered table-sm bg-white mb-4" style="font-size:.9rem;">
|
| 234 |
+
<thead class="table-light"><tr><th>Date</th><th>Class</th><th>%SA</th><th>Overall</th><th>Specificity</th></tr></thead>
|
| 235 |
+
<tbody>
|
| 236 |
+
{% for r in reports %}
|
| 237 |
+
<tr>
|
| 238 |
+
<td>{{ r.report_date }}</td>
|
| 239 |
+
<td>{{ r.dsa_class }}</td>
|
| 240 |
+
<td class="fw-bold">{{ r.pct_sa }}%</td>
|
| 241 |
+
<td><span class="badge {{ 'bg-danger' if r.overall == 'Positive' else 'bg-success' }}">{{ r.overall }}</span></td>
|
| 242 |
+
<td class="small" style="max-width:400px;">
|
| 243 |
+
{%- set spec_text = (r.specificity or '') | replace('>', '>') -%}
|
| 244 |
+
{%- if 'Weak (MFI' in spec_text -%}
|
| 245 |
+
{%- set parts = spec_text.split('Weak (MFI', 1) -%}
|
| 246 |
+
<div class="spec-cell spec-strong-bg">{{ parts[0]|trim }}</div>
|
| 247 |
+
<div class="spec-cell spec-weak-bg">Weak (MFI{{ parts[1] }}</div>
|
| 248 |
+
{%- else -%}
|
| 249 |
+
<div class="spec-cell">{{ spec_text or '(-)' }}</div>
|
| 250 |
+
{%- endif -%}
|
| 251 |
+
</td>
|
| 252 |
+
</tr>
|
| 253 |
+
{% endfor %}
|
| 254 |
+
</tbody>
|
| 255 |
+
</table>
|
| 256 |
+
|
| 257 |
+
{% for comp_label, comp_id, comp_dates, comp_antigens in [
|
| 258 |
+
('DSA Class I', 'chart1', comparison_class1.dates if comparison_class1 else [], comparison_class1.antigens if comparison_class1 else []),
|
| 259 |
+
('DSA Class II', 'chart2', comparison_class2.dates if comparison_class2 else [], comparison_class2.antigens if comparison_class2 else [])
|
| 260 |
+
] %}
|
| 261 |
+
{% if comp_dates and comp_dates | length >= 2 %}
|
| 262 |
+
<h5>{{ comp_label }} — MFI Trend</h5>
|
| 263 |
+
<div class="mb-2">
|
| 264 |
+
<small class="text-muted me-2">點圖例可顯示/隱藏抗體</small>
|
| 265 |
+
<div class="btn-group btn-group-sm" id="filter-{{ comp_id }}">
|
| 266 |
+
<button type="button" class="btn btn-outline-secondary" onclick="setDsaFilter('{{ comp_id }}',this,'all')">全部</button>
|
| 267 |
+
<button type="button" class="btn btn-outline-danger" onclick="setDsaFilter('{{ comp_id }}',this,'dsa')">DSA</button>
|
| 268 |
+
<button type="button" class="btn btn-outline-secondary" onclick="setDsaFilter('{{ comp_id }}',this,'10')">Top 10</button>
|
| 269 |
+
<button type="button" class="btn btn-outline-secondary" onclick="setDsaFilter('{{ comp_id }}',this,'20')">Top 20</button>
|
| 270 |
+
</div>
|
| 271 |
+
</div>
|
| 272 |
+
<div class="chart-container"><canvas id="{{ comp_id }}"></canvas></div>
|
| 273 |
+
<script>
|
| 274 |
+
(function() {
|
| 275 |
+
const dates = {{ comp_dates | tojson }};
|
| 276 |
+
const allData = [
|
| 277 |
+
{% for ag in comp_antigens %}
|
| 278 |
+
{ antigen: '{{ ag.antigen }}', allele: '{{ ag.allele }}',
|
| 279 |
+
values: [{% for d in comp_dates %}{{ ag.mfi_by_date.get(d, {}).get('max_mfi', 'null') }}{% if not loop.last %},{% endif %}{% endfor %}] },
|
| 280 |
+
{% endfor %}
|
| 281 |
+
];
|
| 282 |
+
function getColor(ag, i) {
|
| 283 |
+
const a = ['#e53935','#d81b60','#8e24aa','#5e35b1','#3949ab','#1e88e5','#ef5350','#ec407a'];
|
| 284 |
+
const b = ['#00897b','#43a047','#7cb342','#c0ca33','#26a69a','#66bb6a','#9ccc65','#d4e157'];
|
| 285 |
+
const c = ['#ff8f00','#ff6f00','#f57f17','#ffb300','#ffa000'];
|
| 286 |
+
if (ag.startsWith('A') || ag.startsWith('DR')) return a[i % a.length];
|
| 287 |
+
if (ag.startsWith('B') || ag.startsWith('DQ')) return b[i % b.length];
|
| 288 |
+
return c[i % c.length];
|
| 289 |
+
}
|
| 290 |
+
allData.sort((x, y) => Math.max(...y.values.filter(v=>v!==null)) - Math.max(...x.values.filter(v=>v!==null)));
|
| 291 |
+
let chartInstance = null;
|
| 292 |
+
let currentFilter = '20';
|
| 293 |
+
function matchesDonor(ag, ds) {
|
| 294 |
+
if (ag.antigen && ds.has(ag.antigen)) return true;
|
| 295 |
+
if (ag.allele && ds.has(ag.allele)) return true;
|
| 296 |
+
return false;
|
| 297 |
+
}
|
| 298 |
+
function buildChart(limit) {
|
| 299 |
+
if (limit) currentFilter = limit;
|
| 300 |
+
let data;
|
| 301 |
+
if (currentFilter === 'dsa') {
|
| 302 |
+
const ds = (typeof getDonorSet === 'function') ? getDonorSet() : new Set();
|
| 303 |
+
data = allData.filter(ag => matchesDonor(ag, ds));
|
| 304 |
+
} else if (currentFilter === 'all') {
|
| 305 |
+
data = allData;
|
| 306 |
+
} else {
|
| 307 |
+
data = allData.slice(0, parseInt(currentFilter));
|
| 308 |
+
}
|
| 309 |
+
const datasets = data.map((ag, i) => ({
|
| 310 |
+
label: ag.antigen + (ag.allele && ag.allele !== ag.antigen ? ' (' + ag.allele + ')' : ''),
|
| 311 |
+
data: ag.values, borderColor: getColor(ag.antigen, i),
|
| 312 |
+
backgroundColor: getColor(ag.antigen, i) + '33',
|
| 313 |
+
borderWidth: 2, pointRadius: 4, pointHoverRadius: 6, tension: 0.3, spanGaps: true,
|
| 314 |
+
}));
|
| 315 |
+
if (chartInstance) chartInstance.destroy();
|
| 316 |
+
chartInstance = new Chart(document.getElementById('{{ comp_id }}'), {
|
| 317 |
+
type: 'line', data: { labels: dates, datasets: datasets },
|
| 318 |
+
options: { responsive: true, maintainAspectRatio: false,
|
| 319 |
+
interaction: { mode: 'nearest', intersect: false },
|
| 320 |
+
plugins: { legend: { position: 'right', labels: { font: { size: 11 }, boxWidth: 12, padding: 6 } } },
|
| 321 |
+
scales: { y: { title: { display: true, text: 'Max MFI' }, beginAtZero: true },
|
| 322 |
+
x: { title: { display: true, text: 'Report Date' } } } }
|
| 323 |
+
});
|
| 324 |
+
}
|
| 325 |
+
window['rebuildDsaChart_{{ comp_id }}'] = function() { buildChart(); };
|
| 326 |
+
window['updateDsaFilter_{{ comp_id }}'] = function(val) { buildChart(val); };
|
| 327 |
+
function applyDefault() {
|
| 328 |
+
let defaultFilter = '20';
|
| 329 |
+
try {
|
| 330 |
+
const ds = (typeof getDonorSet === 'function') ? getDonorSet() : new Set();
|
| 331 |
+
if (ds.size > 0 && allData.some(ag => matchesDonor(ag, ds))) defaultFilter = 'dsa';
|
| 332 |
+
} catch (e) {}
|
| 333 |
+
buildChart(defaultFilter);
|
| 334 |
+
document.querySelectorAll('#filter-{{ comp_id }} .btn').forEach(b => {
|
| 335 |
+
b.classList.remove('active');
|
| 336 |
+
if ((defaultFilter === 'dsa' && b.textContent === 'DSA') ||
|
| 337 |
+
(defaultFilter === '20' && b.textContent === 'Top 20'))
|
| 338 |
+
b.classList.add('active');
|
| 339 |
+
});
|
| 340 |
+
}
|
| 341 |
+
window['applyDsaDefault_{{ comp_id }}'] = applyDefault;
|
| 342 |
+
applyDefault();
|
| 343 |
+
})();
|
| 344 |
+
</script>
|
| 345 |
+
{% endif %}
|
| 346 |
+
{% endfor %}
|
| 347 |
+
|
| 348 |
+
<h5>Antibody Strength</h5>
|
| 349 |
+
{% for r in reports %}
|
| 350 |
+
{% if r.antibodies %}
|
| 351 |
+
<div class="card mb-3">
|
| 352 |
+
<div class="card-header"><strong>{{ r.dsa_class }}</strong> — {{ r.report_date }}
|
| 353 |
+
<span class="fw-bold ms-1">%SA {{ r.pct_sa }}%</span></div>
|
| 354 |
+
<div class="card-body p-2">
|
| 355 |
+
<table class="table table-sm table-bordered ab-table mb-0">
|
| 356 |
+
<thead class="table-light"><tr>
|
| 357 |
+
<th>Antigen</th><th>Allele</th><th>Strength</th><th class="text-end">Max MFI</th>
|
| 358 |
+
<th class="text-end">Mean MFI</th><th class="text-center">No of Beads</th><th>Bead IDs</th>
|
| 359 |
+
</tr></thead>
|
| 360 |
+
<tbody>
|
| 361 |
+
{% for a in r.antibodies %}
|
| 362 |
+
<tr class="{% if a.strength == 'Strong' %}strength-strong{% elif a.strength == 'Weak' %}strength-weak{% endif %}">
|
| 363 |
+
<td class="fw-bold">{{ a.antigen }}</td><td class="small">{{ a.allele }}</td>
|
| 364 |
+
<td>{{ a.strength or '' }}</td>
|
| 365 |
+
<td class="text-end">{{ a.max_mfi|int }}</td>
|
| 366 |
+
<td class="text-end">{{ a.mean_mfi|int }}</td>
|
| 367 |
+
<td class="text-center">{{ a.no_of_beads }}</td>
|
| 368 |
+
<td class="small text-muted">{{ a.bead_ids }}</td>
|
| 369 |
+
</tr>
|
| 370 |
+
{% endfor %}
|
| 371 |
+
</tbody>
|
| 372 |
+
</table>
|
| 373 |
+
</div>
|
| 374 |
+
</div>
|
| 375 |
+
{% endif %}
|
| 376 |
+
{% endfor %}
|
| 377 |
+
{% else %}
|
| 378 |
+
<div class="alert alert-info">請先選擇病人。</div>
|
| 379 |
+
{% endif %}
|
| 380 |
+
</div>
|
| 381 |
+
</body>
|
| 382 |
+
</html>
|
templates//dsa_batch.html
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>DSA 批次新增資料</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
body { background: #f5f7fa; }
|
| 10 |
+
.drop-zone { border: 2px dashed #adb5bd; border-radius: 12px; padding: 2rem; text-align: center; cursor: pointer; background: #fff; }
|
| 11 |
+
.drop-zone:hover, .drop-zone.dragover { border-color: #0d6efd; background: #e8f0fe; }
|
| 12 |
+
table.preview td, table.preview th { font-size: .88rem; }
|
| 13 |
+
.status-ok { color: #198754; font-weight: bold; }
|
| 14 |
+
.status-err { color: #dc3545; font-weight: bold; }
|
| 15 |
+
.status-dup { color: #fd7e14; font-weight: bold; }
|
| 16 |
+
</style>
|
| 17 |
+
</head>
|
| 18 |
+
<body>
|
| 19 |
+
<div class="container py-4" style="max-width: 1100px;">
|
| 20 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 21 |
+
<h2 class="mb-0">DSA 批次新增資料</h2>
|
| 22 |
+
<div>
|
| 23 |
+
<a href="/" class="btn btn-dark btn-sm me-1">首頁</a>
|
| 24 |
+
<a href="/dsa/history" class="btn btn-outline-secondary btn-sm me-1">歷史紀錄</a>
|
| 25 |
+
<a href="/dsa/new" class="btn btn-outline-primary btn-sm">單筆新增</a>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<div class="alert alert-info">
|
| 30 |
+
<strong>檔名格式</strong>:必須包含<strong>病歷號</strong>與<strong>姓名</strong>,用底線 <code>_</code> 或空格分隔。<br>
|
| 31 |
+
範例:<code>15713765 黃小明 LS1.xls</code>、<code>40091041_王小明_LS2_20240605.xls</code>。<br>
|
| 32 |
+
系統會自動抓取,日期與 LS1 / LS2 / SA 之類描述會被忽略。
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<div class="card mb-3">
|
| 36 |
+
<div class="card-body">
|
| 37 |
+
<div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
|
| 38 |
+
<div style="font-size:2rem;">📁</div>
|
| 39 |
+
<div class="fw-bold">點擊或拖曳選取多個 XLS 檔</div>
|
| 40 |
+
<input type="file" id="fileInput" multiple accept=".xls,.xlsx" style="display:none" onchange="onPick(this)">
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<div id="previewWrap" style="display:none;">
|
| 46 |
+
<h5>預覽 <span class="text-muted small">(可手動修正病歷號 / 姓名)</span></h5>
|
| 47 |
+
<table class="table table-bordered table-sm bg-white preview">
|
| 48 |
+
<thead class="table-light">
|
| 49 |
+
<tr><th>檔名</th><th style="width:140px;">病歷號</th><th style="width:140px;">姓名</th><th style="width:100px;">狀態</th><th style="width:72px;"></th></tr>
|
| 50 |
+
</thead>
|
| 51 |
+
<tbody id="previewBody"></tbody>
|
| 52 |
+
</table>
|
| 53 |
+
<div class="mb-3">
|
| 54 |
+
<div class="form-check form-check-inline">
|
| 55 |
+
<input class="form-check-input" type="radio" name="dupmode" id="mode-overwrite" value="overwrite">
|
| 56 |
+
<label class="form-check-label" for="mode-overwrite">覆寫</label>
|
| 57 |
+
</div>
|
| 58 |
+
<div class="form-check form-check-inline">
|
| 59 |
+
<input class="form-check-input" type="radio" name="dupmode" id="mode-new" value="new" checked>
|
| 60 |
+
<label class="form-check-label" for="mode-new">可多筆存在</label>
|
| 61 |
+
</div>
|
| 62 |
+
<div class="form-check form-check-inline">
|
| 63 |
+
<input class="form-check-input" type="radio" name="dupmode" id="mode-skip" value="skip">
|
| 64 |
+
<label class="form-check-label" for="mode-skip">跳過</label>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
<button class="btn btn-primary" id="submitBtn" onclick="submitBatch()">全部上傳</button>
|
| 68 |
+
<div id="results" class="mt-3"></div>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
<script>
|
| 72 |
+
let selectedFiles = [];
|
| 73 |
+
|
| 74 |
+
function parseFilename(fname) {
|
| 75 |
+
const stem = fname.replace(/\.(xls|xlsx)$/i, '');
|
| 76 |
+
const tokens = stem.split(/[_\-. ]+/).filter(x => x);
|
| 77 |
+
function isDate(t) {
|
| 78 |
+
if (!/^\d{8}$/.test(t)) return false;
|
| 79 |
+
const y = +t.slice(0,4), m = +t.slice(4,6), d = +t.slice(6,8);
|
| 80 |
+
return y >= 1900 && y <= 2099 && m >= 1 && m <= 12 && d >= 1 && d <= 31;
|
| 81 |
+
}
|
| 82 |
+
function isDesc(t) {
|
| 83 |
+
return /^(PRA\d*|Class\d*|LS\w*|SA\d*|I|II|III)$/i.test(t);
|
| 84 |
+
}
|
| 85 |
+
function isChartNo(t) {
|
| 86 |
+
return /^\d{6,8}$/.test(t) && !isDate(t);
|
| 87 |
+
}
|
| 88 |
+
let chart = '', name = '';
|
| 89 |
+
for (const t of tokens) {
|
| 90 |
+
if (isDate(t) || isDesc(t)) continue;
|
| 91 |
+
if (isChartNo(t) && !chart) { chart = t; continue; }
|
| 92 |
+
if (!/^\d+$/.test(t) && !name) { name = t; }
|
| 93 |
+
}
|
| 94 |
+
return { name, chart };
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function onPick(input) {
|
| 98 |
+
selectedFiles = Array.from(input.files);
|
| 99 |
+
if (!selectedFiles.length) return;
|
| 100 |
+
const tbody = document.getElementById('previewBody');
|
| 101 |
+
tbody.innerHTML = '';
|
| 102 |
+
selectedFiles.forEach((f, i) => {
|
| 103 |
+
const p = parseFilename(f.name);
|
| 104 |
+
const tr = document.createElement('tr');
|
| 105 |
+
tr.dataset.idx = i;
|
| 106 |
+
const missing = !p.chart || !p.name;
|
| 107 |
+
tr.innerHTML = `
|
| 108 |
+
<td class="small">${f.name}</td>
|
| 109 |
+
<td><input type="text" class="form-control form-control-sm" data-field="chart" value="${p.chart}"></td>
|
| 110 |
+
<td><input type="text" class="form-control form-control-sm" data-field="name" value="${p.name}"></td>
|
| 111 |
+
<td class="${missing ? 'status-err' : 'status-ok'}">${missing ? '缺欄位' : '待上傳'}</td>
|
| 112 |
+
<td><button type="button" class="btn btn-sm btn-outline-danger" onclick="removeRow(this)">刪除</button></td>
|
| 113 |
+
`;
|
| 114 |
+
tbody.appendChild(tr);
|
| 115 |
+
});
|
| 116 |
+
document.getElementById('previewWrap').style.display = 'block';
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function removeRow(btn) {
|
| 120 |
+
const tr = btn.closest('tr');
|
| 121 |
+
if (tr) tr.remove();
|
| 122 |
+
if (!document.querySelectorAll('#previewBody tr').length) {
|
| 123 |
+
document.getElementById('previewWrap').style.display = 'none';
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function submitBatch() {
|
| 128 |
+
const btn = document.getElementById('submitBtn');
|
| 129 |
+
const mode = document.querySelector('input[name="dupmode"]:checked').value;
|
| 130 |
+
const rows = document.querySelectorAll('#previewBody tr');
|
| 131 |
+
btn.disabled = true; btn.textContent = '上傳中...';
|
| 132 |
+
const results = document.getElementById('results');
|
| 133 |
+
results.innerHTML = '<div class="text-muted small">處理中...</div>';
|
| 134 |
+
|
| 135 |
+
const fd = new FormData();
|
| 136 |
+
fd.append('mode', mode);
|
| 137 |
+
rows.forEach(tr => {
|
| 138 |
+
const idx = parseInt(tr.dataset.idx);
|
| 139 |
+
const chart = tr.querySelector('[data-field="chart"]').value.trim();
|
| 140 |
+
const name = tr.querySelector('[data-field="name"]').value.trim();
|
| 141 |
+
const f = selectedFiles[idx];
|
| 142 |
+
fd.append('file', f); fd.append('chart', chart); fd.append('name', name);
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
fetch('/dsa/batch_upload', { method: 'POST', body: fd })
|
| 146 |
+
.then(r => r.json())
|
| 147 |
+
.then(d => {
|
| 148 |
+
btn.disabled = false; btn.textContent = '全部上傳';
|
| 149 |
+
let html = '<table class="table table-sm table-bordered"><thead><tr><th>檔名</th><th>結果</th></tr></thead><tbody>';
|
| 150 |
+
(d.results || []).forEach(r => {
|
| 151 |
+
let cls = 'status-ok', msg = '成功新增';
|
| 152 |
+
if (r.status === 'error') { cls = 'status-err'; msg = r.msg || '失敗'; }
|
| 153 |
+
else if (r.status === 'duplicate') { cls = 'status-dup'; msg = '重複,已複寫'; }
|
| 154 |
+
else if (r.status === 'skipped') { cls = 'status-dup'; msg = '重複,跳過不新增'; }
|
| 155 |
+
html += `<tr><td class="small">${r.file}</td><td class="${cls}">${msg}</td></tr>`;
|
| 156 |
+
});
|
| 157 |
+
html += '</tbody></table>';
|
| 158 |
+
html += `<div class="mt-2">成功 ${d.summary.ok}、重複 ${d.summary.dup || 0}、失敗 ${d.summary.err}(共 ${d.summary.total})</div>`;
|
| 159 |
+
if (d.summary.ok > 0) html += '<a href="/dsa/history" class="btn btn-sm btn-outline-primary mt-2">查看歷史紀錄</a>';
|
| 160 |
+
results.innerHTML = html;
|
| 161 |
+
})
|
| 162 |
+
.catch(e => {
|
| 163 |
+
btn.disabled = false; btn.textContent = '全部上傳';
|
| 164 |
+
results.innerHTML = '<div class="alert alert-danger">上傳失敗:' + e + '</div>';
|
| 165 |
+
});
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
const dz = document.getElementById('dropZone');
|
| 169 |
+
['dragenter', 'dragover'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('dragover'); }));
|
| 170 |
+
['dragleave', 'drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('dragover'); }));
|
| 171 |
+
dz.addEventListener('drop', e => {
|
| 172 |
+
const dt = new DataTransfer();
|
| 173 |
+
Array.from(e.dataTransfer.files).forEach(f => dt.items.add(f));
|
| 174 |
+
document.getElementById('fileInput').files = dt.files;
|
| 175 |
+
onPick(document.getElementById('fileInput'));
|
| 176 |
+
});
|
| 177 |
+
</script>
|
| 178 |
+
</body>
|
| 179 |
+
</html>
|
templates//dsa_history.html
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>DSA History</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div class="container py-4" style="max-width: 1200px;">
|
| 11 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 12 |
+
<h2 class="mb-0">DSA 歷史紀錄</h2>
|
| 13 |
+
<div>
|
| 14 |
+
<a href="/" class="btn btn-dark btn-sm me-1">首頁</a>
|
| 15 |
+
<a href="/dsa/new" class="btn btn-outline-primary btn-sm me-1">新增資料</a>
|
| 16 |
+
<a href="/dsa/analysis" class="btn btn-outline-secondary btn-sm">統計分析</a>
|
| 17 |
+
</div>
|
| 18 |
+
</div>
|
| 19 |
+
<div class="small text-muted mb-2">目前共 {{ db_stats.patients }} 位病患、{{ db_stats.reports }} 筆 DSA 報告</div>
|
| 20 |
+
|
| 21 |
+
{% if error %}<div class="alert alert-danger">{{ error }}</div>{% endif %}
|
| 22 |
+
|
| 23 |
+
<form class="row g-2 mb-3" method="get" action="/dsa/history">
|
| 24 |
+
<div class="col">
|
| 25 |
+
<input type="text" class="form-control" name="q" placeholder="搜尋姓名或病歷號" value="{{ request.args.get('q','') }}">
|
| 26 |
+
</div>
|
| 27 |
+
<div class="col-auto d-flex align-items-center">
|
| 28 |
+
<span class="me-1 text-muted small">日期範圍</span>
|
| 29 |
+
<input type="date" class="form-control form-control-sm" name="from" value="{{ request.args.get('from','') }}" style="width:150px;">
|
| 30 |
+
<span class="mx-1">–</span>
|
| 31 |
+
<input type="date" class="form-control form-control-sm" name="to" value="{{ request.args.get('to','') }}" style="width:150px;">
|
| 32 |
+
</div>
|
| 33 |
+
<div class="col-auto"><button class="btn btn-secondary">搜尋</button></div>
|
| 34 |
+
</form>
|
| 35 |
+
|
| 36 |
+
<table class="table table-bordered table-hover bg-white" style="font-size:.9rem;">
|
| 37 |
+
<thead class="table-light">
|
| 38 |
+
<tr>
|
| 39 |
+
<th>病患姓名</th><th>病歷號</th><th>報告日期</th><th>Class</th>
|
| 40 |
+
<th>%SA</th><th>Overall</th><th>Antibodies</th><th>填表人</th><th></th>
|
| 41 |
+
</tr>
|
| 42 |
+
</thead>
|
| 43 |
+
<tbody>
|
| 44 |
+
{% set q = request.args.get('q','').lower() %}
|
| 45 |
+
{% set date_from = request.args.get('from','') %}
|
| 46 |
+
{% set date_to = request.args.get('to','') %}
|
| 47 |
+
{% for r in reports %}
|
| 48 |
+
{% set rd = r.report_date | replace('/','-') %}
|
| 49 |
+
{% if (not q or q in r.chart_no.lower() or q in r.patient_name.lower())
|
| 50 |
+
and (not date_from or rd >= date_from)
|
| 51 |
+
and (not date_to or rd <= date_to) %}
|
| 52 |
+
<tr>
|
| 53 |
+
<td>{{ r.patient_name }}</td>
|
| 54 |
+
<td>{{ r.chart_no }}</td>
|
| 55 |
+
<td>{{ r.report_date }}</td>
|
| 56 |
+
<td>{{ r.dsa_class }}</td>
|
| 57 |
+
<td class="fw-bold">{{ r.pct_sa }}%</td>
|
| 58 |
+
<td><span class="badge {{ 'bg-danger' if r.overall == 'Positive' else 'bg-success' }}">{{ r.overall }}</span></td>
|
| 59 |
+
<td class="small spec-cell" style="max-width:240px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"
|
| 60 |
+
title="{{ r.specificity or '' }}">
|
| 61 |
+
{{ r.specificity if r.specificity and r.specificity != '(-)' else '(-)' }}
|
| 62 |
+
</td>
|
| 63 |
+
<td class="small">{{ r.submitted_by or '' }}</td>
|
| 64 |
+
<td class="text-nowrap">
|
| 65 |
+
<a href="/dsa/history/{{ r.chart_no }}" class="btn btn-sm btn-outline-primary me-1">View</a>
|
| 66 |
+
<a href="/dsa/analysis?chart_no={{ r.chart_no }}" class="btn btn-sm btn-outline-success me-1">Analyze</a>
|
| 67 |
+
<button class="btn btn-sm btn-outline-danger" onclick="deleteDsaReport({{ r.id }}, this)">Del</button>
|
| 68 |
+
</td>
|
| 69 |
+
</tr>
|
| 70 |
+
{% endif %}
|
| 71 |
+
{% endfor %}
|
| 72 |
+
</tbody>
|
| 73 |
+
</table>
|
| 74 |
+
|
| 75 |
+
{% if not reports %}<div class="text-center text-muted py-5">尚無紀錄</div>{% endif %}
|
| 76 |
+
</div>
|
| 77 |
+
<script>
|
| 78 |
+
window.addEventListener('DOMContentLoaded', function() {
|
| 79 |
+
document.querySelectorAll('.spec-cell').forEach(td => {
|
| 80 |
+
const orig = td.textContent.trim();
|
| 81 |
+
if (!orig || orig === '(-)') return;
|
| 82 |
+
const tokens = orig.match(/\S+?\([^)]*\)|\S+/g) || [];
|
| 83 |
+
td.innerHTML = tokens.map(seg => {
|
| 84 |
+
const m = seg.match(/^([^(]+)(\([^)]*\))?$/);
|
| 85 |
+
if (!m) return seg;
|
| 86 |
+
const sero = m[1];
|
| 87 |
+
const paren = m[2] || '';
|
| 88 |
+
const seroHtml = '<span style="color:#1E40AF;font-weight:bold;">' + sero + '</span>';
|
| 89 |
+
if (!paren) return seroHtml;
|
| 90 |
+
const inner = paren.slice(1, -1);
|
| 91 |
+
const alleleHtml = inner.split(/\s+/).map(a =>
|
| 92 |
+
a ? '<span style="color:#374151;">' + a + '</span>' : a
|
| 93 |
+
).join(' ');
|
| 94 |
+
return seroHtml + '(' + alleleHtml + ')';
|
| 95 |
+
}).join(' ');
|
| 96 |
+
});
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
function deleteDsaReport(id, btn) {
|
| 100 |
+
if (!confirm('確定刪除此筆 DSA 報告?')) return;
|
| 101 |
+
fetch('/dsa/delete_report/' + id, { method: 'POST' })
|
| 102 |
+
.then(r => r.json())
|
| 103 |
+
.then(d => {
|
| 104 |
+
if (d.ok) location.reload();
|
| 105 |
+
else alert('刪除失敗: ' + (d.error || ''));
|
| 106 |
+
});
|
| 107 |
+
}
|
| 108 |
+
</script>
|
| 109 |
+
</body>
|
| 110 |
+
</html>
|
templates//dsa_index.html
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>DSA Analysis</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
body { background: #f5f7fa; }
|
| 10 |
+
.upload-zone {
|
| 11 |
+
border: 2px dashed #adb5bd; border-radius: 12px; padding: 2.5rem;
|
| 12 |
+
text-align: center; cursor: pointer; transition: all .2s; background: #fff;
|
| 13 |
+
}
|
| 14 |
+
.upload-zone:hover, .upload-zone.dragover { border-color: #0d6efd; background: #e8f0fe; }
|
| 15 |
+
.pra-panel { min-height: 200px; }
|
| 16 |
+
.report-card {
|
| 17 |
+
background: #fff; border: 1px solid #dee2e6; border-radius: 6px;
|
| 18 |
+
padding: 1.2rem 1.5rem; margin-bottom: 1rem;
|
| 19 |
+
font-family: 'Calibri','Segoe UI',sans-serif; font-size: .95rem; line-height: 1.7;
|
| 20 |
+
}
|
| 21 |
+
.report-card .report-title { font-weight: bold; font-size: 1.05rem; }
|
| 22 |
+
.report-spec-edit {
|
| 23 |
+
border: 1px dashed #adb5bd; border-radius: 4px; padding: .3rem .5rem;
|
| 24 |
+
min-height: 1.8rem; outline: none; width: 100%; font-family: inherit; font-size: inherit;
|
| 25 |
+
}
|
| 26 |
+
.report-spec-edit:focus { border-color: #0d6efd; background: #f8f9ff; }
|
| 27 |
+
.panel-title { font-size: 1.1rem; font-weight: bold; padding: .5rem; border-radius: 4px; text-align: center; margin-bottom: .8rem; }
|
| 28 |
+
.panel-title-dsa1 { background: #dbeafe; color: #1e40af; }
|
| 29 |
+
.panel-title-dsa2 { background: #fef3c7; color: #92400e; }
|
| 30 |
+
.empty-panel { color: #adb5bd; text-align: center; padding: 3rem 1rem; font-style: italic; }
|
| 31 |
+
table.mfi-table { font-size: .82rem; }
|
| 32 |
+
table.mfi-table th { position: sticky; top: 0; background: #fff; z-index: 1; }
|
| 33 |
+
.mfi-scroll { max-height: 350px; overflow-y: auto; }
|
| 34 |
+
table.bead-table { font-size: .82rem; }
|
| 35 |
+
.bead-scroll { max-height: 350px; overflow-y: auto; }
|
| 36 |
+
.strength-strong { --bs-table-bg: #f8d7da; background-color: #f8d7da !important; font-weight: bold; }
|
| 37 |
+
.strength-strong > td { background-color: #f8d7da !important; }
|
| 38 |
+
.strength-weak { --bs-table-bg: #fff9c4; background-color: #fff9c4 !important; }
|
| 39 |
+
.strength-weak > td { background-color: #fff9c4 !important; }
|
| 40 |
+
.badge-pos { background: #dc3545; }
|
| 41 |
+
.badge-neg { background: #198754; }
|
| 42 |
+
@media print {
|
| 43 |
+
.no-print { display: none !important; }
|
| 44 |
+
.report-card { border: none; padding: .5rem 0; }
|
| 45 |
+
.report-spec-edit { border: none; padding: 0; }
|
| 46 |
+
}
|
| 47 |
+
#choice-modal-backdrop {
|
| 48 |
+
display: none; position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
| 49 |
+
z-index: 1060; align-items: center; justify-content: center;
|
| 50 |
+
}
|
| 51 |
+
#choice-modal-backdrop.show { display: flex; }
|
| 52 |
+
#choice-modal {
|
| 53 |
+
background: #fff; border-radius: 8px; padding: 1.25rem 1.5rem;
|
| 54 |
+
min-width: 320px; max-width: 480px; box-shadow: 0 10px 40px rgba(0,0,0,.2);
|
| 55 |
+
}
|
| 56 |
+
#choice-modal .cm-message { white-space: pre-wrap; margin-bottom: 1rem; font-size: .95rem; }
|
| 57 |
+
#choice-modal .cm-input {
|
| 58 |
+
width: 100%; padding: .5rem; font-size: 1.2rem; text-align: center;
|
| 59 |
+
border: 1px solid #ced4da; border-radius: 4px;
|
| 60 |
+
}
|
| 61 |
+
#choice-modal .cm-input:focus { border-color: #0d6efd; outline: none; }
|
| 62 |
+
#choice-modal .cm-btns { text-align: right; margin-top: 1rem; }
|
| 63 |
+
#choice-modal .cm-btns button { margin-left: .5rem; }
|
| 64 |
+
</style>
|
| 65 |
+
</head>
|
| 66 |
+
<body>
|
| 67 |
+
<div id="choice-modal-backdrop">
|
| 68 |
+
<div id="choice-modal" role="dialog" aria-modal="true">
|
| 69 |
+
<div class="cm-message" id="cm-message"></div>
|
| 70 |
+
<input type="text" class="cm-input" id="cm-input"
|
| 71 |
+
inputmode="numeric" pattern="[1-3]" maxlength="1" autocomplete="off">
|
| 72 |
+
<div class="cm-btns">
|
| 73 |
+
<button type="button" class="btn btn-secondary btn-sm" id="cm-cancel">取消</button>
|
| 74 |
+
<button type="button" class="btn btn-primary btn-sm" id="cm-ok">確定</button>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
<script>
|
| 79 |
+
window.askChoice = function(message) {
|
| 80 |
+
return new Promise(resolve => {
|
| 81 |
+
const bd = document.getElementById('choice-modal-backdrop');
|
| 82 |
+
const input = document.getElementById('cm-input');
|
| 83 |
+
const ok = document.getElementById('cm-ok');
|
| 84 |
+
const cancel = document.getElementById('cm-cancel');
|
| 85 |
+
document.getElementById('cm-message').textContent = message;
|
| 86 |
+
input.value = '';
|
| 87 |
+
bd.classList.add('show');
|
| 88 |
+
setTimeout(() => input.focus(), 50);
|
| 89 |
+
const onInput = () => {
|
| 90 |
+
const v = input.value.replace(/[^1-3]/g, '').slice(0, 1);
|
| 91 |
+
if (input.value !== v) input.value = v;
|
| 92 |
+
};
|
| 93 |
+
const onKey = (e) => {
|
| 94 |
+
if (e.key === 'Enter') { e.preventDefault(); submit(); }
|
| 95 |
+
else if (e.key === 'Escape') { e.preventDefault(); cleanup(null); }
|
| 96 |
+
};
|
| 97 |
+
const submit = () => {
|
| 98 |
+
const v = input.value;
|
| 99 |
+
if (v === '1' || v === '2' || v === '3') cleanup(v);
|
| 100 |
+
else { input.focus(); input.style.borderColor = '#dc3545';
|
| 101 |
+
setTimeout(() => input.style.borderColor = '', 800); }
|
| 102 |
+
};
|
| 103 |
+
const cleanup = (result) => {
|
| 104 |
+
bd.classList.remove('show');
|
| 105 |
+
input.removeEventListener('input', onInput);
|
| 106 |
+
input.removeEventListener('keydown', onKey);
|
| 107 |
+
ok.removeEventListener('click', submit);
|
| 108 |
+
cancel.removeEventListener('click', onCancel);
|
| 109 |
+
resolve(result);
|
| 110 |
+
};
|
| 111 |
+
const onCancel = () => cleanup(null);
|
| 112 |
+
input.addEventListener('input', onInput);
|
| 113 |
+
input.addEventListener('keydown', onKey);
|
| 114 |
+
ok.addEventListener('click', submit);
|
| 115 |
+
cancel.addEventListener('click', onCancel);
|
| 116 |
+
});
|
| 117 |
+
};
|
| 118 |
+
</script>
|
| 119 |
+
|
| 120 |
+
<div class="container-fluid py-3" style="max-width: 1400px;">
|
| 121 |
+
<div class="d-flex justify-content-between align-items-center mb-1">
|
| 122 |
+
<h2 class="mb-0">DSA Analysis</h2>
|
| 123 |
+
<div class="no-print">
|
| 124 |
+
<a href="/" class="btn btn-dark btn-sm me-1">首頁</a>
|
| 125 |
+
<a href="/dsa/history" class="btn btn-outline-secondary btn-sm me-1">歷史紀錄</a>
|
| 126 |
+
<a href="/dsa/analysis" class="btn btn-outline-secondary btn-sm me-1">統計分析</a>
|
| 127 |
+
<a href="/dsa/batch_upload" class="btn btn-success btn-sm">批次新增資料</a>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
<p class="text-muted mb-3">LABScreen Single Antigen Class I / II — Strong (MFI>4000) / Weak (1000–3999)</p>
|
| 131 |
+
|
| 132 |
+
{% if error %}<div class="alert alert-danger">{{ error }}</div>{% endif %}
|
| 133 |
+
{% if errors %}
|
| 134 |
+
<div class="alert alert-warning no-print">
|
| 135 |
+
{% for e in errors %}<div class="small">{{ e }}</div>{% endfor %}
|
| 136 |
+
</div>
|
| 137 |
+
{% endif %}
|
| 138 |
+
|
| 139 |
+
<form id="uploadForm" action="/dsa/analyze" method="post" enctype="multipart/form-data" class="mb-3 no-print">
|
| 140 |
+
<div class="row g-2 mb-2">
|
| 141 |
+
<div class="col-sm-5">
|
| 142 |
+
<input type="text" class="form-control" name="patient_name" id="patientName"
|
| 143 |
+
placeholder="病患姓名(必填)" value="{{ patient_name or '' }}" required>
|
| 144 |
+
</div>
|
| 145 |
+
<div class="col-sm-4">
|
| 146 |
+
<input type="text" class="form-control" name="patient_id" id="patientId"
|
| 147 |
+
placeholder="病歷號(必填)" value="{{ patient_id or '' }}" required>
|
| 148 |
+
</div>
|
| 149 |
+
<div class="col-sm-3">
|
| 150 |
+
<button type="button" class="btn btn-primary w-100" id="analyzeBtn" disabled
|
| 151 |
+
onclick="submitWithDelay()">Analyze</button>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
<div class="upload-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
|
| 155 |
+
<input type="file" name="file" id="fileInput" accept=".xls,.xlsx" multiple
|
| 156 |
+
style="display:none" onchange="onFileSelected(this)">
|
| 157 |
+
<div id="dropIcon" style="font-size:1.8rem;">📄</div>
|
| 158 |
+
<div class="mt-1 fw-bold">XLS</div>
|
| 159 |
+
<div class="text-muted small">可拖拉或選取 LABScreen Single Antigen LS1A* / LS2A* 檔案</div>
|
| 160 |
+
<div id="fileList" class="mt-2 small text-primary" style="display:none;"></div>
|
| 161 |
+
<div id="uploadingMsg" class="mt-2 text-muted" style="display:none;">
|
| 162 |
+
<div class="spinner-border spinner-border-sm me-1"></div> Analyzing...
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</form>
|
| 166 |
+
|
| 167 |
+
{% if result_dsa1 or result_dsa2 %}
|
| 168 |
+
<div class="no-print mb-3">
|
| 169 |
+
<div class="card">
|
| 170 |
+
<div class="card-header fw-bold">Donor HLA</div>
|
| 171 |
+
<div class="card-body" style="font-size:.85rem;">
|
| 172 |
+
<style>.donor-hla { width: 65px !important; padding: 2px 4px !important; text-align: center; }</style>
|
| 173 |
+
<div class="row">
|
| 174 |
+
<div class="col-md-5">
|
| 175 |
+
<div class="fw-bold mb-1">Serology</div>
|
| 176 |
+
<table class="table table-sm table-bordered mb-1">
|
| 177 |
+
<tbody>
|
| 178 |
+
<tr><td class="fw-bold">A</td>
|
| 179 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-1"></td>
|
| 180 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-2"></td>
|
| 181 |
+
<td class="fw-bold">DR</td>
|
| 182 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-1"></td>
|
| 183 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-2"></td></tr>
|
| 184 |
+
<tr><td class="fw-bold">B</td>
|
| 185 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-1"></td>
|
| 186 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-2"></td>
|
| 187 |
+
<td class="fw-bold">DQ</td>
|
| 188 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-1"></td>
|
| 189 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-2"></td></tr>
|
| 190 |
+
<tr><td class="fw-bold">Cw</td>
|
| 191 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-1"></td>
|
| 192 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-2"></td>
|
| 193 |
+
<td class="fw-bold">DP</td>
|
| 194 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-1"></td>
|
| 195 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-2"></td></tr>
|
| 196 |
+
</tbody>
|
| 197 |
+
</table>
|
| 198 |
+
</div>
|
| 199 |
+
<div class="col-md-7">
|
| 200 |
+
<div class="fw-bold mb-1">DNA Typing</div>
|
| 201 |
+
<table class="table table-sm table-bordered mb-1">
|
| 202 |
+
<tbody>
|
| 203 |
+
<tr><td class="fw-bold">A*</td>
|
| 204 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-1"></td>
|
| 205 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-2"></td>
|
| 206 |
+
<td class="fw-bold">DRB1*</td>
|
| 207 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-1"></td>
|
| 208 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-2"></td></tr>
|
| 209 |
+
<tr><td class="fw-bold">B*</td>
|
| 210 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-1"></td>
|
| 211 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-2"></td>
|
| 212 |
+
<td class="fw-bold">DQB1*</td>
|
| 213 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-1"></td>
|
| 214 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-2"></td></tr>
|
| 215 |
+
<tr><td class="fw-bold">C*</td>
|
| 216 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-1"></td>
|
| 217 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-2"></td>
|
| 218 |
+
<td class="fw-bold">DQA1*</td>
|
| 219 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-1"></td>
|
| 220 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-2"></td></tr>
|
| 221 |
+
<tr><td></td><td></td><td></td>
|
| 222 |
+
<td class="fw-bold">DPB1*</td>
|
| 223 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-1"></td>
|
| 224 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-2"></td></tr>
|
| 225 |
+
<tr><td></td><td></td><td></td>
|
| 226 |
+
<td class="fw-bold">DPA1*</td>
|
| 227 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-1"></td>
|
| 228 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-2"></td></tr>
|
| 229 |
+
</tbody>
|
| 230 |
+
</table>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
<div class="text-end">
|
| 234 |
+
<button class="btn btn-sm btn-outline-danger" onclick="dsaCheck()">存檔</button>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
{% endif %}
|
| 240 |
+
|
| 241 |
+
{% if result_dsa1 or result_dsa2 %}
|
| 242 |
+
{% if patient_name or patient_id %}
|
| 243 |
+
<div class="text-center mb-3">
|
| 244 |
+
<h4 class="mb-0">{{ patient_name }}{% if patient_id %} <span class="text-muted small">({{ patient_id }})</span>{% endif %}</h4>
|
| 245 |
+
</div>
|
| 246 |
+
{% endif %}
|
| 247 |
+
<div class="row">
|
| 248 |
+
{% for side in [('dsa1', result_dsa1, 'DSA Class I', 'p1_'), ('dsa2', result_dsa2, 'DSA Class II', 'p2_')] %}
|
| 249 |
+
<div class="col-md-6 pra-panel">
|
| 250 |
+
<div class="panel-title panel-title-{{ side[0] }}">{{ side[2] }}</div>
|
| 251 |
+
{% set result = side[1] %}
|
| 252 |
+
{% set prefix = side[3] %}
|
| 253 |
+
{% set class_label = side[2] %}
|
| 254 |
+
{% if result %}
|
| 255 |
+
{% for pt in result.patients %}
|
| 256 |
+
{% set pt_idx = prefix ~ loop.index %}
|
| 257 |
+
<div class="report-card">
|
| 258 |
+
<div class="d-flex justify-content-between align-items-center">
|
| 259 |
+
<div class="report-title" style="display:none;">{{ pt.name }}</div>
|
| 260 |
+
<div class="no-print">
|
| 261 |
+
<button class="btn btn-sm btn-outline-secondary me-1" onclick="copyReport('rc-{{ pt_idx }}')">Copy</button>
|
| 262 |
+
<button class="btn btn-sm btn-outline-primary" onclick="toggleEditNew('{{ pt_idx }}', this)">Edit</button>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
<div id="rc-{{ pt_idx }}">
|
| 266 |
+
<div>{{ class_label }}</div>
|
| 267 |
+
<div>Overall: {{ pt.overall }}</div>
|
| 268 |
+
<div>%SA (or %PRA): {{ pt.pra }}</div>
|
| 269 |
+
<div>Specificity:</div>
|
| 270 |
+
<div class="report-spec-edit" id="spec-{{ pt_idx }}" style="border:1px solid transparent;">{{ pt.specificity|safe }}</div>
|
| 271 |
+
<div>COMMENT:</div>
|
| 272 |
+
<div class="report-spec-edit" id="comment-{{ pt_idx }}" style="border:1px solid transparent; min-height:1.5rem; white-space:pre-line;">{{ pt.comment or '' }}</div>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
|
| 276 |
+
{% if pt.sero_mfi %}
|
| 277 |
+
<div class="mb-3 no-print">
|
| 278 |
+
<div class="small fw-bold mb-1">Antibody Strength</div>
|
| 279 |
+
<div class="mfi-scroll">
|
| 280 |
+
<table class="table table-sm table-bordered mfi-table mb-0">
|
| 281 |
+
<thead class="table-light"><tr>
|
| 282 |
+
<th>Antigen</th><th>Allele</th><th>Strength</th><th class="text-end">Max MFI</th>
|
| 283 |
+
<th class="text-end">Mean MFI</th><th class="text-center">No of Beads</th><th>Bead IDs</th>
|
| 284 |
+
</tr></thead>
|
| 285 |
+
<tbody>
|
| 286 |
+
{% for m in pt.sero_mfi %}
|
| 287 |
+
<tr class="{% if m.strength == 'Strong' %}strength-strong{% elif m.strength == 'Weak' %}strength-weak{% endif %}">
|
| 288 |
+
<td style="color:#1E40AF;font-weight:bold;">{{ m.sero }}</td>
|
| 289 |
+
<td class="small" style="color:#374151;">{{ m.alleles }}</td>
|
| 290 |
+
<td>{{ m.strength }}</td>
|
| 291 |
+
<td class="text-end">{{ m.max_mfi|int }}</td>
|
| 292 |
+
<td class="text-end">{{ m.mean_mfi|int }}</td>
|
| 293 |
+
<td class="text-center">{{ m.count }}</td><td class="small text-muted">{{ m.beads }}</td>
|
| 294 |
+
</tr>
|
| 295 |
+
{% endfor %}
|
| 296 |
+
</tbody>
|
| 297 |
+
</table>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
{% endif %}
|
| 301 |
+
|
| 302 |
+
<div class="no-print">
|
| 303 |
+
<div class="fw-bold small mb-1">Bead Details ({{ pt.beads|length }})</div>
|
| 304 |
+
<div class="mb-3">
|
| 305 |
+
{% if pt.qc %}
|
| 306 |
+
<div class="mb-2 p-2 border rounded bg-light small">
|
| 307 |
+
<strong>Quality Control</strong>
|
| 308 |
+
<table class="table table-sm table-bordered mb-0 mt-1" style="width:auto; font-size:.82rem;">
|
| 309 |
+
<thead class="table-secondary"><tr><th>Item</th><th>Value</th><th>Normal</th><th>Status</th></tr></thead>
|
| 310 |
+
<tbody>
|
| 311 |
+
<tr{% if pt.qc.nc_raw > 500 %} class="table-danger"{% endif %}>
|
| 312 |
+
<td>NC Raw (Bead 001)</td><td>{{ pt.qc.nc_raw }}</td><td>≤500</td>
|
| 313 |
+
<td>{% if pt.qc.nc_raw > 1500 %}<span class="text-danger fw-bold">Uninterpretable due to high background bindings, please repeat</span>{% elif pt.qc.nc_raw > 500 %}<span class="text-warning fw-bold">High background bindings</span>{% endif %}</td>
|
| 314 |
+
</tr>
|
| 315 |
+
<tr{% if pt.qc.pc_raw <= 500 %} class="table-danger"{% endif %}>
|
| 316 |
+
<td>PC Raw (Bead 002)</td><td>{{ pt.qc.pc_raw }}</td><td>>500</td>
|
| 317 |
+
<td>{% if pt.qc.pc_raw <= 500 %}<span class="text-danger fw-bold">Low PC signal, please repeat</span>{% endif %}</td>
|
| 318 |
+
</tr>
|
| 319 |
+
<tr{% if pt.qc.pc_nc_ratio < 2 %} class="table-danger"{% endif %}>
|
| 320 |
+
<td>PC/NC Ratio</td><td>{{ pt.qc.pc_nc_ratio }}</td><td>≥2</td>
|
| 321 |
+
<td>{% if pt.qc.pc_nc_ratio < 2 %}<span class="text-danger fw-bold">Uninterpretable, please repeat</span>{% endif %}</td>
|
| 322 |
+
</tr>
|
| 323 |
+
<tr{% if pt.qc.low_beads %} class="table-warning"{% endif %}>
|
| 324 |
+
<td>Bead Count</td>
|
| 325 |
+
<td>{% if pt.qc.low_beads %}{{ pt.qc.low_beads|length }} beads <80{% else %}All ≥80{% endif %}</td>
|
| 326 |
+
<td>≥80</td>
|
| 327 |
+
<td>{% if pt.qc.low_beads %}<span class="text-danger fw-bold">Low HLA Beads count, please repeat</span>{% endif %}</td>
|
| 328 |
+
</tr>
|
| 329 |
+
</tbody>
|
| 330 |
+
</table>
|
| 331 |
+
</div>
|
| 332 |
+
{% endif %}
|
| 333 |
+
<div class="bead-scroll">
|
| 334 |
+
<table class="table table-sm table-bordered bead-table mb-0">
|
| 335 |
+
<thead><tr><th>Bead</th><th>Normal</th><th>Strength</th><th>Raw</th><th>Ratio</th><th>Count</th><th>Specificity</th><th>Molecular</th></tr></thead>
|
| 336 |
+
<tbody>
|
| 337 |
+
{% for b in pt.beads %}
|
| 338 |
+
<tr class="{% if b.strength == 'Strong' %}strength-strong{% elif b.strength == 'Weak' %}strength-weak{% endif %}" data-sero="{{ b.sero }}" data-allele="{{ b.allele }}">
|
| 339 |
+
<td>{{ b.bead }}</td><td>{{ b.normal }}</td><td>{{ b.strength }}</td>
|
| 340 |
+
<td>{{ b.raw }}</td><td>{{ b.ratio }}</td><td>{{ b.count }}</td>
|
| 341 |
+
<td class="small" style="color:#1E40AF;font-weight:bold;">{{ b.sero }}</td>
|
| 342 |
+
<td class="small" style="color:#374151;">{{ b.allele }}</td>
|
| 343 |
+
</tr>
|
| 344 |
+
{% endfor %}
|
| 345 |
+
</tbody>
|
| 346 |
+
</table>
|
| 347 |
+
</div>
|
| 348 |
+
</div>
|
| 349 |
+
</div>
|
| 350 |
+
{% endfor %}
|
| 351 |
+
{% else %}
|
| 352 |
+
<div class="empty-panel">No {{ side[2] }} data</div>
|
| 353 |
+
{% endif %}
|
| 354 |
+
</div>
|
| 355 |
+
{% endfor %}
|
| 356 |
+
</div>
|
| 357 |
+
|
| 358 |
+
<div class="text-center my-3 no-print">
|
| 359 |
+
<button class="btn btn-outline-secondary me-2" onclick="saveToDb()" id="saveBtn">暫存</button>
|
| 360 |
+
<button class="btn btn-success" onclick="submitReport()" id="submitBtn">送出表單</button>
|
| 361 |
+
<div id="saveStatus" class="small text-muted mt-1"></div>
|
| 362 |
+
</div>
|
| 363 |
+
{% endif %}
|
| 364 |
+
</div>
|
| 365 |
+
|
| 366 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 367 |
+
<script>
|
| 368 |
+
function _loadDonorObj(obj) {
|
| 369 |
+
document.querySelectorAll('.donor-hla').forEach(el => el.value = '');
|
| 370 |
+
const firstKey = Object.keys(obj)[0] || '';
|
| 371 |
+
if (firstKey.startsWith('donor-')) {
|
| 372 |
+
Object.keys(obj).forEach(k => {
|
| 373 |
+
const el = document.getElementById(k);
|
| 374 |
+
if (el) el.value = obj[k];
|
| 375 |
+
});
|
| 376 |
+
} else {
|
| 377 |
+
const seroMap = {'A':['donor-A-1','donor-A-2'],'B':['donor-B-1','donor-B-2'],
|
| 378 |
+
'Cw':['donor-Cw-1','donor-Cw-2'],'DR':['donor-DR-1','donor-DR-2'],
|
| 379 |
+
'DQ':['donor-DQ-1','donor-DQ-2'],'DP':['donor-DP-1','donor-DP-2']};
|
| 380 |
+
const dnaMap = {'A*':['donor-dna-A-1','donor-dna-A-2'],'B*':['donor-dna-B-1','donor-dna-B-2'],
|
| 381 |
+
'C*':['donor-dna-C-1','donor-dna-C-2'],'DRB1*':['donor-dna-DRB1-1','donor-dna-DRB1-2'],
|
| 382 |
+
'DQB1*':['donor-dna-DQB1-1','donor-dna-DQB1-2'],'DQA1*':['donor-dna-DQA1-1','donor-dna-DQA1-2'],
|
| 383 |
+
'DPB1*':['donor-dna-DPB1-1','donor-dna-DPB1-2'],'DPA1*':['donor-dna-DPA1-1','donor-dna-DPA1-2']};
|
| 384 |
+
Object.keys(obj).forEach(k => {
|
| 385 |
+
const vals = String(obj[k]).split(',').map(v => v.trim());
|
| 386 |
+
const ids = seroMap[k] || dnaMap[k];
|
| 387 |
+
if (ids) vals.forEach((v, i) => { if (ids[i] && v) document.getElementById(ids[i]).value = v; });
|
| 388 |
+
});
|
| 389 |
+
}
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
window.addEventListener('DOMContentLoaded', function() {
|
| 393 |
+
const saved = {{ donor_hla | default("", true) | tojson }};
|
| 394 |
+
if (saved) {
|
| 395 |
+
try { _loadDonorObj(JSON.parse(saved)); setTimeout(dsaCheck, 300); } catch(e) {}
|
| 396 |
+
}
|
| 397 |
+
});
|
| 398 |
+
|
| 399 |
+
let collectedFiles = [];
|
| 400 |
+
function updateFileList() {
|
| 401 |
+
const fl = document.getElementById('fileList');
|
| 402 |
+
if (collectedFiles.length === 0) {
|
| 403 |
+
fl.style.display = 'none';
|
| 404 |
+
document.getElementById('dropIcon').style.display = '';
|
| 405 |
+
document.getElementById('analyzeBtn').disabled = true;
|
| 406 |
+
return;
|
| 407 |
+
}
|
| 408 |
+
fl.innerHTML = collectedFiles.map((f,i) =>
|
| 409 |
+
'<span>📄 ' + f.name +
|
| 410 |
+
' <a href="#" style="color:#dc3545;text-decoration:none;" onclick="removeFile('+i+');return false;">×</a></span>'
|
| 411 |
+
).join('<br>');
|
| 412 |
+
fl.style.display = 'block';
|
| 413 |
+
document.getElementById('dropIcon').style.display = 'none';
|
| 414 |
+
document.getElementById('analyzeBtn').disabled = false;
|
| 415 |
+
}
|
| 416 |
+
function addFiles(fileList) {
|
| 417 |
+
for (const f of fileList) {
|
| 418 |
+
if (!collectedFiles.some(x => x.name === f.name && x.size === f.size)) collectedFiles.push(f);
|
| 419 |
+
}
|
| 420 |
+
updateFileList();
|
| 421 |
+
}
|
| 422 |
+
function removeFile(idx) { collectedFiles.splice(idx, 1); updateFileList(); }
|
| 423 |
+
function submitWithDelay() {
|
| 424 |
+
if (collectedFiles.length === 0) return;
|
| 425 |
+
const dt = new DataTransfer();
|
| 426 |
+
collectedFiles.forEach(f => dt.items.add(f));
|
| 427 |
+
document.getElementById('fileInput').files = dt.files;
|
| 428 |
+
document.getElementById('uploadingMsg').style.display = 'block';
|
| 429 |
+
document.getElementById('analyzeBtn').disabled = true;
|
| 430 |
+
setTimeout(() => document.getElementById('uploadForm').submit(), 100);
|
| 431 |
+
}
|
| 432 |
+
function onFileSelected(input) {
|
| 433 |
+
if (input.files.length > 0) addFiles(input.files);
|
| 434 |
+
input.value = '';
|
| 435 |
+
}
|
| 436 |
+
const dz = document.getElementById('dropZone');
|
| 437 |
+
if (dz) {
|
| 438 |
+
['dragenter','dragover'].forEach(e => dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.add('dragover'); }));
|
| 439 |
+
['dragleave','drop'].forEach(e => dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.remove('dragover'); }));
|
| 440 |
+
dz.addEventListener('drop', ev => addFiles(ev.dataTransfer.files));
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
function dsaCheck() {
|
| 444 |
+
const chartNo = document.getElementById('patientId') ? document.getElementById('patientId').value.trim() : '';
|
| 445 |
+
if (chartNo) {
|
| 446 |
+
const obj = {};
|
| 447 |
+
document.querySelectorAll('.donor-hla').forEach(el => {
|
| 448 |
+
if (el.value.trim()) obj[el.id] = el.value.trim();
|
| 449 |
+
});
|
| 450 |
+
fetch('/save_donor_hla', {
|
| 451 |
+
method: 'POST',
|
| 452 |
+
headers: {'Content-Type': 'application/json'},
|
| 453 |
+
body: JSON.stringify({ chart_no: chartNo, donor_hla: JSON.stringify(obj) })
|
| 454 |
+
});
|
| 455 |
+
}
|
| 456 |
+
const donorSet = new Set();
|
| 457 |
+
['A','B','Cw','DR','DQ','DP'].forEach(locus => {
|
| 458 |
+
[1,2].forEach(n => {
|
| 459 |
+
const el = document.getElementById('donor-' + locus + '-' + n);
|
| 460 |
+
if (el && el.value.trim()) donorSet.add(locus + el.value.trim());
|
| 461 |
+
});
|
| 462 |
+
});
|
| 463 |
+
['A','B','C','DRB1','DQB1','DQA1','DPB1','DPA1'].forEach(locus => {
|
| 464 |
+
[1,2].forEach(n => {
|
| 465 |
+
const el = document.getElementById('donor-dna-' + locus + '-' + n);
|
| 466 |
+
if (el && el.value.trim()) donorSet.add(locus + '*' + el.value.trim());
|
| 467 |
+
});
|
| 468 |
+
});
|
| 469 |
+
if (donorSet.size === 0) return;
|
| 470 |
+
|
| 471 |
+
document.querySelectorAll('[id^="spec-"]').forEach(specEl => {
|
| 472 |
+
specEl.querySelectorAll('span').forEach(span => {
|
| 473 |
+
const txt = span.textContent;
|
| 474 |
+
for (const ag of donorSet) {
|
| 475 |
+
if (txt.includes(ag)) { span.style.color = '#dc3545'; span.style.fontWeight = 'bold'; break; }
|
| 476 |
+
}
|
| 477 |
+
});
|
| 478 |
+
specEl.childNodes.forEach(node => {
|
| 479 |
+
if (node.nodeType === 3) {
|
| 480 |
+
const txt = node.textContent;
|
| 481 |
+
for (const ag of donorSet) {
|
| 482 |
+
if (txt.includes(ag)) {
|
| 483 |
+
const span = document.createElement('span');
|
| 484 |
+
span.style.color = '#dc3545';
|
| 485 |
+
span.style.fontWeight = 'bold';
|
| 486 |
+
span.textContent = txt;
|
| 487 |
+
node.replaceWith(span);
|
| 488 |
+
break;
|
| 489 |
+
}
|
| 490 |
+
}
|
| 491 |
+
}
|
| 492 |
+
});
|
| 493 |
+
});
|
| 494 |
+
|
| 495 |
+
function highlightTokens(cell) {
|
| 496 |
+
if (!cell) return;
|
| 497 |
+
const text = cell.textContent;
|
| 498 |
+
const tokens = text.split(/,\s*/);
|
| 499 |
+
const parts = tokens.map(tok => {
|
| 500 |
+
const t = tok.trim();
|
| 501 |
+
if (!t) return tok;
|
| 502 |
+
for (const ag of donorSet) {
|
| 503 |
+
if (t.includes(ag)) return '<span style="color:#dc3545;font-weight:bold;">' + tok + '</span>';
|
| 504 |
+
}
|
| 505 |
+
return tok;
|
| 506 |
+
});
|
| 507 |
+
cell.innerHTML = parts.join(', ');
|
| 508 |
+
}
|
| 509 |
+
document.querySelectorAll('.mfi-table tbody tr').forEach(tr => highlightTokens(tr.cells[1]));
|
| 510 |
+
document.querySelectorAll('.bead-table tbody tr').forEach(tr => highlightTokens(tr.cells[8]));
|
| 511 |
+
|
| 512 |
+
const btn = document.querySelector('[onclick="dsaCheck()"]');
|
| 513 |
+
if (btn) { btn.classList.remove('btn-outline-danger'); btn.classList.add('btn-danger'); btn.textContent = '已存檔'; }
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
function toggleEditNew(idx, btn) {
|
| 517 |
+
const specEl = document.getElementById('spec-' + idx);
|
| 518 |
+
const commentEl = document.getElementById('comment-' + idx);
|
| 519 |
+
if (btn.textContent === 'Edit') {
|
| 520 |
+
specEl.contentEditable = 'true'; commentEl.contentEditable = 'true';
|
| 521 |
+
specEl.style.border = '1px dashed #0d6efd'; specEl.style.background = '#f8f9ff';
|
| 522 |
+
commentEl.style.border = '1px dashed #0d6efd'; commentEl.style.background = '#f8f9ff';
|
| 523 |
+
btn.textContent = 'Save'; btn.className = 'btn btn-sm btn-success'; specEl.focus();
|
| 524 |
+
} else {
|
| 525 |
+
specEl.contentEditable = 'false'; commentEl.contentEditable = 'false';
|
| 526 |
+
specEl.style.border = '1px solid transparent'; specEl.style.background = '';
|
| 527 |
+
commentEl.style.border = '1px solid transparent'; commentEl.style.background = '';
|
| 528 |
+
btn.textContent = 'Edit'; btn.className = 'btn btn-sm btn-outline-primary';
|
| 529 |
+
}
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
function copyReport(id) {
|
| 533 |
+
const el = document.getElementById(id);
|
| 534 |
+
const range = document.createRange(); range.selectNodeContents(el);
|
| 535 |
+
const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
|
| 536 |
+
document.execCommand('copy'); sel.removeAllRanges();
|
| 537 |
+
const btn = el.parentElement.querySelector('button');
|
| 538 |
+
const orig = btn.textContent; btn.textContent = 'Copied!';
|
| 539 |
+
btn.classList.replace('btn-outline-secondary','btn-success');
|
| 540 |
+
setTimeout(() => { btn.textContent = orig; btn.classList.replace('btn-success','btn-outline-secondary'); }, 1500);
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
function saveToDb(isSubmit) {
|
| 544 |
+
const name = document.getElementById('patientName').value.trim();
|
| 545 |
+
const chartNo = document.getElementById('patientId').value.trim();
|
| 546 |
+
if (!name) { alert('請輸入病患姓名'); return; }
|
| 547 |
+
if (!chartNo) { alert('請輸入病歷號'); return; }
|
| 548 |
+
|
| 549 |
+
const reports = [];
|
| 550 |
+
{% if result_dsa1 %}
|
| 551 |
+
{% for pt in result_dsa1.patients %}
|
| 552 |
+
reports.push({
|
| 553 |
+
report_date: '{{ result_dsa1.date }}',
|
| 554 |
+
dsa_class: 'DSA Class I',
|
| 555 |
+
pct_sa: {{ pt.pra }},
|
| 556 |
+
overall: '{{ pt.overall }}',
|
| 557 |
+
specificity: document.getElementById('spec-p1_{{ loop.index }}').innerText.trim(),
|
| 558 |
+
comment: document.getElementById('comment-p1_{{ loop.index }}').innerText.trim(),
|
| 559 |
+
sero_mfi: {{ pt.sero_mfi | tojson }},
|
| 560 |
+
upload_file: '{{ pt._upload_file | default("", true) }}'
|
| 561 |
+
});
|
| 562 |
+
{% endfor %}
|
| 563 |
+
{% endif %}
|
| 564 |
+
{% if result_dsa2 %}
|
| 565 |
+
{% for pt in result_dsa2.patients %}
|
| 566 |
+
reports.push({
|
| 567 |
+
report_date: '{{ result_dsa2.date }}',
|
| 568 |
+
dsa_class: 'DSA Class II',
|
| 569 |
+
pct_sa: {{ pt.pra }},
|
| 570 |
+
overall: '{{ pt.overall }}',
|
| 571 |
+
specificity: document.getElementById('spec-p2_{{ loop.index }}').innerText.trim(),
|
| 572 |
+
comment: document.getElementById('comment-p2_{{ loop.index }}').innerText.trim(),
|
| 573 |
+
sero_mfi: {{ pt.sero_mfi | tojson }},
|
| 574 |
+
upload_file: '{{ pt._upload_file | default("", true) }}'
|
| 575 |
+
});
|
| 576 |
+
{% endfor %}
|
| 577 |
+
{% endif %}
|
| 578 |
+
|
| 579 |
+
const btn = isSubmit ? document.getElementById('submitBtn') : document.getElementById('saveBtn');
|
| 580 |
+
btn.disabled = true;
|
| 581 |
+
document.getElementById('saveStatus').textContent = isSubmit ? 'Submitting...' : 'Saving...';
|
| 582 |
+
|
| 583 |
+
function postSave(mode) {
|
| 584 |
+
return fetch('/dsa/save', {
|
| 585 |
+
method: 'POST', headers: {'Content-Type': 'application/json'},
|
| 586 |
+
body: JSON.stringify({ patient_name: name, chart_no: chartNo, reports: reports, submitted: !!isSubmit, mode: mode })
|
| 587 |
+
}).then(r => r.json());
|
| 588 |
+
}
|
| 589 |
+
function handleResp(d) {
|
| 590 |
+
if (d.duplicate) {
|
| 591 |
+
const list = d.duplicate.map(x => `${x.dsa_class} (${x.report_date})`).join('、');
|
| 592 |
+
const msg = `此病患已有相同日期的紀錄:${list}\n請輸入選項:\n 1 = 覆寫(取代舊紀錄)\n 2 = 新增(另存一筆)\n 3 = 放棄`;
|
| 593 |
+
return askChoice(msg).then(choice => {
|
| 594 |
+
if (choice === '1') return postSave('overwrite').then(handleResp);
|
| 595 |
+
else if (choice === '2') return postSave('new').then(handleResp);
|
| 596 |
+
else { document.getElementById('saveStatus').textContent = '已取消'; btn.disabled = false; return; }
|
| 597 |
+
});
|
| 598 |
+
}
|
| 599 |
+
if (d.ok) {
|
| 600 |
+
const msg = isSubmit ? '已送出' : '已暫存';
|
| 601 |
+
document.getElementById('saveStatus').innerHTML = msg + ' <a href="/dsa/history/' + chartNo + '">查看紀錄</a>';
|
| 602 |
+
btn.textContent = msg;
|
| 603 |
+
} else {
|
| 604 |
+
document.getElementById('saveStatus').textContent = 'Error: ' + (d.error || 'unknown');
|
| 605 |
+
btn.disabled = false;
|
| 606 |
+
}
|
| 607 |
+
}
|
| 608 |
+
postSave('auto').then(handleResp).catch(e => {
|
| 609 |
+
document.getElementById('saveStatus').textContent = 'Error: ' + e;
|
| 610 |
+
btn.disabled = false;
|
| 611 |
+
});
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
function submitReport() {
|
| 615 |
+
if (!confirm('確定送出表單?')) return;
|
| 616 |
+
saveToDb(true);
|
| 617 |
+
}
|
| 618 |
+
</script>
|
| 619 |
+
</body>
|
| 620 |
+
</html>
|
templates//dsa_patient.html
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{{ patient.patient_name }} - DSA History</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
| 9 |
+
<style>
|
| 10 |
+
.chart-container { position: relative; height: 400px; background: #fff; border: 1px solid #dee2e6; border-radius: 6px; padding: 1rem; margin-bottom: 1.5rem; }
|
| 11 |
+
.ab-table { font-size: .82rem; }
|
| 12 |
+
.strength-strong { --bs-table-bg: #f8d7da; background-color: #f8d7da !important; font-weight: bold; }
|
| 13 |
+
.strength-strong > td { background-color: #f8d7da !important; }
|
| 14 |
+
.strength-weak { --bs-table-bg: #fff9c4; background-color: #fff9c4 !important; }
|
| 15 |
+
.strength-weak > td { background-color: #fff9c4 !important; }
|
| 16 |
+
.spec-strong-bg { background-color: #f8d7da; padding: 3px 6px; border-radius: 3px; margin-bottom: 2px; display: block; }
|
| 17 |
+
.spec-weak-bg { background-color: #fff9c4; padding: 3px 6px; border-radius: 3px; display: block; }
|
| 18 |
+
[id^="spec-"] .spec-cell + .spec-cell { margin-top: 0.8em; }
|
| 19 |
+
</style>
|
| 20 |
+
</head>
|
| 21 |
+
<body>
|
| 22 |
+
<div class="container py-4" style="max-width: 1200px;">
|
| 23 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 24 |
+
<div>
|
| 25 |
+
<h2 class="mb-0">{{ patient.patient_name }}
|
| 26 |
+
<span class="text-muted fs-5">({{ patient.chart_no }})</span>
|
| 27 |
+
</h2>
|
| 28 |
+
</div>
|
| 29 |
+
<div>
|
| 30 |
+
<a href="/" class="btn btn-dark btn-sm me-1">首頁</a>
|
| 31 |
+
<a href="/dsa/history" class="btn btn-outline-secondary btn-sm me-1">歷史紀錄</a>
|
| 32 |
+
<a href="/dsa/analysis?chart_no={{ patient.chart_no }}" class="btn btn-outline-success btn-sm me-1">Analyze</a>
|
| 33 |
+
<a href="/dsa/new" class="btn btn-outline-primary btn-sm">新增資料</a>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<div class="card mb-3">
|
| 38 |
+
<div class="card-header fw-bold">Donor HLA</div>
|
| 39 |
+
<div class="card-body" style="font-size:.85rem;">
|
| 40 |
+
<style>.donor-hla { width: 80px !important; padding: 2px 4px !important; text-align: center; }</style>
|
| 41 |
+
<div class="row">
|
| 42 |
+
<div class="col-md-5">
|
| 43 |
+
<div class="fw-bold mb-1">Serology</div>
|
| 44 |
+
<table class="table table-sm table-bordered mb-1"><tbody>
|
| 45 |
+
<tr><td class="fw-bold">A</td>
|
| 46 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-1" oninput="highlightDSA()"></td>
|
| 47 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-2" oninput="highlightDSA()"></td>
|
| 48 |
+
<td class="fw-bold">DR</td>
|
| 49 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-1" oninput="highlightDSA()"></td>
|
| 50 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-2" oninput="highlightDSA()"></td></tr>
|
| 51 |
+
<tr><td class="fw-bold">B</td>
|
| 52 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-1" oninput="highlightDSA()"></td>
|
| 53 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-2" oninput="highlightDSA()"></td>
|
| 54 |
+
<td class="fw-bold">DQ</td>
|
| 55 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-1" oninput="highlightDSA()"></td>
|
| 56 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-2" oninput="highlightDSA()"></td></tr>
|
| 57 |
+
<tr><td class="fw-bold">Cw</td>
|
| 58 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-1" oninput="highlightDSA()"></td>
|
| 59 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-2" oninput="highlightDSA()"></td>
|
| 60 |
+
<td class="fw-bold">DP</td>
|
| 61 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-1" oninput="highlightDSA()"></td>
|
| 62 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-2" oninput="highlightDSA()"></td></tr>
|
| 63 |
+
</tbody></table>
|
| 64 |
+
</div>
|
| 65 |
+
<div class="col-md-7">
|
| 66 |
+
<div class="fw-bold mb-1">DNA Typing</div>
|
| 67 |
+
<table class="table table-sm table-bordered mb-1"><tbody>
|
| 68 |
+
<tr><td class="fw-bold">A*</td>
|
| 69 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-1" oninput="highlightDSA()"></td>
|
| 70 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-2" oninput="highlightDSA()"></td>
|
| 71 |
+
<td class="fw-bold">DRB1*</td>
|
| 72 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-1" oninput="highlightDSA()"></td>
|
| 73 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-2" oninput="highlightDSA()"></td></tr>
|
| 74 |
+
<tr><td class="fw-bold">B*</td>
|
| 75 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-1" oninput="highlightDSA()"></td>
|
| 76 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-2" oninput="highlightDSA()"></td>
|
| 77 |
+
<td class="fw-bold">DQB1*</td>
|
| 78 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-1" oninput="highlightDSA()"></td>
|
| 79 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-2" oninput="highlightDSA()"></td></tr>
|
| 80 |
+
<tr><td class="fw-bold">C*</td>
|
| 81 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-1" oninput="highlightDSA()"></td>
|
| 82 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-2" oninput="highlightDSA()"></td>
|
| 83 |
+
<td class="fw-bold">DQA1*</td>
|
| 84 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-1" oninput="highlightDSA()"></td>
|
| 85 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-2" oninput="highlightDSA()"></td></tr>
|
| 86 |
+
<tr><td></td><td></td><td></td>
|
| 87 |
+
<td class="fw-bold">DPB1*</td>
|
| 88 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-1" oninput="highlightDSA()"></td>
|
| 89 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-2" oninput="highlightDSA()"></td></tr>
|
| 90 |
+
<tr><td></td><td></td><td></td>
|
| 91 |
+
<td class="fw-bold">DPA1*</td>
|
| 92 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-1" oninput="highlightDSA()"></td>
|
| 93 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-2" oninput="highlightDSA()"></td></tr>
|
| 94 |
+
</tbody></table>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
<div class="text-end">
|
| 98 |
+
<button class="btn btn-sm btn-outline-danger" onclick="saveDonorHLA(this)">存檔</button>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<script>
|
| 104 |
+
function getDonorSet() {
|
| 105 |
+
const s = new Set();
|
| 106 |
+
['A','B','Cw','DR','DQ','DP'].forEach(l => {
|
| 107 |
+
[1,2].forEach(n => {
|
| 108 |
+
const el = document.getElementById('donor-' + l + '-' + n);
|
| 109 |
+
if (el && el.value.trim()) s.add(l + el.value.trim());
|
| 110 |
+
});
|
| 111 |
+
});
|
| 112 |
+
['A','B','C','DRB1','DQB1','DQA1','DPB1','DPA1'].forEach(l => {
|
| 113 |
+
[1,2].forEach(n => {
|
| 114 |
+
const el = document.getElementById('donor-dna-' + l + '-' + n);
|
| 115 |
+
if (el && el.value.trim()) s.add(l + '*' + el.value.trim());
|
| 116 |
+
});
|
| 117 |
+
});
|
| 118 |
+
return s;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function highlightTokensInCell(cell, donorSet) {
|
| 122 |
+
if (!cell) return;
|
| 123 |
+
const text = cell.dataset.orig || cell.textContent;
|
| 124 |
+
cell.dataset.orig = text;
|
| 125 |
+
const tokens = text.split(/,\s*/);
|
| 126 |
+
cell.innerHTML = tokens.map(tok => {
|
| 127 |
+
const t = tok.trim();
|
| 128 |
+
if (!t) return tok;
|
| 129 |
+
for (const ag of donorSet) {
|
| 130 |
+
if (t.includes(ag)) {
|
| 131 |
+
return '<span style="color:#dc3545;font-weight:bold;">' + tok + '</span>';
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
return tok;
|
| 135 |
+
}).join(', ');
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
function highlightSpecText(specEl, donorSet) {
|
| 139 |
+
if (!specEl) return;
|
| 140 |
+
const orig = specEl.dataset.orig || specEl.textContent;
|
| 141 |
+
specEl.dataset.orig = orig;
|
| 142 |
+
const inCardArea = specEl.closest('[id^="spec-"]') !== null;
|
| 143 |
+
const tokens = orig.match(/\S+?\([^)]*\)|\S+/g) || [];
|
| 144 |
+
specEl.innerHTML = tokens.map(seg => {
|
| 145 |
+
if (inCardArea) {
|
| 146 |
+
if (seg === 'Strong') return '<span style="color:#dc3545;font-weight:bold;">Strong</span>';
|
| 147 |
+
if (seg === 'Weak') return '<span style="color:#198754;font-weight:bold;">Weak</span>';
|
| 148 |
+
}
|
| 149 |
+
const m = seg.match(/^([^(]+)(\([^)]*\))?$/);
|
| 150 |
+
if (!m) return seg;
|
| 151 |
+
const sero = m[1];
|
| 152 |
+
const paren = m[2] || '';
|
| 153 |
+
if (!/^[A-Za-z]/.test(sero)) return seg;
|
| 154 |
+
let isDSA = false;
|
| 155 |
+
for (const ag of donorSet) {
|
| 156 |
+
if (!ag) continue;
|
| 157 |
+
if (sero === ag || (paren && paren.indexOf(ag) >= 0)) { isDSA = true; break; }
|
| 158 |
+
}
|
| 159 |
+
if (isDSA) return '<span style="color:#dc3545;font-weight:bold;">' + seg + '</span>';
|
| 160 |
+
const seroHtml = '<span style="color:#1E40AF;font-weight:bold;">' + sero + '</span>';
|
| 161 |
+
if (!paren) return seroHtml;
|
| 162 |
+
const inner = paren.slice(1, -1);
|
| 163 |
+
const alleleHtml = inner.split(/\s+/).map(a =>
|
| 164 |
+
a ? '<span style="color:#374151;">' + a + '</span>' : a
|
| 165 |
+
).join(' ');
|
| 166 |
+
return seroHtml + '(' + alleleHtml + ')';
|
| 167 |
+
}).join(' ');
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
function highlightDSA() {
|
| 171 |
+
const ds = getDonorSet();
|
| 172 |
+
document.querySelectorAll('.spec-cell').forEach(el => highlightSpecText(el, ds));
|
| 173 |
+
document.querySelectorAll('.ab-table tbody tr').forEach(tr => {
|
| 174 |
+
highlightTokensInCell(tr.cells[0], ds);
|
| 175 |
+
highlightTokensInCell(tr.cells[1], ds);
|
| 176 |
+
});
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function saveDonorHLA(btn) {
|
| 180 |
+
const obj = {};
|
| 181 |
+
document.querySelectorAll('.donor-hla').forEach(el => {
|
| 182 |
+
if (el.value.trim()) obj[el.id] = el.value.trim();
|
| 183 |
+
});
|
| 184 |
+
btn.disabled = true;
|
| 185 |
+
fetch('/save_donor_hla', {
|
| 186 |
+
method: 'POST',
|
| 187 |
+
headers: {'Content-Type': 'application/json'},
|
| 188 |
+
body: JSON.stringify({ chart_no: '{{ patient.chart_no }}', donor_hla: JSON.stringify(obj) })
|
| 189 |
+
}).then(r => r.json()).then(d => {
|
| 190 |
+
btn.disabled = false;
|
| 191 |
+
if (d.ok) {
|
| 192 |
+
btn.classList.remove('btn-outline-danger'); btn.classList.add('btn-danger');
|
| 193 |
+
btn.textContent = '已存檔';
|
| 194 |
+
}
|
| 195 |
+
});
|
| 196 |
+
highlightDSA();
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
window.addEventListener('DOMContentLoaded', function() {
|
| 200 |
+
const saved = {{ donor_hla | default('', true) | tojson }};
|
| 201 |
+
if (saved) {
|
| 202 |
+
try {
|
| 203 |
+
const obj = JSON.parse(saved);
|
| 204 |
+
Object.keys(obj).forEach(k => {
|
| 205 |
+
const el = document.getElementById(k);
|
| 206 |
+
if (el) el.value = obj[k];
|
| 207 |
+
});
|
| 208 |
+
} catch(e) {}
|
| 209 |
+
}
|
| 210 |
+
highlightDSA();
|
| 211 |
+
});
|
| 212 |
+
</script>
|
| 213 |
+
|
| 214 |
+
<h5>Reports</h5>
|
| 215 |
+
<table class="table table-bordered table-sm bg-white mb-4" style="font-size:.9rem;">
|
| 216 |
+
<thead class="table-light">
|
| 217 |
+
<tr><th>Date</th><th>Class</th><th>%SA</th><th>Overall</th><th>Specificity</th><th>Comment</th></tr>
|
| 218 |
+
</thead>
|
| 219 |
+
<tbody>
|
| 220 |
+
{% for r in reports %}
|
| 221 |
+
<tr>
|
| 222 |
+
<td>{{ r.report_date }}</td>
|
| 223 |
+
<td>{{ r.dsa_class }}</td>
|
| 224 |
+
<td class="fw-bold">{{ r.pct_sa }}%</td>
|
| 225 |
+
<td><span class="badge {{ 'bg-danger' if r.overall == 'Positive' else 'bg-success' }}">{{ r.overall }}</span></td>
|
| 226 |
+
<td class="small" style="max-width:300px;">
|
| 227 |
+
{%- set spec_text = (r.specificity or '') | replace('>', '>') -%}
|
| 228 |
+
{%- if 'Weak (MFI' in spec_text -%}
|
| 229 |
+
{%- set parts = spec_text.split('Weak (MFI', 1) -%}
|
| 230 |
+
<div class="spec-cell spec-strong-bg">{{ parts[0]|trim }}</div>
|
| 231 |
+
<div class="spec-cell spec-weak-bg">Weak (MFI{{ parts[1] }}</div>
|
| 232 |
+
{%- else -%}
|
| 233 |
+
<div class="spec-cell">{{ spec_text or '(-)' }}</div>
|
| 234 |
+
{%- endif -%}
|
| 235 |
+
</td>
|
| 236 |
+
<td class="small">
|
| 237 |
+
{%- set cmt_text = (r.comment or '') | replace('>', '>') -%}
|
| 238 |
+
{%- if 'Weak (MFI' in cmt_text -%}
|
| 239 |
+
{%- set cparts = cmt_text.split('Weak (MFI', 1) -%}
|
| 240 |
+
<div class="spec-strong-bg">{{ cparts[0]|trim }}</div>
|
| 241 |
+
<div class="spec-weak-bg">Weak (MFI{{ cparts[1] }}</div>
|
| 242 |
+
{%- else -%}
|
| 243 |
+
{{ cmt_text }}
|
| 244 |
+
{%- endif -%}
|
| 245 |
+
</td>
|
| 246 |
+
</tr>
|
| 247 |
+
{% endfor %}
|
| 248 |
+
</tbody>
|
| 249 |
+
</table>
|
| 250 |
+
|
| 251 |
+
<div id="reportOutput" class="mb-4">
|
| 252 |
+
{% for r in reports %}
|
| 253 |
+
<div class="card mb-2">
|
| 254 |
+
<div class="card-body p-3">
|
| 255 |
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
| 256 |
+
<strong>{{ r.dsa_class }} — {{ r.report_date }}</strong>
|
| 257 |
+
<div>
|
| 258 |
+
<button class="btn btn-sm btn-outline-secondary me-1" onclick="copyReport('rpt-{{ loop.index }}')">Copy</button>
|
| 259 |
+
<button class="btn btn-sm btn-outline-primary" id="editbtn-{{ loop.index }}" onclick="toggleEdit({{ r.id }}, {{ loop.index }}, this)">Edit</button>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
<div id="rpt-{{ loop.index }}" style="font-family:'Calibri','Segoe UI',sans-serif; line-height:1.8;">
|
| 263 |
+
<div>{{ r.dsa_class }}</div>
|
| 264 |
+
<div>Overall: {{ r.overall }}</div>
|
| 265 |
+
<div>%SA (or %PRA): {{ r.pct_sa }}</div>
|
| 266 |
+
<div>Specificity:</div>
|
| 267 |
+
<div id="spec-{{ loop.index }}" style="border:1px solid transparent; border-radius:4px; padding:3px 6px; min-height:1.8rem;">
|
| 268 |
+
{%- set spec_text = (r.specificity or '') | replace('>', '>') -%}
|
| 269 |
+
{%- if 'Weak (MFI' in spec_text -%}
|
| 270 |
+
{%- set parts = spec_text.split('Weak (MFI', 1) -%}
|
| 271 |
+
<div class="spec-cell">{{ parts[0]|trim }}</div>
|
| 272 |
+
<div class="spec-cell">Weak (MFI{{ parts[1] }}</div>
|
| 273 |
+
{%- else -%}
|
| 274 |
+
<div class="spec-cell">{{ spec_text or '(-)' }}</div>
|
| 275 |
+
{%- endif -%}
|
| 276 |
+
</div>
|
| 277 |
+
<div>COMMENT:</div>
|
| 278 |
+
<div id="comment-{{ loop.index }}" style="border:1px solid transparent; border-radius:4px; padding:3px 6px; min-height:1.5rem;">{{ r.comment or '' }}</div>
|
| 279 |
+
</div>
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
+
{% endfor %}
|
| 283 |
+
</div>
|
| 284 |
+
|
| 285 |
+
<script>
|
| 286 |
+
function toggleEdit(reportId, idx, btn) {
|
| 287 |
+
const specEl = document.getElementById('spec-' + idx);
|
| 288 |
+
const commentEl = document.getElementById('comment-' + idx);
|
| 289 |
+
if (btn.textContent === 'Edit') {
|
| 290 |
+
specEl.contentEditable = 'true'; commentEl.contentEditable = 'true';
|
| 291 |
+
specEl.style.border = '1px dashed #0d6efd'; specEl.style.background = '#f8f9ff';
|
| 292 |
+
commentEl.style.border = '1px dashed #0d6efd'; commentEl.style.background = '#f8f9ff';
|
| 293 |
+
btn.textContent = 'Save'; btn.className = 'btn btn-sm btn-success'; specEl.focus();
|
| 294 |
+
} else {
|
| 295 |
+
const spec = specEl.innerText.trim();
|
| 296 |
+
const comment = commentEl.innerText.trim();
|
| 297 |
+
btn.disabled = true; btn.textContent = 'Saving...';
|
| 298 |
+
fetch('/dsa/update_report', {
|
| 299 |
+
method: 'POST', headers: {'Content-Type': 'application/json'},
|
| 300 |
+
body: JSON.stringify({ report_id: reportId, specificity: spec, comment: comment })
|
| 301 |
+
})
|
| 302 |
+
.then(r => r.json()).then(d => {
|
| 303 |
+
if (d.ok) {
|
| 304 |
+
specEl.contentEditable = 'false'; commentEl.contentEditable = 'false';
|
| 305 |
+
specEl.style.border = '1px solid transparent'; specEl.style.background = '';
|
| 306 |
+
commentEl.style.border = '1px solid transparent'; commentEl.style.background = '';
|
| 307 |
+
btn.textContent = 'Edit'; btn.className = 'btn btn-sm btn-outline-primary'; btn.disabled = false;
|
| 308 |
+
} else { alert('儲存失敗: ' + (d.error || '')); btn.textContent = 'Save'; btn.disabled = false; }
|
| 309 |
+
}).catch(e => { alert('Error: ' + e); btn.textContent = 'Save'; btn.disabled = false; });
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
function copyReport(id) {
|
| 313 |
+
const el = document.getElementById(id);
|
| 314 |
+
const range = document.createRange(); range.selectNodeContents(el);
|
| 315 |
+
const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
|
| 316 |
+
document.execCommand('copy'); sel.removeAllRanges();
|
| 317 |
+
const btn = el.parentElement.querySelector('button');
|
| 318 |
+
const orig = btn.textContent; btn.textContent = 'Copied!';
|
| 319 |
+
btn.classList.replace('btn-outline-secondary','btn-success');
|
| 320 |
+
setTimeout(() => { btn.textContent = orig; btn.classList.replace('btn-success','btn-outline-secondary'); }, 1500);
|
| 321 |
+
}
|
| 322 |
+
</script>
|
| 323 |
+
|
| 324 |
+
{% for comp_label, comp_id, comp_dates, comp_antigens, comp_pra in [
|
| 325 |
+
('DSA Class I', 'chart1', comparison_class1.dates if comparison_class1 else [],
|
| 326 |
+
comparison_class1.antigens if comparison_class1 else [],
|
| 327 |
+
comparison_class1.pra_by_date if comparison_class1 else {}),
|
| 328 |
+
('DSA Class II', 'chart2', comparison_class2.dates if comparison_class2 else [],
|
| 329 |
+
comparison_class2.antigens if comparison_class2 else [],
|
| 330 |
+
comparison_class2.pra_by_date if comparison_class2 else {})
|
| 331 |
+
] %}
|
| 332 |
+
{% if comp_dates and comp_dates | length >= 2 %}
|
| 333 |
+
<h5>{{ comp_label }} — MFI Trend</h5>
|
| 334 |
+
<div class="mb-2">
|
| 335 |
+
<small class="text-muted">Top antibodies by Max MFI (click legend to show/hide)</small>
|
| 336 |
+
<select id="filter-{{ comp_id }}" class="form-select form-select-sm d-inline-block ms-2" style="width:auto;"
|
| 337 |
+
onchange="updateChart('{{ comp_id }}')">
|
| 338 |
+
<option value="10">Top 10</option>
|
| 339 |
+
<option value="20" selected>Top 20</option>
|
| 340 |
+
<option value="all">All</option>
|
| 341 |
+
</select>
|
| 342 |
+
</div>
|
| 343 |
+
<div class="chart-container"><canvas id="{{ comp_id }}"></canvas></div>
|
| 344 |
+
<script>
|
| 345 |
+
(function() {
|
| 346 |
+
const dates = {{ comp_dates | tojson }};
|
| 347 |
+
const allData = [
|
| 348 |
+
{% for ag in comp_antigens %}
|
| 349 |
+
{ antigen: '{{ ag.antigen }}', allele: '{{ ag.allele }}',
|
| 350 |
+
values: [{% for d in comp_dates %}{{ ag.mfi_by_date.get(d, {}).get('max_mfi', 'null') }}{% if not loop.last %},{% endif %}{% endfor %}] },
|
| 351 |
+
{% endfor %}
|
| 352 |
+
];
|
| 353 |
+
function getColor(ag, i) {
|
| 354 |
+
const a = ['#e53935','#d81b60','#8e24aa','#5e35b1','#3949ab','#1e88e5','#ef5350','#ec407a'];
|
| 355 |
+
const b = ['#00897b','#43a047','#7cb342','#c0ca33','#26a69a','#66bb6a','#9ccc65','#d4e157','#2e7d32','#558b2f'];
|
| 356 |
+
const c = ['#ff8f00','#ff6f00','#f57f17','#ffb300','#ffa000','#ff8f00'];
|
| 357 |
+
if (ag.startsWith('A') || ag.startsWith('DR')) return a[i % a.length];
|
| 358 |
+
if (ag.startsWith('B') || ag.startsWith('DQ')) return b[i % b.length];
|
| 359 |
+
return c[i % c.length];
|
| 360 |
+
}
|
| 361 |
+
allData.sort((a, b) => {
|
| 362 |
+
const maxA = Math.max(...a.values.filter(v => v !== null));
|
| 363 |
+
const maxB = Math.max(...b.values.filter(v => v !== null));
|
| 364 |
+
return maxB - maxA;
|
| 365 |
+
});
|
| 366 |
+
let chartInstance = null;
|
| 367 |
+
function buildChart(limit) {
|
| 368 |
+
const data = limit === 'all' ? allData : allData.slice(0, parseInt(limit));
|
| 369 |
+
const datasets = data.map((ag, i) => ({
|
| 370 |
+
label: ag.antigen + (ag.allele && ag.allele !== ag.antigen ? ' (' + ag.allele + ')' : ''),
|
| 371 |
+
data: ag.values, borderColor: getColor(ag.antigen, i),
|
| 372 |
+
backgroundColor: getColor(ag.antigen, i) + '33',
|
| 373 |
+
borderWidth: 2, pointRadius: 4, pointHoverRadius: 6, tension: 0.3, spanGaps: true,
|
| 374 |
+
}));
|
| 375 |
+
if (chartInstance) chartInstance.destroy();
|
| 376 |
+
chartInstance = new Chart(document.getElementById('{{ comp_id }}'), {
|
| 377 |
+
type: 'line', data: { labels: dates, datasets: datasets },
|
| 378 |
+
options: {
|
| 379 |
+
responsive: true, maintainAspectRatio: false,
|
| 380 |
+
interaction: { mode: 'nearest', intersect: false },
|
| 381 |
+
plugins: { legend: { position: 'right', labels: { font: { size: 11 }, boxWidth: 12, padding: 6 } },
|
| 382 |
+
tooltip: { callbacks: { label: c => c.dataset.label + ': ' + (c.parsed.y !== null ? c.parsed.y.toLocaleString() : '-') } } },
|
| 383 |
+
scales: { y: { title: { display: true, text: 'Max MFI (Normal)' }, beginAtZero: true },
|
| 384 |
+
x: { title: { display: true, text: 'Report Date' } } }
|
| 385 |
+
}
|
| 386 |
+
});
|
| 387 |
+
}
|
| 388 |
+
const origUpdate = window['updateChart'];
|
| 389 |
+
window['updateChart'] = function(id) {
|
| 390 |
+
if (id === '{{ comp_id }}') { const sel = document.getElementById('filter-' + id); buildChart(sel.value); }
|
| 391 |
+
else if (origUpdate) origUpdate(id);
|
| 392 |
+
};
|
| 393 |
+
buildChart(20);
|
| 394 |
+
})();
|
| 395 |
+
</script>
|
| 396 |
+
{% endif %}
|
| 397 |
+
{% endfor %}
|
| 398 |
+
|
| 399 |
+
<h5>Antibody Strength</h5>
|
| 400 |
+
{% for r in reports %}
|
| 401 |
+
{% if r.antibodies %}
|
| 402 |
+
<div class="card mb-3">
|
| 403 |
+
<div class="card-header">
|
| 404 |
+
<strong>{{ r.dsa_class }}</strong> — {{ r.report_date }}
|
| 405 |
+
<span class="badge ms-1 {{ 'bg-danger' if r.overall == 'Positive' else 'bg-success' }}">{{ r.overall }}</span>
|
| 406 |
+
<span class="fw-bold ms-1">%SA {{ r.pct_sa }}%</span>
|
| 407 |
+
</div>
|
| 408 |
+
<div class="card-body p-2">
|
| 409 |
+
<table class="table table-sm table-bordered ab-table mb-0">
|
| 410 |
+
<thead class="table-light"><tr>
|
| 411 |
+
<th>Antigen</th><th>Allele</th><th>Strength</th><th class="text-end">Max MFI</th>
|
| 412 |
+
<th class="text-end">Mean MFI</th><th class="text-center">No of Beads</th><th>Bead IDs</th>
|
| 413 |
+
</tr></thead>
|
| 414 |
+
<tbody>
|
| 415 |
+
{% for a in r.antibodies %}
|
| 416 |
+
<tr class="{% if a.strength == 'Strong' %}strength-strong{% elif a.strength == 'Weak' %}strength-weak{% endif %}">
|
| 417 |
+
<td class="fw-bold">{{ a.antigen }}</td>
|
| 418 |
+
<td class="small">{{ a.allele }}</td>
|
| 419 |
+
<td>{{ a.strength or '' }}</td>
|
| 420 |
+
<td class="text-end">{{ a.max_mfi|int }}</td>
|
| 421 |
+
<td class="text-end">{{ a.mean_mfi|int }}</td>
|
| 422 |
+
<td class="text-center">{{ a.no_of_beads }}</td>
|
| 423 |
+
<td class="small text-muted">{{ a.bead_ids }}</td>
|
| 424 |
+
</tr>
|
| 425 |
+
{% endfor %}
|
| 426 |
+
</tbody>
|
| 427 |
+
</table>
|
| 428 |
+
</div>
|
| 429 |
+
</div>
|
| 430 |
+
{% endif %}
|
| 431 |
+
{% endfor %}
|
| 432 |
+
</div>
|
| 433 |
+
</body>
|
| 434 |
+
</html>
|
templates//history.html
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>PRA History</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
</style>
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div class="container py-4" style="max-width: 1200px;">
|
| 13 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 14 |
+
<h2 class="mb-0">PRA 歷史紀錄</h2>
|
| 15 |
+
<div>
|
| 16 |
+
{% if is_admin %}
|
| 17 |
+
<button class="btn btn-warning btn-sm me-1" onclick="cloudSync(this)">雲端同步</button>
|
| 18 |
+
{% endif %}
|
| 19 |
+
<a href="/" class="btn btn-dark btn-sm me-1">首頁</a>
|
| 20 |
+
<a href="/new" class="btn btn-outline-primary btn-sm me-1">新增資料</a>
|
| 21 |
+
<a href="/analysis" class="btn btn-outline-secondary btn-sm">統計分析</a>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
<div id="syncStatus" class="alert alert-info py-2 small mb-2" style="display:none;"></div>
|
| 25 |
+
<div class="small text-muted mb-2">目前共 {{ db_stats.patients }} 位病患、{{ db_stats.reports }} 筆報告</div>
|
| 26 |
+
|
| 27 |
+
{% if error %}
|
| 28 |
+
<div class="alert alert-danger">{{ error }}</div>
|
| 29 |
+
{% endif %}
|
| 30 |
+
|
| 31 |
+
<form class="row g-2 mb-3" method="get" action="/history">
|
| 32 |
+
<div class="col">
|
| 33 |
+
<input type="text" class="form-control" name="q" placeholder="搜尋姓名或病歷號" value="{{ request.args.get('q','') }}">
|
| 34 |
+
</div>
|
| 35 |
+
<div class="col-auto d-flex align-items-center">
|
| 36 |
+
<span class="me-1 text-muted small">日期範圍</span>
|
| 37 |
+
<input type="date" class="form-control form-control-sm" name="from" value="{{ request.args.get('from','') }}" style="width:150px;">
|
| 38 |
+
<span class="mx-1">–</span>
|
| 39 |
+
<input type="date" class="form-control form-control-sm" name="to" value="{{ request.args.get('to','') }}" style="width:150px;">
|
| 40 |
+
</div>
|
| 41 |
+
<div class="col-auto">
|
| 42 |
+
<button class="btn btn-secondary">搜尋</button>
|
| 43 |
+
</div>
|
| 44 |
+
</form>
|
| 45 |
+
|
| 46 |
+
<table class="table table-bordered table-hover bg-white" style="font-size:.9rem;">
|
| 47 |
+
<thead class="table-light">
|
| 48 |
+
<tr>
|
| 49 |
+
<th>病患姓名</th>
|
| 50 |
+
<th>病歷號</th>
|
| 51 |
+
<th>報告日期</th>
|
| 52 |
+
<th>Class</th>
|
| 53 |
+
<th>PRA%</th>
|
| 54 |
+
<th>Overall</th>
|
| 55 |
+
<th>Antibodies</th>
|
| 56 |
+
<th>填表人</th>
|
| 57 |
+
<th></th>
|
| 58 |
+
</tr>
|
| 59 |
+
</thead>
|
| 60 |
+
<tbody>
|
| 61 |
+
{% set q = request.args.get('q','').lower() %}
|
| 62 |
+
{% set date_from = request.args.get('from','') %}
|
| 63 |
+
{% set date_to = request.args.get('to','') %}
|
| 64 |
+
{% for r in reports %}
|
| 65 |
+
{% set rd = r.report_date | replace('/','-') %}
|
| 66 |
+
{% if (not q or q in r.chart_no.lower() or q in r.patient_name.lower())
|
| 67 |
+
and (not date_from or rd >= date_from)
|
| 68 |
+
and (not date_to or rd <= date_to) %}
|
| 69 |
+
<tr>
|
| 70 |
+
<td>{{ r.patient_name }}</td>
|
| 71 |
+
<td>{{ r.chart_no }}</td>
|
| 72 |
+
<td>{{ r.report_date }}</td>
|
| 73 |
+
<td>{{ r.pra_class }}</td>
|
| 74 |
+
<td class="fw-bold">{{ r.pra_percent }}%</td>
|
| 75 |
+
<td>
|
| 76 |
+
<span class="badge {{ 'bg-danger' if r.overall == 'Positive' else 'bg-success' }}">
|
| 77 |
+
{{ r.overall }}</span>
|
| 78 |
+
</td>
|
| 79 |
+
<td class="small spec-cell" style="max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"
|
| 80 |
+
title="{{ r.specificity or '' }}">
|
| 81 |
+
{{ r.specificity if r.specificity and r.specificity != '(-)' else '(-)' }}
|
| 82 |
+
</td>
|
| 83 |
+
<td class="small">{{ r.submitted_by or '' }}</td>
|
| 84 |
+
<td class="text-nowrap">
|
| 85 |
+
<a href="/history/{{ r.chart_no }}" class="btn btn-sm btn-outline-primary me-1">View</a>
|
| 86 |
+
<a href="/analysis?chart_no={{ r.chart_no }}" class="btn btn-sm btn-outline-success me-1">Analyze</a>
|
| 87 |
+
<button class="btn btn-sm btn-outline-danger" onclick="deleteReport({{ r.id }}, this)">Del</button>
|
| 88 |
+
</td>
|
| 89 |
+
</tr>
|
| 90 |
+
{% endif %}
|
| 91 |
+
{% endfor %}
|
| 92 |
+
</tbody>
|
| 93 |
+
</table>
|
| 94 |
+
|
| 95 |
+
{% if not reports %}
|
| 96 |
+
<div class="text-center text-muted py-5">尚無紀錄</div>
|
| 97 |
+
{% endif %}
|
| 98 |
+
</div>
|
| 99 |
+
<script>
|
| 100 |
+
// Specificity 藍/灰預設色(list view 多病人,不上 DSA 紅)
|
| 101 |
+
window.addEventListener('DOMContentLoaded', function() {
|
| 102 |
+
document.querySelectorAll('.spec-cell').forEach(td => {
|
| 103 |
+
const orig = td.textContent.trim();
|
| 104 |
+
if (!orig || orig === '(-)') return;
|
| 105 |
+
const tokens = orig.match(/\S+?\([^)]*\)|\S+/g) || [];
|
| 106 |
+
td.innerHTML = tokens.map(seg => {
|
| 107 |
+
const m = seg.match(/^([^(]+)(\([^)]*\))?$/);
|
| 108 |
+
if (!m) return seg;
|
| 109 |
+
const sero = m[1];
|
| 110 |
+
const paren = m[2] || '';
|
| 111 |
+
const seroHtml = '<span style="color:#1E40AF;font-weight:bold;">' + sero + '</span>';
|
| 112 |
+
if (!paren) return seroHtml;
|
| 113 |
+
const inner = paren.slice(1, -1);
|
| 114 |
+
const alleleHtml = inner.split(/\s+/).map(a =>
|
| 115 |
+
a ? '<span style="color:#374151;">' + a + '</span>' : a
|
| 116 |
+
).join(' ');
|
| 117 |
+
return seroHtml + '(' + alleleHtml + ')';
|
| 118 |
+
}).join(' ');
|
| 119 |
+
});
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
function cloudSync(btn) {
|
| 123 |
+
btn.disabled = true;
|
| 124 |
+
btn.textContent = '同步中...';
|
| 125 |
+
const ss = document.getElementById('syncStatus');
|
| 126 |
+
ss.style.display = 'block';
|
| 127 |
+
ss.className = 'alert alert-info py-2 small mb-2';
|
| 128 |
+
ss.innerHTML = '<strong>正在與雲端同步...</strong>';
|
| 129 |
+
fetch('/cloud_sync', { method: 'POST' })
|
| 130 |
+
.then(r => r.json())
|
| 131 |
+
.then(d => {
|
| 132 |
+
if (d.ok) {
|
| 133 |
+
let html = '<table style="width:100%;font-size:.9rem;"><tbody>';
|
| 134 |
+
html += '<tr><td><strong>同步前 Local:</strong></td><td>' + d.before.local + '</td></tr>';
|
| 135 |
+
html += '<tr><td><strong>同步前 Cloud:</strong></td><td>' + d.before.cloud + '</td></tr>';
|
| 136 |
+
html += '<tr><td colspan="2"><hr style="margin:.3rem 0;"></td></tr>';
|
| 137 |
+
html += '<tr><td><strong>同步後:</strong></td><td><strong>' + d.after + '</strong></td></tr>';
|
| 138 |
+
if (d.details) {
|
| 139 |
+
const dt = d.details;
|
| 140 |
+
let changes = [];
|
| 141 |
+
if (dt.patients_added) changes.push('新增 ' + dt.patients_added + ' 病患');
|
| 142 |
+
if (dt.reports_added) changes.push('新增 ' + dt.reports_added + ' 報告');
|
| 143 |
+
if (dt.reports_updated) changes.push('更新 ' + dt.reports_updated + ' 報告');
|
| 144 |
+
if (dt.users_added) changes.push('新增 ' + dt.users_added + ' 使用者');
|
| 145 |
+
if (dt.users_updated) changes.push('更新 ' + dt.users_updated + ' 使用者');
|
| 146 |
+
if (changes.length) html += '<tr><td><strong>變更:</strong></td><td>' + changes.join('、') + '</td></tr>';
|
| 147 |
+
}
|
| 148 |
+
html += '</tbody></table>';
|
| 149 |
+
ss.className = d.status === 'identical' ? 'alert alert-secondary py-2 small mb-2' : 'alert alert-success py-2 small mb-2';
|
| 150 |
+
ss.innerHTML = '<strong>' + d.message + '</strong><br>' + html;
|
| 151 |
+
if (d.status !== 'identical') setTimeout(() => location.reload(), 3000);
|
| 152 |
+
} else {
|
| 153 |
+
ss.className = 'alert alert-danger py-2 small mb-2';
|
| 154 |
+
ss.textContent = '同步失敗: ' + (d.error || '');
|
| 155 |
+
btn.disabled = false;
|
| 156 |
+
btn.textContent = '雲端同步';
|
| 157 |
+
}
|
| 158 |
+
})
|
| 159 |
+
.catch(e => {
|
| 160 |
+
ss.className = 'alert alert-danger py-2 small mb-2';
|
| 161 |
+
ss.textContent = '同步失敗: ' + e;
|
| 162 |
+
btn.disabled = false;
|
| 163 |
+
btn.textContent = '雲端同步';
|
| 164 |
+
});
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
function deleteReport(id, btn) {
|
| 168 |
+
if (!confirm('確定刪除此筆報告?')) return;
|
| 169 |
+
fetch('/delete_report/' + id, { method: 'POST' })
|
| 170 |
+
.then(r => r.json())
|
| 171 |
+
.then(d => {
|
| 172 |
+
if (d.ok) location.reload();
|
| 173 |
+
else alert('刪除失敗: ' + (d.error || ''));
|
| 174 |
+
});
|
| 175 |
+
}
|
| 176 |
+
</script>
|
| 177 |
+
</body>
|
| 178 |
+
</html>
|
templates//index.html
ADDED
|
@@ -0,0 +1,927 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>PRA Analysis</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
body { background: #f5f7fa; }
|
| 10 |
+
.upload-zone {
|
| 11 |
+
border: 2px dashed #adb5bd; border-radius: 12px; padding: 2.5rem;
|
| 12 |
+
text-align: center; cursor: pointer; transition: all .2s; background: #fff;
|
| 13 |
+
}
|
| 14 |
+
.upload-zone:hover, .upload-zone.dragover { border-color: #0d6efd; background: #e8f0fe; }
|
| 15 |
+
.pra-panel { min-height: 200px; }
|
| 16 |
+
.report-card {
|
| 17 |
+
background: #fff; border: 1px solid #dee2e6; border-radius: 6px;
|
| 18 |
+
padding: 1.2rem 1.5rem; margin-bottom: 1rem;
|
| 19 |
+
font-family: 'Calibri','Segoe UI',sans-serif; font-size: .95rem; line-height: 1.7;
|
| 20 |
+
}
|
| 21 |
+
.report-card .report-title { font-weight: bold; font-size: 1.05rem; }
|
| 22 |
+
.report-spec-edit {
|
| 23 |
+
border: 1px dashed #adb5bd; border-radius: 4px; padding: .3rem .5rem;
|
| 24 |
+
min-height: 1.8rem; outline: none; width: 100%; font-family: inherit; font-size: inherit;
|
| 25 |
+
}
|
| 26 |
+
.report-spec-edit:focus { border-color: #0d6efd; background: #f8f9ff; }
|
| 27 |
+
.panel-title { font-size: 1.1rem; font-weight: bold; padding: .5rem; border-radius: 4px; text-align: center; margin-bottom: .8rem; }
|
| 28 |
+
.panel-title-pra1 { background: #dbeafe; color: #1e40af; }
|
| 29 |
+
.panel-title-pra2 { background: #fef3c7; color: #92400e; }
|
| 30 |
+
.empty-panel { color: #adb5bd; text-align: center; padding: 3rem 1rem; font-style: italic; }
|
| 31 |
+
table.mfi-table { font-size: .82rem; }
|
| 32 |
+
table.mfi-table th { position: sticky; top: 0; background: #fff; z-index: 1; }
|
| 33 |
+
.mfi-scroll { max-height: 350px; overflow-y: auto; }
|
| 34 |
+
table.bead-table { font-size: .82rem; }
|
| 35 |
+
.bead-scroll { max-height: 350px; overflow-y: auto; }
|
| 36 |
+
.rxn-8 { background: #f8d7da !important; font-weight: bold; }
|
| 37 |
+
.rxn-6 { background: #ffe0b2 !important; }
|
| 38 |
+
.rxn-4 { background: #fff9c4 !important; }
|
| 39 |
+
.bead-confident td:first-child { border-left: 4px solid #198754 !important; }
|
| 40 |
+
.dsa-highlight { background: #ff0 !important; font-weight: bold; }
|
| 41 |
+
.badge-pos { background: #dc3545; }
|
| 42 |
+
.badge-neg { background: #198754; }
|
| 43 |
+
@media print {
|
| 44 |
+
.no-print { display: none !important; }
|
| 45 |
+
.report-card { border: none; padding: .5rem 0; }
|
| 46 |
+
.report-spec-edit { border: none; padding: 0; }
|
| 47 |
+
}
|
| 48 |
+
/* Custom choice modal(取代 prompt() 以避免瀏覽器顯示網域) */
|
| 49 |
+
#choice-modal-backdrop {
|
| 50 |
+
display: none; position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
| 51 |
+
z-index: 1060; align-items: center; justify-content: center;
|
| 52 |
+
}
|
| 53 |
+
#choice-modal-backdrop.show { display: flex; }
|
| 54 |
+
#choice-modal {
|
| 55 |
+
background: #fff; border-radius: 8px; padding: 1.25rem 1.5rem;
|
| 56 |
+
min-width: 320px; max-width: 480px; box-shadow: 0 10px 40px rgba(0,0,0,.2);
|
| 57 |
+
}
|
| 58 |
+
#choice-modal .cm-message { white-space: pre-wrap; margin-bottom: 1rem; font-size: .95rem; }
|
| 59 |
+
#choice-modal .cm-input {
|
| 60 |
+
width: 100%; padding: .5rem; font-size: 1.2rem; text-align: center;
|
| 61 |
+
border: 1px solid #ced4da; border-radius: 4px;
|
| 62 |
+
}
|
| 63 |
+
#choice-modal .cm-input:focus { border-color: #0d6efd; outline: none; }
|
| 64 |
+
#choice-modal .cm-btns { text-align: right; margin-top: 1rem; }
|
| 65 |
+
#choice-modal .cm-btns button { margin-left: .5rem; }
|
| 66 |
+
</style>
|
| 67 |
+
</head>
|
| 68 |
+
<body>
|
| 69 |
+
<!-- Custom choice modal -->
|
| 70 |
+
<div id="choice-modal-backdrop">
|
| 71 |
+
<div id="choice-modal" role="dialog" aria-modal="true">
|
| 72 |
+
<div class="cm-message" id="cm-message"></div>
|
| 73 |
+
<input type="text" class="cm-input" id="cm-input"
|
| 74 |
+
inputmode="numeric" pattern="[1-3]" maxlength="1"
|
| 75 |
+
autocomplete="off">
|
| 76 |
+
<div class="cm-btns">
|
| 77 |
+
<button type="button" class="btn btn-secondary btn-sm" id="cm-cancel">取消</button>
|
| 78 |
+
<button type="button" class="btn btn-primary btn-sm" id="cm-ok">確定</button>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
<script>
|
| 83 |
+
/* askChoice(message) -> Promise<string|null>(返回 '1'/'2'/'3' 或 null) */
|
| 84 |
+
window.askChoice = function(message) {
|
| 85 |
+
return new Promise(resolve => {
|
| 86 |
+
const bd = document.getElementById('choice-modal-backdrop');
|
| 87 |
+
const input = document.getElementById('cm-input');
|
| 88 |
+
const ok = document.getElementById('cm-ok');
|
| 89 |
+
const cancel = document.getElementById('cm-cancel');
|
| 90 |
+
document.getElementById('cm-message').textContent = message;
|
| 91 |
+
input.value = '';
|
| 92 |
+
bd.classList.add('show');
|
| 93 |
+
setTimeout(() => input.focus(), 50);
|
| 94 |
+
|
| 95 |
+
// 只允許 1-3 單一數字
|
| 96 |
+
const onInput = () => {
|
| 97 |
+
const v = input.value.replace(/[^1-3]/g, '').slice(0, 1);
|
| 98 |
+
if (input.value !== v) input.value = v;
|
| 99 |
+
};
|
| 100 |
+
const onKey = (e) => {
|
| 101 |
+
if (e.key === 'Enter') { e.preventDefault(); submit(); }
|
| 102 |
+
else if (e.key === 'Escape') { e.preventDefault(); cleanup(null); }
|
| 103 |
+
};
|
| 104 |
+
const submit = () => {
|
| 105 |
+
const v = input.value;
|
| 106 |
+
if (v === '1' || v === '2' || v === '3') cleanup(v);
|
| 107 |
+
else {
|
| 108 |
+
input.focus();
|
| 109 |
+
input.style.borderColor = '#dc3545';
|
| 110 |
+
setTimeout(() => input.style.borderColor = '', 800);
|
| 111 |
+
}
|
| 112 |
+
};
|
| 113 |
+
const cleanup = (result) => {
|
| 114 |
+
bd.classList.remove('show');
|
| 115 |
+
input.removeEventListener('input', onInput);
|
| 116 |
+
input.removeEventListener('keydown', onKey);
|
| 117 |
+
ok.removeEventListener('click', submit);
|
| 118 |
+
cancel.removeEventListener('click', onCancel);
|
| 119 |
+
resolve(result);
|
| 120 |
+
};
|
| 121 |
+
const onCancel = () => cleanup(null);
|
| 122 |
+
|
| 123 |
+
input.addEventListener('input', onInput);
|
| 124 |
+
input.addEventListener('keydown', onKey);
|
| 125 |
+
ok.addEventListener('click', submit);
|
| 126 |
+
cancel.addEventListener('click', onCancel);
|
| 127 |
+
});
|
| 128 |
+
};
|
| 129 |
+
</script>
|
| 130 |
+
|
| 131 |
+
<div class="container-fluid py-3" style="max-width: 1400px;">
|
| 132 |
+
<div class="d-flex justify-content-between align-items-center mb-1">
|
| 133 |
+
<h2 class="mb-0">PRA Analysis</h2>
|
| 134 |
+
<div class="no-print">
|
| 135 |
+
<a href="/" class="btn btn-dark btn-sm me-1">首頁</a>
|
| 136 |
+
<a href="/history" class="btn btn-outline-secondary btn-sm me-1">歷史紀錄</a>
|
| 137 |
+
<a href="/analysis" class="btn btn-outline-secondary btn-sm me-1">統計分析</a>
|
| 138 |
+
<a href="/batch_upload" class="btn btn-success btn-sm">批次新增資料</a>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
<p class="text-muted mb-3">LABScreen PRA Class I / II</p>
|
| 142 |
+
|
| 143 |
+
{% if error %}
|
| 144 |
+
<div class="alert alert-danger">{{ error }}</div>
|
| 145 |
+
{% endif %}
|
| 146 |
+
{% if errors %}
|
| 147 |
+
<div class="alert alert-warning no-print">
|
| 148 |
+
{% for e in errors %}<div class="small">{{ e }}</div>{% endfor %}
|
| 149 |
+
</div>
|
| 150 |
+
{% endif %}
|
| 151 |
+
|
| 152 |
+
<!-- Patient Info + Upload -->
|
| 153 |
+
<form id="uploadForm" action="/analyze" method="post" enctype="multipart/form-data" class="mb-3 no-print">
|
| 154 |
+
<div class="row g-2 mb-2">
|
| 155 |
+
<div class="col-sm-5">
|
| 156 |
+
<input type="text" class="form-control" name="patient_name" id="patientName"
|
| 157 |
+
placeholder="病患姓名(必填)" value="{{ patient_name or '' }}" required>
|
| 158 |
+
</div>
|
| 159 |
+
<div class="col-sm-4">
|
| 160 |
+
<input type="text" class="form-control" name="patient_id" id="patientId"
|
| 161 |
+
placeholder="病歷號(必填)" value="{{ patient_id or '' }}" required>
|
| 162 |
+
</div>
|
| 163 |
+
<div class="col-sm-3">
|
| 164 |
+
<button type="button" class="btn btn-primary w-100" id="analyzeBtn" disabled
|
| 165 |
+
onclick="submitWithDelay()">Analyze</button>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
<div class="upload-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
|
| 169 |
+
<input type="file" name="file" id="fileInput" accept=".xls,.xlsx" multiple
|
| 170 |
+
style="display:none" onchange="onFileSelected(this)">
|
| 171 |
+
<div id="dropIcon" style="font-size:1.8rem;">📄</div>
|
| 172 |
+
<div class="mt-1 fw-bold">XLS</div>
|
| 173 |
+
<div class="text-muted small">可多次拖拉或選取 HLA Fusion PRA Class I / II 檔案</div>
|
| 174 |
+
<div id="fileList" class="mt-2 small text-primary" style="display:none;"></div>
|
| 175 |
+
<div id="uploadingMsg" class="mt-2 text-muted" style="display:none;">
|
| 176 |
+
<div class="spinner-border spinner-border-sm me-1"></div> Analyzing...
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
</form>
|
| 180 |
+
|
| 181 |
+
{% if result_pra1 or result_pra2 %}
|
| 182 |
+
<!-- Donor HLA -->
|
| 183 |
+
<div class="no-print mb-3">
|
| 184 |
+
<div class="card">
|
| 185 |
+
<div class="card-header fw-bold">Donor HLA</div>
|
| 186 |
+
<div class="card-body" style="font-size:.85rem;">
|
| 187 |
+
<style>.donor-hla { width: 65px !important; padding: 2px 4px !important; text-align: center; }</style>
|
| 188 |
+
<div class="row">
|
| 189 |
+
<div class="col-md-5">
|
| 190 |
+
<div class="fw-bold mb-1">Serology</div>
|
| 191 |
+
<table class="table table-sm table-bordered mb-1">
|
| 192 |
+
<tbody>
|
| 193 |
+
<tr><td class="fw-bold">A</td>
|
| 194 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-1"></td>
|
| 195 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-2"></td>
|
| 196 |
+
<td class="fw-bold">DR</td>
|
| 197 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-1"></td>
|
| 198 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-2"></td></tr>
|
| 199 |
+
<tr><td class="fw-bold">B</td>
|
| 200 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-1"></td>
|
| 201 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-2"></td>
|
| 202 |
+
<td class="fw-bold">DQ</td>
|
| 203 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-1"></td>
|
| 204 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-2"></td></tr>
|
| 205 |
+
<tr><td class="fw-bold">Cw</td>
|
| 206 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-1"></td>
|
| 207 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-2"></td>
|
| 208 |
+
<td class="fw-bold">DP</td>
|
| 209 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-1"></td>
|
| 210 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-2"></td></tr>
|
| 211 |
+
</tbody>
|
| 212 |
+
</table>
|
| 213 |
+
</div>
|
| 214 |
+
<div class="col-md-7">
|
| 215 |
+
<div class="fw-bold mb-1">DNA Typing</div>
|
| 216 |
+
<table class="table table-sm table-bordered mb-1">
|
| 217 |
+
<tbody>
|
| 218 |
+
<tr><td class="fw-bold">A*</td>
|
| 219 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-1" ></td>
|
| 220 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-2" ></td>
|
| 221 |
+
<td class="fw-bold">DRB1*</td>
|
| 222 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-1" ></td>
|
| 223 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-2" ></td></tr>
|
| 224 |
+
<tr><td class="fw-bold">B*</td>
|
| 225 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-1" ></td>
|
| 226 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-2" ></td>
|
| 227 |
+
<td class="fw-bold">DQB1*</td>
|
| 228 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-1" ></td>
|
| 229 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-2" ></td></tr>
|
| 230 |
+
<tr><td class="fw-bold">C*</td>
|
| 231 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-1" ></td>
|
| 232 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-2" ></td>
|
| 233 |
+
<td class="fw-bold">DQA1*</td>
|
| 234 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-1" ></td>
|
| 235 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-2" ></td></tr>
|
| 236 |
+
<tr><td></td><td></td><td></td>
|
| 237 |
+
<td class="fw-bold">DPB1*</td>
|
| 238 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-1" ></td>
|
| 239 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-2" ></td></tr>
|
| 240 |
+
<tr><td></td><td></td><td></td>
|
| 241 |
+
<td class="fw-bold">DPA1*</td>
|
| 242 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-1" ></td>
|
| 243 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-2" ></td></tr>
|
| 244 |
+
</tbody>
|
| 245 |
+
</table>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
<div class="text-end">
|
| 249 |
+
<button class="btn btn-sm btn-outline-danger" onclick="dsaCheck()">存檔</button>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
{% endif %}
|
| 255 |
+
|
| 256 |
+
{% if result_pra1 or result_pra2 %}
|
| 257 |
+
{% if patient_name or patient_id %}
|
| 258 |
+
<div class="text-center mb-3">
|
| 259 |
+
<h4 class="mb-0">{{ patient_name }}{% if patient_id %} <span class="text-muted small">({{ patient_id }})</span>{% endif %}</h4>
|
| 260 |
+
</div>
|
| 261 |
+
{% endif %}
|
| 262 |
+
<!-- ============ Side-by-side layout ============ -->
|
| 263 |
+
<div class="row">
|
| 264 |
+
<!-- LEFT: PRA Class I -->
|
| 265 |
+
<div class="col-md-6 pra-panel">
|
| 266 |
+
<div class="panel-title panel-title-pra1">PRA Class I</div>
|
| 267 |
+
{% if result_pra1 %}
|
| 268 |
+
{% set result = result_pra1 %}
|
| 269 |
+
{% for pt in result.patients %}
|
| 270 |
+
{% set pt_idx = 'p1_' ~ loop.index %}
|
| 271 |
+
{% set storage_key = 'pra1_' ~ pt.name | replace(' ','_') %}
|
| 272 |
+
<div class="report-card">
|
| 273 |
+
<div class="d-flex justify-content-between align-items-center">
|
| 274 |
+
<div class="report-title" style="display:none;">{{ pt.name }}</div>
|
| 275 |
+
<div class="no-print">
|
| 276 |
+
<button class="btn btn-sm btn-outline-secondary me-1" onclick="copyReport('rc-{{ pt_idx }}')">Copy</button>
|
| 277 |
+
<button class="btn btn-sm btn-outline-primary" onclick="toggleEditNew('{{ pt_idx }}', this)">Edit</button>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
<div id="rc-{{ pt_idx }}">
|
| 281 |
+
<div>PRA Class I</div>
|
| 282 |
+
<div>Overall: {{ pt.overall }}</div>
|
| 283 |
+
<div>%SA (or %PRA): {{ pt.pra }}</div>
|
| 284 |
+
<div>Specificity:</div>
|
| 285 |
+
<div class="report-spec-edit" id="spec-{{ pt_idx }}" style="border:1px solid transparent;">{{ pt.specificity|safe }}</div>
|
| 286 |
+
<div>COMMENT:</div>
|
| 287 |
+
<div class="report-spec-edit" id="comment-{{ pt_idx }}" style="border:1px solid transparent; min-height:1.5rem; white-space:pre-line;">{{ pt.comment or '' }}</div>
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
|
| 291 |
+
{% if pt.sero_mfi %}
|
| 292 |
+
<div class="mb-3 no-print">
|
| 293 |
+
<div class="small fw-bold mb-1">Antibody Strength
|
| 294 |
+
</div>
|
| 295 |
+
<div class="mfi-scroll">
|
| 296 |
+
<table class="table table-sm table-bordered mfi-table mb-0">
|
| 297 |
+
<thead class="table-light"><tr>
|
| 298 |
+
<th>Antigen</th><th>Allele</th><th class="text-end">Max MFI</th>
|
| 299 |
+
<th class="text-end">Mean MFI</th><th class="text-center">No of Beads</th><th>Bead IDs</th>
|
| 300 |
+
</tr></thead>
|
| 301 |
+
<tbody>
|
| 302 |
+
{% for m in pt.sero_mfi %}
|
| 303 |
+
<tr>
|
| 304 |
+
<td class="fw-bold">{{ m.sero }}</td><td class="small">{{ m.alleles }}</td>
|
| 305 |
+
<td class="text-end">{{ m.max_mfi|int }}</td>
|
| 306 |
+
<td class="text-end">{{ m.mean_mfi|int }}</td>
|
| 307 |
+
<td class="text-center">{{ m.count }}</td><td class="small text-muted">{{ m.beads }}</td>
|
| 308 |
+
</tr>
|
| 309 |
+
{% endfor %}
|
| 310 |
+
</tbody>
|
| 311 |
+
</table>
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
+
{% endif %}
|
| 315 |
+
|
| 316 |
+
<div class="no-print">
|
| 317 |
+
<div class="fw-bold small mb-1">Bead Details ({{ pt.beads|length }})</div>
|
| 318 |
+
<div class="mb-3">
|
| 319 |
+
{% if pt.qc %}
|
| 320 |
+
<div class="mb-2 p-2 border rounded bg-light small">
|
| 321 |
+
<strong>Quality Control</strong>
|
| 322 |
+
<table class="table table-sm table-bordered mb-0 mt-1" style="width:auto; font-size:.82rem;">
|
| 323 |
+
<thead class="table-secondary"><tr><th>Item</th><th>Value</th><th>Normal</th><th>Status</th></tr></thead>
|
| 324 |
+
<tbody>
|
| 325 |
+
<tr{% if pt.qc.nc_raw > 500 %} class="table-danger"{% endif %}>
|
| 326 |
+
<td>NC Raw (Bead 001)</td><td>{{ pt.qc.nc_raw }}</td><td>≤500</td>
|
| 327 |
+
<td>{% if pt.qc.nc_raw > 1500 %}<span class="text-danger fw-bold">Uninterpretable due to high background bindings, please repeat</span>{% elif pt.qc.nc_raw > 500 %}<span class="text-warning fw-bold">High background bindings</span>{% endif %}</td>
|
| 328 |
+
</tr>
|
| 329 |
+
<tr{% if pt.qc.pc_raw <= 500 %} class="table-danger"{% endif %}>
|
| 330 |
+
<td>PC Raw (Bead 002)</td><td>{{ pt.qc.pc_raw }}</td><td>>500</td>
|
| 331 |
+
<td>{% if pt.qc.pc_raw <= 500 %}<span class="text-danger fw-bold">Low PC signal, please repeat</span>{% endif %}</td>
|
| 332 |
+
</tr>
|
| 333 |
+
<tr{% if pt.qc.pc_nc_ratio < 2 %} class="table-danger"{% endif %}>
|
| 334 |
+
<td>PC/NC Ratio</td><td>{{ pt.qc.pc_nc_ratio }}</td><td>≥2</td>
|
| 335 |
+
<td>{% if pt.qc.pc_nc_ratio < 2 %}<span class="text-danger fw-bold">Uninterpretable, please repeat</span>{% endif %}</td>
|
| 336 |
+
</tr>
|
| 337 |
+
<tr{% if pt.qc.low_beads %} class="table-warning"{% endif %}>
|
| 338 |
+
<td>Bead Count</td>
|
| 339 |
+
<td>{% if pt.qc.low_beads %}{{ pt.qc.low_beads|length }} beads <80{% else %}All ≥80{% endif %}</td>
|
| 340 |
+
<td>≥80</td>
|
| 341 |
+
<td>{% if pt.qc.low_beads %}<span class="text-danger fw-bold">Low HLA Beads count, please repeat</span>{% endif %}</td>
|
| 342 |
+
</tr>
|
| 343 |
+
</tbody>
|
| 344 |
+
</table>
|
| 345 |
+
</div>
|
| 346 |
+
{% endif %}
|
| 347 |
+
<div class="bead-scroll">
|
| 348 |
+
<table class="table table-sm table-bordered bead-table mb-0">
|
| 349 |
+
<thead><tr><th></th><th>Bead</th><th>Rxn</th><th>Raw</th><th>Normal</th><th>Ratio</th><th>Count</th><th>Specificity</th><th>Molecular</th></tr></thead>
|
| 350 |
+
<tbody>
|
| 351 |
+
{% for b in pt.beads %}
|
| 352 |
+
<tr class="{% if b.rxn >= 8 %}rxn-8{% elif b.rxn >= 6 %}rxn-6{% elif b.rxn >= 4 %}rxn-4{% endif %}{% if b.is_confident %} bead-confident{% endif %}" data-sero="{{ b.sero }}" data-allele="{{ b.allele }}">
|
| 353 |
+
<td>X{{ b.rxn }}</td><td>{{ b.bead }}</td><td>{{ b.rxn }}</td>
|
| 354 |
+
<td>{{ b.raw }}</td><td>{{ b.normal }}</td><td>{{ b.ratio }}</td><td>{{ b.count }}</td>
|
| 355 |
+
<td class="small">{{ b.sero }}</td>
|
| 356 |
+
<td class="small">{{ b.allele }}</td>
|
| 357 |
+
</tr>
|
| 358 |
+
{% endfor %}
|
| 359 |
+
</tbody>
|
| 360 |
+
</table>
|
| 361 |
+
</div>
|
| 362 |
+
</div>
|
| 363 |
+
{% if pt.allele_decisions %}
|
| 364 |
+
<button class="btn btn-sm btn-outline-secondary mb-2" type="button"
|
| 365 |
+
data-bs-toggle="collapse" data-bs-target="#rule80-{{ pt_idx }}">
|
| 366 |
+
<strong>Allele Specificity 紀錄</strong>
|
| 367 |
+
</button>
|
| 368 |
+
<div class="collapse show mb-3" id="rule80-{{ pt_idx }}">
|
| 369 |
+
<div class="bead-scroll">
|
| 370 |
+
<table class="table table-sm table-bordered bead-table mb-0">
|
| 371 |
+
<thead class="table-secondary"><tr><th>Allele</th><th>Sero</th><th>X6/X8</th><th>X4</th><th>X2/X1</th><th>Decision</th></tr></thead>
|
| 372 |
+
<tbody>
|
| 373 |
+
{% for d in pt.allele_decisions %}
|
| 374 |
+
<tr class="{% if 'Assign' in d.decision %}table-danger{% endif %}">
|
| 375 |
+
<td>{{ d.allele }}</td><td>{{ d.sero }}</td><td>{{ d.x6x8 }}</td><td>{{ d.x4 }}</td><td>{{ d.x2x1 }}</td>
|
| 376 |
+
<td>{{ d.decision }}</td>
|
| 377 |
+
</tr>
|
| 378 |
+
{% endfor %}
|
| 379 |
+
</tbody>
|
| 380 |
+
</table>
|
| 381 |
+
</div>
|
| 382 |
+
</div>
|
| 383 |
+
{% endif %}
|
| 384 |
+
</div>
|
| 385 |
+
{% endfor %}
|
| 386 |
+
{% else %}
|
| 387 |
+
<div class="empty-panel">No PRA Class I data</div>
|
| 388 |
+
{% endif %}
|
| 389 |
+
</div>
|
| 390 |
+
|
| 391 |
+
<!-- RIGHT: PRA Class II -->
|
| 392 |
+
<div class="col-md-6 pra-panel">
|
| 393 |
+
<div class="panel-title panel-title-pra2">PRA Class II</div>
|
| 394 |
+
{% if result_pra2 %}
|
| 395 |
+
{% set result = result_pra2 %}
|
| 396 |
+
{% for pt in result.patients %}
|
| 397 |
+
{% set pt_idx = 'p2_' ~ loop.index %}
|
| 398 |
+
{% set storage_key = 'pra2_' ~ pt.name | replace(' ','_') %}
|
| 399 |
+
<div class="report-card">
|
| 400 |
+
<div class="d-flex justify-content-between align-items-center">
|
| 401 |
+
<div class="report-title" style="display:none;">{{ pt.name }}</div>
|
| 402 |
+
<div class="no-print">
|
| 403 |
+
<button class="btn btn-sm btn-outline-secondary me-1" onclick="copyReport('rc-{{ pt_idx }}')">Copy</button>
|
| 404 |
+
<button class="btn btn-sm btn-outline-primary" onclick="toggleEditNew('{{ pt_idx }}', this)">Edit</button>
|
| 405 |
+
</div>
|
| 406 |
+
</div>
|
| 407 |
+
<div id="rc-{{ pt_idx }}">
|
| 408 |
+
<div>PRA Class II</div>
|
| 409 |
+
<div>Overall: {{ pt.overall }}</div>
|
| 410 |
+
<div>%SA (or %PRA): {{ pt.pra }}</div>
|
| 411 |
+
<div>Specificity:</div>
|
| 412 |
+
<div class="report-spec-edit" id="spec-{{ pt_idx }}" style="border:1px solid transparent;">{{ pt.specificity|safe }}</div>
|
| 413 |
+
<div>COMMENT:</div>
|
| 414 |
+
<div class="report-spec-edit" id="comment-{{ pt_idx }}" style="border:1px solid transparent; min-height:1.5rem; white-space:pre-line;">{{ pt.comment or '' }}</div>
|
| 415 |
+
</div>
|
| 416 |
+
</div>
|
| 417 |
+
|
| 418 |
+
{% if pt.sero_mfi %}
|
| 419 |
+
<div class="mb-3 no-print">
|
| 420 |
+
<div class="small fw-bold mb-1">Antibody Strength
|
| 421 |
+
</div>
|
| 422 |
+
<div class="mfi-scroll">
|
| 423 |
+
<table class="table table-sm table-bordered mfi-table mb-0">
|
| 424 |
+
<thead class="table-light"><tr>
|
| 425 |
+
<th>Antigen</th><th>Allele</th><th class="text-end">Max MFI</th>
|
| 426 |
+
<th class="text-end">Mean MFI</th><th class="text-center">No of Beads</th><th>Bead IDs</th>
|
| 427 |
+
</tr></thead>
|
| 428 |
+
<tbody>
|
| 429 |
+
{% for m in pt.sero_mfi %}
|
| 430 |
+
<tr>
|
| 431 |
+
<td class="fw-bold">{{ m.sero }}</td><td class="small">{{ m.alleles }}</td>
|
| 432 |
+
<td class="text-end">{{ m.max_mfi|int }}</td>
|
| 433 |
+
<td class="text-end">{{ m.mean_mfi|int }}</td>
|
| 434 |
+
<td class="text-center">{{ m.count }}</td><td class="small text-muted">{{ m.beads }}</td>
|
| 435 |
+
</tr>
|
| 436 |
+
{% endfor %}
|
| 437 |
+
</tbody>
|
| 438 |
+
</table>
|
| 439 |
+
</div>
|
| 440 |
+
</div>
|
| 441 |
+
{% endif %}
|
| 442 |
+
|
| 443 |
+
<div class="no-print">
|
| 444 |
+
<div class="fw-bold small mb-1">Bead Details ({{ pt.beads|length }})</div>
|
| 445 |
+
<div class="mb-3">
|
| 446 |
+
{% if pt.qc %}
|
| 447 |
+
<div class="mb-2 p-2 border rounded bg-light small">
|
| 448 |
+
<strong>Quality Control</strong>
|
| 449 |
+
<table class="table table-sm table-bordered mb-0 mt-1" style="width:auto; font-size:.82rem;">
|
| 450 |
+
<thead class="table-secondary"><tr><th>Item</th><th>Value</th><th>Normal</th><th>Status</th></tr></thead>
|
| 451 |
+
<tbody>
|
| 452 |
+
<tr{% if pt.qc.nc_raw > 500 %} class="table-danger"{% endif %}>
|
| 453 |
+
<td>NC Raw (Bead 001)</td><td>{{ pt.qc.nc_raw }}</td><td>≤500</td>
|
| 454 |
+
<td>{% if pt.qc.nc_raw > 1500 %}<span class="text-danger fw-bold">Uninterpretable due to high background bindings, please repeat</span>{% elif pt.qc.nc_raw > 500 %}<span class="text-warning fw-bold">High background bindings</span>{% endif %}</td>
|
| 455 |
+
</tr>
|
| 456 |
+
<tr{% if pt.qc.pc_raw <= 500 %} class="table-danger"{% endif %}>
|
| 457 |
+
<td>PC Raw (Bead 002)</td><td>{{ pt.qc.pc_raw }}</td><td>>500</td>
|
| 458 |
+
<td>{% if pt.qc.pc_raw <= 500 %}<span class="text-danger fw-bold">Low PC signal, please repeat</span>{% endif %}</td>
|
| 459 |
+
</tr>
|
| 460 |
+
<tr{% if pt.qc.pc_nc_ratio < 2 %} class="table-danger"{% endif %}>
|
| 461 |
+
<td>PC/NC Ratio</td><td>{{ pt.qc.pc_nc_ratio }}</td><td>≥2</td>
|
| 462 |
+
<td>{% if pt.qc.pc_nc_ratio < 2 %}<span class="text-danger fw-bold">Uninterpretable, please repeat</span>{% endif %}</td>
|
| 463 |
+
</tr>
|
| 464 |
+
<tr{% if pt.qc.low_beads %} class="table-warning"{% endif %}>
|
| 465 |
+
<td>Bead Count</td>
|
| 466 |
+
<td>{% if pt.qc.low_beads %}{{ pt.qc.low_beads|length }} beads <80{% else %}All ≥80{% endif %}</td>
|
| 467 |
+
<td>≥80</td>
|
| 468 |
+
<td>{% if pt.qc.low_beads %}<span class="text-danger fw-bold">Low HLA Beads count, please repeat</span>{% endif %}</td>
|
| 469 |
+
</tr>
|
| 470 |
+
</tbody>
|
| 471 |
+
</table>
|
| 472 |
+
</div>
|
| 473 |
+
{% endif %}
|
| 474 |
+
<div class="bead-scroll">
|
| 475 |
+
<table class="table table-sm table-bordered bead-table mb-0">
|
| 476 |
+
<thead><tr><th></th><th>Bead</th><th>Rxn</th><th>Raw</th><th>Normal</th><th>Ratio</th><th>Count</th><th>Specificity</th><th>Molecular</th></tr></thead>
|
| 477 |
+
<tbody>
|
| 478 |
+
{% for b in pt.beads %}
|
| 479 |
+
<tr class="{% if b.rxn >= 8 %}rxn-8{% elif b.rxn >= 6 %}rxn-6{% elif b.rxn >= 4 %}rxn-4{% endif %}{% if b.is_confident %} bead-confident{% endif %}" data-sero="{{ b.sero }}" data-allele="{{ b.allele }}">
|
| 480 |
+
<td>X{{ b.rxn }}</td><td>{{ b.bead }}</td><td>{{ b.rxn }}</td>
|
| 481 |
+
<td>{{ b.raw }}</td><td>{{ b.normal }}</td><td>{{ b.ratio }}</td><td>{{ b.count }}</td>
|
| 482 |
+
<td class="small">{{ b.sero }}</td>
|
| 483 |
+
<td class="small">{{ b.allele }}</td>
|
| 484 |
+
</tr>
|
| 485 |
+
{% endfor %}
|
| 486 |
+
</tbody>
|
| 487 |
+
</table>
|
| 488 |
+
</div>
|
| 489 |
+
</div>
|
| 490 |
+
{% if pt.allele_decisions %}
|
| 491 |
+
<button class="btn btn-sm btn-outline-secondary mb-2" type="button"
|
| 492 |
+
data-bs-toggle="collapse" data-bs-target="#rule80-{{ pt_idx }}">
|
| 493 |
+
<strong>Allele Specificity 紀錄</strong>
|
| 494 |
+
</button>
|
| 495 |
+
<div class="collapse show mb-3" id="rule80-{{ pt_idx }}">
|
| 496 |
+
<div class="bead-scroll">
|
| 497 |
+
<table class="table table-sm table-bordered bead-table mb-0">
|
| 498 |
+
<thead class="table-secondary"><tr><th>Allele</th><th>Sero</th><th>X6/X8</th><th>X4</th><th>X2/X1</th><th>Decision</th></tr></thead>
|
| 499 |
+
<tbody>
|
| 500 |
+
{% for d in pt.allele_decisions %}
|
| 501 |
+
<tr class="{% if 'Assign' in d.decision %}table-danger{% endif %}">
|
| 502 |
+
<td>{{ d.allele }}</td><td>{{ d.sero }}</td><td>{{ d.x6x8 }}</td><td>{{ d.x4 }}</td><td>{{ d.x2x1 }}</td>
|
| 503 |
+
<td>{{ d.decision }}</td>
|
| 504 |
+
</tr>
|
| 505 |
+
{% endfor %}
|
| 506 |
+
</tbody>
|
| 507 |
+
</table>
|
| 508 |
+
</div>
|
| 509 |
+
</div>
|
| 510 |
+
{% endif %}
|
| 511 |
+
</div>
|
| 512 |
+
{% endfor %}
|
| 513 |
+
{% else %}
|
| 514 |
+
<div class="empty-panel">No PRA Class II data</div>
|
| 515 |
+
{% endif %}
|
| 516 |
+
</div>
|
| 517 |
+
</div>
|
| 518 |
+
|
| 519 |
+
<!-- Action buttons -->
|
| 520 |
+
<div class="text-center my-3 no-print">
|
| 521 |
+
<button class="btn btn-outline-secondary me-2" onclick="saveToDb()" id="saveBtn">暫存</button>
|
| 522 |
+
<button class="btn btn-success" onclick="submitReport()" id="submitBtn">送出表單</button>
|
| 523 |
+
<div id="saveStatus" class="small text-muted mt-1"></div>
|
| 524 |
+
</div>
|
| 525 |
+
{% endif %}
|
| 526 |
+
</div>
|
| 527 |
+
|
| 528 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 529 |
+
<script>
|
| 530 |
+
// Load saved donor HLA on page load
|
| 531 |
+
function _loadDonorObj(obj) {
|
| 532 |
+
// 新格式: {"donor-A-1":"2", "donor-dna-DRB1-1":"04:01", ...}
|
| 533 |
+
// 舊格式: {"A":"2,24", "B":"7,46", "DR":"4,7", ...}
|
| 534 |
+
document.querySelectorAll('.donor-hla').forEach(el => el.value = '');
|
| 535 |
+
const firstKey = Object.keys(obj)[0] || '';
|
| 536 |
+
if (firstKey.startsWith('donor-')) {
|
| 537 |
+
// 新格式
|
| 538 |
+
Object.keys(obj).forEach(k => {
|
| 539 |
+
const el = document.getElementById(k);
|
| 540 |
+
if (el) el.value = obj[k];
|
| 541 |
+
});
|
| 542 |
+
} else {
|
| 543 |
+
// 舊格式
|
| 544 |
+
const seroMap = {'A':['donor-A-1','donor-A-2'],'B':['donor-B-1','donor-B-2'],
|
| 545 |
+
'Cw':['donor-Cw-1','donor-Cw-2'],'DR':['donor-DR-1','donor-DR-2'],
|
| 546 |
+
'DQ':['donor-DQ-1','donor-DQ-2'],'DP':['donor-DP-1','donor-DP-2']};
|
| 547 |
+
const dnaMap = {'A*':['donor-dna-A-1','donor-dna-A-2'],'B*':['donor-dna-B-1','donor-dna-B-2'],
|
| 548 |
+
'C*':['donor-dna-C-1','donor-dna-C-2'],'DRB1*':['donor-dna-DRB1-1','donor-dna-DRB1-2'],
|
| 549 |
+
'DQB1*':['donor-dna-DQB1-1','donor-dna-DQB1-2'],'DQA1*':['donor-dna-DQA1-1','donor-dna-DQA1-2'],
|
| 550 |
+
'DPB1*':['donor-dna-DPB1-1','donor-dna-DPB1-2'],'DPA1*':['donor-dna-DPA1-1','donor-dna-DPA1-2']};
|
| 551 |
+
Object.keys(obj).forEach(k => {
|
| 552 |
+
const vals = String(obj[k]).split(',').map(v => v.trim());
|
| 553 |
+
const ids = seroMap[k] || dnaMap[k];
|
| 554 |
+
if (ids) vals.forEach((v, i) => { if (ids[i] && v) document.getElementById(ids[i]).value = v; });
|
| 555 |
+
});
|
| 556 |
+
}
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
window.addEventListener('DOMContentLoaded', function() {
|
| 560 |
+
const saved = {{ donor_hla | default("", true) | tojson }};
|
| 561 |
+
if (saved) {
|
| 562 |
+
try {
|
| 563 |
+
_loadDonorObj(JSON.parse(saved));
|
| 564 |
+
setTimeout(dsaCheck, 300);
|
| 565 |
+
} catch(e) {}
|
| 566 |
+
}
|
| 567 |
+
});
|
| 568 |
+
|
| 569 |
+
function loadDonorHla(chartNo) {
|
| 570 |
+
if (!chartNo || !chartNo.trim()) return;
|
| 571 |
+
fetch('/get_donor_hla/' + chartNo.trim())
|
| 572 |
+
.then(r => r.json())
|
| 573 |
+
.then(data => {
|
| 574 |
+
if (!data.donor_hla) return;
|
| 575 |
+
try { _loadDonorObj(JSON.parse(data.donor_hla)); } catch(e) {}
|
| 576 |
+
})
|
| 577 |
+
.catch(() => {});
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
// Accumulate files across multiple drops/selects
|
| 581 |
+
let collectedFiles = [];
|
| 582 |
+
|
| 583 |
+
function updateFileList() {
|
| 584 |
+
const fl = document.getElementById('fileList');
|
| 585 |
+
if (collectedFiles.length === 0) {
|
| 586 |
+
fl.style.display = 'none';
|
| 587 |
+
document.getElementById('dropIcon').style.display = '';
|
| 588 |
+
document.getElementById('analyzeBtn').disabled = true;
|
| 589 |
+
return;
|
| 590 |
+
}
|
| 591 |
+
fl.innerHTML = collectedFiles.map((f,i) =>
|
| 592 |
+
'<span>📄 ' + f.name +
|
| 593 |
+
' <a href="#" style="color:#dc3545;text-decoration:none;" onclick="removeFile('+i+');return false;">×</a></span>'
|
| 594 |
+
).join('<br>');
|
| 595 |
+
fl.style.display = 'block';
|
| 596 |
+
document.getElementById('dropIcon').style.display = 'none';
|
| 597 |
+
document.getElementById('analyzeBtn').disabled = false;
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
function addFiles(fileList) {
|
| 601 |
+
for (const f of fileList) {
|
| 602 |
+
// avoid duplicates
|
| 603 |
+
if (!collectedFiles.some(x => x.name === f.name && x.size === f.size)) {
|
| 604 |
+
collectedFiles.push(f);
|
| 605 |
+
}
|
| 606 |
+
}
|
| 607 |
+
updateFileList();
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
function removeFile(idx) {
|
| 611 |
+
collectedFiles.splice(idx, 1);
|
| 612 |
+
updateFileList();
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
function submitWithDelay() {
|
| 616 |
+
if (collectedFiles.length === 0) return;
|
| 617 |
+
// Build a new DataTransfer to set on the file input
|
| 618 |
+
const dt = new DataTransfer();
|
| 619 |
+
collectedFiles.forEach(f => dt.items.add(f));
|
| 620 |
+
document.getElementById('fileInput').files = dt.files;
|
| 621 |
+
document.getElementById('uploadingMsg').style.display = 'block';
|
| 622 |
+
document.getElementById('analyzeBtn').disabled = true;
|
| 623 |
+
setTimeout(() => document.getElementById('uploadForm').submit(), 100);
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
function onFileSelected(input) {
|
| 627 |
+
if (input.files.length > 0) addFiles(input.files);
|
| 628 |
+
// Reset so same file can be added again if needed
|
| 629 |
+
input.value = '';
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
const dz = document.getElementById('dropZone');
|
| 633 |
+
if (dz) {
|
| 634 |
+
['dragenter','dragover'].forEach(e => dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.add('dragover'); }));
|
| 635 |
+
['dragleave','drop'].forEach(e => dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.remove('dragover'); }));
|
| 636 |
+
dz.addEventListener('drop', ev => {
|
| 637 |
+
addFiles(ev.dataTransfer.files);
|
| 638 |
+
});
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
function dsaCheck() {
|
| 642 |
+
// Save donor HLA to DB
|
| 643 |
+
const chartNo = document.getElementById('patientId') ? document.getElementById('patientId').value.trim() : '';
|
| 644 |
+
if (chartNo) {
|
| 645 |
+
const obj = {};
|
| 646 |
+
document.querySelectorAll('.donor-hla').forEach(el => {
|
| 647 |
+
if (el.value.trim()) obj[el.id] = el.value.trim();
|
| 648 |
+
});
|
| 649 |
+
fetch('/save_donor_hla', {
|
| 650 |
+
method: 'POST',
|
| 651 |
+
headers: {'Content-Type': 'application/json'},
|
| 652 |
+
body: JSON.stringify({ chart_no: chartNo, donor_hla: JSON.stringify(obj) })
|
| 653 |
+
});
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
// Build donor HLA set from inputs
|
| 657 |
+
const donorSet = new Set();
|
| 658 |
+
// Serology
|
| 659 |
+
['A','B','Cw','DR','DQ','DP'].forEach(locus => {
|
| 660 |
+
[1,2].forEach(n => {
|
| 661 |
+
const el = document.getElementById('donor-' + locus + '-' + n);
|
| 662 |
+
if (el && el.value.trim()) donorSet.add(locus + el.value.trim());
|
| 663 |
+
});
|
| 664 |
+
});
|
| 665 |
+
// DNA Typing
|
| 666 |
+
['A','B','C','DRB1','DQB1','DQA1','DPB1','DPA1'].forEach(locus => {
|
| 667 |
+
[1,2].forEach(n => {
|
| 668 |
+
const el = document.getElementById('donor-dna-' + locus + '-' + n);
|
| 669 |
+
if (el && el.value.trim()) donorSet.add(locus + '*' + el.value.trim());
|
| 670 |
+
});
|
| 671 |
+
});
|
| 672 |
+
if (donorSet.size === 0) return;
|
| 673 |
+
|
| 674 |
+
// Helper: check if text contains any donor antigen
|
| 675 |
+
function hasDonorMatch(text) {
|
| 676 |
+
for (const ag of donorSet) {
|
| 677 |
+
if (text.includes(ag)) return true;
|
| 678 |
+
}
|
| 679 |
+
return false;
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
// Highlight matching specificities in report cards — change matching span colors to red
|
| 683 |
+
document.querySelectorAll('[id^="spec-"]').forEach(specEl => {
|
| 684 |
+
specEl.querySelectorAll('span').forEach(span => {
|
| 685 |
+
const txt = span.textContent;
|
| 686 |
+
for (const ag of donorSet) {
|
| 687 |
+
if (txt.includes(ag)) {
|
| 688 |
+
span.style.color = '#dc3545';
|
| 689 |
+
span.style.fontWeight = 'bold';
|
| 690 |
+
break;
|
| 691 |
+
}
|
| 692 |
+
}
|
| 693 |
+
});
|
| 694 |
+
// Also check bare text (parentheses, etc.)
|
| 695 |
+
specEl.childNodes.forEach(node => {
|
| 696 |
+
if (node.nodeType === 3) { // text node
|
| 697 |
+
const txt = node.textContent;
|
| 698 |
+
for (const ag of donorSet) {
|
| 699 |
+
if (txt.includes(ag)) {
|
| 700 |
+
const span = document.createElement('span');
|
| 701 |
+
span.style.color = '#dc3545';
|
| 702 |
+
span.style.fontWeight = 'bold';
|
| 703 |
+
span.textContent = txt;
|
| 704 |
+
node.replaceWith(span);
|
| 705 |
+
break;
|
| 706 |
+
}
|
| 707 |
+
}
|
| 708 |
+
}
|
| 709 |
+
});
|
| 710 |
+
});
|
| 711 |
+
|
| 712 |
+
// Highlight only matching allele tokens (not whole row)
|
| 713 |
+
function highlightTokens(cell) {
|
| 714 |
+
if (!cell) return;
|
| 715 |
+
const text = cell.textContent;
|
| 716 |
+
const tokens = text.split(/,\s*/);
|
| 717 |
+
const parts = tokens.map(tok => {
|
| 718 |
+
const t = tok.trim();
|
| 719 |
+
if (!t) return tok;
|
| 720 |
+
for (const ag of donorSet) {
|
| 721 |
+
if (t.includes(ag)) {
|
| 722 |
+
return '<span style="color:#dc3545;font-weight:bold;">' + tok + '</span>';
|
| 723 |
+
}
|
| 724 |
+
}
|
| 725 |
+
return tok;
|
| 726 |
+
});
|
| 727 |
+
cell.innerHTML = parts.join(', ');
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
// Antibody Strength table → highlight tokens in Allele column (index 1)
|
| 731 |
+
document.querySelectorAll('.mfi-table tbody tr').forEach(tr => {
|
| 732 |
+
highlightTokens(tr.cells[1]);
|
| 733 |
+
});
|
| 734 |
+
|
| 735 |
+
// Bead Detail table → highlight tokens in Molecular column (index 8)
|
| 736 |
+
document.querySelectorAll('.bead-table tbody tr').forEach(tr => {
|
| 737 |
+
highlightTokens(tr.cells[8]);
|
| 738 |
+
});
|
| 739 |
+
|
| 740 |
+
const btn = document.querySelector('[onclick="dsaCheck()"]');
|
| 741 |
+
if (btn) {
|
| 742 |
+
btn.classList.remove('btn-outline-danger');
|
| 743 |
+
btn.classList.add('btn-danger');
|
| 744 |
+
btn.textContent = '已存檔';
|
| 745 |
+
}
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
function toggleEditNew(idx, btn) {
|
| 749 |
+
const specEl = document.getElementById('spec-' + idx);
|
| 750 |
+
const commentEl = document.getElementById('comment-' + idx);
|
| 751 |
+
if (btn.textContent === 'Edit') {
|
| 752 |
+
specEl.contentEditable = 'true';
|
| 753 |
+
commentEl.contentEditable = 'true';
|
| 754 |
+
specEl.style.border = '1px dashed #0d6efd';
|
| 755 |
+
specEl.style.background = '#f8f9ff';
|
| 756 |
+
commentEl.style.border = '1px dashed #0d6efd';
|
| 757 |
+
commentEl.style.background = '#f8f9ff';
|
| 758 |
+
btn.textContent = 'Save';
|
| 759 |
+
btn.className = 'btn btn-sm btn-success';
|
| 760 |
+
specEl.focus();
|
| 761 |
+
} else {
|
| 762 |
+
specEl.contentEditable = 'false';
|
| 763 |
+
commentEl.contentEditable = 'false';
|
| 764 |
+
specEl.style.border = '1px solid transparent';
|
| 765 |
+
specEl.style.background = '';
|
| 766 |
+
commentEl.style.border = '1px solid transparent';
|
| 767 |
+
commentEl.style.background = '';
|
| 768 |
+
btn.textContent = 'Edit';
|
| 769 |
+
btn.className = 'btn btn-sm btn-outline-primary';
|
| 770 |
+
}
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
function copyReport(id) {
|
| 774 |
+
const el = document.getElementById(id);
|
| 775 |
+
const range = document.createRange();
|
| 776 |
+
range.selectNodeContents(el);
|
| 777 |
+
const sel = window.getSelection();
|
| 778 |
+
sel.removeAllRanges(); sel.addRange(range);
|
| 779 |
+
document.execCommand('copy');
|
| 780 |
+
sel.removeAllRanges();
|
| 781 |
+
const btn = el.parentElement.querySelector('button');
|
| 782 |
+
const orig = btn.textContent;
|
| 783 |
+
btn.textContent = 'Copied!';
|
| 784 |
+
btn.classList.replace('btn-outline-secondary','btn-success');
|
| 785 |
+
setTimeout(() => { btn.textContent = orig; btn.classList.replace('btn-success','btn-outline-secondary'); }, 1500);
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
let _saveTimers = {};
|
| 789 |
+
function autoSave(key, value) {
|
| 790 |
+
clearTimeout(_saveTimers[key]);
|
| 791 |
+
_saveTimers[key] = setTimeout(() => localStorage.setItem(key, value), 500);
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
window.addEventListener('DOMContentLoaded', () => {
|
| 795 |
+
document.querySelectorAll('[id^="spec-"], [id^="comment-"]').forEach(el => {
|
| 796 |
+
const key = el.getAttribute('oninput');
|
| 797 |
+
if (!key) return;
|
| 798 |
+
const m = key.match(/autoSave\('([^']+)'/);
|
| 799 |
+
if (!m) return;
|
| 800 |
+
const saved = localStorage.getItem(m[1]);
|
| 801 |
+
if (saved) el.innerText = saved;
|
| 802 |
+
});
|
| 803 |
+
});
|
| 804 |
+
|
| 805 |
+
function saveToDb(isSubmit) {
|
| 806 |
+
const name = document.getElementById('patientName').value.trim();
|
| 807 |
+
const chartNo = document.getElementById('patientId').value.trim();
|
| 808 |
+
if (!name) { alert('請輸入病患姓名'); return; }
|
| 809 |
+
if (!chartNo) { alert('請輸入病歷號'); return; }
|
| 810 |
+
|
| 811 |
+
const reports = [];
|
| 812 |
+
{% if result_pra1 %}
|
| 813 |
+
{% for pt in result_pra1.patients %}
|
| 814 |
+
reports.push({
|
| 815 |
+
report_date: '{{ result_pra1.date }}',
|
| 816 |
+
pra_class: 'PRA Class I',
|
| 817 |
+
pra_percent: {{ pt.pra }},
|
| 818 |
+
overall: '{{ pt.overall }}',
|
| 819 |
+
specificity: document.getElementById('spec-p1_{{ loop.index }}').innerText.trim(),
|
| 820 |
+
comment: document.getElementById('comment-p1_{{ loop.index }}').innerText.trim(),
|
| 821 |
+
sero_mfi: {{ pt.sero_mfi | tojson }},
|
| 822 |
+
upload_file: '{{ pt._upload_file | default("", true) }}'
|
| 823 |
+
});
|
| 824 |
+
{% endfor %}
|
| 825 |
+
{% endif %}
|
| 826 |
+
{% if result_pra2 %}
|
| 827 |
+
{% for pt in result_pra2.patients %}
|
| 828 |
+
reports.push({
|
| 829 |
+
report_date: '{{ result_pra2.date }}',
|
| 830 |
+
pra_class: 'PRA Class II',
|
| 831 |
+
pra_percent: {{ pt.pra }},
|
| 832 |
+
overall: '{{ pt.overall }}',
|
| 833 |
+
specificity: document.getElementById('spec-p2_{{ loop.index }}').innerText.trim(),
|
| 834 |
+
comment: document.getElementById('comment-p2_{{ loop.index }}').innerText.trim(),
|
| 835 |
+
sero_mfi: {{ pt.sero_mfi | tojson }},
|
| 836 |
+
upload_file: '{{ pt._upload_file | default("", true) }}'
|
| 837 |
+
});
|
| 838 |
+
{% endfor %}
|
| 839 |
+
{% endif %}
|
| 840 |
+
|
| 841 |
+
const btn = isSubmit ? document.getElementById('submitBtn') : document.getElementById('saveBtn');
|
| 842 |
+
btn.disabled = true;
|
| 843 |
+
document.getElementById('saveStatus').textContent = isSubmit ? 'Submitting...' : 'Saving...';
|
| 844 |
+
|
| 845 |
+
function postSave(mode) {
|
| 846 |
+
return fetch('/save', {
|
| 847 |
+
method: 'POST',
|
| 848 |
+
headers: {'Content-Type': 'application/json'},
|
| 849 |
+
body: JSON.stringify({ patient_name: name, chart_no: chartNo, reports: reports, submitted: !!isSubmit, mode: mode })
|
| 850 |
+
}).then(r => r.json());
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
function handleResp(d) {
|
| 854 |
+
if (d.duplicate) {
|
| 855 |
+
const list = d.duplicate.map(x => `${x.pra_class} (${x.report_date})`).join('、');
|
| 856 |
+
const msg = `此病患已有相同日期的紀錄:${list}\n請輸入選項:\n 1 = 覆寫(取代舊紀錄)\n 2 = 新增(另存一筆)\n 3 = 放棄`;
|
| 857 |
+
return askChoice(msg).then(choice => {
|
| 858 |
+
if (choice === '1') {
|
| 859 |
+
return postSave('overwrite').then(handleResp);
|
| 860 |
+
} else if (choice === '2') {
|
| 861 |
+
return postSave('new').then(handleResp);
|
| 862 |
+
} else {
|
| 863 |
+
document.getElementById('saveStatus').textContent = '已取消';
|
| 864 |
+
btn.disabled = false;
|
| 865 |
+
return;
|
| 866 |
+
}
|
| 867 |
+
});
|
| 868 |
+
}
|
| 869 |
+
if (d.ok) {
|
| 870 |
+
const msg = isSubmit ? '已送出' : '已暫存';
|
| 871 |
+
document.getElementById('saveStatus').innerHTML =
|
| 872 |
+
msg + ' <a href="/history/' + chartNo + '">查看紀錄</a>';
|
| 873 |
+
btn.textContent = msg;
|
| 874 |
+
} else {
|
| 875 |
+
document.getElementById('saveStatus').textContent = 'Error: ' + (d.error || 'unknown');
|
| 876 |
+
btn.disabled = false;
|
| 877 |
+
}
|
| 878 |
+
}
|
| 879 |
+
|
| 880 |
+
postSave('auto').then(handleResp).catch(e => {
|
| 881 |
+
document.getElementById('saveStatus').textContent = 'Error: ' + e;
|
| 882 |
+
btn.disabled = false;
|
| 883 |
+
});
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
function submitReport() {
|
| 887 |
+
if (!confirm('確定送出表單?')) return;
|
| 888 |
+
saveToDb(true);
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
function exportDocx() {
|
| 892 |
+
const patients = [];
|
| 893 |
+
{% if result_pra1 %}
|
| 894 |
+
{% for pt in result_pra1.patients %}
|
| 895 |
+
patients.push({
|
| 896 |
+
name: '{{ pt.name }}', overall: '{{ pt.overall }}', pra: {{ pt.pra }},
|
| 897 |
+
class_label: 'PRA Class I',
|
| 898 |
+
specificity: document.getElementById('spec-p1_{{ loop.index }}').innerText.trim() || '{{ "(-)" if pt.overall == "Negative" else "" }}'
|
| 899 |
+
});
|
| 900 |
+
{% endfor %}
|
| 901 |
+
{% endif %}
|
| 902 |
+
{% if result_pra2 %}
|
| 903 |
+
{% for pt in result_pra2.patients %}
|
| 904 |
+
patients.push({
|
| 905 |
+
name: '{{ pt.name }}', overall: '{{ pt.overall }}', pra: {{ pt.pra }},
|
| 906 |
+
class_label: 'PRA Class II',
|
| 907 |
+
specificity: document.getElementById('spec-p2_{{ loop.index }}').innerText.trim() || '{{ "(-)" if pt.overall == "Negative" else "" }}'
|
| 908 |
+
});
|
| 909 |
+
{% endfor %}
|
| 910 |
+
{% endif %}
|
| 911 |
+
fetch('/export_docx', {
|
| 912 |
+
method: 'POST', headers: {'Content-Type':'application/json'},
|
| 913 |
+
body: JSON.stringify({
|
| 914 |
+
class_label: 'PRA',
|
| 915 |
+
date: '{{ result_pra1.date if result_pra1 else (result_pra2.date if result_pra2 else "") }}',
|
| 916 |
+
patients: patients
|
| 917 |
+
})
|
| 918 |
+
}).then(r => r.blob()).then(blob => {
|
| 919 |
+
const a = document.createElement('a');
|
| 920 |
+
a.href = URL.createObjectURL(blob);
|
| 921 |
+
a.download = 'PRA_report.docx';
|
| 922 |
+
a.click();
|
| 923 |
+
});
|
| 924 |
+
}
|
| 925 |
+
</script>
|
| 926 |
+
</body>
|
| 927 |
+
</html>
|
templates//login.html
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>腎臟移植免疫分析</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
body {
|
| 10 |
+
background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%);
|
| 11 |
+
display: flex; align-items: center; justify-content: center; min-height: 100vh;
|
| 12 |
+
font-family: 'Segoe UI', 'Microsoft JhengHei', sans-serif;
|
| 13 |
+
}
|
| 14 |
+
.login-card {
|
| 15 |
+
width: 420px; background: #fff; border-radius: 16px;
|
| 16 |
+
box-shadow: 0 8px 40px rgba(0,0,0,.2); padding: 2.5rem;
|
| 17 |
+
animation: fadeIn .5s ease;
|
| 18 |
+
}
|
| 19 |
+
@keyframes fadeIn { from { opacity:0; transform:translateY(20px); } to { opacity:1; transform:translateY(0); } }
|
| 20 |
+
.login-title { text-align: center; margin-bottom: 1.8rem; }
|
| 21 |
+
.login-title h3 { color: #1e293b; font-weight: 700; margin-bottom: .2rem; }
|
| 22 |
+
.login-title .subtitle { color: #64748b; font-size: .85rem; letter-spacing: 1px; }
|
| 23 |
+
.form-control { border-radius: 8px; padding: .6rem .9rem; }
|
| 24 |
+
.form-control:focus { box-shadow: 0 0 0 3px rgba(2,132,199,.15); border-color: #0284c7; }
|
| 25 |
+
.btn-login {
|
| 26 |
+
background: linear-gradient(135deg, #0284c7, #0ea5e9);
|
| 27 |
+
border: none; border-radius: 8px; padding: .6rem; font-size: 1rem; font-weight: 600;
|
| 28 |
+
transition: all .2s;
|
| 29 |
+
}
|
| 30 |
+
.btn-login:hover { transform: translateY(-1px); box-shadow: 0 4px 15px rgba(2,132,199,.4); }
|
| 31 |
+
</style>
|
| 32 |
+
</head>
|
| 33 |
+
<body>
|
| 34 |
+
<div class="login-card">
|
| 35 |
+
<div class="login-title">
|
| 36 |
+
<div class="subtitle">台北榮民總醫院腎臟科</div>
|
| 37 |
+
<h3>腎臟移植免疫分析</h3>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
{% if error %}
|
| 41 |
+
<div class="alert alert-danger py-2 small">{{ error }}</div>
|
| 42 |
+
{% endif %}
|
| 43 |
+
{% if success %}
|
| 44 |
+
<div class="alert alert-success py-2 small">{{ success }}</div>
|
| 45 |
+
{% endif %}
|
| 46 |
+
|
| 47 |
+
<form method="post" action="/login">
|
| 48 |
+
<div class="mb-3">
|
| 49 |
+
<label class="form-label small fw-bold">帳號</label>
|
| 50 |
+
<input type="text" class="form-control" name="username" autofocus required placeholder="Email 或帳號">
|
| 51 |
+
</div>
|
| 52 |
+
<div class="mb-3">
|
| 53 |
+
<label class="form-label small fw-bold">密碼</label>
|
| 54 |
+
<input type="password" class="form-control" name="password" required placeholder="輸入密碼">
|
| 55 |
+
</div>
|
| 56 |
+
<button type="submit" class="btn btn-login btn-primary w-100">登入</button>
|
| 57 |
+
<div class="text-center mt-3">
|
| 58 |
+
<a href="/register" class="small text-decoration-none" style="color:#0284c7;">註冊新帳號</a>
|
| 59 |
+
</div>
|
| 60 |
+
</form>
|
| 61 |
+
</div>
|
| 62 |
+
</body>
|
| 63 |
+
</html>
|
templates//patient.html
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{{ patient.patient_name }} - PRA History</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
| 9 |
+
<style>
|
| 10 |
+
.chart-container { position: relative; height: 400px; background: #fff; border: 1px solid #dee2e6; border-radius: 6px; padding: 1rem; margin-bottom: 1.5rem; }
|
| 11 |
+
.ab-table { font-size: .82rem; }
|
| 12 |
+
.mfi-up { color: #dc3545; }
|
| 13 |
+
.mfi-down { color: #198754; }
|
| 14 |
+
.mfi-new { color: #0d6efd; font-weight: bold; }
|
| 15 |
+
.mfi-gone { color: #adb5bd; text-decoration: line-through; }
|
| 16 |
+
</style>
|
| 17 |
+
</head>
|
| 18 |
+
<body>
|
| 19 |
+
<div class="container py-4" style="max-width: 1200px;">
|
| 20 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 21 |
+
<div>
|
| 22 |
+
<h2 class="mb-0">{{ patient.patient_name }}
|
| 23 |
+
<span class="text-muted fs-5">({{ patient.chart_no }})</span>
|
| 24 |
+
</h2>
|
| 25 |
+
</div>
|
| 26 |
+
<div>
|
| 27 |
+
<a href="/" class="btn btn-dark btn-sm me-1">首頁</a>
|
| 28 |
+
<a href="/history" class="btn btn-outline-secondary btn-sm me-1">歷史紀錄</a>
|
| 29 |
+
<a href="/analysis?chart_no={{ patient.chart_no }}" class="btn btn-outline-success btn-sm me-1">Analyze</a>
|
| 30 |
+
<a href="/new" class="btn btn-outline-primary btn-sm">新增資料</a>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<!-- ============ Donor HLA ============ -->
|
| 35 |
+
<div class="card mb-3">
|
| 36 |
+
<div class="card-header fw-bold">Donor HLA</div>
|
| 37 |
+
<div class="card-body" style="font-size:.85rem;">
|
| 38 |
+
<style>.donor-hla { width: 80px !important; padding: 2px 4px !important; text-align: center; }</style>
|
| 39 |
+
<div class="row">
|
| 40 |
+
<div class="col-md-5">
|
| 41 |
+
<div class="fw-bold mb-1">Serology</div>
|
| 42 |
+
<table class="table table-sm table-bordered mb-1"><tbody>
|
| 43 |
+
<tr><td class="fw-bold">A</td>
|
| 44 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-1" oninput="highlightDSA()"></td>
|
| 45 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-2" oninput="highlightDSA()"></td>
|
| 46 |
+
<td class="fw-bold">DR</td>
|
| 47 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-1" oninput="highlightDSA()"></td>
|
| 48 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-2" oninput="highlightDSA()"></td></tr>
|
| 49 |
+
<tr><td class="fw-bold">B</td>
|
| 50 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-1" oninput="highlightDSA()"></td>
|
| 51 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-2" oninput="highlightDSA()"></td>
|
| 52 |
+
<td class="fw-bold">DQ</td>
|
| 53 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-1" oninput="highlightDSA()"></td>
|
| 54 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-2" oninput="highlightDSA()"></td></tr>
|
| 55 |
+
<tr><td class="fw-bold">Cw</td>
|
| 56 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-1" oninput="highlightDSA()"></td>
|
| 57 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-2" oninput="highlightDSA()"></td>
|
| 58 |
+
<td class="fw-bold">DP</td>
|
| 59 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-1" oninput="highlightDSA()"></td>
|
| 60 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-2" oninput="highlightDSA()"></td></tr>
|
| 61 |
+
</tbody></table>
|
| 62 |
+
</div>
|
| 63 |
+
<div class="col-md-7">
|
| 64 |
+
<div class="fw-bold mb-1">DNA Typing</div>
|
| 65 |
+
<table class="table table-sm table-bordered mb-1"><tbody>
|
| 66 |
+
<tr><td class="fw-bold">A*</td>
|
| 67 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-1" oninput="highlightDSA()"></td>
|
| 68 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-2" oninput="highlightDSA()"></td>
|
| 69 |
+
<td class="fw-bold">DRB1*</td>
|
| 70 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-1" oninput="highlightDSA()"></td>
|
| 71 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-2" oninput="highlightDSA()"></td></tr>
|
| 72 |
+
<tr><td class="fw-bold">B*</td>
|
| 73 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-1" oninput="highlightDSA()"></td>
|
| 74 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-2" oninput="highlightDSA()"></td>
|
| 75 |
+
<td class="fw-bold">DQB1*</td>
|
| 76 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-1" oninput="highlightDSA()"></td>
|
| 77 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-2" oninput="highlightDSA()"></td></tr>
|
| 78 |
+
<tr><td class="fw-bold">C*</td>
|
| 79 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-1" oninput="highlightDSA()"></td>
|
| 80 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-2" oninput="highlightDSA()"></td>
|
| 81 |
+
<td class="fw-bold">DQA1*</td>
|
| 82 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-1" oninput="highlightDSA()"></td>
|
| 83 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-2" oninput="highlightDSA()"></td></tr>
|
| 84 |
+
<tr><td></td><td></td><td></td>
|
| 85 |
+
<td class="fw-bold">DPB1*</td>
|
| 86 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-1" oninput="highlightDSA()"></td>
|
| 87 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-2" oninput="highlightDSA()"></td></tr>
|
| 88 |
+
<tr><td></td><td></td><td></td>
|
| 89 |
+
<td class="fw-bold">DPA1*</td>
|
| 90 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-1" oninput="highlightDSA()"></td>
|
| 91 |
+
<td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-2" oninput="highlightDSA()"></td></tr>
|
| 92 |
+
</tbody></table>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
<div class="text-end">
|
| 96 |
+
<button class="btn btn-sm btn-outline-danger" onclick="saveDonorHLA(this)">存檔</button>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<script>
|
| 102 |
+
function getDonorSet() {
|
| 103 |
+
const s = new Set();
|
| 104 |
+
['A','B','Cw','DR','DQ','DP'].forEach(l => {
|
| 105 |
+
[1,2].forEach(n => {
|
| 106 |
+
const el = document.getElementById('donor-' + l + '-' + n);
|
| 107 |
+
if (el && el.value.trim()) s.add(l + el.value.trim());
|
| 108 |
+
});
|
| 109 |
+
});
|
| 110 |
+
['A','B','C','DRB1','DQB1','DQA1','DPB1','DPA1'].forEach(l => {
|
| 111 |
+
[1,2].forEach(n => {
|
| 112 |
+
const el = document.getElementById('donor-dna-' + l + '-' + n);
|
| 113 |
+
if (el && el.value.trim()) s.add(l + '*' + el.value.trim());
|
| 114 |
+
});
|
| 115 |
+
});
|
| 116 |
+
return s;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function highlightTokensInCell(cell, donorSet) {
|
| 120 |
+
if (!cell) return;
|
| 121 |
+
const text = cell.dataset.orig || cell.textContent;
|
| 122 |
+
cell.dataset.orig = text;
|
| 123 |
+
const tokens = text.split(/,\s*/);
|
| 124 |
+
cell.innerHTML = tokens.map(tok => {
|
| 125 |
+
const t = tok.trim();
|
| 126 |
+
if (!t) return tok;
|
| 127 |
+
for (const ag of donorSet) {
|
| 128 |
+
if (t.includes(ag)) {
|
| 129 |
+
return '<span style="color:#dc3545;font-weight:bold;">' + tok + '</span>';
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
return tok;
|
| 133 |
+
}).join(', ');
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function highlightSpecText(specEl, donorSet) {
|
| 137 |
+
if (!specEl) return;
|
| 138 |
+
const orig = specEl.dataset.orig || specEl.textContent;
|
| 139 |
+
specEl.dataset.orig = orig;
|
| 140 |
+
// 抓 token: 優先 "sero(allele list)" 整段(括號內含空白也視為單一 token),其次裸字
|
| 141 |
+
const tokens = orig.match(/\S+?\([^)]*\)|\S+/g) || [];
|
| 142 |
+
specEl.innerHTML = tokens.map(seg => {
|
| 143 |
+
const m = seg.match(/^([^(]+)(\([^)]*\))?$/);
|
| 144 |
+
if (!m) return seg;
|
| 145 |
+
const sero = m[1];
|
| 146 |
+
const paren = m[2] || '';
|
| 147 |
+
let isDSA = false;
|
| 148 |
+
for (const ag of donorSet) {
|
| 149 |
+
if (!ag) continue;
|
| 150 |
+
if (sero === ag || (paren && paren.indexOf(ag) >= 0)) { isDSA = true; break; }
|
| 151 |
+
}
|
| 152 |
+
if (isDSA) {
|
| 153 |
+
return '<span style="color:#dc3545;font-weight:bold;">' + seg + '</span>';
|
| 154 |
+
}
|
| 155 |
+
const seroHtml = '<span style="color:#1E40AF;font-weight:bold;">' + sero + '</span>';
|
| 156 |
+
if (!paren) return seroHtml;
|
| 157 |
+
const inner = paren.slice(1, -1);
|
| 158 |
+
const alleleHtml = inner.split(/\s+/).map(a =>
|
| 159 |
+
a ? '<span style="color:#374151;">' + a + '</span>' : a
|
| 160 |
+
).join(' ');
|
| 161 |
+
return seroHtml + '(' + alleleHtml + ')';
|
| 162 |
+
}).join(' ');
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function highlightDSA() {
|
| 166 |
+
const ds = getDonorSet();
|
| 167 |
+
// Specificity in Report Output cards + Reports Timeline
|
| 168 |
+
document.querySelectorAll('[id^="spec-"]').forEach(el => highlightSpecText(el, ds));
|
| 169 |
+
document.querySelectorAll('.spec-cell').forEach(el => highlightSpecText(el, ds));
|
| 170 |
+
// Antigen (idx 0) + Allele (idx 1) columns in Antibody Strength tables
|
| 171 |
+
document.querySelectorAll('.ab-table tbody tr').forEach(tr => {
|
| 172 |
+
highlightTokensInCell(tr.cells[0], ds);
|
| 173 |
+
highlightTokensInCell(tr.cells[1], ds);
|
| 174 |
+
});
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
function saveDonorHLA(btn) {
|
| 178 |
+
const obj = {};
|
| 179 |
+
document.querySelectorAll('.donor-hla').forEach(el => {
|
| 180 |
+
if (el.value.trim()) obj[el.id] = el.value.trim();
|
| 181 |
+
});
|
| 182 |
+
btn.disabled = true;
|
| 183 |
+
fetch('/save_donor_hla', {
|
| 184 |
+
method: 'POST',
|
| 185 |
+
headers: {'Content-Type': 'application/json'},
|
| 186 |
+
body: JSON.stringify({ chart_no: '{{ patient.chart_no }}', donor_hla: JSON.stringify(obj) })
|
| 187 |
+
}).then(r => r.json()).then(d => {
|
| 188 |
+
btn.disabled = false;
|
| 189 |
+
if (d.ok) {
|
| 190 |
+
btn.classList.remove('btn-outline-danger');
|
| 191 |
+
btn.classList.add('btn-danger');
|
| 192 |
+
btn.textContent = '已存檔';
|
| 193 |
+
}
|
| 194 |
+
});
|
| 195 |
+
highlightDSA();
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
window.addEventListener('DOMContentLoaded', function() {
|
| 199 |
+
const saved = {{ donor_hla | default('', true) | tojson }};
|
| 200 |
+
if (saved) {
|
| 201 |
+
try {
|
| 202 |
+
const obj = JSON.parse(saved);
|
| 203 |
+
Object.keys(obj).forEach(k => {
|
| 204 |
+
const el = document.getElementById(k);
|
| 205 |
+
if (el) el.value = obj[k];
|
| 206 |
+
});
|
| 207 |
+
} catch(e) {}
|
| 208 |
+
}
|
| 209 |
+
highlightDSA();
|
| 210 |
+
});
|
| 211 |
+
</script>
|
| 212 |
+
|
| 213 |
+
<!-- ============ Reports Timeline ============ -->
|
| 214 |
+
<h5>Reports</h5>
|
| 215 |
+
<table class="table table-bordered table-sm bg-white mb-4" style="font-size:.9rem;">
|
| 216 |
+
<thead class="table-light">
|
| 217 |
+
<tr><th>Date</th><th>Class</th><th>PRA%</th><th>Overall</th><th>Specificity</th><th>Comment</th></tr>
|
| 218 |
+
</thead>
|
| 219 |
+
<tbody>
|
| 220 |
+
{% for r in reports %}
|
| 221 |
+
<tr>
|
| 222 |
+
<td>{{ r.report_date }}</td>
|
| 223 |
+
<td>{{ r.pra_class }}</td>
|
| 224 |
+
<td class="fw-bold">{{ r.pra_percent }}%</td>
|
| 225 |
+
<td><span class="badge {{ 'bg-danger' if r.overall == 'Positive' else 'bg-success' }}">{{ r.overall }}</span></td>
|
| 226 |
+
<td class="small spec-cell" style="max-width:300px;">{{ r.specificity or '(-)' }}</td>
|
| 227 |
+
<td class="small">{{ r.comment or '' }}</td>
|
| 228 |
+
</tr>
|
| 229 |
+
{% endfor %}
|
| 230 |
+
</tbody>
|
| 231 |
+
</table>
|
| 232 |
+
|
| 233 |
+
<!-- ============ Report Output (copy-paste) ============ -->
|
| 234 |
+
<div id="reportOutput" class="mb-4">
|
| 235 |
+
{% for r in reports %}
|
| 236 |
+
<div class="card mb-2">
|
| 237 |
+
<div class="card-body p-3">
|
| 238 |
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
| 239 |
+
<strong>{{ r.pra_class }} — {{ r.report_date }}</strong>
|
| 240 |
+
<div>
|
| 241 |
+
<button class="btn btn-sm btn-outline-secondary me-1" onclick="copyReport('rpt-{{ loop.index }}')">Copy</button>
|
| 242 |
+
<button class="btn btn-sm btn-outline-primary" id="editbtn-{{ loop.index }}" onclick="toggleEdit({{ r.id }}, {{ loop.index }}, this)">Edit</button>
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
<div id="rpt-{{ loop.index }}" style="font-family:'Calibri','Segoe UI',sans-serif; line-height:1.8;">
|
| 246 |
+
<div>{{ r.pra_class }}</div>
|
| 247 |
+
<div>Overall: {{ r.overall }}</div>
|
| 248 |
+
<div>%SA (or %PRA): {{ r.pra_percent }}</div>
|
| 249 |
+
<div>Specificity:</div>
|
| 250 |
+
<div id="spec-{{ loop.index }}" style="border:1px solid transparent; border-radius:4px; padding:3px 6px; min-height:1.8rem;">{{ r.specificity or '(-)' }}</div>
|
| 251 |
+
<div>COMMENT:</div>
|
| 252 |
+
<div id="comment-{{ loop.index }}" style="border:1px solid transparent; border-radius:4px; padding:3px 6px; min-height:1.5rem;">{{ r.comment or '' }}</div>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
{% endfor %}
|
| 257 |
+
</div>
|
| 258 |
+
|
| 259 |
+
<script>
|
| 260 |
+
function toggleEdit(reportId, idx, btn) {
|
| 261 |
+
const specEl = document.getElementById('spec-' + idx);
|
| 262 |
+
const commentEl = document.getElementById('comment-' + idx);
|
| 263 |
+
|
| 264 |
+
if (btn.textContent === 'Edit') {
|
| 265 |
+
// 進入編輯模式
|
| 266 |
+
specEl.contentEditable = 'true';
|
| 267 |
+
commentEl.contentEditable = 'true';
|
| 268 |
+
specEl.style.border = '1px dashed #0d6efd';
|
| 269 |
+
specEl.style.background = '#f8f9ff';
|
| 270 |
+
commentEl.style.border = '1px dashed #0d6efd';
|
| 271 |
+
commentEl.style.background = '#f8f9ff';
|
| 272 |
+
btn.textContent = 'Save';
|
| 273 |
+
btn.className = 'btn btn-sm btn-success';
|
| 274 |
+
specEl.focus();
|
| 275 |
+
} else {
|
| 276 |
+
// 儲存
|
| 277 |
+
const spec = specEl.innerText.trim();
|
| 278 |
+
const comment = commentEl.innerText.trim();
|
| 279 |
+
btn.disabled = true;
|
| 280 |
+
btn.textContent = 'Saving...';
|
| 281 |
+
fetch('/update_report', {
|
| 282 |
+
method: 'POST',
|
| 283 |
+
headers: {'Content-Type': 'application/json'},
|
| 284 |
+
body: JSON.stringify({ report_id: reportId, specificity: spec, comment: comment })
|
| 285 |
+
})
|
| 286 |
+
.then(r => r.json())
|
| 287 |
+
.then(d => {
|
| 288 |
+
if (d.ok) {
|
| 289 |
+
specEl.contentEditable = 'false';
|
| 290 |
+
commentEl.contentEditable = 'false';
|
| 291 |
+
specEl.style.border = '1px solid transparent';
|
| 292 |
+
specEl.style.background = '';
|
| 293 |
+
commentEl.style.border = '1px solid transparent';
|
| 294 |
+
commentEl.style.background = '';
|
| 295 |
+
btn.textContent = 'Edit';
|
| 296 |
+
btn.className = 'btn btn-sm btn-outline-primary';
|
| 297 |
+
btn.disabled = false;
|
| 298 |
+
} else {
|
| 299 |
+
alert('儲存失敗: ' + (d.error || ''));
|
| 300 |
+
btn.textContent = 'Save'; btn.disabled = false;
|
| 301 |
+
}
|
| 302 |
+
})
|
| 303 |
+
.catch(e => { alert('Error: ' + e); btn.textContent = 'Save'; btn.disabled = false; });
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
function copyReport(id) {
|
| 308 |
+
const el = document.getElementById(id);
|
| 309 |
+
const range = document.createRange();
|
| 310 |
+
range.selectNodeContents(el);
|
| 311 |
+
const sel = window.getSelection();
|
| 312 |
+
sel.removeAllRanges(); sel.addRange(range);
|
| 313 |
+
document.execCommand('copy');
|
| 314 |
+
sel.removeAllRanges();
|
| 315 |
+
const btn = el.parentElement.querySelector('button');
|
| 316 |
+
const orig = btn.textContent;
|
| 317 |
+
btn.textContent = 'Copied!';
|
| 318 |
+
btn.classList.replace('btn-outline-secondary','btn-success');
|
| 319 |
+
setTimeout(() => { btn.textContent = orig; btn.classList.replace('btn-success','btn-outline-secondary'); }, 1500);
|
| 320 |
+
}
|
| 321 |
+
</script>
|
| 322 |
+
|
| 323 |
+
<!-- ============ MFI Trend Charts ============ -->
|
| 324 |
+
{% for comp_label, comp_id, comp_dates, comp_antigens, comp_pra in [
|
| 325 |
+
('PRA Class I', 'chart1', comparison_class1.dates if comparison_class1 else [],
|
| 326 |
+
comparison_class1.antigens if comparison_class1 else [],
|
| 327 |
+
comparison_class1.pra_by_date if comparison_class1 else {}),
|
| 328 |
+
('PRA Class II', 'chart2', comparison_class2.dates if comparison_class2 else [],
|
| 329 |
+
comparison_class2.antigens if comparison_class2 else [],
|
| 330 |
+
comparison_class2.pra_by_date if comparison_class2 else {})
|
| 331 |
+
] %}
|
| 332 |
+
{% if comp_dates and comp_dates | length >= 2 %}
|
| 333 |
+
<h5>{{ comp_label }} — MFI Trend</h5>
|
| 334 |
+
<div class="mb-2">
|
| 335 |
+
<small class="text-muted">Top antibodies by Max MFI (click legend to show/hide)</small>
|
| 336 |
+
<select id="filter-{{ comp_id }}" class="form-select form-select-sm d-inline-block ms-2" style="width:auto;"
|
| 337 |
+
onchange="updateChart('{{ comp_id }}')">
|
| 338 |
+
<option value="10">Top 10</option>
|
| 339 |
+
<option value="20" selected>Top 20</option>
|
| 340 |
+
<option value="all">All</option>
|
| 341 |
+
</select>
|
| 342 |
+
</div>
|
| 343 |
+
<div class="chart-container">
|
| 344 |
+
<canvas id="{{ comp_id }}"></canvas>
|
| 345 |
+
</div>
|
| 346 |
+
|
| 347 |
+
<script>
|
| 348 |
+
(function() {
|
| 349 |
+
const dates = {{ comp_dates | tojson }};
|
| 350 |
+
const allData = [
|
| 351 |
+
{% for ag in comp_antigens %}
|
| 352 |
+
{
|
| 353 |
+
antigen: '{{ ag.antigen }}',
|
| 354 |
+
allele: '{{ ag.allele }}',
|
| 355 |
+
values: [{% for d in comp_dates %}{{ ag.mfi_by_date.get(d, {}).get('max_mfi', 'null') }}{% if not loop.last %},{% endif %}{% endfor %}]
|
| 356 |
+
},
|
| 357 |
+
{% endfor %}
|
| 358 |
+
];
|
| 359 |
+
|
| 360 |
+
// Color palette by locus
|
| 361 |
+
function getColor(ag, i) {
|
| 362 |
+
const colors_a = ['#e53935','#d81b60','#8e24aa','#5e35b1','#3949ab','#1e88e5','#ef5350','#ec407a'];
|
| 363 |
+
const colors_b = ['#00897b','#43a047','#7cb342','#c0ca33','#26a69a','#66bb6a','#9ccc65','#d4e157','#2e7d32','#558b2f'];
|
| 364 |
+
const colors_c = ['#ff8f00','#ff6f00','#f57f17','#ffb300','#ffa000','#ff8f00'];
|
| 365 |
+
if (ag.startsWith('A') || ag.startsWith('DR')) return colors_a[i % colors_a.length];
|
| 366 |
+
if (ag.startsWith('B') || ag.startsWith('DQ')) return colors_b[i % colors_b.length];
|
| 367 |
+
return colors_c[i % colors_c.length];
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
// Sort by max MFI descending
|
| 371 |
+
allData.sort((a, b) => {
|
| 372 |
+
const maxA = Math.max(...a.values.filter(v => v !== null));
|
| 373 |
+
const maxB = Math.max(...b.values.filter(v => v !== null));
|
| 374 |
+
return maxB - maxA;
|
| 375 |
+
});
|
| 376 |
+
|
| 377 |
+
let chartInstance = null;
|
| 378 |
+
|
| 379 |
+
window['allData_{{ comp_id }}'] = allData;
|
| 380 |
+
window['dates_{{ comp_id }}'] = dates;
|
| 381 |
+
|
| 382 |
+
function buildChart(limit) {
|
| 383 |
+
const data = limit === 'all' ? allData : allData.slice(0, parseInt(limit));
|
| 384 |
+
const datasets = data.map((ag, i) => ({
|
| 385 |
+
label: ag.antigen + (ag.allele && ag.allele !== ag.antigen ? ' (' + ag.allele + ')' : ''),
|
| 386 |
+
antigen: ag.antigen,
|
| 387 |
+
allele: ag.allele,
|
| 388 |
+
data: ag.values,
|
| 389 |
+
borderColor: getColor(ag.antigen, i),
|
| 390 |
+
backgroundColor: getColor(ag.antigen, i) + '33',
|
| 391 |
+
borderWidth: 2,
|
| 392 |
+
pointRadius: 4,
|
| 393 |
+
pointHoverRadius: 6,
|
| 394 |
+
tension: 0.3,
|
| 395 |
+
spanGaps: true,
|
| 396 |
+
}));
|
| 397 |
+
|
| 398 |
+
if (chartInstance) chartInstance.destroy();
|
| 399 |
+
chartInstance = new Chart(document.getElementById('{{ comp_id }}'), {
|
| 400 |
+
type: 'line',
|
| 401 |
+
data: { labels: dates, datasets: datasets },
|
| 402 |
+
options: {
|
| 403 |
+
responsive: true,
|
| 404 |
+
maintainAspectRatio: false,
|
| 405 |
+
interaction: { mode: 'nearest', intersect: false },
|
| 406 |
+
plugins: {
|
| 407 |
+
legend: {
|
| 408 |
+
position: 'right',
|
| 409 |
+
labels: { font: { size: 11 }, boxWidth: 12, padding: 6 }
|
| 410 |
+
},
|
| 411 |
+
tooltip: {
|
| 412 |
+
callbacks: {
|
| 413 |
+
label: function(ctx) {
|
| 414 |
+
return ctx.dataset.label + ': ' + (ctx.parsed.y !== null ? ctx.parsed.y.toLocaleString() : '-');
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
},
|
| 419 |
+
scales: {
|
| 420 |
+
y: {
|
| 421 |
+
title: { display: true, text: 'Max MFI (Normal)' },
|
| 422 |
+
beginAtZero: true,
|
| 423 |
+
},
|
| 424 |
+
x: {
|
| 425 |
+
title: { display: true, text: 'Report Date' }
|
| 426 |
+
}
|
| 427 |
+
}
|
| 428 |
+
}
|
| 429 |
+
});
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
window['updateChart'] = window['updateChart'] || function(id) {
|
| 433 |
+
const sel = document.getElementById('filter-' + id);
|
| 434 |
+
buildChart(sel.value);
|
| 435 |
+
};
|
| 436 |
+
// Specific update for this chart
|
| 437 |
+
const origUpdate = window['updateChart'];
|
| 438 |
+
window['updateChart'] = function(id) {
|
| 439 |
+
if (id === '{{ comp_id }}') {
|
| 440 |
+
const sel = document.getElementById('filter-' + id);
|
| 441 |
+
buildChart(sel.value);
|
| 442 |
+
} else if (origUpdate) origUpdate(id);
|
| 443 |
+
};
|
| 444 |
+
|
| 445 |
+
buildChart(20);
|
| 446 |
+
})();
|
| 447 |
+
</script>
|
| 448 |
+
{% endif %}
|
| 449 |
+
{% endfor %}
|
| 450 |
+
|
| 451 |
+
<!-- ============ Individual Report Details ============ -->
|
| 452 |
+
<h5>Antibody Strength</h5>
|
| 453 |
+
{% for r in reports %}
|
| 454 |
+
{% if r.antibodies %}
|
| 455 |
+
<div class="card mb-3">
|
| 456 |
+
<div class="card-header">
|
| 457 |
+
<strong>{{ r.pra_class }}</strong> — {{ r.report_date }}
|
| 458 |
+
<span class="badge ms-1 {{ 'bg-danger' if r.overall == 'Positive' else 'bg-success' }}">{{ r.overall }}</span>
|
| 459 |
+
<span class="fw-bold ms-1">PRA {{ r.pra_percent }}%</span>
|
| 460 |
+
</div>
|
| 461 |
+
<div class="card-body p-2">
|
| 462 |
+
<table class="table table-sm table-bordered ab-table mb-0">
|
| 463 |
+
<thead class="table-light"><tr>
|
| 464 |
+
<th>Antigen</th><th>Allele</th><th class="text-end">Max MFI</th>
|
| 465 |
+
<th class="text-end">Mean MFI</th><th class="text-center">No of Beads</th><th>Bead IDs</th>
|
| 466 |
+
</tr></thead>
|
| 467 |
+
<tbody>
|
| 468 |
+
{% for a in r.antibodies %}
|
| 469 |
+
<tr>
|
| 470 |
+
<td class="fw-bold">{{ a.antigen }}</td>
|
| 471 |
+
<td class="small">{{ a.allele }}</td>
|
| 472 |
+
<td class="text-end">{{ a.max_mfi|int }}</td>
|
| 473 |
+
<td class="text-end">{{ a.mean_mfi|int }}</td>
|
| 474 |
+
<td class="text-center">{{ a.no_of_beads }}</td>
|
| 475 |
+
<td class="small text-muted">{{ a.bead_ids }}</td>
|
| 476 |
+
</tr>
|
| 477 |
+
{% endfor %}
|
| 478 |
+
</tbody>
|
| 479 |
+
</table>
|
| 480 |
+
</div>
|
| 481 |
+
</div>
|
| 482 |
+
{% endif %}
|
| 483 |
+
{% endfor %}
|
| 484 |
+
</div>
|
| 485 |
+
</body>
|
| 486 |
+
</html>
|
templates//register.html
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-TW">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>PRA Analysis - 註冊</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
body { background: #f5f7fa; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
| 10 |
+
.login-card { width: 400px; background: #fff; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,.1); padding: 2.5rem; }
|
| 11 |
+
.login-title { text-align: center; margin-bottom: 1.5rem; }
|
| 12 |
+
</style>
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<div class="login-card">
|
| 16 |
+
<div class="login-title">
|
| 17 |
+
<h2>註冊帳號</h2>
|
| 18 |
+
<p class="text-muted small">PRA Analysis System</p>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
{% if error %}
|
| 22 |
+
<div class="alert alert-danger py-2 small">{{ error }}</div>
|
| 23 |
+
{% endif %}
|
| 24 |
+
|
| 25 |
+
<form method="post" action="/register">
|
| 26 |
+
<div class="mb-3">
|
| 27 |
+
<label class="form-label small">姓名</label>
|
| 28 |
+
<input type="text" class="form-control" name="display_name" required>
|
| 29 |
+
</div>
|
| 30 |
+
<div class="mb-3">
|
| 31 |
+
<label class="form-label small">帳號(Email 或自訂)</label>
|
| 32 |
+
<input type="text" class="form-control" name="username" required>
|
| 33 |
+
</div>
|
| 34 |
+
<div class="mb-3">
|
| 35 |
+
<label class="form-label small">密碼</label>
|
| 36 |
+
<input type="password" class="form-control" name="password" required minlength="4">
|
| 37 |
+
<div class="form-text text-danger fw-bold">請勿使用常用密碼<br>(管理員看的到你的密碼 可以用簡單的123456等)</div>
|
| 38 |
+
</div>
|
| 39 |
+
<div class="mb-3">
|
| 40 |
+
<label class="form-label small">確認密碼</label>
|
| 41 |
+
<input type="password" class="form-control" name="password2" required>
|
| 42 |
+
</div>
|
| 43 |
+
<button type="submit" class="btn btn-primary w-100">註冊</button>
|
| 44 |
+
<div class="text-center mt-3">
|
| 45 |
+
<a href="/login" class="small">已有帳號?登入</a>
|
| 46 |
+
</div>
|
| 47 |
+
</form>
|
| 48 |
+
</div>
|
| 49 |
+
</body>
|
| 50 |
+
</html>
|