chore(history): import initial web product snapshot
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
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 { DemoNote } from '@/components/common/DemoNote'
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user