feat(credit): 完善客户跟进流程功能
- 添加了客户跟进流程的第2至第7步页面路由配置 - 实现了完整的七步跟进流程,包括加微信、建群沟通、合同定稿等环节 - 添加了各步骤的状态管理、审批机制和进度跟踪功能 - 集成了录音功能,支持现场录音和从聊天记录选择音频 - 优化了图片上传组件,使用Image组件替代背景图方式显示 - 添加了步骤间的导航控制和前置条件检查逻辑 - 实现了审核接口和批量审批功能 - 增加了跟进统计和流程结束功能 - 完善了用户界面,提供更清晰的流程指引和状态展示
This commit is contained in:
459
src/credit/mp-customer/follow-step6.tsx
Normal file
459
src/credit/mp-customer/follow-step6.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user