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.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -16,6 +17,7 @@ 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'
|
||||
@@ -24,7 +26,11 @@ 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 [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)
|
||||
|
||||
@@ -36,6 +42,8 @@ export function OpportunitiesPage() {
|
||||
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 }),
|
||||
@@ -77,7 +85,7 @@ export function OpportunitiesPage() {
|
||||
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1).getDay()
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="page-shell">
|
||||
<PageHeader
|
||||
title="商机与跟进"
|
||||
description="管理商机流转与团队跟进任务"
|
||||
@@ -90,8 +98,11 @@ export function OpportunitiesPage() {
|
||||
|
||||
<Tabs
|
||||
value={view}
|
||||
onChange={setView}
|
||||
className="mb-4"
|
||||
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 },
|
||||
@@ -100,17 +111,17 @@ export function OpportunitiesPage() {
|
||||
/>
|
||||
|
||||
{view === 'kanban' && (
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin" style={{ minHeight: '60vh' }}>
|
||||
<div className="flex gap-4 overflow-x-auto pb-3 scrollbar-thin" style={{ minHeight: '60vh' }}>
|
||||
{groupedOpps.map((col) => (
|
||||
<div
|
||||
key={col.value}
|
||||
className="w-64 shrink-0 rounded-lg bg-muted/40"
|
||||
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-lg bg-muted px-3 py-2">
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
<Badge variant="primary">{col.items.length}</Badge>
|
||||
<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) => (
|
||||
@@ -118,7 +129,7 @@ export function OpportunitiesPage() {
|
||||
key={opp.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, opp)}
|
||||
className="cursor-grab p-3 active:cursor-grabbing hover:shadow-sm"
|
||||
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>
|
||||
@@ -138,13 +149,13 @@ export function OpportunitiesPage() {
|
||||
|
||||
{view === 'tasks' && (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap gap-1">
|
||||
<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-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>
|
||||
<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>
|
||||
@@ -160,7 +171,7 @@ export function OpportunitiesPage() {
|
||||
<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><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>
|
||||
|
||||
Reference in New Issue
Block a user