plokmii commited on
Commit
86b1581
·
verified ·
1 Parent(s): d62e3af

Force clean rebuild (recover from BUILD_ERROR)

Browse files
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">&#9650; +' + diff + '</span>';
231
+ else if (diff < -500) trendTd.innerHTML = '<span class="mfi-down">&#9660; ' + diff + '</span>';
232
+ else trendTd.innerHTML = '<span class="text-muted">&ndash;</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">&#9650; 上升</span>
419
+ <span class="mfi-down ms-2">&#9660; 下降</span>
420
+ <span class="mfi-new ms-2">新增</span>
421
+ <span class="mfi-gone ms-2">&#10006; 消失</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">&#9650; +{{ diff|int }}</span>
455
+ {% elif diff < -500 %}<span class="mfi-down">&#9660; {{ diff|int }}</span>
456
+ {% else %}<span class="text-muted">&ndash;</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">&#10006; 消失</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;">&#128193;</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('&gt;', '>') -%}
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 }} &mdash; Combined MFI Trend
266
+ <small class="text-muted ms-2">DSA 實線&#x25B2; / PRA 虛線&#x25CF;</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> &mdash; {{ 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 &amp; 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">&#128196;</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">&#128218;</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">&#128269;</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">&#128200;</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">&#128196;</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">&#128218;</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">&#128269;</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">&#128200;</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">&#128202;</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">&#9881;</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('&gt;', '>') -%}
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 }} &mdash; 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> &mdash; {{ 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;">&#128193;</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&gt;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;">&#128196;</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>&gt;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 &lt;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>&#128196; ' + f.name +
410
+ ' <a href="#" style="color:#dc3545;text-decoration:none;" onclick="removeFile('+i+');return false;">&times;</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('&gt;', '>') -%}
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('&gt;', '>') -%}
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('&gt;', '>') -%}
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 }} &mdash; 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> &mdash; {{ 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;">&#128196;</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>&gt;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 &lt;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>&gt;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 &lt;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>&#128196; ' + f.name +
593
+ ' <a href="#" style="color:#dc3545;text-decoration:none;" onclick="removeFile('+i+');return false;">&times;</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 }} &mdash; 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> &mdash; {{ 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>