Files
lingxi-web/src/pages/opportunities/OpportunitiesPage.tsx
T
wmq af49d22ca7 feat: release contextual omnichannel customer platform
Add Telegram, WhatsApp and email account management, realtime messaging, contextual multilingual AI, customer intelligence, knowledge workflows, enterprise controls, a unified design system and complete repository documentation.
2026-07-30 02:29:53 +08:00

217 lines
10 KiB
TypeScript

import { useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '@/app/store/auth'
import { toast, errorMessage } from '@/app/store/toast'
import { confirmDialog } from '@/app/store/confirm'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { Avatar } from '@/components/ui/avatar'
import { Checkbox } from '@/components/ui/checkbox'
import { Tabs } from '@/components/ui/tabs'
import { Table, THead, TBody, Tr, Th, Td } from '@/components/ui/table'
import { CountryFlag } from '@/components/common/CountryFlag'
import { IntentScoreBadge, OpportunityStageBadge, TaskPriorityBadge } from '@/components/common/StatusBadges'
import { TaskFormDialog } from '@/components/crm/TaskFormDialog'
import { getOpportunities, updateOpportunity, getTasks, updateTask } from '@/services/api/crm'
import { getTeam } from '@/services/api/org'
import { OPPORTUNITY_STAGES, taskTypeLabel, TASK_TYPES, TASK_PRIORITIES } from '@/config/constants'
import { formatDateTime, formatMoney } from '@/utils/format'
import { cn } from '@/utils/cn'
import type { FollowTask, Opportunity } from '@/types'
export function OpportunitiesPage() {
const queryClient = useQueryClient()
const user = useAuthStore((s) => s.user)
const [searchParams, setSearchParams] = useSearchParams()
const requestedView = searchParams.get('tab')
const [view, setView] = useState(
requestedView === 'tasks' || requestedView === 'calendar' ? requestedView : 'kanban',
)
const [taskFilter, setTaskFilter] = useState('all')
const [taskDialog, setTaskDialog] = useState(false)
const { data: oppData, isLoading: oppLoading } = useQuery({
queryKey: ['opportunities'],
queryFn: () => getOpportunities(),
})
const { data: taskData, isLoading: taskLoading } = useQuery({
queryKey: ['tasks', taskFilter],
queryFn: () => getTasks({ filter: taskFilter, ownerId: user?.id }),
})
const { data: team = [] } = useQuery({ queryKey: ['team'], queryFn: getTeam })
const memberNames = new Map(team.map((member) => [member.id, member.name]))
const oppMut = useMutation({
mutationFn: ({ id, stage }: { id: string; stage: string }) => updateOpportunity(id, { stage: stage as any }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['opportunities'] })
toast.success('商机阶段已更新')
},
onError: (e) => toast.error(errorMessage(e)),
})
const taskMut = useMutation({
mutationFn: (p: { id: string; data: Partial<FollowTask> }) => updateTask(p.id, p.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('任务已更新')
},
onError: (e) => toast.error(errorMessage(e)),
})
const handleDragStart = (e: React.DragEvent, opp: Opportunity) => {
e.dataTransfer.setData('oppId', opp.id)
}
const handleDrop = (e: React.DragEvent, stage: string) => {
const id = e.dataTransfer.getData('oppId')
if (id) oppMut.mutate({ id, stage: stage as any })
}
const opps = oppData?.list ?? []
const tasks = taskData?.list ?? []
const groupedOpps = OPPORTUNITY_STAGES.map((s) => ({
...s,
items: opps.filter((o) => o.stage === s.value),
}))
// 简单月历
const today = new Date()
const monthDays = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate()
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1).getDay()
return (
<div className="page-shell">
<PageHeader
title="商机与跟进"
description="管理商机流转与团队跟进任务"
actions={
<>
<Button size="sm" onClick={() => setTaskDialog(true)}>新建跟进</Button>
</>
}
/>
<Tabs
value={view}
onChange={(next) => {
setView(next)
setSearchParams(next === 'kanban' ? {} : { tab: next }, { replace: true })
}}
className="mb-5"
items={[
{ value: 'kanban', label: '商机看板', count: opps.length },
{ value: 'tasks', label: '跟进任务', count: tasks.filter((t) => t.status !== 'done').length },
{ value: 'calendar', label: '日历视图' },
]}
/>
{view === 'kanban' && (
<div className="flex gap-4 overflow-x-auto pb-3 scrollbar-thin" style={{ minHeight: '60vh' }}>
{groupedOpps.map((col) => (
<div
key={col.value}
className="w-[272px] shrink-0 rounded-[14px] border border-slate-200 bg-slate-100/65"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleDrop(e, col.value)}
>
<div className="sticky top-0 z-10 flex items-center justify-between rounded-t-[14px] border-b border-slate-200 bg-white/90 px-3.5 py-3 backdrop-blur">
<span className="text-sm font-semibold text-slate-800">{col.label}</span>
<Badge variant="outline">{col.items.length}</Badge>
</div>
<div className="space-y-2 p-2">
{col.items.map((opp) => (
<Card
key={opp.id}
draggable
onDragStart={(e) => handleDragStart(e, opp)}
className="cursor-grab p-3.5 active:cursor-grabbing hover:border-primary-200 hover:shadow-[0_8px_20px_-16px_rgba(37,99,235,.55)]"
>
<p className="text-sm font-medium truncate">{opp.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{opp.customerName} <CountryFlag code={opp.country} withName={false} /></p>
<div className="mt-1.5 flex items-center justify-between">
<span className="text-xs text-foreground font-medium">{formatMoney(opp.amount)}</span>
<Badge variant="primary">{opp.probability}%</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground truncate">{opp.nextAction}</p>
</Card>
))}
{col.items.length === 0 && <p className="py-4 text-center text-xs text-muted-foreground">拖拽至此列</p>}
</div>
</div>
))}
</div>
)}
{view === 'tasks' && (
<div>
<div className="mb-4 flex flex-wrap gap-1 rounded-[12px] border border-slate-200 bg-white p-1.5 shadow-sm">
{[
{ v: 'all', l: '全部' }, { v: 'mine', l: '我的待办' },
{ v: 'today', l: '今日' }, { v: 'overdue', l: '已逾期' },
{ v: 'done', l: '已完成' },
].map((f) => (
<button key={f.v} onClick={() => setTaskFilter(f.v)} className={cn('rounded-[8px] px-3 py-1.5 text-xs transition-colors', taskFilter === f.v ? 'bg-slate-900 font-medium text-white' : 'text-slate-500 hover:bg-slate-100 hover:text-slate-900')}>{f.l}</button>
))}
</div>
<Card>
<Table>
<THead><Tr><Th className="w-10">状态</Th><Th>任务</Th><Th>类型</Th><Th>客户</Th><Th>优先级</Th><Th>截止时间</Th><Th>负责人</Th><Th className="w-20">操作</Th></Tr></THead>
<TBody>
{tasks.length === 0 && <Tr><Td colSpan={8}><EmptyState title="暂无跟进任务" /></Td></Tr>}
{tasks.map((t) => (
<Tr key={t.id}>
<Td><Checkbox checked={t.status === 'done'} onCheckedChange={() => taskMut.mutate({ id: t.id, data: { status: t.status === 'done' ? 'pending' : 'done' } })} /></Td>
<Td><span className={cn('text-sm', t.status === 'done' && 'line-through text-muted-foreground')}>{t.title}</span></Td>
<Td><Badge variant="outline">{taskTypeLabel(t.type)}</Badge></Td>
<Td><span className="text-xs">{t.customerName}</span></Td>
<Td><TaskPriorityBadge priority={t.priority} /></Td>
<Td><span className={cn('text-xs', t.status === 'overdue' && 'text-red-600 font-medium')}>{formatDateTime(t.dueAt)}</span></Td>
<Td><span className="text-xs">{memberNames.get(t.ownerId) ?? '未分配'}</span></Td>
<Td>
{t.status !== 'done' && (
<Button size="sm" variant="ghost" onClick={() => taskMut.mutate({ id: t.id, data: { status: 'done' } })}>完成</Button>
)}
</Td>
</Tr>
))}
</TBody>
</Table>
</Card>
</div>
)}
{view === 'calendar' && (
<Card className="p-4">
<div className="mb-3 text-center font-semibold">{today.getFullYear()} 年 {today.getMonth() + 1} 月</div>
<div className="grid grid-cols-7 gap-1">
{['日', '一', '二', '三', '四', '五', '六'].map((d) => <div key={d} className="py-1 text-center text-xs font-medium text-muted-foreground">{d}</div>)}
{Array.from({ length: firstDay }).map((_, i) => <div key={'e' + i} />)}
{Array.from({ length: monthDays }).map((_, i) => {
const d = i + 1
const key = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const dayTasks = tasks.filter((t) => t.dueAt?.startsWith?.(key))
const isToday = d === today.getDate() && today.getMonth() === new Date().getMonth()
return (
<div key={d} className={cn('min-h-14 rounded border border-border p-1.5 text-xs', isToday && 'bg-primary-50 border-primary-200')}>
<span className={cn(isToday && 'font-bold text-primary-700')}>{d}</span>
{dayTasks.slice(0, 2).map((dt) => (
<div key={dt.id} className="mt-0.5 truncate rounded bg-primary/10 px-1 text-[10px] text-primary-700 leading-5">{dt.title}</div>
))}
{dayTasks.length > 2 && <div className="text-[10px] text-muted-foreground">+{dayTasks.length - 2} 项</div>}
</div>
)
})}
</div>
</Card>
)}
<TaskFormDialog open={taskDialog} onOpenChange={setTaskDialog} />
</div>
)
}