- 添加了客户跟进流程的第2至第7步页面路由配置 - 实现了完整的七步跟进流程,包括加微信、建群沟通、合同定稿等环节 - 添加了各步骤的状态管理、审批机制和进度跟踪功能 - 集成了录音功能,支持现场录音和从聊天记录选择音频 - 优化了图片上传组件,使用Image组件替代背景图方式显示 - 添加了步骤间的导航控制和前置条件检查逻辑 - 实现了审核接口和批量审批功能 - 增加了跟进统计和流程结束功能 - 完善了用户界面,提供更清晰的流程指引和状态展示
460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
import { useCallback, useMemo, useState } from 'react'
|
|
import Taro, { useDidShow, useRouter } from '@tarojs/taro'
|
|
import { Text, View } from '@tarojs/components'
|
|
import { Button, ConfigProvider, Empty, Loading } from '@nutui/nutui-react-taro'
|
|
import dayjs from 'dayjs'
|
|
|
|
import { getCreditMpCustomer, updateCreditMpCustomer } from '@/api/credit/creditMpCustomer'
|
|
import type { CreditMpCustomer } from '@/api/credit/creditMpCustomer/model'
|
|
|
|
const safeParseJSON = <T,>(v: any): T | null => {
|
|
try {
|
|
if (!v) return null
|
|
if (typeof v === 'object') return v as T
|
|
if (typeof v === 'string') return JSON.parse(v) as T
|
|
return null
|
|
} catch (_e) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
type PaymentRecord = {
|
|
id: string
|
|
amount: number
|
|
principal: number
|
|
interest: number
|
|
paymentTime: string
|
|
status: 'pending' | 'completed'
|
|
}
|
|
|
|
type ExpectedPayment = {
|
|
id: string
|
|
amount: number
|
|
expectedDate: string
|
|
createdAt: string
|
|
}
|
|
|
|
type PaymentStats = {
|
|
totalAmount: number
|
|
totalPrincipal: number
|
|
totalInterest: number
|
|
paidPrincipal: number
|
|
paidInterest: number
|
|
unpaidPrincipal: number
|
|
unpaidInterest: number
|
|
}
|
|
|
|
const makeId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
|
|
const parsePaymentRecords = (raw?: string): PaymentRecord[] => {
|
|
const parsed = safeParseJSON<any[]>(raw)
|
|
if (!Array.isArray(parsed)) return []
|
|
return parsed.map(item => ({
|
|
id: makeId(),
|
|
amount: Number(item?.amount || 0),
|
|
principal: Number(item?.principal || 0),
|
|
interest: Number(item?.interest || 0),
|
|
paymentTime: String(item?.paymentTime || '').trim(),
|
|
status: item?.status || 'completed'
|
|
}))
|
|
}
|
|
|
|
const parseExpectedPayments = (raw?: string): ExpectedPayment[] => {
|
|
const parsed = safeParseJSON<any[]>(raw)
|
|
if (!Array.isArray(parsed)) return []
|
|
return parsed.map(item => ({
|
|
id: makeId(),
|
|
amount: Number(item?.amount || 0),
|
|
expectedDate: String(item?.expectedDate || '').trim(),
|
|
createdAt: String(item?.createdAt || dayjs().format('YYYY-MM-DD HH:mm:ss'))
|
|
}))
|
|
}
|
|
|
|
export default function CreditMpCustomerFollowStep6Page() {
|
|
const router = useRouter()
|
|
const customerId = useMemo(() => {
|
|
const id = Number((router?.params as any)?.id)
|
|
return Number.isFinite(id) && id > 0 ? id : undefined
|
|
}, [router?.params])
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [row, setRow] = useState<CreditMpCustomer | null>(null)
|
|
|
|
const [paymentRecords, setPaymentRecords] = useState<PaymentRecord[]>([])
|
|
const [expectedPayments, setExpectedPayments] = useState<ExpectedPayment[]>([])
|
|
const [paymentStats, setPaymentStats] = useState<PaymentStats>({
|
|
totalAmount: 0,
|
|
totalPrincipal: 0,
|
|
totalInterest: 0,
|
|
paidPrincipal: 0,
|
|
paidInterest: 0,
|
|
unpaidPrincipal: 0,
|
|
unpaidInterest: 0
|
|
})
|
|
|
|
const reload = useCallback(async () => {
|
|
setError(null)
|
|
setLoading(true)
|
|
try {
|
|
if (!customerId) throw new Error('缺少客户ID')
|
|
const res = await getCreditMpCustomer(customerId)
|
|
const next = (res || null) as CreditMpCustomer | null
|
|
setRow(next)
|
|
|
|
const anyRow = next as any
|
|
const submitted = Boolean(anyRow?.followStep5Submitted) || Boolean(anyRow?.followStep5SubmittedAt)
|
|
|
|
// 解析财务录入的回款记录
|
|
const records = parsePaymentRecords(anyRow?.followStep6PaymentRecords)
|
|
setPaymentRecords(records)
|
|
|
|
// 解析预计回款
|
|
const expected = parseExpectedPayments(anyRow?.followStep6ExpectedPayments)
|
|
setExpectedPayments(expected)
|
|
|
|
// 计算统计信息
|
|
const stats = calculatePaymentStats(records, anyRow)
|
|
setPaymentStats(stats)
|
|
|
|
// 如果第五步未完成,显示提示
|
|
if (!submitted) {
|
|
setError('请先完成第五步合同签订')
|
|
}
|
|
} catch (e) {
|
|
console.error('加载失败:', e)
|
|
setRow(null)
|
|
setError(String((e as any)?.message || '加载失败'))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [customerId])
|
|
|
|
const calculatePaymentStats = useCallback((records: PaymentRecord[], rowData: any): PaymentStats => {
|
|
const contracts = safeParseJSON<any[]>(rowData?.followStep5Contracts) || []
|
|
|
|
// 计算应回款总额(从合同中获取)
|
|
let totalPrincipal = 0
|
|
let totalInterest = 0
|
|
|
|
contracts.forEach((contract: any) => {
|
|
totalPrincipal += Number(contract?.principal || 0)
|
|
totalInterest += Number(contract?.interest || 0)
|
|
})
|
|
|
|
const totalAmount = totalPrincipal + totalInterest
|
|
|
|
// 计算已回款金额
|
|
let paidPrincipal = 0
|
|
let paidInterest = 0
|
|
|
|
records.forEach(record => {
|
|
if (record.status === 'completed') {
|
|
paidPrincipal += record.principal
|
|
paidInterest += record.interest
|
|
}
|
|
})
|
|
|
|
const unpaidPrincipal = totalPrincipal - paidPrincipal
|
|
const unpaidInterest = totalInterest - paidInterest
|
|
|
|
return {
|
|
totalAmount,
|
|
totalPrincipal,
|
|
totalInterest,
|
|
paidPrincipal,
|
|
paidInterest,
|
|
unpaidPrincipal,
|
|
unpaidInterest
|
|
}
|
|
}, [])
|
|
|
|
useDidShow(() => {
|
|
reload().then()
|
|
})
|
|
|
|
const step5Done = useMemo(() => {
|
|
const anyRow = row as any
|
|
return Boolean(anyRow?.followStep5Submitted) || Boolean(anyRow?.followStep5SubmittedAt)
|
|
}, [row])
|
|
|
|
const addExpectedPayment = useCallback(async () => {
|
|
if (!step5Done) {
|
|
Taro.showToast({ title: '请先完成第五步合同签订', icon: 'none' })
|
|
return
|
|
}
|
|
|
|
try {
|
|
// 简化实现,直接使用当前日期和固定金额作为示例
|
|
// 在实际项目中,您可能需要使用自定义输入组件
|
|
const amount = 1000 // 默认金额
|
|
const date = dayjs().add(7, 'day').format('YYYY-MM-DD') // 默认7天后
|
|
|
|
const newExpected: ExpectedPayment = {
|
|
id: makeId(),
|
|
amount,
|
|
expectedDate: date,
|
|
createdAt: dayjs().format('YYYY-MM-DD HH:mm:ss')
|
|
}
|
|
|
|
setExpectedPayments(prev => [...prev, newExpected])
|
|
|
|
// 保存到后端
|
|
if (row && customerId) {
|
|
const payload = [...expectedPayments, newExpected].map(item => ({
|
|
amount: item.amount,
|
|
expectedDate: item.expectedDate,
|
|
createdAt: item.createdAt
|
|
}))
|
|
|
|
await updateCreditMpCustomer({
|
|
...(row as any),
|
|
id: customerId,
|
|
followStep6ExpectedPayments: JSON.stringify(payload)
|
|
} as any)
|
|
}
|
|
|
|
Taro.showToast({ title: '添加成功', icon: 'success' })
|
|
} catch (e) {
|
|
Taro.showToast({ title: (e as any)?.message || '添加失败', icon: 'none' })
|
|
}
|
|
}, [step5Done, expectedPayments, row, customerId])
|
|
|
|
const deleteExpectedPayment = useCallback(async (expectedId: string) => {
|
|
try {
|
|
const res = await Taro.showModal({
|
|
title: '确认删除',
|
|
content: '确定要删除这条预计回款记录吗?'
|
|
})
|
|
|
|
if (!res.confirm) return
|
|
|
|
const updated = expectedPayments.filter(item => item.id !== expectedId)
|
|
setExpectedPayments(updated)
|
|
|
|
// 保存到后端
|
|
if (row && customerId) {
|
|
const payload = updated.map(item => ({
|
|
amount: item.amount,
|
|
expectedDate: item.expectedDate,
|
|
createdAt: item.createdAt
|
|
}))
|
|
|
|
await updateCreditMpCustomer({
|
|
...(row as any),
|
|
id: customerId,
|
|
followStep6ExpectedPayments: JSON.stringify(payload)
|
|
} as any)
|
|
}
|
|
|
|
Taro.showToast({ title: '删除成功', icon: 'success' })
|
|
} catch (e) {
|
|
Taro.showToast({ title: (e as any)?.message || '删除失败', icon: 'none' })
|
|
}
|
|
}, [expectedPayments, row, customerId])
|
|
|
|
const goStep5 = () => {
|
|
if (!customerId) return
|
|
Taro.navigateTo({ url: `/credit/mp-customer/follow-step5?id=${customerId}` }).catch(() => {})
|
|
}
|
|
|
|
const goStep7 = () => {
|
|
if (!customerId) return
|
|
Taro.navigateTo({ url: `/credit/mp-customer/follow-step7?id=${customerId}` }).catch(() => {})
|
|
}
|
|
|
|
const formatCurrency = (amount: number) => {
|
|
return `¥${amount.toFixed(2)}`
|
|
}
|
|
|
|
const headerOffset = 12
|
|
|
|
return (
|
|
<View className="bg-pink-50 min-h-screen">
|
|
<ConfigProvider>
|
|
<View style={{ paddingTop: `${headerOffset}px` }} className="max-w-md mx-auto px-4 pb-32">
|
|
{loading ? (
|
|
<View className="py-16 flex justify-center items-center text-gray-500">
|
|
<Loading />
|
|
<Text className="ml-2">加载中...</Text>
|
|
</View>
|
|
) : error ? (
|
|
<View className="bg-white rounded-xl border border-pink-100 p-6">
|
|
<View className="text-red-500 text-sm">{error}</View>
|
|
<View className="mt-4">
|
|
<Button type="primary" onClick={reload}>
|
|
重试
|
|
</Button>
|
|
</View>
|
|
{!step5Done && (
|
|
<View className="mt-4">
|
|
<Button type="primary" onClick={goStep5}>
|
|
去完成第五步
|
|
</Button>
|
|
</View>
|
|
)}
|
|
</View>
|
|
) : !row ? (
|
|
<View className="bg-white rounded-xl border border-pink-100 py-10">
|
|
<Empty description="暂无客户信息" />
|
|
</View>
|
|
) : (
|
|
<View className="space-y-4">
|
|
{/* 回款统计 */}
|
|
<View className="bg-white rounded-xl border border-pink-100 p-4">
|
|
<View className="text-sm font-semibold text-red-600 mb-4">回款统计</View>
|
|
|
|
<View className="grid grid-cols-2 gap-4 text-sm">
|
|
<View className="bg-gray-50 rounded-lg p-3">
|
|
<Text className="text-gray-600 text-xs">回款总金额</Text>
|
|
<Text className="text-lg font-semibold text-gray-800 mt-1">
|
|
{formatCurrency(paymentStats.totalAmount)}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="bg-gray-50 rounded-lg p-3">
|
|
<Text className="text-gray-600 text-xs">应回款本金</Text>
|
|
<Text className="text-lg font-semibold text-gray-800 mt-1">
|
|
{formatCurrency(paymentStats.totalPrincipal)}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="bg-gray-50 rounded-lg p-3">
|
|
<Text className="text-gray-600 text-xs">应回款利息</Text>
|
|
<Text className="text-lg font-semibold text-gray-800 mt-1">
|
|
{formatCurrency(paymentStats.totalInterest)}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="bg-green-50 rounded-lg p-3">
|
|
<Text className="text-green-600 text-xs">已回款本金</Text>
|
|
<Text className="text-lg font-semibold text-green-800 mt-1">
|
|
{formatCurrency(paymentStats.paidPrincipal)}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="bg-green-50 rounded-lg p-3">
|
|
<Text className="text-green-600 text-xs">已回款利息</Text>
|
|
<Text className="text-lg font-semibold text-green-800 mt-1">
|
|
{formatCurrency(paymentStats.paidInterest)}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="bg-red-50 rounded-lg p-3">
|
|
<Text className="text-red-600 text-xs">未回款本金</Text>
|
|
<Text className="text-lg font-semibold text-red-800 mt-1">
|
|
{formatCurrency(paymentStats.unpaidPrincipal)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View className="mt-4 bg-amber-50 rounded-lg p-3">
|
|
<Text className="text-amber-600 text-xs">未回款利息</Text>
|
|
<Text className="text-lg font-semibold text-amber-800 mt-1">
|
|
{formatCurrency(paymentStats.unpaidInterest)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* 回款记录 */}
|
|
<View className="bg-white rounded-xl border border-pink-100 p-4">
|
|
<View className="text-sm font-semibold text-red-600 mb-4">回款记录</View>
|
|
|
|
{paymentRecords.length === 0 ? (
|
|
<View className="text-center py-8">
|
|
<Text className="text-gray-400 text-sm">暂无回款记录</Text>
|
|
<Text className="text-gray-400 text-xs mt-2">回款信息由财务人员录入</Text>
|
|
</View>
|
|
) : (
|
|
<View className="space-y-3">
|
|
{paymentRecords.map((record) => (
|
|
<View key={record.id} className="border border-gray-100 rounded-lg p-3">
|
|
<View className="flex justify-between items-start">
|
|
<View>
|
|
<Text className="text-gray-800 font-medium">
|
|
{formatCurrency(record.amount)}
|
|
</Text>
|
|
<Text className="text-gray-500 text-xs mt-1">
|
|
本金: {formatCurrency(record.principal)} | 利息: {formatCurrency(record.interest)}
|
|
</Text>
|
|
<Text className="text-gray-400 text-xs mt-1">
|
|
{record.paymentTime}
|
|
</Text>
|
|
</View>
|
|
<View className={`px-2 py-1 rounded text-xs ${
|
|
record.status === 'completed'
|
|
? 'bg-green-100 text-green-600'
|
|
: 'bg-yellow-100 text-yellow-600'
|
|
}`}>
|
|
{record.status === 'completed' ? '已完成' : '待确认'}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* 预计回款 */}
|
|
<View className="bg-white rounded-xl border border-pink-100 p-4">
|
|
<View className="flex justify-between items-center mb-4">
|
|
<Text className="text-sm font-semibold text-red-600">预计回款</Text>
|
|
<Button size="small" type="primary" onClick={addExpectedPayment}>
|
|
增加回款内容
|
|
</Button>
|
|
</View>
|
|
|
|
{expectedPayments.length === 0 ? (
|
|
<View className="text-center py-8">
|
|
<Text className="text-gray-400 text-sm">暂无预计回款</Text>
|
|
</View>
|
|
) : (
|
|
<View className="space-y-3">
|
|
{expectedPayments.map((expected) => (
|
|
<View key={expected.id} className="border border-gray-100 rounded-lg p-3">
|
|
<View className="flex justify-between items-center">
|
|
<View>
|
|
<Text className="text-gray-800 font-medium">
|
|
{formatCurrency(expected.amount)}
|
|
</Text>
|
|
<Text className="text-gray-500 text-xs mt-1">
|
|
预计日期: {expected.expectedDate}
|
|
</Text>
|
|
<Text className="text-gray-400 text-xs mt-1">
|
|
添加时间: {expected.createdAt}
|
|
</Text>
|
|
</View>
|
|
<Button
|
|
size="small"
|
|
type="danger"
|
|
fill="outline"
|
|
onClick={() => deleteExpectedPayment(expected.id)}
|
|
>
|
|
删除
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{step5Done && (
|
|
<View className="fixed z-50 bottom-0 left-0 right-0 bg-pink-50 px-4 py-4 safe-area-bottom">
|
|
<Button
|
|
type="primary"
|
|
block
|
|
onClick={goStep7}
|
|
>
|
|
进入第七步回访
|
|
</Button>
|
|
</View>
|
|
)}
|
|
</ConfigProvider>
|
|
</View>
|
|
)
|
|
}
|