1353 lines
54 KiB
JavaScript
1353 lines
54 KiB
JavaScript
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;
|