Files
ipad/圆和医疗_体检中心网页原型_2.jsx
YI FANG 8155c9f95d init
2025-11-26 09:50:49 +08:00

1353 lines
54 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useMemo, useState } from 'react';
// ===== 工具与通用组件 =====
const cls = (...xs: (string | false | null | undefined)[]) => xs.filter(Boolean).join(' ');
// 简单自测(不会影响页面逻辑,仅在控制台验证 cls 的行为)
(function testCls() {
// 期望: 'a b c'
const v = cls('a', false && 'x', null, 'b', undefined, 'c');
if (v !== 'a b c') {
// eslint-disable-next-line no-console
console.warn('cls 函数自测不通过,当前为:', v);
}
})();
const Card: React.FC<{ className?: string; children: React.ReactNode }> = ({ className = '', children }) => (
<div className={cls('rounded-2xl border bg-white shadow-sm', className)}>{children}</div>
);
const CardHeader: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className='px-5 pt-4 pb-2 font-medium flex items-center justify-between'>{children}</div>
);
const CardContent: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className='px-5 pb-5'>{children}</div>
);
const Button: React.FC<
React.ButtonHTMLAttributes<HTMLButtonElement> & { className?: string; children: React.ReactNode }
> = ({ className = '', children, ...rest }) => (
<button
className={cls(
'inline-flex items-center gap-2 px-4 py-2 rounded-2xl border text-sm bg-white hover:bg-gray-50 transition-colors',
className,
)}
{...rest}
>
{children}
</button>
);
const Input: React.FC<React.InputHTMLAttributes<HTMLInputElement> & { className?: string }> = ({
className = '',
...rest
}) => (
<input
className={cls(
'w-full rounded-2xl border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-gray-200',
className,
)}
{...rest}
/>
);
const Badge: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<span className='px-2 py-0.5 rounded-full border text-xs bg-gray-50'>{children}</span>
);
const InfoCard: React.FC<{ label: React.ReactNode; value: React.ReactNode }> = ({ label, value }) => (
<div className='p-3 rounded-xl border flex items-center justify-between text-sm'>
<span>{label}</span>
<span className='text-lg font-semibold'>{value}</span>
</div>
);
// 简单图标(避免外部依赖)
const IconHome = () => <span className='text-xs'>🏠</span>;
const IconHospital = () => <span className='text-xs'>🏥</span>;
const IconCalendar = () => <span className='text-xs'>📅</span>;
const IconSupport = () => <span className='text-xs'>💬</span>;
// ===== 静态数据 =====
const NAV_ITEMS = [
{ key: 'home', icon: IconHome, label: '首页' },
{ key: 'exam', icon: IconHospital, label: '体检中心' },
{ key: 'booking', icon: IconCalendar, label: '预约中心' },
{ key: 'support', icon: IconSupport, label: '客服咨询' },
] as const;
const HOME_STATS: [string, number][] = [
['今日预约', 80],
['签到人数', 60],
['在检人数', 25],
['打印导检单', 40],
['已完成人数', 30],
];
const REVENUE_STATS: [string, string][] = [
['体检收入', '¥ 86,000'],
['加项收入', '¥ 12,400'],
['整体收入', '¥ 98,400'],
['目标收入', '¥ 120,000'],
['完成百分比', '82%'],
['缺口', '¥ 21,600'],
];
// B1科室维度 [科室, 医生, 已检人数, 在检人数, 待检人数, 平均检查时长(分钟)]
const B1_ROWS: [string, string, number, number, number, number][] = [
['B超1', '张医生', 6, 2, 2, 15],
['B超2', '李医生', 5, 2, 1, 14],
['B超3', '王医生', 4, 2, 2, 16],
['耳鼻喉', '王医生', 10, 3, 2, 10],
['外科', '周医生', 8, 3, 2, 20],
];
const B1_SUMMARY = {
totalClients: B1_ROWS.reduce((s, r) => s + r[2] + r[3] + r[4], 0),
waiting: B1_ROWS.reduce((s, r) => s + r[4], 0),
inExam: B1_ROWS.reduce((s, r) => s + r[3], 0),
};
// 北3家医视角 [家医, 分配客户, 面诊数]
const NORTH3_ROWS: [string, number, number][] = [
['刘医生', 15, 9],
['高医生', 12, 7],
['马医生', 18, 10],
];
const NORTH3_SUMMARY = {
totalDoctor: NORTH3_ROWS.length,
totalAssigned: NORTH3_ROWS.reduce((s, r) => s + r[1], 0),
consult: NORTH3_ROWS.reduce((s, r) => s + r[2], 0),
};
// 体检客户类型
interface ExamClient {
id: string;
name: string;
gender: '男' | '女';
age: number;
level: string;
packageName: string;
status: '体检中' | '已签到' | '用餐';
elapsed: string;
checkedItems: string[];
pendingItems: string[];
timeSlot: '上午' | '下午';
vipType: '高客' | '普客';
signStatus: '已登记' | '未登记';
customerType: '团客' | '散客';
guidePrinted?: boolean; // 是否已打印导检单
addonCount?: number; // 加项数量
}
const EXAM_CLIENTS: ExamClient[] = [
{
id: 'A001',
name: '张伟',
gender: '男',
age: 35,
level: 'VIP',
packageName: '高端入职体检套餐',
status: '体检中',
elapsed: '00:45',
checkedItems: ['签到', '更衣', '预检', '抽血'],
pendingItems: ['家医面诊', 'B超'],
timeSlot: '上午',
vipType: '高客',
signStatus: '已登记',
customerType: '团客',
guidePrinted: true,
addonCount: 2,
},
{
id: 'A002',
name: '李静',
gender: '女',
age: 29,
level: '普通',
packageName: '基础体检套餐',
status: '已签到',
elapsed: '00:10',
checkedItems: ['签到'],
pendingItems: ['更衣', '预检', '抽血'],
timeSlot: '上午',
vipType: '普客',
signStatus: '已登记',
customerType: '散客',
guidePrinted: false,
addonCount: 0,
},
{
id: 'A003',
name: '孙丽',
gender: '女',
age: 31,
level: 'VIP',
packageName: '健康管理套餐',
status: '用餐',
elapsed: '00:50',
checkedItems: ['签到', '更衣', '预检', '抽血', '家医面诊'],
pendingItems: ['B超'],
timeSlot: '下午',
vipType: '高客',
signStatus: '已登记',
customerType: '团客',
guidePrinted: true,
addonCount: 1,
},
];
const EXAM_STATS: [string, number][] = [
['预约人数', EXAM_CLIENTS.length],
['已签到', EXAM_CLIENTS.filter((c) => c.status === '已签到').length],
['体检中', EXAM_CLIENTS.filter((c) => c.status === '体检中').length],
['用餐', EXAM_CLIENTS.filter((c) => c.status === '用餐').length],
];
// 额外测试:验证 EXAM_STATS 与 EXAM_CLIENTS 的数量关系(简单自测,不影响运行)
(function testExamStats() {
const total = EXAM_CLIENTS.length;
if (EXAM_STATS[0][1] !== total) {
// eslint-disable-next-line no-console
console.warn('EXAM_STATS[0] 预约人数 与 EXAM_CLIENTS.length 不一致');
}
})();
const EXAM_TAGS = ['全部', '上午', '下午', '高客', '普客', '已登记', '未登记', '散客', '团客'] as const;
const BOOKING_DOCTORS = [
{ id: 'zhang', name: '张主任', dept: '内科 · 主任医师', period: '上午', total: 20, remain: 8 },
{ id: 'wang', name: '王教授', dept: '外科 · 主任医师', period: '下午', total: 16, remain: 10 },
];
// ===== 主组件 =====
const App: React.FC = () => {
const [active, setActive] = useState<'home' | 'exam' | 'booking' | 'support'>('home');
const [selectedDay, setSelectedDay] = useState(1);
const [search, setSearch] = useState('');
const [examSelectedId, setExamSelectedId] = useState<string>('A001');
const [examPanelTab, setExamPanelTab] = useState<'detail' | 'sign' | 'addon' | 'print'>('detail');
const [examModalOpen, setExamModalOpen] = useState(false);
const [quickAction, setQuickAction] = useState<'none' | 'meal' | 'vip' | 'delivery' | 'note'>('none');
const [noteText, setNoteText] = useState('');
const [examFilterTag, setExamFilterTag] = useState<(typeof EXAM_TAGS)[number]>('全部');
const [mealDoneIds, setMealDoneIds] = useState<string[]>(
EXAM_CLIENTS.filter((c) => c.status === '用餐').map((c) => c.id),
);
const [bookingDoctor, setBookingDoctor] = useState<(typeof BOOKING_DOCTORS)[number] | null>(null);
const filteredClients = useMemo(() => {
return EXAM_CLIENTS.filter((c) =>
(c.name + c.packageName + c.id).toLowerCase().includes(search.trim().toLowerCase()),
).filter((c) => {
switch (examFilterTag) {
case '上午':
return c.timeSlot === '上午';
case '下午':
return c.timeSlot === '下午';
case '高客':
return c.vipType === '高客';
case '普客':
return c.vipType === '普客';
case '已登记':
return c.signStatus === '已登记';
case '未登记':
return c.signStatus !== '已登记';
case '散客':
return c.customerType === '散客';
case '团客':
return c.customerType === '团客';
default:
return true;
}
});
}, [search, examFilterTag]);
const selectedExamClient: ExamClient =
EXAM_CLIENTS.find((c) => c.id === examSelectedId) || EXAM_CLIENTS[0];
const totalExamCount = EXAM_CLIENTS.length;
const mealCount = mealDoneIds.length;
const notMealCount = totalExamCount - mealCount;
return (
<div className='min-h-screen bg-gray-50 text-gray-900 grid grid-cols-[240px_1fr]'>
{/* 左侧导航 */}
<aside className='bg-white border-r p-4 flex flex-col gap-4'>
<div>
<div className='text-base font-semibold'>圆和医疗 · 体检中心</div>
<div className='text-xs text-gray-500 mt-1'>iPad 首页驾驶舱预览</div>
</div>
<nav className='space-y-1'>
{NAV_ITEMS.map((m) => (
<Button
key={m.key}
onClick={() => setActive(m.key)}
className={cls(
'w-full justify-start',
active === m.key && 'bg-gray-100 border-gray-900 text-gray-900',
)}
>
<m.icon />
<span>{m.label}</span>
</Button>
))}
</nav>
<section className='mt-2 p-3 rounded-2xl border bg-gray-50/50'>
<div className='text-sm text-gray-700 mb-2'>快捷操作</div>
<div className='grid grid-cols-2 gap-2 text-xs'>
<Button className='justify-center py-1.5' onClick={() => setQuickAction('meal')}>
用餐登记
</Button>
<Button className='justify-center py-1.5' onClick={() => setQuickAction('vip')}>
太平VIP认证
</Button>
<Button className='justify-center py-1.5' onClick={() => setQuickAction('delivery')}>
报告寄送
</Button>
<Button className='justify-center py-1.5' onClick={() => setQuickAction('note')}>
备注窗
</Button>
</div>
</section>
<section className='mt-auto p-3 rounded-2xl border bg-gray-50/70 text-xs flex flex-col gap-2'>
<div className='text-sm font-medium flex items-center gap-2'>
<span>💻</span>
<span>客服 / IT 支持</span>
</div>
<div className='text-gray-600'>遇到系统问题可一键联系 IT / 运营支持</div>
</section>
</aside>
{/* 右侧主区域 */}
<div className='flex flex-col min-h-screen'>
{/* 顶部工具条 */}
<header className='flex items-center gap-3 p-4 border-b bg-white'>
<div className='flex-1 flex items-center gap-3'>
<div className='w-[420px] max-w-[60vw]'>
<Input
placeholder='搜索姓名 / 体检号 / 套餐'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className='flex items-center gap-3 text-xs'>
<span className='px-3 py-1 rounded-2xl border bg-gray-50'>操作员 · Admin</span>
</div>
</header>
{/* 页面内容 */}
<main className='p-6 space-y-6 flex-1 overflow-auto'>
{/* 首页 */}
{active === 'home' && (
<div className='space-y-6'>
<Card>
<CardHeader>今日体检统计</CardHeader>
<CardContent>
<div className='grid grid-cols-5 gap-3'>
{HOME_STATS.map(([label, value]) => (
<InfoCard key={label} label={label} value={value} />
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>营收数据统计</CardHeader>
<CardContent>
<div className='grid grid-cols-3 gap-3'>
{REVENUE_STATS.map(([label, value]) => (
<InfoCard key={label} label={label} value={value} />
))}
</div>
</CardContent>
</Card>
<div className='grid grid-cols-2 gap-4'>
<Card>
<CardHeader>B1 服务看板</CardHeader>
<CardContent>
<div className='grid grid-cols-3 gap-3 mb-3'>
<InfoCard label='当前客户总数' value={B1_SUMMARY.totalClients} />
<InfoCard label='待检人数' value={B1_SUMMARY.waiting} />
<InfoCard label='在检人数' value={B1_SUMMARY.inExam} />
</div>
<table className='w-full text-xs'>
<thead>
<tr className='text-gray-500 border-b'>
<th className='py-2 text-left'>科室</th>
<th className='py-2 text-left'>医生</th>
<th className='py-2 text-right'>已检人数</th>
<th className='py-2 text-right'>已检部位数</th>
<th className='py-2 text-right'>总时长</th>
<th className='py-2 text-right'>平均时长</th>
<th className='py-2 text-right'>在检</th>
<th className='py-2 text-right'>待检</th>
</tr>
</thead>
<tbody>
{B1_ROWS.map(([dept, doctor, done, inExam, waiting, avg]) => {
const parts = done * 3; // 简化:每人 3 个部位
const totalTime = done * avg;
return (
<tr key={dept} className='border-b last:border-b-0'>
<td className='py-2'>{dept}</td>
<td className='py-2'>{doctor}</td>
<td className='py-2 text-right'>{done}</td>
<td className='py-2 text-right'>{parts}</td>
<td className='py-2 text-right'>{totalTime}</td>
<td className='py-2 text-right'>{avg}</td>
<td className='py-2 text-right'>{inExam}</td>
<td className='py-2 text-right'>{waiting}</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
<Card>
<CardHeader>北3服务看板</CardHeader>
<CardContent>
<div className='grid grid-cols-3 gap-3 mb-3'>
<InfoCard label='今日家医数' value={NORTH3_SUMMARY.totalDoctor} />
<InfoCard label='分配客户数' value={NORTH3_SUMMARY.totalAssigned} />
<InfoCard label='面诊数' value={NORTH3_SUMMARY.consult} />
</div>
<table className='w-full text-xs'>
<thead>
<tr className='text-gray-500 border-b'>
<th className='py-2 text-left'>家医</th>
<th className='py-2 text-right'>分配客户数</th>
<th className='py-2 text-right'>面诊数</th>
</tr>
</thead>
<tbody>
{NORTH3_ROWS.map(([name, total, consult]) => (
<tr key={name} className='border-b last:border-b-0'>
<td className='py-2'>{name}</td>
<td className='py-2 text-right'>{total}</td>
<td className='py-2 text-right'>{consult}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
</div>
)}
{/* 体检中心 */}
{active === 'exam' && (
<div className='space-y-4'>
<Card>
<CardHeader>今日体检进度</CardHeader>
<CardContent>
<div className='grid grid-cols-4 gap-3'>
{EXAM_STATS.map(([label, value]) => (
<InfoCard key={label} label={label} value={value} />
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<span>体检客户列表</span>
<div className='flex items-center gap-2 text-xs'>
{EXAM_TAGS.map((tag) => (
<button
key={tag}
onClick={() => setExamFilterTag(tag)}
className={cls(
'px-3 py-1 rounded-2xl border',
examFilterTag === tag
? 'bg-gray-900 text-white border-gray-900'
: 'bg-white text-gray-700',
)}
>
{tag}
</button>
))}
</div>
</CardHeader>
<CardContent>
<div className='grid grid-cols-3 gap-3 text-sm'>
{filteredClients.map((c) => {
const signDone = c.signStatus === '已登记' || c.checkedItems.includes('签到');
const addonCount = c.addonCount || 0;
const printDone = !!c.guidePrinted;
return (
<button
key={c.id}
onClick={() => {
setExamSelectedId(c.id);
setExamPanelTab('detail');
setExamModalOpen(true);
}}
className={cls(
'text-left p-3 rounded-2xl border bg-white hover:bg-gray-50 flex flex-col gap-1',
selectedExamClient.id === c.id && 'border-gray-900 bg-gray-50',
)}
>
<div className='flex items-center justify-between mb-1'>
<span className='font-medium'>{c.name}</span>
<Badge>{c.level}</Badge>
</div>
<div className='text-xs text-gray-500 truncate'>套餐{c.packageName}</div>
<div className='flex items-center justify-between text-xs text-gray-500 mt-1'>
<span>状态{c.status}</span>
<span>已耗时{c.elapsed}</span>
</div>
<div className='mt-2 flex flex-wrap gap-1 text-[11px]'>
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
e.stopPropagation();
setExamSelectedId(c.id);
setExamPanelTab('detail');
setExamModalOpen(true);
}}
>
<span>详情</span>
</button>
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
e.stopPropagation();
setExamSelectedId(c.id);
setExamPanelTab('sign');
setExamModalOpen(true);
}}
>
<span>签到</span>
{signDone && <span></span>}
</button>
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
e.stopPropagation();
setExamSelectedId(c.id);
setExamPanelTab('addon');
setExamModalOpen(true);
}}
>
<span>加项</span>
{addonCount > 0 && (
<span className='opacity-80'>({addonCount})</span>
)}
</button>
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
e.stopPropagation();
setExamSelectedId(c.id);
setExamPanelTab('print');
setExamModalOpen(true);
}}
>
<span>打印导检单</span>
{printDone && <span></span>}
</button>
</div>
</button>
);
})}
</div>
</CardContent>
</Card>
{examModalOpen && (
<ExamModal
client={selectedExamClient}
tab={examPanelTab}
onTabChange={setExamPanelTab}
onClose={() => setExamModalOpen(false)}
/>
)}
</div>
)}
{/* 预约中心 */}
{active === 'booking' && (
<div className='grid grid-cols-12 gap-6'>
<div className='col-span-4 space-y-4'>
<Card>
<CardHeader>预约日历</CardHeader>
<CardContent>
<div className='grid grid-cols-6 gap-2 text-sm'>
{Array.from({ length: 30 }, (_, i) => i + 1).map((day) => (
<button
key={day}
onClick={() => setSelectedDay(day)}
className={cls(
'h-9 rounded-2xl border flex items-center justify-center',
selectedDay === day
? 'bg-gray-900 text-white border-gray-900'
: 'bg-white hover:bg-gray-50',
)}
>
{day}
</button>
))}
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className='flex items-center justify-between text-sm'>
<span>当日预约数</span>
<span className='text-lg font-semibold'>{BOOKING_DOCTORS.length}</span>
</div>
</CardContent>
</Card>
</div>
<div className='col-span-8 space-y-4'>
<Card>
<CardHeader>
<span>预约医生 · {selectedDay} </span>
<div className='flex items-center gap-2 text-xs'>
<span className='text-gray-500'>按科室筛选</span>
<select className='border rounded-2xl px-3 py-1 bg-white outline-none text-xs'>
<option>全部科室</option>
<option>内科</option>
<option>外科</option>
</select>
</div>
</CardHeader>
</Card>
<div className='grid grid-cols-2 gap-4'>
{BOOKING_DOCTORS.map((d) => {
const ratio = d.total ? (d.total - d.remain) / d.total : 0;
const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100);
return (
<button
key={d.id}
onClick={() => setBookingDoctor(d)}
className='text-left w-full'
>
<Card className='bg-gray-50/40'>
<CardContent>
<div className='flex items-start justify-between mb-3'>
<div>
<div className='font-semibold mb-1'>{d.name}</div>
<div className='text-xs text-gray-500'>{d.dept}</div>
</div>
<span className='px-3 py-1 rounded-2xl border text-xs text-gray-600 bg-white'>
{d.period}
</span>
</div>
<div className='text-xs text-gray-600 space-y-1 mb-2'>
<div>当日号源{d.total} </div>
<div>
剩余预约号 <span className='font-semibold text-gray-900'>{d.remain}</span>
</div>
</div>
<div className='h-2 rounded-full bg-gray-200 overflow-hidden'>
<div
className='h-full bg-gray-900'
style={{ width: percent + '%' }}
/>
</div>
</CardContent>
</Card>
</button>
);
})}
</div>
</div>
{bookingDoctor && (
<BookingModal doctor={bookingDoctor} onClose={() => setBookingDoctor(null)} />
)}
</div>
)}
{/* 客服咨询 */}
{active === 'support' && (
<Card>
<CardHeader>客服咨询 · 圆圆客服台卡</CardHeader>
<CardContent>
<div className='grid grid-cols-[1.2fr_1fr] gap-6 items-center'>
<div className='space-y-3 text-sm text-gray-700'>
<p>通过圆圆客服二维码客户可获得一站式健康服务包含体检预约报告查询报告解读等</p>
<ul className='list-disc ml-5 space-y-1 text-xs text-gray-600'>
<li>支持体检当天现场扫码添加绑定客户信息</li>
<li>扫码后可在线查看体检进度报告结果</li>
<li>提供一对一健康咨询与报告解读服务</li>
</ul>
<div className='text-xs text-gray-500'>实际系统中可上传设计好的圆圆客服二维码台卡图片用于前台展示与打印</div>
</div>
<div className='h-64 rounded-3xl overflow-hidden shadow-inner flex items-center justify-center bg-gradient-to-b from-[#152749] to-[#c73545]'>
<div className='flex flex-col items-center gap-3 text-white'>
<div className='text-[11px] tracking-[0.2em] opacity-80'>CIRCLE HARMONY · 圆和医疗</div>
<div className='text-sm font-medium'>圆圆客服 · 一站式健康服务</div>
<div className='w-28 h-28 rounded-full bg-white flex items-center justify-center'>
<div className='w-20 h-20 rounded-md bg-gray-200 flex items-center justify-center text-[10px] text-gray-500'>
二维码占位
</div>
</div>
<div className='text-sm font-semibold'>一对一专属服务</div>
<div className='px-4 py-1.5 rounded-full border border-white/70 text-[11px] flex gap-2'>
<span>服务预约</span>
<span>/</span>
<span>报告查询</span>
<span>/</span>
<span>报告解读</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* 快捷操作弹层 */}
{quickAction !== 'none' && (
<QuickActionModal
action={quickAction}
noteText={noteText}
onNoteChange={setNoteText}
onClose={() => setQuickAction('none')}
totalExamCount={totalExamCount}
mealCount={mealCount}
notMealCount={notMealCount}
mealDoneIds={mealDoneIds}
onMealDone={(id) =>
setMealDoneIds((prev) => (prev.includes(id) ? prev : prev.concat(id)))
}
/>
)}
</main>
</div>
</div>
);
};
// ===== 体检详情弹窗 =====
interface ExamModalProps {
client: ExamClient;
tab: 'detail' | 'sign' | 'addon' | 'print';
onTabChange: (key: 'detail' | 'sign' | 'addon' | 'print') => void;
onClose: () => void;
}
function ExamModal({ client, tab, onTabChange, onClose }: ExamModalProps) {
const tabs: { key: ExamModalProps['tab']; label: string }[] = [
{ key: 'detail', label: '详情' },
{ key: 'sign', label: '签到' },
{ key: 'addon', label: '加项' },
{ key: 'print', label: '打印导检单' },
];
const signDone = client.signStatus === '已登记' || client.checkedItems.includes('签到');
const addonDone = (client.addonCount || 0) > 0;
const printDone = !!client.guidePrinted;
const tabDone: Record<ExamModalProps['tab'], boolean> = {
detail: false,
sign: signDone,
addon: addonDone,
print: printDone,
};
return (
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/30'>
<div className='w-[720px] max-w-[95vw] bg-white rounded-3xl shadow-xl overflow-hidden text-sm'>
<div className='px-4 py-3 border-b flex items-center justify-between'>
<div className='flex items-center gap-3'>
<span className='font-semibold'>{client.name}</span>
<span className='text-xs text-gray-500'>体检号{client.id}</span>
<Badge>{client.level}</Badge>
</div>
<button className='text-xs text-gray-500' onClick={onClose}>
关闭
</button>
</div>
<div className='px-4 py-2 border-b flex items-center gap-2 text-xs'>
{tabs.map((t) => {
const isActive = tab === t.key;
const isDone = tabDone[t.key];
return (
<button
key={t.key}
onClick={() => onTabChange(t.key)}
className={cls(
'px-3 py-1 rounded-2xl border text-xs',
isActive
? 'bg-gray-900 text-white border-gray-900'
: isDone
? 'bg-gray-100 text-gray-400 border-gray-200'
: 'bg-white text-gray-700',
)}
>
{t.label}
{t.key === 'addon' && (client.addonCount || 0) > 0 && (
<span className='ml-1 text-[10px] opacity-80'>({client.addonCount})</span>
)}
{((t.key === 'sign' && signDone) || (t.key === 'print' && printDone)) && (
<span className='ml-1'></span>
)}
</button>
);
})}
</div>
<div className='px-4 py-4 bg-gray-50/60'>
{tab === 'detail' && <ExamDetailInfo client={client} />}
{tab === 'sign' && <ExamSignPanel />}
{tab === 'addon' && <ExamAddonPanel client={client} />}
{tab === 'print' && <ExamPrintPanel client={client} />}
</div>
</div>
</div>
);
}
function ExamSignPanel() {
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 gap-2 text-xs'>
<Button className='py-1.5 px-3'>上传身份证正面</Button>
<Button className='py-1.5 px-3'>上传身份证反面</Button>
</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>
<div className='flex gap-2 text-xs text-gray-600'>
<Badge>阅读记录</Badge>
<Badge>签名图片</Badge>
</div>
<Button className='py-1.5 px-3'>打开知情同意书并签署</Button>
</div>
</div>
);
}
function ExamAddonPanel({ client }: { client: ExamClient }) {
return (
<div className='grid grid-cols-2 gap-6 text-sm'>
<div>
<div className='font-medium mb-2'>当前套餐项目</div>
<ul className='space-y-1 text-xs text-gray-600'>
{client.checkedItems.concat(client.pendingItems).map((item, idx) => (
<li key={idx} className='flex items-center justify-between'>
<span>{item}</span>
<span className='text-gray-400 text-[11px]'>
{client.checkedItems.includes(item) ? '已检查' : '未检查'}
</span>
</li>
))}
</ul>
</div>
<div>
<div className='font-medium mb-2'>可选加项</div>
<div className='space-y-2 text-xs text-gray-600'>
{['肿瘤标志物筛查', '甲状腺彩超', '骨密度检测'].map((label) => (
<label key={label} className='flex items-center gap-2'>
<input type='checkbox' className='rounded' /> {label}
</label>
))}
</div>
<Button className='mt-3'>确认加项并生成费用</Button>
</div>
</div>
);
}
function ExamDetailInfo({ client }: { client: ExamClient }) {
const [phone, setPhone] = useState(
(client['mobile' as keyof ExamClient] as string | undefined) || '137****9988',
);
const [marital, setMarital] = useState(
(client['maritalStatus' as keyof ExamClient] as string | undefined) || '未婚',
);
const [phoneEditing, setPhoneEditing] = useState(false);
const [maritalEditing, setMaritalEditing] = useState(false);
const customerChannel = client.customerType === '团客' ? '团体客户' : '散客客户';
const familyDoctor = (client['familyDoctor' as keyof ExamClient] as string | undefined) || '—';
const groupTag = client['groupTag' as keyof ExamClient] || (client.customerType === '团客' ? '团检' : '—');
const bookingTime = client['bookingTime' as keyof ExamClient] || '—';
const signTime = client['signTime' as keyof ExamClient] || '—';
const addonSummary = client['addonSummary' as keyof ExamClient] || '—';
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'>基础信息头像姓名证件号手机号等点击图标可进行编辑</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'>{client.name}</span>
</div>
<div>
证件号<span className='text-gray-900'>4401********1234</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'>
{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'>{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'>
<div className='font-medium mb-2'>已查项目 {client.checkedItems.length} </div>
<div className='flex flex-wrap gap-2'>
{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'>
<div className='font-medium mb-2'>弃检项目 0 </div>
<div className='flex flex-wrap gap-2'>
<span className='text-gray-400 text-[11px]'>暂无弃检项目</span>
</div>
</div>
<div className='p-3 rounded-2xl bg-yellow-50 border'>
<div className='font-medium mb-2'>未查项目 {client.pendingItems.length} </div>
<div className='flex flex-wrap gap-2'>
{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'>
<div className='font-medium mb-2'>延期项目 0 </div>
<div className='flex flex-wrap gap-2'>
<span className='text-gray-400 text-[11px]'>暂无延期项目</span>
</div>
</div>
</div>
</div>
);
}
// 导检单预览(纯预览,无状态)
function ExamPrintPanel({ client }: { client: ExamClient }) {
return (
<div className='flex justify-center'>
<div className='w-[520px] 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='text-right text-[11px] text-gray-500'>
<div>体检号{client.id}</div>
<div>日期2025-11-18</div>
</div>
</div>
<div className='grid grid-cols-2 gap-y-1 gap-x-6 mb-3'>
<div>
姓名<span className='font-medium'>{client.name}</span>
</div>
<div>
性别/年龄
<span className='font-medium'>
{client.gender} / {client.age}
</span>
</div>
<div>
体检套餐<span className='font-medium'>{client.packageName}</span>
</div>
<div>
客户类型<span className='font-medium'>{client.customerType}</span>
</div>
</div>
<div className='mb-2 font-medium'>检查项目列表预览</div>
<table className='w-full border text-[11px] mb-3'>
<thead>
<tr className='bg-gray-50'>
<th className='border px-2 py-1 text-left'>序号</th>
<th className='border px-2 py-1 text-left'>检查项目</th>
<th className='border px-2 py-1 text-left'>科室</th>
<th className='border px-2 py-1 text-left'>备注</th>
</tr>
</thead>
<tbody>
{client.checkedItems.map((item, idx) => (
<tr key={`c-${idx}`}>
<td className='border px-2 py-1'>{idx + 1}</td>
<td className='border px-2 py-1'>{item}</td>
<td className='border px-2 py-1'></td>
<td className='border px-2 py-1'>已预约</td>
</tr>
))}
{client.pendingItems.map((item, idx) => (
<tr key={`p-${idx}`}>
<td className='border px-2 py-1'>{client.checkedItems.length + idx + 1}</td>
<td className='border px-2 py-1'>{item}</td>
<td className='border px-2 py-1'></td>
<td className='border px-2 py-1'>待检查</td>
</tr>
))}
</tbody>
</table>
<div className='grid grid-cols-2 gap-4 text-[11px] text-gray-600'>
<div>
<div className='mb-1 font-medium text-gray-800'>导检提示</div>
<ul className='list-disc ml-4 space-y-0.5'>
<li>请按导检单顺序前往相应科室检查</li>
<li>如有不适或特殊情况请及时告知导检护士</li>
<li>部分检查项目需空腹或憋尿请遵从现场指引</li>
</ul>
</div>
<div className='flex flex-col items-end justify-between'>
<div className='text-right'>
<div>导检护士签名________________</div>
<div className='mt-2'>打印时间2025-11-18 09:30</div>
</div>
<div className='mt-4 w-24 h-24 border border-dashed flex items-center justify-center text-[10px] text-gray-400'>
条码 / 二维码
</div>
</div>
</div>
</div>
</div>
);
}
// 快捷操作弹层
interface QuickActionModalProps {
action: 'meal' | 'vip' | 'delivery' | 'note';
noteText: string;
onNoteChange: (v: string) => void;
onClose: () => void;
totalExamCount: number;
mealCount: number;
notMealCount: number;
mealDoneIds: string[];
onMealDone: (id: string) => void;
}
function QuickActionModal({
action,
noteText,
onNoteChange,
onClose,
totalExamCount,
mealCount,
notMealCount,
mealDoneIds,
onMealDone,
}: QuickActionModalProps) {
const titleMap: Record<QuickActionModalProps['action'], string> = {
meal: '用餐登记',
vip: '太平 VIP 认证说明',
delivery: '报告寄送登记',
note: '备注窗',
};
return (
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/30'>
<div className='w-[560px] max-w-[95vw] bg-white rounded-3xl shadow-xl overflow-hidden text-sm'>
<div className='px-4 py-3 border-b flex items-center justify-between'>
<div className='font-semibold'>{titleMap[action]}</div>
<button className='text-xs text-gray-500' onClick={onClose}>
关闭
</button>
</div>
<div className='px-4 py-4 bg-gray-50/60'>
{action === 'meal' && (
<div className='space-y-3'>
<div className='grid grid-cols-3 gap-3 text-xs'>
<InfoCard label='今日体检人数' value={totalExamCount} />
<InfoCard label='已用餐人数' value={mealCount} />
<InfoCard label='未用餐人数' value={notMealCount} />
</div>
<div className='text-xs text-gray-600 mt-2 mb-1'>
选择已用餐客户进行登记
</div>
<div className='max-h-60 overflow-auto border rounded-2xl bg-white p-2 text-xs'>
{EXAM_CLIENTS.map((c) => {
const checked = mealDoneIds.includes(c.id);
return (
<label
key={c.id}
className='flex items-center justify-between px-3 py-1.5 rounded-2xl hover:bg-gray-50 cursor-pointer'
>
<span>
{c.name} <span className='text-gray-400 text-[11px]'>({c.id})</span>
</span>
<span className='flex items-center gap-2'>
<span className='text-gray-400'>{c.status}</span>
<input
type='checkbox'
checked={checked}
onChange={() => onMealDone(c.id)}
/>
</span>
</label>
);
})}
</div>
</div>
)}
{action === 'vip' && (
<div className='flex gap-4 items-center'>
<div className='flex-1 text-xs text-gray-700 space-y-2'>
<p>通过太平 VIP 认证二维码可完成太平渠道客户的身份绑定与权益确认</p>
<ul className='list-disc ml-5 space-y-1'>
<li>客户出示太平 APP 内会员二维码由工作人员扫码完成认证</li>
<li>认证成功后系统会自动标记为太平 VIP 客户并记录在体检档案中</li>
<li>支持后续报告寄送复查预约等专属服务</li>
</ul>
</div>
<div className='w-40 h-40 rounded-3xl bg-white border flex items-center justify-center text-xs text-gray-500'>
太平认证二维码占位
</div>
</div>
)}
{action === 'delivery' && (
<div className='space-y-3 text-xs text-gray-700'>
<div className='grid grid-cols-2 gap-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>
备注说明
<textarea
className='w-full mt-1 rounded-2xl border px-3 py-2 text-xs outline-none focus:ring-2 focus:ring-gray-200 min-h-[72px]'
placeholder='如需多份报告、加急寄送等,请在此备注'
/>
</div>
<div className='text-right'>
<Button>保存寄送信息</Button>
</div>
</div>
)}
{action === 'note' && (
<div className='space-y-3 text-xs text-gray-700'>
<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-[96px]'
placeholder='例如:客户有既往疾病史、沟通偏好、特殊关怀需求等,可在此记录。'
value={noteText}
onChange={(e) => onNoteChange(e.target.value)}
/>
<div className='text-right text-[11px] text-gray-500'>
备注内容会同步至客户详情页供前台和导检护士查看
</div>
</div>
)}
</div>
</div>
</div>
);
}
// 预约申请弹层
interface BookingModalProps {
doctor: (typeof BOOKING_DOCTORS)[number];
onClose: () => void;
}
function BookingModal({ doctor, onClose }: BookingModalProps) {
return (
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/30'>
<div className='w-[520px] max-w-[95vw] bg-white rounded-3xl shadow-xl overflow-hidden text-sm'>
<div className='px-4 py-3 border-b flex items-center justify-between'>
<div className='font-semibold'>预约申请 · {doctor.name}</div>
<button className='text-xs text-gray-500' onClick={onClose}>
关闭
</button>
</div>
<div className='px-4 py-4 bg-gray-50/60 space-y-3 text-xs text-gray-700'>
<div className='grid grid-cols-2 gap-3'>
<div>
付费方式
<select className='mt-1 w-full rounded-2xl border px-3 py-1.5 bg-white outline-none text-xs'>
<option>自费</option>
<option>单位结算</option>
</select>
</div>
<div>
产品名称
<Input placeholder='如:专家门诊咨询' className='mt-1' />
</div>
<div>
是否定制
<select className='mt-1 w-full rounded-2xl border px-3 py-1.5 bg-white outline-none text-xs'>
<option></option>
<option></option>
</select>
</div>
<div>
期望预约时间
<Input placeholder='例如2025-11-20 上午' className='mt-1' />
</div>
</div>
<div>
备注
<textarea
className='w-full mt-1 rounded-2xl border px-3 py-2 text-xs outline-none focus:ring-2 focus:ring-gray-200 min-h-[72px]'
placeholder='可填写病情简要、既往史、特殊需求等信息'
/>
</div>
<div className='flex items-center justify-between text-[11px] text-gray-500'>
<span>
医生{doctor.name}{doctor.dept}
</span>
<Button>提交预约申请</Button>
</div>
</div>
</div>
</div>
);
}
export default App;