eab91174db
Co-Authored-By: Claude <noreply@anthropic.com>
171 lines
6.3 KiB
TypeScript
171 lines
6.3 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
import { api, ApiError } from '../api/client';
|
|
import { useToast } from '../components/Toast';
|
|
import { useAutoRefresh } from '../lib/useAutoRefresh';
|
|
import type { PatientSummary, RiskLevel } from '../api/types';
|
|
import { riskBadgeClass, riskLabel } from '../lib/format';
|
|
|
|
const RISK_ORDER: Record<RiskLevel, number> = { high: 0, medium: 1, low: 2 };
|
|
const PAGE_SIZE = 10;
|
|
|
|
export function WorklistPage(): JSX.Element {
|
|
const navigate = useNavigate();
|
|
const { show } = useToast();
|
|
const [patients, setPatients] = useState<PatientSummary[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [query, setQuery] = useState('');
|
|
const [riskFilter, setRiskFilter] = useState<'all' | RiskLevel>('all');
|
|
const [page, setPage] = useState(1);
|
|
|
|
const load = useCallback(
|
|
(silent = false) => {
|
|
if (!silent) setLoading(true);
|
|
api
|
|
.listPatients()
|
|
.then(setPatients)
|
|
.catch((err) => {
|
|
if (!silent) show(err instanceof ApiError ? err.message : '加载孕妇列表失败');
|
|
})
|
|
.finally(() => setLoading(false));
|
|
},
|
|
[show],
|
|
);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
// 多端一致:新建档/风险变化近实时反映到工作列表
|
|
useAutoRefresh(() => load(true));
|
|
|
|
const rows = useMemo(() => {
|
|
const q = query.trim();
|
|
return patients
|
|
.filter((p) => (riskFilter === 'all' ? true : p.initialRiskLevel === riskFilter))
|
|
.filter((p) => (q ? p.name.includes(q) || (p.patientNo ?? '').toUpperCase().includes(q.toUpperCase()) : true))
|
|
.sort((a, b) => RISK_ORDER[a.initialRiskLevel] - RISK_ORDER[b.initialRiskLevel]);
|
|
}, [patients, query, riskFilter]);
|
|
|
|
// 过滤条件变化时回到第一页
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [query, riskFilter]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
|
|
const currentPage = Math.min(page, totalPages);
|
|
const pageRows = rows.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
|
|
|
const stats = useMemo(() => {
|
|
return {
|
|
total: patients.length,
|
|
high: patients.filter((p) => p.initialRiskLevel === 'high').length,
|
|
medium: patients.filter((p) => p.initialRiskLevel === 'medium').length,
|
|
};
|
|
}, [patients]);
|
|
|
|
return (
|
|
<div className="stack">
|
|
<div className="spread">
|
|
<div>
|
|
<h1 style={{ fontSize: 'var(--font-xxl)' }}>工作列表</h1>
|
|
<p className="muted">在管孕妇 {stats.total} 人 · 高风险 {stats.high} · 中风险 {stats.medium}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="row" style={{ marginBottom: 'var(--space-4)' }}>
|
|
<input
|
|
placeholder="搜索姓名 / 编号…"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
style={{ maxWidth: 260, padding: '8px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
|
|
/>
|
|
<select
|
|
value={riskFilter}
|
|
onChange={(e) => setRiskFilter(e.target.value as 'all' | RiskLevel)}
|
|
style={{ padding: '8px 12px', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)' }}
|
|
>
|
|
<option value="all">全部风险</option>
|
|
<option value="high">高风险</option>
|
|
<option value="medium">中风险</option>
|
|
<option value="low">低风险</option>
|
|
</select>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<p className="empty">加载中…</p>
|
|
) : rows.length === 0 ? (
|
|
<p className="empty">暂无匹配的孕妇。建档后将出现在此列表。</p>
|
|
) : (
|
|
<>
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
<th>编号</th>
|
|
<th>姓名</th>
|
|
<th>年龄</th>
|
|
<th>孕周</th>
|
|
<th>分期</th>
|
|
<th>初始风险</th>
|
|
<th>风险因素</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{pageRows.map((p) => (
|
|
<tr key={p.id} className="clickable" onClick={() => navigate(`/patients/${p.id}`)}>
|
|
<td style={{ fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 'var(--font-sm)' }}>
|
|
{p.patientNo || '—'}
|
|
</td>
|
|
<td style={{ fontWeight: 600 }}>{p.name}</td>
|
|
<td>{p.age}</td>
|
|
<td>
|
|
{p.gestationalWeeks}周{p.gestationalDays}天
|
|
</td>
|
|
<td>{trimesterLabel(p.trimester)}</td>
|
|
<td>
|
|
<span className={`badge ${riskBadgeClass(p.initialRiskLevel)}`}>
|
|
{riskLabel(p.initialRiskLevel)}
|
|
</span>
|
|
</td>
|
|
<td className="muted">{p.initialRiskFactors.join('、') || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
<div className="pager">
|
|
<span className="muted">
|
|
共 {rows.length} 条 · 第 {currentPage}/{totalPages} 页
|
|
</span>
|
|
<div className="pager__btns">
|
|
<button
|
|
className="btn btn-ghost btn-sm"
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={currentPage <= 1}
|
|
type="button"
|
|
>
|
|
<ChevronLeft size={16} strokeWidth={1.9} /> 上一页
|
|
</button>
|
|
<button
|
|
className="btn btn-ghost btn-sm"
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={currentPage >= totalPages}
|
|
type="button"
|
|
>
|
|
下一页 <ChevronRight size={16} strokeWidth={1.9} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function trimesterLabel(t: string): string {
|
|
return { first: '孕早期', second: '孕中期', third: '孕晚期' }[t] ?? t;
|
|
}
|