Commit c149a0ce by jianan

feat: 新增「我的客户」,在途佣金支持按保单号与佣金月筛选

- 首页新增「我的客户」入口卡(跟进记录数 + 跟进中副标),新增列表页按新单跟进记录展示客户进展阶段(8 态文案与配色)
- 在途佣金页新增保单号实时搜索与佣金月下拉,前端组合过滤
- 简繁 i18n、类型与 /customers 路由补齐
parent 73883c84
import type { Customer } from '../types'
import { get } from '../http'
// 后端 /c/customer/list 返回体(com.yd.csf.service.vo.ReferrerCustomerVO)
interface CustomerDTO {
name: string
stage: Customer['stage'] // 8 态语义码
productName: string
mobile?: string | null
}
export async function fetchCustomers(): Promise<Customer[]> {
const list = await get<CustomerDTO[]>('/c/customer/list')
return (list ?? []).map((dto) => ({
name: dto.name,
stage: dto.stage,
productName: dto.productName,
mobile: dto.mobile ?? ''
}))
}
...@@ -10,6 +10,8 @@ interface DashboardSummaryDTO { ...@@ -10,6 +10,8 @@ interface DashboardSummaryDTO {
pipelineCommission: number pipelineCommission: number
effectivePolicyCount: number effectivePolicyCount: number
firstYearFyp: number firstYearFyp: number
caseCount: number
followingCount: number
} }
export async function fetchMetricSummary(): Promise<MetricSummary> { export async function fetchMetricSummary(): Promise<MetricSummary> {
...@@ -20,7 +22,9 @@ export async function fetchMetricSummary(): Promise<MetricSummary> { ...@@ -20,7 +22,9 @@ export async function fetchMetricSummary(): Promise<MetricSummary> {
taxYearPaidHkd: dto.taxYearPaidCommission, taxYearPaidHkd: dto.taxYearPaidCommission,
pipelineHkd: dto.pipelineCommission, pipelineHkd: dto.pipelineCommission,
inforceCount: dto.effectivePolicyCount, inforceCount: dto.effectivePolicyCount,
firstYearFypHkd: dto.firstYearFyp firstYearFypHkd: dto.firstYearFyp,
caseCount: dto.caseCount,
followingCount: dto.followingCount
} }
} }
......
...@@ -6,6 +6,28 @@ export interface MetricSummary { ...@@ -6,6 +6,28 @@ export interface MetricSummary {
pipelineHkd: number pipelineHkd: number
inforceCount: number inforceCount: number
firstYearFypHkd: number firstYearFypHkd: number
/** 我的客户:名下新单跟进记录数(不去重,含全部状态) */
caseCount: number
/** 我的客户:仍在「跟进中」阶段的数量(不含暂缓) */
followingCount: number
}
/** 我的客户进展阶段语义码,由后端输出,前端只按码查文案/配色。 */
export type CustomerStage =
| 'IN_PROGRESS'
| 'DELAYED'
| 'EFFECTIVE'
| 'CANCELLED'
| 'DECLINED'
| 'LAPSED'
| 'CLOSED'
| 'APPOINTMENT_CANCELLED'
export interface Customer {
name: string
stage: CustomerStage
productName: string
mobile: string
} }
export interface Statement { export interface Statement {
......
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Badge from '@/components/common/Badge.vue'
import type { Customer, CustomerStage } from '@/api/types'
defineProps<{
customer: Customer
}>()
const { t } = useI18n()
/** 生效绿 / 跟进中琥珀 / 暂缓灰 / 未成功红 */
function toneOf(stage: CustomerStage): 'success' | 'warning' | 'neutral' | 'danger' {
if (stage === 'EFFECTIVE') return 'success'
if (stage === 'IN_PROGRESS') return 'warning'
if (stage === 'DELAYED') return 'neutral'
return 'danger'
}
</script>
<template>
<div class="rounded-2xl border border-slate-200/80 bg-white p-4 shadow-card">
<div class="flex items-center justify-between gap-3">
<div class="truncate text-sm font-medium tracking-tight text-slate-900">{{ customer.name }}</div>
<Badge :tone="toneOf(customer.stage)">{{ t(`customerStage.${customer.stage}`) }}</Badge>
</div>
<div v-if="customer.productName" class="mt-2 truncate text-xs text-slate-500">{{ customer.productName }}</div>
<div v-if="customer.mobile" class="tnum mt-1 text-xs text-slate-400">{{ customer.mobile }}</div>
</div>
</template>
...@@ -13,6 +13,18 @@ export default { ...@@ -13,6 +13,18 @@ export default {
'metric.pipeline': '在途佣金', 'metric.pipeline': '在途佣金',
'metric.inforceCount': '生效件数', 'metric.inforceCount': '生效件数',
'metric.firstYearFyp': '首年业绩 FYP', 'metric.firstYearFyp': '首年业绩 FYP',
'metric.myCustomers': '我的客户',
'customer.title': '我的客户',
'customer.followingHint': '跟进中 {count}',
'customer.empty': '暂无客户跟进记录',
'customerStage.IN_PROGRESS': '跟进中',
'customerStage.DELAYED': '暂缓',
'customerStage.EFFECTIVE': '已生效',
'customerStage.CANCELLED': '已取消',
'customerStage.DECLINED': '已拒保',
'customerStage.LAPSED': '已失效',
'customerStage.CLOSED': '已关闭',
'customerStage.APPOINTMENT_CANCELLED': '预约已取消',
'dashboard.actions': '智能行动看板', 'dashboard.actions': '智能行动看板',
'todo.renewal': '续期缴费', 'todo.renewal': '续期缴费',
'todo.lapse': '失效预警', 'todo.lapse': '失效预警',
...@@ -45,6 +57,11 @@ export default { ...@@ -45,6 +57,11 @@ export default {
'policies.year': '保单年度', 'policies.year': '保单年度',
'pipeline.payoutMonth': '预计出账月', 'pipeline.payoutMonth': '预计出账月',
'pipeline.adjustNote': '保司来佣后发放,金额可能调整', 'pipeline.adjustNote': '保司来佣后发放,金额可能调整',
'pipeline.filterPolicy': '保单号',
'pipeline.filterPolicyPlaceholder': '搜索保单号',
'pipeline.filterMonth': '佣金月',
'pipeline.allMonths': '全部月份',
'pipeline.noResult': '没有符合条件的在途佣金',
'stage.PAID': '已发', 'stage.PAID': '已发',
'stage.PIPELINE': '在途', 'stage.PIPELINE': '在途',
'stage.RETAINED': '保留', 'stage.RETAINED': '保留',
......
...@@ -13,6 +13,18 @@ export default { ...@@ -13,6 +13,18 @@ export default {
'metric.pipeline': '在途佣金', 'metric.pipeline': '在途佣金',
'metric.inforceCount': '生效件數', 'metric.inforceCount': '生效件數',
'metric.firstYearFyp': '首年業績 FYP', 'metric.firstYearFyp': '首年業績 FYP',
'metric.myCustomers': '我的客戶',
'customer.title': '我的客戶',
'customer.followingHint': '跟進中 {count}',
'customer.empty': '暫無客戶跟進記錄',
'customerStage.IN_PROGRESS': '跟進中',
'customerStage.DELAYED': '暫緩',
'customerStage.EFFECTIVE': '已生效',
'customerStage.CANCELLED': '已取消',
'customerStage.DECLINED': '已拒保',
'customerStage.LAPSED': '已失效',
'customerStage.CLOSED': '已關閉',
'customerStage.APPOINTMENT_CANCELLED': '預約已取消',
'dashboard.actions': '智能行動看板', 'dashboard.actions': '智能行動看板',
'todo.renewal': '續期繳費', 'todo.renewal': '續期繳費',
'todo.lapse': '失效預警', 'todo.lapse': '失效預警',
...@@ -45,6 +57,11 @@ export default { ...@@ -45,6 +57,11 @@ export default {
'policies.year': '保單年度', 'policies.year': '保單年度',
'pipeline.payoutMonth': '預計出賬月', 'pipeline.payoutMonth': '預計出賬月',
'pipeline.adjustNote': '保司來佣後發放,金額可能調整', 'pipeline.adjustNote': '保司來佣後發放,金額可能調整',
'pipeline.filterPolicy': '保單號',
'pipeline.filterPolicyPlaceholder': '搜尋保單號',
'pipeline.filterMonth': '佣金月',
'pipeline.allMonths': '全部月份',
'pipeline.noResult': '沒有符合條件的在途佣金',
'stage.PAID': '已發', 'stage.PAID': '已發',
'stage.PIPELINE': '在途', 'stage.PIPELINE': '在途',
'stage.RETAINED': '保留', 'stage.RETAINED': '保留',
......
...@@ -9,6 +9,7 @@ const router = createRouter({ ...@@ -9,6 +9,7 @@ const router = createRouter({
{ path: '/statement/:id', name: 'statement-detail', component: () => import('@/views/StatementDetailView.vue') }, { path: '/statement/:id', name: 'statement-detail', component: () => import('@/views/StatementDetailView.vue') },
{ path: '/policies', name: 'policies', component: () => import('@/views/PoliciesView.vue') }, { path: '/policies', name: 'policies', component: () => import('@/views/PoliciesView.vue') },
{ path: '/pipeline', name: 'pipeline', component: () => import('@/views/PipelineView.vue') }, { path: '/pipeline', name: 'pipeline', component: () => import('@/views/PipelineView.vue') },
{ path: '/customers', name: 'customers', component: () => import('@/views/CustomersView.vue') },
{ path: '/auth/callback', name: 'auth-callback', component: () => import('@/views/AuthCallbackView.vue') }, { path: '/auth/callback', name: 'auth-callback', component: () => import('@/views/AuthCallbackView.vue') },
{ path: '/logged-out', name: 'logged-out', component: () => import('@/views/LoggedOutView.vue') } { path: '/logged-out', name: 'logged-out', component: () => import('@/views/LoggedOutView.vue') }
] ]
......
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ApiLoader from '@/components/common/ApiLoader.vue'
import CustomerCard from '@/components/feature/CustomerCard.vue'
import { fetchCustomers } from '@/api/modules/customer'
import type { Customer } from '@/api/types'
const { t } = useI18n()
const loading = ref(true)
const error = ref<string | null>(null)
const customers = ref<Customer[]>([])
async function load() {
loading.value = true
error.value = null
try {
customers.value = await fetchCustomers()
} catch (e) {
error.value = e instanceof Error ? e.message : 'error'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<ApiLoader :loading="loading" :error="error" @retry="load">
<div v-if="customers.length" class="space-y-3">
<CustomerCard
v-for="(customer, index) in customers"
:key="index"
:customer="customer"
/>
</div>
<div v-else class="py-10 text-center text-sm text-slate-400">{{ t('customer.empty') }}</div>
</ApiLoader>
</template>
...@@ -61,6 +61,23 @@ onMounted(load) ...@@ -61,6 +61,23 @@ onMounted(load)
<MetricCard v-for="card in restCards" :key="card.label" :label="card.label" :value="card.value" /> <MetricCard v-for="card in restCards" :key="card.label" :label="card.label" :value="card.value" />
</div> </div>
<router-link
v-if="metrics"
to="/customers"
class="mt-3 flex items-center justify-between rounded-2xl border border-slate-200/80 bg-white p-4 shadow-card transition-shadow hover:shadow-soft"
>
<div>
<div class="text-xs text-slate-500">{{ t('metric.myCustomers') }}</div>
<div class="tnum mt-2 text-xl font-semibold tracking-tight text-slate-900">{{ metrics.caseCount }}</div>
</div>
<div class="flex items-center gap-1 text-xs text-slate-400">
{{ t('customer.followingHint', { count: metrics.followingCount }) }}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</router-link>
<section class="mt-7"> <section class="mt-7">
<h2 class="mb-3 text-[13px] font-semibold tracking-wide text-slate-700">{{ t('dashboard.actions') }}</h2> <h2 class="mb-3 text-[13px] font-semibold tracking-wide text-slate-700">{{ t('dashboard.actions') }}</h2>
<div class="mb-3 flex flex-wrap gap-2 text-xs text-slate-500"> <div class="mb-3 flex flex-wrap gap-2 text-xs text-slate-500">
......
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import ApiLoader from '@/components/common/ApiLoader.vue' import ApiLoader from '@/components/common/ApiLoader.vue'
import PipelineCard from '@/components/feature/PipelineCard.vue' import PipelineCard from '@/components/feature/PipelineCard.vue'
...@@ -11,6 +11,22 @@ const loading = ref(true) ...@@ -11,6 +11,22 @@ const loading = ref(true)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const items = ref<PipelineItem[]>([]) const items = ref<PipelineItem[]>([])
const policyQuery = ref('')
const selectedMonth = ref('')
const months = computed(() =>
Array.from(new Set(items.value.map((x) => x.payoutMonth).filter(Boolean))).sort().reverse()
)
const filtered = computed(() => {
const keyword = policyQuery.value.trim().toUpperCase()
return items.value.filter((x) => {
const matchPolicy = !keyword || x.policyNo.toUpperCase().includes(keyword)
const matchMonth = !selectedMonth.value || x.payoutMonth === selectedMonth.value
return matchPolicy && matchMonth
})
})
async function load() { async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
...@@ -29,10 +45,34 @@ onMounted(load) ...@@ -29,10 +45,34 @@ onMounted(load)
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">{{ t('pipeline.adjustNote') }}</div> <div class="rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">{{ t('pipeline.adjustNote') }}</div>
<div class="grid grid-cols-2 gap-3">
<label class="block">
<span class="mb-1 block text-xs text-slate-500">{{ t('pipeline.filterPolicy') }}</span>
<input
v-model="policyQuery"
type="search"
:placeholder="t('pipeline.filterPolicyPlaceholder')"
class="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition-colors placeholder:text-slate-400 focus:border-brand-200"
>
</label>
<label class="block">
<span class="mb-1 block text-xs text-slate-500">{{ t('pipeline.filterMonth') }}</span>
<select
v-model="selectedMonth"
class="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition-colors focus:border-brand-200"
>
<option value="">{{ t('pipeline.allMonths') }}</option>
<option v-for="month in months" :key="month" :value="month">{{ month }}</option>
</select>
</label>
</div>
<ApiLoader :loading="loading" :error="error" :empty="!loading && !error && items.length === 0" @retry="load"> <ApiLoader :loading="loading" :error="error" :empty="!loading && !error && items.length === 0" @retry="load">
<div class="space-y-3"> <div v-if="filtered.length" class="space-y-3">
<PipelineCard v-for="item in items" :key="item.id" :item="item" /> <PipelineCard v-for="item in filtered" :key="item.id" :item="item" />
</div> </div>
<div v-else class="py-10 text-center text-sm text-slate-400">{{ t('pipeline.noResult') }}</div>
</ApiLoader> </ApiLoader>
</div> </div>
</template> </template>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment