Files
ipad/src/components/exam/ExamAddonPanel.tsx
2026-01-04 10:11:16 +08:00

585 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { useEffect, useMemo, useState, useRef } from 'react';
import type { ExamClient } from '../../data/mockData';
import { searchPhysicalExamAddItem, getAddItemCustomerInfo, getChannelCompanyList } from '../../api';
import { Button, Input } from '../ui';
import { cls } from '../../utils/cls';
import nozImage from '../../assets/image/noz.png';
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;
}
interface ExamAddonPanelProps {
client: ExamClient;
}
export const ExamAddonPanel = ({ client }: ExamAddonPanelProps) => {
const [addonList, setAddonList] = useState<AddonItem[]>([]);
// 防抖:内部输入值(用于显示)
const [addonSearchInput, setAddonSearchInput] = useState('');
// 防抖:实际用于 API 调用的值(延迟更新)
const [debouncedAddonSearch, setDebouncedAddonSearch] = useState('');
const debounceTimerRef = useRef<number | null>(null);
const [addonLoading, setAddonLoading] = useState(false);
const [addonError, setAddonError] = useState<string | null>(null);
// 折扣比例1 = 100%
const [discountRatio, setDiscountRatio] = useState<number>(1);
// 渠道折扣列表
const [channelDiscounts, setChannelDiscounts] = useState<
{ channel_id?: string | null; channel_name?: string | null; discount_rate?: number | null; discount_name?: string | null }[]
>([]);
// 下拉框状态
const [isDiscountDropdownOpen, setIsDiscountDropdownOpen] = useState(false);
const discountDropdownRef = useRef<HTMLDivElement>(null);
// 结算方式:'self' 自费, 'account' 挂账
const [paymentMethod, setPaymentMethod] = useState<'self' | 'account'>('self');
const [isPaymentMethodDropdownOpen, setIsPaymentMethodDropdownOpen] = useState(false);
const paymentMethodDropdownRef = useRef<HTMLDivElement>(null);
// 挂账公司
const [accountCompany, setAccountCompany] = useState<string>('圆和');
const [isAccountCompanyDropdownOpen, setIsAccountCompanyDropdownOpen] = useState(false);
const accountCompanyDropdownRef = useRef<HTMLDivElement>(null);
// 挂账公司列表
const [accountCompanyList, setAccountCompanyList] = useState<
Array<{ company_id: number; company_name?: string | null; pinyin?: string | null }>
>([]);
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (discountDropdownRef.current && !discountDropdownRef.current.contains(event.target as Node)) {
setIsDiscountDropdownOpen(false);
}
if (paymentMethodDropdownRef.current && !paymentMethodDropdownRef.current.contains(event.target as Node)) {
setIsPaymentMethodDropdownOpen(false);
}
if (accountCompanyDropdownRef.current && !accountCompanyDropdownRef.current.contains(event.target as Node)) {
setIsAccountCompanyDropdownOpen(false);
}
};
if (isDiscountDropdownOpen || isPaymentMethodDropdownOpen || isAccountCompanyDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isDiscountDropdownOpen, isPaymentMethodDropdownOpen, isAccountCompanyDropdownOpen]);
// 获取体检加项客户信息(用于拿到渠道折扣等信息)
useEffect(() => {
const physical_exam_id = Number(client.id);
if (!physical_exam_id) return;
const fetchCustomerInfo = async () => {
try {
const res = await getAddItemCustomerInfo({ physical_exam_id });
if (res.Status === 200 && res.Data?.listChannelDiscount && res.Data.listChannelDiscount.length > 0) {
setChannelDiscounts(res.Data.listChannelDiscount);
const rate = res.Data.listChannelDiscount[0]?.discount_rate;
if (typeof rate === 'number' && rate > 0) {
setDiscountRatio(rate);
}
} else {
setChannelDiscounts([]);
}
} catch (err) {
console.error('获取加项客户信息失败', err);
}
};
fetchCustomerInfo();
}, [client.id]);
// 获取挂账公司列表
useEffect(() => {
const fetchCompanyList = async () => {
try {
const res = await getChannelCompanyList({});
if (res.Status === 200 && Array.isArray(res.Data) && res.Data.length > 0) {
setAccountCompanyList(res.Data);
// 设置默认值为第一个公司
const firstCompany = res.Data[0];
if (firstCompany?.company_name) {
setAccountCompany(firstCompany.company_name);
}
} else {
setAccountCompanyList([]);
}
} catch (err) {
console.error('获取挂账公司列表失败', err);
setAccountCompanyList([]);
}
};
fetchCompanyList();
}, []);
// 当挂账公司列表更新时,确保当前选中的公司在列表中
useEffect(() => {
if (accountCompanyList.length > 0) {
const companyNames = accountCompanyList.map((c) => c.company_name).filter(Boolean) as string[];
if (!companyNames.includes(accountCompany)) {
// 如果当前选中的公司不在列表中,更新为第一个公司
const firstCompany = accountCompanyList[0];
if (firstCompany?.company_name) {
setAccountCompany(firstCompany.company_name);
}
}
}
}, [accountCompanyList, accountCompany]);
// 防抖:当输入值变化时,延迟 0.5 秒后更新 debouncedAddonSearch用于 API 调用)
useEffect(() => {
if (debounceTimerRef.current) {
window.clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = window.setTimeout(() => {
setDebouncedAddonSearch(addonSearchInput);
}, 500);
return () => {
if (debounceTimerRef.current) {
window.clearTimeout(debounceTimerRef.current);
}
};
}, [addonSearchInput]);
// 拉取加项列表
useEffect(() => {
const fetchList = async () => {
setAddonLoading(true);
setAddonError(null);
try {
const res = await searchPhysicalExamAddItem({
discount_ratio: discountRatio || 1,
item_name: debouncedAddonSearch.trim() || null,
});
if (res.Status === 200 && Array.isArray(res.Data)) {
const list: AddonItem[] = res.Data.map((item) => ({
id: item.item_id ? String(item.item_id) : `addon_${item.item_name}`,
name: item.item_name || '',
originalPrice:
item.original_price !== undefined && item.original_price !== null
? Number(item.original_price).toFixed(2)
: '0.00',
currentPrice:
item.actual_received_amount !== undefined && item.actual_received_amount !== null
? Number(item.actual_received_amount).toFixed(2)
: item.original_price !== undefined && item.original_price !== null
? Number(item.original_price).toFixed(2)
: '0.00',
tags: [],
paid: false,
}));
setAddonList(list);
} else {
setAddonError(res.Message || '获取加项列表失败');
setAddonList([]);
}
} catch (err) {
console.error('获取加项列表失败', err);
setAddonError('获取加项列表失败,请稍后重试');
setAddonList([]);
} finally {
setAddonLoading(false);
}
};
fetchList();
}, [debouncedAddonSearch, discountRatio]);
const allAddons = useMemo(() => addonList, [addonList]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const maxSelect = 15;
const selectedCount = selectedIds.size;
const filteredAddons = 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 '渠道价';
};
// 构建折扣选项列表
const discountOptions = useMemo(() => {
const options: Array<{ value: number; label: string }> = [
{ value: 1, label: '100%(无折扣)' },
];
channelDiscounts.forEach((item) => {
const rate = typeof item.discount_rate === 'number' && item.discount_rate > 0 ? item.discount_rate : 1;
const percent = Math.round(rate * 100);
const label = item.discount_name || `${percent}%`;
options.push({ value: rate, label });
});
return options;
}, [channelDiscounts]);
// 获取当前选中的标签
const currentDiscountLabel = useMemo(() => {
const option = discountOptions.find(opt => opt.value === discountRatio);
return option?.label || '100%(无折扣)';
}, [discountRatio, discountOptions]);
// 处理折扣选择
const handleDiscountSelect = (value: number) => {
setDiscountRatio(value);
setIsDiscountDropdownOpen(false);
};
// 处理结算方式选择
const handlePaymentMethodSelect = (value: 'self' | 'account') => {
setPaymentMethod(value);
setIsPaymentMethodDropdownOpen(false);
};
// 处理挂账公司选择
const handleAccountCompanySelect = (value: string) => {
setAccountCompany(value);
setIsAccountCompanyDropdownOpen(false);
};
// 结算方式选项
const paymentMethodOptions: Array<{ value: 'self' | 'account'; label: string }> = [
{ value: 'self', label: '自费' },
{ value: 'account', label: '挂账' },
];
// 挂账公司选项(从接口获取)
const accountCompanyOptions = useMemo(() => {
if (accountCompanyList.length === 0) {
// 如果没有数据,返回默认的"圆和"
return [{ value: '圆和', label: '圆和' }];
}
return accountCompanyList.map((company) => ({
value: company.company_name || `公司${company.company_id}`,
label: company.company_name || `公司${company.company_id}`,
}));
}, [accountCompanyList]);
return (
<div className='space-y-4'>
{/* 标题和说明 */}
<div>
<div className='flex items-center justify-between mb-3 gap-4'>
<div className='flex items-center gap-2'>
<h3 className='text-lg font-semibold text-gray-900 whitespace-nowrap'></h3>
<div className='flex items-center gap-2 text-xs text-gray-600'>
{/* <span className='whitespace-nowrap'>折扣比例</span> */}
<div ref={discountDropdownRef} className='relative'>
<button
type='button'
onClick={() => setIsDiscountDropdownOpen(!isDiscountDropdownOpen)}
className={cls(
'border-gray-300 rounded-lg px-3 py-1.5 text-xs text-gray-700 bg-white',
'focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500',
'min-w-[140px] flex items-center justify-between gap-2',
'hover:border-gray-400 transition-colors'
)}
>
<span>{currentDiscountLabel}</span>
<span className={cls('text-gray-400 transition-transform text-[10px]', isDiscountDropdownOpen && 'rotate-180')}>
</span>
</button>
{isDiscountDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg overflow-hidden'>
{discountOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => handleDiscountSelect(option.value)}
className={cls(
'w-full px-3 py-2 text-xs text-left transition-colors',
'hover:bg-gray-50',
discountRatio === option.value && 'bg-blue-50 text-blue-700 font-medium',
discountRatio !== option.value && 'text-gray-700'
)}
>
{option.label}
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className='w-[260px] flex items-center gap-2'>
<Input
placeholder='搜索 加项名称'
value={addonSearchInput}
onChange={(e) => setAddonSearchInput(e.target.value)}
className='text-sm flex-1'
/>
{addonSearchInput && (
<Button
className='px-3 py-1.5 text-xs whitespace-nowrap'
onClick={() => setAddonSearchInput('')}
>
</Button>
)}
</div>
</div>
{/* <div className='text-xs text-gray-600 space-y-1'>
<div>最多可选 {maxSelect} 项 · 一排 5 个</div>
<div>已勾选 {selectedCount} 项,自费加项费用按渠道折扣价结算。</div>
</div> */}
</div>
{/* 加项网格 */}
<div className='overflow-y-auto overflow-x-hidden max-h-[366px]'>
<div className='grid grid-cols-5 gap-3 min-h-[142px]'>
{addonError && (
<div className='col-span-5 text-xs text-amber-600'>{addonError}</div>
)}
{addonLoading && (
<div className='col-span-5 text-xs text-gray-500'>...</div>
)}
{!addonLoading && !addonError && filteredAddons.length === 0 && (
<div className='col-span-5 text-xs text-gray-500'></div>
)}
{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 flex flex-col relative'
onClick={() => toggleSelect(id)}
>
{/* 无折扣标签图片 - 浮动在右上角 */}
<img
src={nozImage}
alt='无折扣'
className='absolute top-[-10px] right-[-10px] w-12 h-12 object-contain pointer-events-none z-10'
/>
{/* 第一行:复选框 + 名称 */}
<div className='flex items-start gap-2 mb-1'>
<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'>{item.name}</div>
</div>
</div>
{/* 第二行:标签 */}
{item.tags && item.tags.length >= 0 && (
<div className='flex flex-wrap gap-1 mb-1 pl-6'>
{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 className='mt-auto pt-1'>
<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>
{/* 底部汇总和支付 */}
<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>
<div className='flex items-center gap-3'>
{/* 结算方式 */}
<div className='flex items-center gap-2 text-xs text-gray-600'>
<span className='whitespace-nowrap'></span>
<div ref={paymentMethodDropdownRef} className='relative'>
<button
type='button'
onClick={() => setIsPaymentMethodDropdownOpen(!isPaymentMethodDropdownOpen)}
className={cls(
'border border-gray-300 rounded-lg px-3 py-1.5 text-xs text-gray-700 bg-white',
'focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500',
'min-w-[100px] flex items-center justify-between gap-2',
'hover:border-gray-400 transition-colors'
)}
>
<span>{paymentMethodOptions.find(opt => opt.value === paymentMethod)?.label || '自费'}</span>
<span className={cls('text-gray-400 transition-transform text-[10px]', isPaymentMethodDropdownOpen && 'rotate-180')}>
</span>
</button>
{isPaymentMethodDropdownOpen && (
<div className='absolute z-50 w-full bottom-full mb-1 bg-white border border-gray-300 rounded-lg shadow-lg overflow-hidden'>
{paymentMethodOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => handlePaymentMethodSelect(option.value)}
className={cls(
'w-full px-3 py-2 text-xs text-left transition-colors',
'hover:bg-gray-50',
paymentMethod === option.value && 'bg-blue-50 text-blue-700 font-medium',
paymentMethod !== option.value && 'text-gray-700'
)}
>
{option.label}
</button>
))}
</div>
)}
</div>
</div>
{/* 挂账公司 */}
{paymentMethod === 'account' && (
<div className='flex items-center gap-2 text-xs text-gray-600'>
<span className='whitespace-nowrap'></span>
<div ref={accountCompanyDropdownRef} className='relative'>
<button
type='button'
onClick={() => setIsAccountCompanyDropdownOpen(!isAccountCompanyDropdownOpen)}
className={cls(
'border border-gray-300 rounded-lg px-3 py-1.5 text-xs text-gray-700 bg-white',
'focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500',
'min-w-[100px] flex items-center justify-between gap-2',
'hover:border-gray-400 transition-colors'
)}
>
<span>{accountCompany}</span>
<span className={cls('text-gray-400 transition-transform text-[10px]', isAccountCompanyDropdownOpen && 'rotate-180')}>
</span>
</button>
{isAccountCompanyDropdownOpen && (
<div className='absolute z-50 w-full bottom-full mb-1 bg-white border border-gray-300 rounded-lg shadow-lg overflow-hidden max-h-[500px] overflow-y-auto'>
{accountCompanyOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => handleAccountCompanySelect(option.value)}
className={cls(
'w-full px-3 py-2 text-xs text-left transition-colors',
'hover:bg-gray-50',
accountCompany === option.value && 'bg-blue-50 text-blue-700 font-medium',
accountCompany !== option.value && 'text-gray-700'
)}
>
{option.label}
</button>
))}
</div>
)}
</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>
</div>
);
};