909 lines
34 KiB
TypeScript
909 lines
34 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import type { ExamClient, ExamModalTab } from '../../data/mockData';
|
||
import type {
|
||
CustomerAppointmentInfo,
|
||
CustomerExamAddItem,
|
||
CustomerInfo,
|
||
PhysicalExamProgressItem,
|
||
} from '../../api';
|
||
import { getCustomerDetail, getPhysicalExamProgressDetail, signInMedicalExamCenter, getTongyishuPdf } from '../../api';
|
||
import { Button, Input } from '../ui';
|
||
|
||
interface ExamModalProps {
|
||
client: ExamClient;
|
||
tab: ExamModalTab;
|
||
onTabChange: (key: ExamModalTab) => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export const ExamModal = ({ client, tab, onTabChange, onClose }: ExamModalProps) => {
|
||
const tabs: { key: ExamModalTab; label: string }[] = [
|
||
{ key: 'detail', label: '详情' },
|
||
{ key: 'sign', label: '签到' },
|
||
{ key: 'addon', label: '加项' },
|
||
{ key: 'print', label: '打印导检单' },
|
||
{ key: 'delivery', label: '报告寄送' },
|
||
];
|
||
|
||
const signDone = client.signStatus === '已登记' || client.checkedItems.includes('签到');
|
||
const addonDone = (client.addonCount || 0) > 0;
|
||
const printDone = !!client.guidePrinted;
|
||
const deliveryDone = !!client.deliveryDone;
|
||
|
||
const tabDone: Record<ExamModalTab, boolean> = {
|
||
detail: false,
|
||
sign: signDone,
|
||
addon: addonDone,
|
||
print: printDone,
|
||
delivery: deliveryDone,
|
||
};
|
||
|
||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
};
|
||
|
||
const handleTouchStart = (e: React.TouchEvent) => {
|
||
if (e.touches.length > 1) {
|
||
e.preventDefault();
|
||
}
|
||
};
|
||
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null);
|
||
const [appointmentInfo, setAppointmentInfo] = useState<CustomerAppointmentInfo | null>(null);
|
||
const [addItemInfoList, setAddItemInfoList] = useState<CustomerExamAddItem[] | null>(null);
|
||
const [progressList, setProgressList] = useState<PhysicalExamProgressItem[] | null>(null);
|
||
|
||
useEffect(() => {
|
||
const physical_exam_id = Number(client.id);
|
||
if (!physical_exam_id) return;
|
||
setDetailLoading(true);
|
||
Promise.all([
|
||
getCustomerDetail({ physical_exam_id }),
|
||
getPhysicalExamProgressDetail({ physical_exam_id }),
|
||
])
|
||
.then(([detailRes, progressRes]) => {
|
||
setCustomerInfo(detailRes.Data?.customerInfo ?? null);
|
||
setAppointmentInfo(detailRes.Data?.appointmentInfo ?? null);
|
||
setAddItemInfoList(detailRes.Data?.addItemInfoList ?? null);
|
||
setProgressList(progressRes.Data?.examProgressesList ?? null);
|
||
})
|
||
.catch((err) => {
|
||
console.error('获取客户详情/进度失败', err);
|
||
})
|
||
.finally(() => setDetailLoading(false));
|
||
}, [client.id]);
|
||
|
||
return (
|
||
<div
|
||
className='fixed inset-0 z-40 flex items-center justify-center bg-black/50'
|
||
style={{ touchAction: 'none' }}
|
||
onDoubleClick={handleDoubleClick}
|
||
onTouchStart={handleTouchStart}
|
||
>
|
||
<div
|
||
className='w-[960px] max-w-[95vw] max-h-[95vh] bg-white rounded-2xl shadow-xl overflow-hidden text-sm'
|
||
style={{ touchAction: 'none' }}
|
||
onDoubleClick={handleDoubleClick}
|
||
onTouchStart={handleTouchStart}
|
||
>
|
||
|
||
{/* Header 区域:增加了内边距 padding */}
|
||
<div className='px-8 pt-6 pb-2'>
|
||
<div className='flex items-center justify-between'>
|
||
{/* 左侧:姓名 + VIP + 体检号 */}
|
||
<div className='flex items-end gap-3'>
|
||
<span className='text-2xl font-bold text-gray-900 leading-none'>
|
||
{client.name}
|
||
</span>
|
||
{/* VIP 徽章样式优化:紫色背景 + 左右内边距 */}
|
||
<span className='inline-flex items-center justify-center bg-indigo-100 text-indigo-600 text-[11px] font-bold px-2 py-0.5 rounded h-5 align-bottom'>
|
||
VIP
|
||
</span>
|
||
<span className='text-sm text-gray-400 ml-1 leading-none mb-0.5'>
|
||
体检号:{client.id}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 右侧:仅保留关闭按钮 */}
|
||
<button
|
||
className='text-gray-400 hover:text-gray-600 transition-colors text-sm'
|
||
onClick={onClose}
|
||
>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tabs 区域 */}
|
||
<div className='px-8 py-4 flex items-center justify-between'>
|
||
<div className='flex items-center gap-3'>
|
||
{tabs.map((t) => {
|
||
const isActive = tab === t.key;
|
||
const isDone = tabDone[t.key];
|
||
return (
|
||
<button
|
||
key={t.key}
|
||
onClick={() => onTabChange(t.key)}
|
||
// 样式修改:rounded-full (完全圆角), border 颜色调整, 增加选中时的阴影
|
||
className={`px-5 py-2 rounded-2xl border text-sm transition-all duration-200 ${isActive
|
||
? 'bg-blue-600 text-white border-blue-600 shadow-md shadow-blue-200'
|
||
: isDone
|
||
? 'bg-gray-50 text-gray-400 border-gray-100'
|
||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
{t.label}
|
||
{/* 这里保留了原有逻辑,但微调了样式 */}
|
||
{t.key === 'addon' && (client.addonCount || 0) > 0 && (
|
||
<span className={`text-xs ${isActive ? 'text-blue-100' : 'text-gray-400'}`}>
|
||
({client.addonCount})
|
||
</span>
|
||
)}
|
||
{((t.key === 'sign' && signDone) || (t.key === 'print' && printDone)) && (
|
||
<span className=''></span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 按钮部分:将原本在 Tab 右侧的任何操作可以放这里,或者留空 */}
|
||
</div>
|
||
|
||
<div className='px-4 py-4 bg-gray-50/60'>
|
||
{tab === 'detail' && (
|
||
<ExamDetailInfo
|
||
client={client}
|
||
customerInfo={customerInfo}
|
||
appointmentInfo={appointmentInfo}
|
||
addItemInfoList={addItemInfoList}
|
||
progressList={progressList}
|
||
loading={detailLoading}
|
||
/>
|
||
)}
|
||
{tab === 'sign' && <ExamSignPanel />}
|
||
{tab === 'addon' && <ExamAddonPanel client={client} />}
|
||
{tab === 'print' && <ExamPrintPanel client={client} />}
|
||
{tab === 'delivery' && <ExamDeliveryPanel client={client} />}
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ExamSignPanel = () => {
|
||
const [idNo, setIdNo] = useState('');
|
||
const [ocrLoading, setOcrLoading] = useState(false);
|
||
const [signLoading, setSignLoading] = useState(false);
|
||
const [message, setMessage] = useState<string | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
const handlePickFile = () => {
|
||
fileInputRef.current?.click();
|
||
};
|
||
|
||
const mockOcr = async (file: File) => {
|
||
// 简单模拟 OCR:提取文件名中的数字,或返回示例身份证
|
||
const match = file.name.match(/\d{6,18}/);
|
||
await new Promise((r) => setTimeout(r, 600));
|
||
return match?.[0] || '440101199001010010';
|
||
};
|
||
|
||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
setOcrLoading(true);
|
||
setMessage(null);
|
||
try {
|
||
const ocrId = await mockOcr(file);
|
||
setIdNo(ocrId);
|
||
} catch (err) {
|
||
console.error(err);
|
||
setMessage('OCR 识别失败,请重试或手动输入');
|
||
} finally {
|
||
setOcrLoading(false);
|
||
e.target.value = '';
|
||
}
|
||
};
|
||
|
||
const handleSign = async () => {
|
||
const trimmed = idNo.trim();
|
||
if (!trimmed) {
|
||
setMessage('请输入身份证号');
|
||
return;
|
||
}
|
||
setSignLoading(true);
|
||
setMessage(null);
|
||
try {
|
||
const res = await signInMedicalExamCenter({ id_no: trimmed });
|
||
const ok = res.Status === 200 && res.Data?.is_success === 0;
|
||
setMessage(ok ? '签到成功' : res.Message || '签到失败');
|
||
} catch (err) {
|
||
console.error(err);
|
||
setMessage('签到请求失败,请稍后重试');
|
||
} finally {
|
||
setSignLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className='grid grid-cols-2 gap-4 text-sm'>
|
||
<div className='p-4 rounded-2xl border bg-gray-50/60 flex flex-col gap-3'>
|
||
<div className='font-medium'>身份证扫描与签到</div>
|
||
<div className='text-xs text-gray-500'>
|
||
支持扫描身份证识别证号,确认后再点击签到。
|
||
</div>
|
||
<div className='flex items-center gap-3'>
|
||
<Button className='py-1.5 px-3' onClick={handlePickFile} disabled={ocrLoading || signLoading}>
|
||
{ocrLoading ? '识别中...' : '扫描身份证'}
|
||
</Button>
|
||
<input
|
||
ref={fileInputRef}
|
||
type='file'
|
||
accept='image/*'
|
||
className='hidden'
|
||
onChange={handleFileChange}
|
||
/>
|
||
<Input
|
||
placeholder='身份证号'
|
||
value={idNo}
|
||
onChange={(e) => setIdNo(e.target.value)}
|
||
className='flex-1'
|
||
/>
|
||
<Button
|
||
className='py-1.5 px-4'
|
||
onClick={handleSign}
|
||
disabled={signLoading}
|
||
>
|
||
{signLoading ? '签到中...' : '签到'}
|
||
</Button>
|
||
</div>
|
||
{message && <div className='text-xs text-gray-600'>{message}</div>}
|
||
<div className='text-[11px] text-gray-400'>如识别不准,可手动修改后再签到。</div>
|
||
</div>
|
||
<div className='p-4 rounded-2xl border bg-gray-50/60 flex flex-col gap-3'>
|
||
<div className='font-medium'>体检知情同意书</div>
|
||
<div className='text-xs text-gray-500'>点击后弹出知情同意书全文及签名区域,签署完成后回到签到页面。</div>
|
||
<Button className='py-1.5 px-3'>打开知情同意书并签署</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
interface AddonTag {
|
||
title: string;
|
||
type: 1 | 2 | 3 | 4; // 1: 热门(红), 2: 普通(灰), 3: 医生推荐(蓝), 4: 折扣信息
|
||
}
|
||
|
||
interface AddonItem {
|
||
id?: string;
|
||
name: string;
|
||
paid?: boolean;
|
||
tags?: AddonTag[];
|
||
originalPrice?: string;
|
||
currentPrice?: string;
|
||
price?: number; // 兼容 addonOptions 结构
|
||
}
|
||
|
||
const ExamAddonPanel = ({ client }: { client: ExamClient }) => {
|
||
// 从 client 获取加项选项数据
|
||
const addonOptions = (client['addonOptions' as keyof ExamClient] as AddonItem[] | undefined) || [];
|
||
const addonSummary = (client['addonSummary' as keyof ExamClient] as AddonItem[] | undefined) || [];
|
||
|
||
// 合并数据,优先使用 addonOptions,如果没有则使用 addonSummary
|
||
const allAddons: AddonItem[] = addonOptions.length > 0
|
||
? addonOptions.map(item => ({
|
||
id: item.id || `addon_${item.name}`,
|
||
name: item.name,
|
||
paid: item.paid || false,
|
||
tags: item.tags || [],
|
||
originalPrice: item.originalPrice || (item.price ? item.price.toFixed(2) : '0.00'),
|
||
currentPrice: item.currentPrice || (item.price ? item.price.toFixed(2) : '0.00'),
|
||
}))
|
||
: addonSummary;
|
||
|
||
const [selectedIds, setSelectedIds] = useState<Set<string>>(
|
||
new Set(allAddons.filter(item => item.paid).map(item => item.id || item.name))
|
||
);
|
||
|
||
const maxSelect = 15;
|
||
const selectedCount = selectedIds.size;
|
||
|
||
const [addonSearch, setAddonSearch] = useState('');
|
||
|
||
const filteredAddons = addonSearch.trim()
|
||
? allAddons.filter(item =>
|
||
item.name.toLowerCase().includes(addonSearch.toLowerCase())
|
||
)
|
||
: allAddons;
|
||
|
||
const toggleSelect = (id: string) => {
|
||
if (selectedIds.has(id)) {
|
||
setSelectedIds(prev => {
|
||
const next = new Set(prev);
|
||
next.delete(id);
|
||
return next;
|
||
});
|
||
} else {
|
||
if (selectedCount < maxSelect) {
|
||
setSelectedIds(prev => new Set(prev).add(id));
|
||
}
|
||
}
|
||
};
|
||
|
||
// 计算价格汇总
|
||
const selectedItems = allAddons.filter(item => selectedIds.has(item.id || item.name));
|
||
const totalOriginal = selectedItems.reduce((sum, item) => {
|
||
return sum + parseFloat(item.originalPrice || item.currentPrice || '0');
|
||
}, 0);
|
||
const totalCurrent = selectedItems.reduce((sum, item) => {
|
||
return sum + parseFloat(item.currentPrice || item.originalPrice || '0');
|
||
}, 0);
|
||
const discount = totalOriginal - totalCurrent;
|
||
|
||
// 获取标签样式
|
||
const getTagStyle = (tag: AddonTag) => {
|
||
switch (tag.type) {
|
||
case 1: // 热门
|
||
return 'bg-[#FDF0F0] text-[#BC4845]';
|
||
case 3: // 医生推荐
|
||
return 'bg-[#ECF0FF] text-[#6A6AE5]';
|
||
case 4: // 折扣信息
|
||
return 'bg-[#4C5460] text-[#F1F2F5]';
|
||
default: // 2: 普通
|
||
return 'bg-[#F1F2F5] text-[#464E5B]';
|
||
}
|
||
};
|
||
|
||
// 获取折扣信息文字(从 tags 中提取 type 4 的标签,或计算折扣)
|
||
const getDiscountText = (item: AddonItem) => {
|
||
const discountTag = item.tags?.find(t => t.type === 4);
|
||
if (discountTag) return discountTag.title;
|
||
|
||
const orig = parseFloat(item.originalPrice || '0');
|
||
const curr = parseFloat(item.currentPrice || '0');
|
||
if (orig > 0 && curr < orig) {
|
||
const percent = Math.round((curr / orig) * 100);
|
||
return `渠道 ${percent} 折`;
|
||
}
|
||
return '渠道价';
|
||
};
|
||
|
||
return (
|
||
<div className='space-y-4'>
|
||
{/* 标题和说明 */}
|
||
<div>
|
||
<div className='flex items-center justify-between mb-2 gap-3'>
|
||
<h3 className='text-lg font-semibold text-gray-900'>体检套餐加项选择</h3>
|
||
<div className='w-[260px]'>
|
||
<Input
|
||
placeholder='搜索 加项名称'
|
||
value={addonSearch}
|
||
onChange={(e) => setAddonSearch(e.target.value)}
|
||
className='text-sm'
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className='text-xs text-gray-600 space-y-1'>
|
||
<div>最多可选 {maxSelect} 项 · 一排 5 个</div>
|
||
<div>已勾选 {selectedCount} 项,自费加项费用按渠道折扣价结算。</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 加项网格 */}
|
||
<div className='grid grid-cols-5 gap-3 min-h-[142px]'>
|
||
{filteredAddons.map((item) => {
|
||
const id = item.id || item.name;
|
||
const isSelected = selectedIds.has(id);
|
||
const origPrice = parseFloat(item.originalPrice || '0');
|
||
const currPrice = parseFloat(item.currentPrice || '0');
|
||
|
||
return (
|
||
<div
|
||
key={id}
|
||
className={`border rounded-lg p-3 cursor-pointer transition-all'
|
||
}`}
|
||
onClick={() => toggleSelect(id)}
|
||
>
|
||
{/* 复选框 */}
|
||
<div className='flex items-start gap-2 mb-2'>
|
||
<input
|
||
type='checkbox'
|
||
checked={isSelected}
|
||
onChange={() => toggleSelect(id)}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className='mt-0.5 w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500'
|
||
/>
|
||
<div className='flex-1 min-w-0'>
|
||
{/* 项目名称 */}
|
||
<div className='font-semibold text-[14px] text-gray-900 mb-1'>{item.name}</div>
|
||
|
||
{/* 标签 */}
|
||
{item.tags && item.tags.length > 0 && (
|
||
<div className='flex flex-wrap gap-1 mb-2'>
|
||
{item.tags
|
||
.filter(t => t.type !== 4) // 折扣信息单独显示
|
||
.map((tag, idx) => (
|
||
<span
|
||
key={idx}
|
||
className={`text-[10px] px-2 rounded-full ${getTagStyle(tag)}`}
|
||
>
|
||
{tag.title}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 价格信息 */}
|
||
<div className='mt-2'>
|
||
<div className='flex flex-col'>
|
||
{origPrice > 0 && origPrice > currPrice && (
|
||
<span className='text-xs text-gray-400 line-through'>¥{origPrice.toFixed(0)}</span>
|
||
)}
|
||
<div className='flex items-center justify-between gap-2'>
|
||
<span className='text-[14px] font-bold text-red-600'>¥{currPrice.toFixed(0)}</span>
|
||
<span className={`text-[10px] px-2 rounded-full bg-[#EAFCF1] text-[#447955] whitespace-nowrap`}>{getDiscountText(item)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 底部汇总和支付 */}
|
||
<div className='border-t pt-4 mt-6 flex items-center justify-between'>
|
||
<div className='space-y-1 text-sm'>
|
||
<div className='text-gray-600'>
|
||
加项原价合计: <span className='text-gray-900'>¥{totalOriginal.toFixed(0)}</span>
|
||
</div>
|
||
<div className='text-gray-600'>
|
||
渠道折扣价: <span className='text-xl font-bold text-red-600'>¥{totalCurrent.toFixed(0)}</span>
|
||
{discount > 0 && (
|
||
<span className='text-gray-500 ml-1'>已优惠 ¥{discount.toFixed(0)}</span>
|
||
)}
|
||
</div>
|
||
<div className='text-xs text-gray-500'>结算方式: 个人支付 (微信 / 支付宝)</div>
|
||
</div>
|
||
<Button
|
||
className='bg-[#269745] hover:bg-[#269745]/80 rounded-3xl text-white px-6 py-3 text-base font-medium'
|
||
disabled={selectedCount === 0}
|
||
>
|
||
确认支付 ¥{totalCurrent.toFixed(0)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ExamDetailInfo = ({
|
||
client,
|
||
customerInfo,
|
||
appointmentInfo,
|
||
addItemInfoList,
|
||
progressList,
|
||
loading,
|
||
}: {
|
||
client: ExamClient;
|
||
customerInfo: CustomerInfo | null;
|
||
appointmentInfo: CustomerAppointmentInfo | null;
|
||
addItemInfoList: CustomerExamAddItem[] | null;
|
||
progressList: PhysicalExamProgressItem[] | null;
|
||
loading: boolean;
|
||
}) => {
|
||
const basePhone = customerInfo?.phone || (client['mobile' as keyof ExamClient] as string | undefined) || '';
|
||
const baseMarital =
|
||
customerInfo?.patient_marital_status_name ||
|
||
(client['maritalStatus' as keyof ExamClient] as string | undefined) ||
|
||
'—';
|
||
const [phone, setPhone] = useState(basePhone || '—');
|
||
const [marital, setMarital] = useState(baseMarital);
|
||
const [phoneEditing, setPhoneEditing] = useState(false);
|
||
const [maritalEditing, setMaritalEditing] = useState(false);
|
||
|
||
const customerChannel = client.customerType === '团客' ? '团体客户' : '散客客户';
|
||
const familyDoctor = customerInfo?.family_doctor_name || (client['familyDoctor' as keyof ExamClient] as string | undefined) || '—';
|
||
const groupTag = client['groupTag' as keyof ExamClient] || (client.customerType === '团客' ? '团检' : '—');
|
||
const bookingTime = appointmentInfo?.appointment_time || (client['bookingTime' as keyof ExamClient] || '—');
|
||
const signTime = appointmentInfo?.sign_in_time || (client['signTime' as keyof ExamClient] || '—');
|
||
const addonSummary =
|
||
addItemInfoList && addItemInfoList.length > 0
|
||
? addItemInfoList.map((i) => `${i.dept_name ?? ''} ${i.combination_name ?? ''}`.trim()).join('、')
|
||
: client['addonSummary' as keyof ExamClient] || '—';
|
||
|
||
const progressGroups = useMemo(() => {
|
||
const checked: string[] = [];
|
||
const abandoned: string[] = [];
|
||
const pending: string[] = [];
|
||
const deferred: string[] = [];
|
||
(progressList || []).forEach((p) => {
|
||
const name = p.project_name || p.department_name || '项目';
|
||
switch (p.exam_status) {
|
||
case 1:
|
||
checked.push(name);
|
||
break;
|
||
case 2:
|
||
abandoned.push(name);
|
||
break;
|
||
case 4:
|
||
deferred.push(name);
|
||
break;
|
||
default:
|
||
pending.push(name);
|
||
}
|
||
});
|
||
return { checked, abandoned, pending, deferred };
|
||
}, [progressList]);
|
||
|
||
return (
|
||
<div className='space-y-4 text-sm'>
|
||
<div className='flex items-center gap-4'>
|
||
<div className='w-14 h-14 rounded-full bg-gray-200 flex-shrink-0 overflow-hidden'>
|
||
<div className='w-full h-full rounded-full bg-gray-300 flex items-center justify-center text-[10px] text-gray-500'>
|
||
头像
|
||
</div>
|
||
</div>
|
||
<div className='text-xs text-gray-500'>
|
||
{loading ? '加载中…' : '基础信息:头像、姓名、证件号、手机号等(点击图标可进行编辑)'}
|
||
</div>
|
||
</div>
|
||
|
||
<div className='space-y-2 text-xs text-gray-700'>
|
||
<div className='font-medium text-gray-900'>基础信息</div>
|
||
<div className='grid grid-cols-2 gap-x-8 gap-y-1'>
|
||
<div>
|
||
姓名:<span className='text-gray-900'>{customerInfo?.customer_name || client.name}</span>
|
||
</div>
|
||
<div>
|
||
证件号:<span className='text-gray-900'>{customerInfo?.id_no || '—'}</span>
|
||
</div>
|
||
<div className='flex items-center'>
|
||
<span>手机号:</span>
|
||
{!phoneEditing ? (
|
||
<span className='text-gray-900 flex items-center'>
|
||
{phone}
|
||
<button className='ml-1 text-blue-500 text-[11px] hover:underline' onClick={() => setPhoneEditing(true)}>
|
||
✏️ 编辑
|
||
</button>
|
||
</span>
|
||
) : (
|
||
<span className='flex items-center gap-1'>
|
||
<input
|
||
className='w-28 rounded-xl border px-2 py-0.5 text-[11px] outline-none'
|
||
value={phone}
|
||
onChange={(e) => setPhone(e.target.value)}
|
||
/>
|
||
<button className='px-2 py-0.5 rounded-xl border text-[11px]' onClick={() => setPhoneEditing(false)}>
|
||
保存
|
||
</button>
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div>
|
||
性别/年龄:
|
||
<span className='text-gray-900'>
|
||
{customerInfo?.gender_name || client.gender} / {client.age}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
客户级别:<span className='text-gray-900'>{client.level}</span>
|
||
</div>
|
||
<div>
|
||
所属渠道:<span className='text-gray-900'>{customerChannel}</span>
|
||
</div>
|
||
<div className='flex items-center'>
|
||
<span>婚姻状况:</span>
|
||
{!maritalEditing ? (
|
||
<span className='text-gray-900 flex items-center'>
|
||
{marital}
|
||
<button className='ml-1 text-blue-500 text-[11px] hover:underline' onClick={() => setMaritalEditing(true)}>
|
||
✏️ 编辑
|
||
</button>
|
||
</span>
|
||
) : (
|
||
<span className='flex items-center gap-1'>
|
||
<input
|
||
className='w-20 rounded-xl border px-2 py-0.5 text-[11px] outline-none'
|
||
value={marital}
|
||
onChange={(e) => setMarital(e.target.value)}
|
||
/>
|
||
<button className='px-2 py-0.5 rounded-xl border text-[11px]' onClick={() => setMaritalEditing(false)}>
|
||
保存
|
||
</button>
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div>
|
||
家医:<span className='text-gray-900'>{familyDoctor}</span>
|
||
</div>
|
||
<div>
|
||
团标签:<span className='text-gray-900'>{groupTag as string}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className='space-y-2 text-xs text-gray-700'>
|
||
<div className='font-medium text-gray-900'>预约信息</div>
|
||
<div className='grid grid-cols-2 gap-x-8 gap-y-1'>
|
||
<div>
|
||
预约时间:<span className='text-gray-900'>{bookingTime as string}</span>
|
||
</div>
|
||
<div>
|
||
签到时间:<span className='text-gray-900'>{signTime as string}</span>
|
||
</div>
|
||
<div>
|
||
已消耗时长:
|
||
<span className='text-gray-900'>
|
||
{appointmentInfo?.physical_exam_complete_time && appointmentInfo?.sign_in_time
|
||
? `${appointmentInfo.physical_exam_complete_time} - ${appointmentInfo.sign_in_time}`
|
||
: client.elapsed}
|
||
</span>
|
||
</div>
|
||
<div className='col-span-2'>
|
||
体检套餐名称:<span className='text-gray-900'>{client.packageName}</span>
|
||
</div>
|
||
<div className='col-span-2'>
|
||
加项内容:<span className='text-gray-900'>{addonSummary as string}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className='grid grid-cols-2 gap-4 text-xs'>
|
||
<div className='p-3 rounded-2xl bg-green-50 border max-h-48 overflow-auto custom-scroll'>
|
||
<div className='font-medium mb-2'>已查项目 共 {progressGroups.checked.length} 项</div>
|
||
<div className='flex flex-wrap gap-2'>
|
||
{(progressGroups.checked.length ? progressGroups.checked : client.checkedItems).map((i) => (
|
||
<span key={i} className='px-2 py-0.5 rounded-full bg-white border text-[11px]'>
|
||
{i}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className='p-3 rounded-2xl bg-red-50 border max-h-48 overflow-auto custom-scroll'>
|
||
<div className='font-medium mb-2'>弃检项目 共 {progressGroups.abandoned.length} 项</div>
|
||
<div className='flex flex-wrap gap-2'>
|
||
{progressGroups.abandoned.length ? (
|
||
progressGroups.abandoned.map((i) => (
|
||
<span key={i} className='px-2 py-0.5 rounded-full bg-white border text-[11px]'>
|
||
{i}
|
||
</span>
|
||
))
|
||
) : (
|
||
<span className='text-gray-400 text-[11px]'>暂无弃检项目</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className='p-3 rounded-2xl bg-yellow-50 border max-h-48 overflow-auto custom-scroll'>
|
||
<div className='font-medium mb-2'>未查项目 共 {progressGroups.pending.length} 项</div>
|
||
<div className='flex flex-wrap gap-2'>
|
||
{(progressGroups.pending.length ? progressGroups.pending : client.pendingItems).map((i) => (
|
||
<span key={i} className='px-2 py-0.5 rounded-full bg-white border text-[11px]'>
|
||
{i}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className='p-3 rounded-2xl bg-blue-50 border max-h-48 overflow-auto custom-scroll'>
|
||
<div className='font-medium mb-2'>延期项目 共 {progressGroups.deferred.length} 项</div>
|
||
<div className='flex flex-wrap gap-2'>
|
||
{progressGroups.deferred.length ? (
|
||
progressGroups.deferred.map((i) => (
|
||
<span key={i} className='px-2 py-0.5 rounded-full bg-white border text-[11px]'>
|
||
{i}
|
||
</span>
|
||
))
|
||
) : (
|
||
<span className='text-gray-400 text-[11px]'>暂无延期项目</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ExamDeliveryPanel = ({ client }: { client: ExamClient }) => (
|
||
<div className='flex justify-center'>
|
||
<div className='w-full rounded-2xl border shadow-sm px-6 py-4 text-xs text-gray-800'>
|
||
<div className='text-lg font-semibold text-gray-900 mb-3'>报告寄送</div>
|
||
<div className='grid grid-cols-2 gap-3 mb-3'>
|
||
<div>
|
||
收件人姓名
|
||
<Input placeholder='请输入收件人姓名' className='mt-1' />
|
||
</div>
|
||
<div>
|
||
联系电话
|
||
<Input placeholder='用于快递联系' className='mt-1' />
|
||
</div>
|
||
<div className='col-span-2'>
|
||
寄送地址
|
||
<Input placeholder='请输入详细寄送地址' className='mt-1' />
|
||
</div>
|
||
</div>
|
||
<div className='space-y-2'>‘
|
||
<div>备注说明</div>
|
||
<textarea
|
||
className='w-full rounded-2xl border px-3 py-2 text-xs outline-none focus:ring-2 focus:ring-gray-200 min-h-[80px]'
|
||
placeholder='如需多份报告、加急寄送等,请在此备注'
|
||
/>
|
||
</div>
|
||
<div className='mt-4 flex items-center justify-between text-[11px] text-gray-500'>
|
||
<div>
|
||
当前客户:<span className='font-medium text-gray-800'>{client.name}</span>(体检号:{client.id})
|
||
</div>
|
||
<Button className='px-4 py-1.5 text-xs'>保存寄送信息</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const ExamPrintPanel = ({ client }: { client: ExamClient }) => {
|
||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [pdfReady, setPdfReady] = useState(false);
|
||
const [pdfBlobUrl, setPdfBlobUrl] = useState<string | null>(null);
|
||
const printRef = useRef<HTMLDivElement>(null);
|
||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||
|
||
// 获取 PDF
|
||
useEffect(() => {
|
||
const physical_exam_id = Number(client.id);
|
||
if (!physical_exam_id) {
|
||
setError('无效的体检ID');
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
setPdfReady(false);
|
||
getTongyishuPdf({ exam_id: physical_exam_id })
|
||
.then((res) => {
|
||
if (res.Status === 200 && res.Data?.list_pdf_url && res.Data.list_pdf_url.length > 0) {
|
||
// 取第一个PDF URL
|
||
setPdfUrl(res.Data.list_pdf_url[0].pdf_url);
|
||
setPdfReady(false);
|
||
} else {
|
||
setError(res.Message || '未获取到PDF文件');
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
console.error('获取PDF失败', err);
|
||
setError('获取PDF失败,请稍后重试');
|
||
})
|
||
.finally(() => {
|
||
setLoading(false);
|
||
});
|
||
}, [client.id]);
|
||
|
||
// 将 PDF 拉取为同源 blob,避免跨域打印限制
|
||
useEffect(() => {
|
||
if (!pdfUrl) return;
|
||
|
||
let objectUrl: string | null = null;
|
||
setPdfReady(false);
|
||
setLoading(true);
|
||
|
||
fetch(pdfUrl)
|
||
.then((resp) => {
|
||
if (!resp.ok) throw new Error('获取PDF文件失败');
|
||
return resp.blob();
|
||
})
|
||
.then((blob) => {
|
||
objectUrl = URL.createObjectURL(blob);
|
||
setPdfBlobUrl(objectUrl);
|
||
})
|
||
.catch((err) => {
|
||
console.error('PDF 拉取失败', err);
|
||
setError('PDF 加载失败,请稍后重试');
|
||
})
|
||
.finally(() => {
|
||
setLoading(false);
|
||
});
|
||
|
||
return () => {
|
||
if (objectUrl) {
|
||
URL.revokeObjectURL(objectUrl);
|
||
}
|
||
};
|
||
}, [pdfUrl]);
|
||
|
||
const handlePrint = () => {
|
||
if (!pdfBlobUrl || !pdfReady) return;
|
||
|
||
// 打开新窗口打印(同源 blob),避免跨域和空白页问题
|
||
const printWindow = window.open(pdfBlobUrl, '_blank');
|
||
if (printWindow) {
|
||
printWindow.onload = () => {
|
||
printWindow.focus();
|
||
printWindow.print();
|
||
};
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className='flex justify-center'>
|
||
<div className='w-full max-w-[95%] bg-white rounded-2xl border shadow-sm px-6 py-4 text-xs text-gray-800'>
|
||
<div className='flex items-center justify-between border-b pb-3 mb-3'>
|
||
<div>
|
||
<div className='text-sm font-semibold'>圆和医疗体检中心 · 导检单预览</div>
|
||
<div className='text-[11px] text-gray-500 mt-1'>此为预览页面,实际打印效果以院内打印机为准。</div>
|
||
</div>
|
||
<div className='flex items-center gap-3'>
|
||
<div className='text-right text-[11px] text-gray-500'>
|
||
<div>体检号:{client.id}</div>
|
||
<div>日期:{new Date().toLocaleDateString('zh-CN')}</div>
|
||
</div>
|
||
<Button
|
||
className='px-4 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
|
||
onClick={handlePrint}
|
||
disabled={loading || !pdfReady || !pdfUrl}
|
||
>
|
||
打印
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className='flex items-center justify-center py-12 text-gray-500'>
|
||
<div>正在加载PDF...</div>
|
||
</div>
|
||
) : error ? (
|
||
<div className='flex flex-col items-center justify-center py-12 text-gray-500'>
|
||
<div className='mb-2'>{error}</div>
|
||
<Button
|
||
className='px-4 py-1.5 text-xs'
|
||
onClick={() => {
|
||
setLoading(true);
|
||
setError(null);
|
||
const physical_exam_id = Number(client.id);
|
||
if (physical_exam_id) {
|
||
getTongyishuPdf({ exam_id: physical_exam_id })
|
||
.then((res) => {
|
||
if (res.Status === 200 && res.Data?.list_pdf_url && res.Data.list_pdf_url.length > 0) {
|
||
setPdfUrl(res.Data.list_pdf_url[0].pdf_url);
|
||
} else {
|
||
setError(res.Message || '未获取到PDF文件');
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
console.error('获取PDF失败', err);
|
||
setError('获取PDF失败,请稍后重试');
|
||
})
|
||
.finally(() => {
|
||
setLoading(false);
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
重试
|
||
</Button>
|
||
</div>
|
||
) : pdfUrl ? (
|
||
<div className='w-full'>
|
||
{/* PDF预览区域 */}
|
||
<div ref={printRef} className='print-content'>
|
||
<div className='flex justify-center border rounded-lg p-4 bg-gray-50 overflow-auto max-h-[600px]'>
|
||
<iframe
|
||
src={pdfBlobUrl || ''}
|
||
className='w-full h-[600px] border rounded-lg'
|
||
title='导检单PDF预览'
|
||
onLoad={() => setPdfReady(true)}
|
||
ref={iframeRef}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
|