536 lines
21 KiB
TypeScript
536 lines
21 KiB
TypeScript
import { useEffect, useMemo, useState, useRef } from 'react';
|
||
|
||
import type { ExamClient } from '../../data/mockData';
|
||
import { searchPhysicalExamAddItem, getAddItemCustomerInfo } 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);
|
||
|
||
// 点击外部关闭下拉框
|
||
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]);
|
||
|
||
// 防抖:当输入值变化时,延迟 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: Array<{ value: string; label: string }> = [
|
||
{ value: '圆和', label: '圆和' },
|
||
];
|
||
|
||
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'>
|
||
{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>
|
||
);
|
||
};
|
||
|
||
|