206 lines
9.7 KiB
TypeScript
206 lines
9.7 KiB
TypeScript
import { useState } from 'react'
|
|
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 { 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 [view, setView] = useState('kanban') // 'kanban' | 'tasks' | 'calendar'
|
|
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 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="p-4 md:p-6">
|
|
<PageHeader
|
|
title="商机与跟进"
|
|
description="管理商机流转与团队跟进任务"
|
|
actions={
|
|
<>
|
|
<Button size="sm" onClick={() => setTaskDialog(true)}>新建跟进</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<Tabs
|
|
value={view}
|
|
onChange={setView}
|
|
className="mb-4"
|
|
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-3 overflow-x-auto pb-2 scrollbar-thin" style={{ minHeight: '60vh' }}>
|
|
{groupedOpps.map((col) => (
|
|
<div
|
|
key={col.value}
|
|
className="w-64 shrink-0 rounded-lg bg-muted/40"
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={(e) => handleDrop(e, col.value)}
|
|
>
|
|
<div className="sticky top-0 z-10 flex items-center justify-between rounded-t-lg bg-muted px-3 py-2">
|
|
<span className="text-sm font-medium">{col.label}</span>
|
|
<Badge variant="primary">{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 active:cursor-grabbing hover:shadow-sm"
|
|
>
|
|
<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-3 flex flex-wrap gap-1">
|
|
{[
|
|
{ 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-full px-2.5 py-1 text-xs transition-colors', taskFilter === f.v ? 'bg-primary font-medium text-white' : 'bg-muted text-muted-foreground hover:bg-border')}>{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">{t.ownerId === 'u-003' ? '李婷' : t.ownerId === 'u-004' ? '赵磊' : t.ownerId === 'u-005' ? '周敏' : t.ownerId === 'u-008' ? '郑楠' : '其他'}</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>
|
|
)
|
|
}
|