gongxiangp2/components/department-dashboard/PerformanceConversionDashboard.tsx

278 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import Link from 'next/link'
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'
import {
Activity,
AlertTriangle,
CalendarDays,
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock3,
RefreshCw,
Search,
} from 'lucide-react'
import DepartmentShell from './DepartmentShell'
import {
BUSINESS_TREND_BRANDS,
type PerformanceConversionSourceSync,
type PerformanceConversionWorkflowSync,
type PerformanceConversionWorkflowItem,
type PerformanceConversionWorkflowList,
isValidDateKey,
} from './performance-conversion'
type ListState = {
data: PerformanceConversionWorkflowList | null
loading: boolean
error: string | null
}
type WorkflowFilters = {
query: string
completedFrom: string
completedTo: string
}
const PAGE_SIZE = 20
const EMPTY_FILTERS: WorkflowFilters = { query: '', completedFrom: '', completedTo: '' }
function formatDate(value: string | null, includeTime = false) {
if (!value) return '—'
const date = /^\d{4}-\d{2}-\d{2}$/.test(value) ? new Date(`${value}T00:00:00+08:00`) : new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
...(includeTime ? { hour: '2-digit', minute: '2-digit', hour12: false } : {}),
}).format(date)
}
function WorkflowSyncStatus({ sync }: { sync: PerformanceConversionWorkflowSync }) {
const latestSuccess = sync.lastCompletedAt || sync.lastFullReconciledAt
const latestSuccessLabel = latestSuccess ? formatDate(latestSuccess, true) : null
const latestReconciliation = sync.lastFullReconciledAt || sync.lastCompletedAt
const isDelayed = sync.status === 'ready'
&& latestReconciliation !== null
&& Date.now() - new Date(latestReconciliation).getTime() > 30 * 60 * 60 * 1000
const view = sync.status === 'syncing'
? { label: '正在同步流程与商品 ID', detail: latestSuccessLabel ? `上次成功 ${latestSuccessLabel}` : '首次同步进行中', tone: 'text-[#0050cb]', icon: RefreshCw, spin: true }
: sync.status === 'error'
? { label: '流程与商品 ID 同步异常', detail: latestSuccessLabel ? `暂显示 ${latestSuccessLabel} 的数据` : '暂无可用同步记录', tone: 'text-amber-700', icon: AlertTriangle, spin: false }
: sync.status === 'pending'
? { label: '等待首次流程与商品 ID 同步', detail: '后台任务尚未完成', tone: 'text-slate-500', icon: Clock3, spin: false }
: isDelayed
? { label: '流程与商品 ID 每日校准延迟', detail: `最近增量成功 ${latestSuccessLabel}`, tone: 'text-amber-700', icon: AlertTriangle, spin: false }
: { label: '流程与商品 ID 每日增量同步', detail: latestSuccessLabel ? `最近成功 ${latestSuccessLabel}` : '等待首次成功记录', tone: 'text-emerald-700', icon: CheckCircle2, spin: false }
const Icon = view.icon
return (
<div
role="status"
aria-live="polite"
className={`min-w-0 text-left text-xs leading-5 lg:text-right ${view.tone}`}
title={latestSuccess ? `最近同步成功:${latestSuccessLabel}(北京时间)` : undefined}
>
<p className="inline-flex items-center gap-1.5 font-bold">
<Icon className={`h-3.5 w-3.5 shrink-0 ${view.spin ? 'animate-spin' : ''}`} aria-hidden="true" />
<span>{view.label}</span>
</p>
<p className="text-slate-500">{view.detail}</p>
</div>
)
}
function PerformanceDataSyncStatus({ sync }: { sync?: PerformanceConversionSourceSync }) {
if (!sync) return null
const latestSuccess = sync.lastCompletedAt
const latestSuccessLabel = latestSuccess ? formatDate(latestSuccess, true) : null
const isDelayed = sync.status === 'ready'
&& latestSuccess !== null
&& Date.now() - new Date(latestSuccess).getTime() > 48 * 60 * 60 * 1000
const view = sync.status === 'syncing'
? { label: '正在同步经营数据', detail: latestSuccessLabel ? `上次成功 ${latestSuccessLabel}` : '首次同步进行中', tone: 'text-[#0050cb]', icon: RefreshCw, spin: true }
: sync.status === 'error'
? { label: '经营数据同步异常', detail: latestSuccessLabel ? `暂显示 ${latestSuccessLabel} 的数据` : '暂无可用同步记录', tone: 'text-amber-700', icon: AlertTriangle, spin: false }
: sync.status === 'pending'
? { label: '等待首次经营数据同步', detail: '后台任务尚未完成', tone: 'text-slate-500', icon: Clock3, spin: false }
: isDelayed
? { label: '经营数据每日同步延迟', detail: `最近成功 ${latestSuccessLabel}`, tone: 'text-amber-700', icon: AlertTriangle, spin: false }
: { label: '经营数据每日增量同步', detail: latestSuccessLabel ? `最近成功 ${latestSuccessLabel}` : '等待首次成功记录', tone: 'text-emerald-700', icon: CheckCircle2, spin: false }
const Icon = view.icon
return (
<div
role="status"
aria-live="polite"
className={`min-w-0 text-left text-xs leading-5 lg:text-right ${view.tone}`}
title={latestSuccess ? `最近同步成功:${latestSuccessLabel}(北京时间)` : undefined}
>
<p className="inline-flex items-center gap-1.5 font-bold">
<Icon className={`h-3.5 w-3.5 shrink-0 ${view.spin ? 'animate-spin' : ''}`} aria-hidden="true" />
<span>{view.label}</span>
</p>
<p className="text-slate-500">{view.detail}{sync.lastSourceDate ? ` · 已扫描至 ${formatDate(sync.lastSourceDate)}` : ''}</p>
</div>
)
}
function WorkflowTable({ items, detailHref }: {
items: PerformanceConversionWorkflowItem[]
detailHref: (item: PerformanceConversionWorkflowItem) => string
}) {
return (
<>
<div className="hidden overflow-x-auto md:block" tabIndex={0} aria-label="美术流程商品 ID 列表">
<table className="w-full min-w-[920px] table-fixed text-left text-sm">
<caption className="sr-only">2026 年 4 月 1 日以后已完结美术流程关联的商品 ID</caption>
<thead className="bg-slate-50 text-xs font-bold text-slate-500"><tr><th className="w-[18%] px-5 py-3">商品 ID</th><th className="w-[24%] px-4 py-3">审批编号</th><th className="w-[18%] px-4 py-3">完结时间</th><th className="w-[25%] px-4 py-3">监控区间</th><th className="w-[15%] px-4 py-3">状态</th></tr></thead>
<tbody className="divide-y divide-slate-200">
{items.map((item) => <tr key={item.key} className="bg-white hover:bg-slate-50"><td className="px-5 py-3"><Link href={detailHref(item)} className="break-all font-mono text-sm font-bold text-[#0050cb] underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">{item.productId}</Link><p className="mt-1 flex flex-wrap gap-x-2 text-xs text-slate-500">{item.spu && <span className="min-w-0 max-w-full break-all">SPU {item.spu}</span>}<span className="min-w-0 max-w-full break-all">平台 {item.platform || '—'}</span></p></td><td className="px-4 py-3"><p className="break-all font-mono text-xs text-slate-700">{item.approvalNumber || '—'}</p></td><td className="px-4 py-3 tabular-nums text-slate-700">{formatDate(item.completedAt, true)}</td><td className="px-4 py-3 tabular-nums text-slate-700">{item.monitorStartDate} 至 {item.monitorEndDate}</td><td className="px-4 py-3"><span className={`inline-flex min-h-7 items-center rounded-md px-2 text-xs font-bold ${item.monitoringStatus === 'monitoring' ? 'bg-amber-50 text-amber-800 ring-1 ring-inset ring-amber-200' : 'bg-emerald-50 text-emerald-800 ring-1 ring-inset ring-emerald-200'}`}>{item.monitoringStatus === 'monitoring' ? '监控中' : '已满 6 个月'}</span></td></tr>)}
</tbody>
</table>
</div>
<ul className="divide-y divide-slate-200 md:hidden" aria-label="美术流程商品 ID 列表">
{items.map((item) => <li key={item.key} className="bg-white p-4"><Link href={detailHref(item)} className="block w-full text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><span className="font-mono text-sm font-bold text-[#0050cb]">{item.productId}</span><span className="mt-1 flex flex-wrap gap-x-2 text-xs text-slate-500">{item.spu && <span className="min-w-0 max-w-full break-all">SPU {item.spu}</span>}<span className="min-w-0 max-w-full break-all">平台 {item.platform || '—'}</span></span><span className="mt-1 block break-all text-xs text-slate-500">审批编号 {item.approvalNumber || '—'}</span><dl className="mt-3 grid grid-cols-2 gap-3 text-xs"><div><dt className="text-slate-500">完结时间</dt><dd className="mt-1 font-semibold text-slate-800">{formatDate(item.completedAt)}</dd></div><div><dt className="text-slate-500">状态</dt><dd className="mt-1 font-semibold text-slate-800">{item.monitoringStatus === 'monitoring' ? '监控中' : '已满 6 个月'}</dd></div><div className="col-span-2"><dt className="text-slate-500">监控区间</dt><dd className="mt-1 font-semibold text-slate-800">{item.monitorStartDate} 至 {item.monitorEndDate}</dd></div></dl></Link></li>)}
</ul>
</>
)
}
export default function PerformanceConversionDashboard() {
const [selectedBrand, setSelectedBrand] = useState<(typeof BUSINESS_TREND_BRANDS)[number]>(BUSINESS_TREND_BRANDS[0])
const [query, setQuery] = useState('')
const [completedFrom, setCompletedFrom] = useState('')
const [completedTo, setCompletedTo] = useState('')
const [appliedFilters, setAppliedFilters] = useState<WorkflowFilters>(EMPTY_FILTERS)
const [filterError, setFilterError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [listState, setListState] = useState<ListState>({ data: null, loading: true, error: null })
const listAbort = useRef<AbortController | null>(null)
const updateUrl = useCallback((brand: string, targetPage: number, filters: WorkflowFilters) => {
const url = new URL(window.location.href)
url.searchParams.set('brand', brand)
const filterParams = {
q: filters.query,
completedFrom: filters.completedFrom,
completedTo: filters.completedTo,
}
Object.entries(filterParams).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value)
else url.searchParams.delete(key)
})
if (targetPage > 1) url.searchParams.set('page', String(targetPage))
else url.searchParams.delete('page')
url.searchParams.delete('workflowId')
url.searchParams.delete('productId')
window.history.replaceState(null, '', url)
}, [])
const loadList = useCallback(async (brand: typeof selectedBrand, targetPage: number, filters: WorkflowFilters) => {
listAbort.current?.abort()
const controller = new AbortController()
listAbort.current = controller
setListState({ data: null, loading: true, error: null })
updateUrl(brand, targetPage, filters)
try {
const params = new URLSearchParams({ brand, page: String(targetPage), pageSize: String(PAGE_SIZE) })
if (filters.query) params.set('q', filters.query)
if (filters.completedFrom) params.set('completedFrom', filters.completedFrom)
if (filters.completedTo) params.set('completedTo', filters.completedTo)
const response = await fetch(`/api/department-dashboard/performance-conversion/workflows?${params}`, { cache: 'no-store', signal: controller.signal })
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success || !payload.data) throw new Error(payload?.error || '业绩转化流程读取失败')
const data = payload.data as PerformanceConversionWorkflowList
setListState({ data, loading: false, error: null })
} catch (error) {
if ((error as Error).name === 'AbortError') return
setListState({ data: null, loading: false, error: (error as Error).message || '业绩转化流程读取失败' })
}
}, [updateUrl])
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const brandParam = params.get('brand')
const initialBrand = BUSINESS_TREND_BRANDS.includes(brandParam as typeof selectedBrand)
? brandParam as typeof selectedBrand
: BUSINESS_TREND_BRANDS[0]
const pageParam = Number.parseInt(params.get('page') || '', 10)
const initialPage = Number.isSafeInteger(pageParam) && pageParam > 0 ? pageParam : 1
const initialFilters = {
query: params.get('q') || '',
completedFrom: isValidDateKey(params.get('completedFrom')) ? params.get('completedFrom') as string : '',
completedTo: isValidDateKey(params.get('completedTo')) ? params.get('completedTo') as string : '',
}
setSelectedBrand(initialBrand)
setQuery(initialFilters.query)
setCompletedFrom(initialFilters.completedFrom)
setCompletedTo(initialFilters.completedTo)
setAppliedFilters(initialFilters)
setPage(initialPage)
void loadList(initialBrand, initialPage, initialFilters)
return () => listAbort.current?.abort()
}, [loadList])
const submitSearch = (event: FormEvent) => {
event.preventDefault()
if (completedFrom && completedTo && completedFrom > completedTo) {
setFilterError('完结开始日期不能晚于结束日期')
return
}
const nextFilters = { query: query.trim(), completedFrom, completedTo }
setFilterError(null)
setAppliedFilters(nextFilters)
setPage(1)
void loadList(selectedBrand, 1, nextFilters)
}
const selectedBrandCount = listState.data?.brands.find((item) => item.brand === selectedBrand)?.count || 0
const detailHref = useCallback((item: PerformanceConversionWorkflowItem) => {
const params = new URLSearchParams({
workflowId: item.workflowId,
productId: item.productId,
brand: selectedBrand,
page: String(page),
})
if (appliedFilters.query) params.set('q', appliedFilters.query)
if (appliedFilters.completedFrom) params.set('completedFrom', appliedFilters.completedFrom)
if (appliedFilters.completedTo) params.set('completedTo', appliedFilters.completedTo)
return `/workspaces/business/performance-conversion/detail?${params}`
}, [appliedFilters, page, selectedBrand])
return (
<DepartmentShell>
<main className="mx-auto max-w-[1600px] px-4 pb-16 pt-7 sm:px-6 lg:px-8">
<header className="flex flex-col gap-4 border-b border-[#dfe4ea] pb-6 lg:flex-row lg:items-end lg:justify-between"><div><h1 className="text-3xl font-bold tracking-normal text-[#101828] sm:text-4xl">业绩转化</h1><p className="mt-3 max-w-3xl text-base leading-7 text-[#66758b]">关联 4 月 1 日以后已完结美术申请中的商品 ID,跟踪流程完结后六个月的每日经营变化。</p></div><p className="inline-flex items-center gap-2 self-start text-xs font-semibold text-slate-600 lg:self-auto"><CalendarDays className="h-4 w-4 text-[#9a4d08]" aria-hidden="true" />流程完结日起按日监控</p></header>
{listState.error && <div role="alert" className="mt-6 flex flex-col gap-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-800 sm:flex-row sm:items-center sm:justify-between"><span>{listState.error}</span><button type="button" onClick={() => void loadList(selectedBrand, page, appliedFilters)} className="inline-flex min-h-9 items-center justify-center gap-2 rounded-lg border border-red-200 bg-white px-3 text-xs font-bold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"><RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />重试</button></div>}
<div className="mt-6 overflow-hidden rounded-lg border border-slate-200 bg-white lg:grid lg:grid-cols-[176px_minmax(0,1fr)]">
<aside aria-label="品牌导航" className="border-b border-slate-200 bg-slate-50 lg:border-b-0 lg:border-r"><div className="border-b border-slate-200 px-4 py-4"><p className="text-xs font-bold text-slate-500">美术申请流程</p><p className="mt-1 text-sm font-bold text-slate-900">六品牌</p></div><nav className="flex gap-1 overflow-x-auto p-2 lg:block lg:space-y-1 lg:overflow-visible" aria-label="选择品牌">{BUSINESS_TREND_BRANDS.map((brand) => { const count = listState.data?.brands.find((item) => item.brand === brand)?.count; return <button key={brand} type="button" aria-current={selectedBrand === brand ? 'page' : undefined} onClick={() => { const nextFilters = { ...appliedFilters, query: '' }; setSelectedBrand(brand); setQuery(''); setCompletedFrom(nextFilters.completedFrom); setCompletedTo(nextFilters.completedTo); setAppliedFilters(nextFilters); setFilterError(null); setPage(1); void loadList(brand, 1, nextFilters) }} className={`flex min-h-11 shrink-0 items-center gap-2 rounded-lg px-3 text-left text-sm font-bold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] lg:w-full ${selectedBrand === brand ? 'bg-[#1677ff] text-white' : 'text-slate-600 hover:bg-slate-200/70 hover:text-slate-950'}`}><span className="min-w-0 flex-1 truncate">{brand}</span>{count !== undefined && <span className={`tabular-nums text-xs ${selectedBrand === brand ? 'text-white/80' : 'text-slate-400'}`}>{count}</span>}</button> })}</nav></aside>
<section className="min-w-0 bg-white" aria-labelledby="performance-brand-title" aria-busy={listState.loading}>
<header className="flex flex-col gap-4 border-b border-slate-200 px-5 py-5 sm:px-7 lg:flex-row lg:items-end lg:justify-between"><div><p className="text-xs font-bold text-[#9a4d08]">当前品牌</p><h2 id="performance-brand-title" className="mt-1 text-xl font-bold tracking-normal text-slate-950">{selectedBrand}</h2><p className="mt-1 text-xs text-slate-500">{appliedFilters.completedFrom || appliedFilters.completedTo ? '区间内 ' : ''}{selectedBrandCount} 个有效流程商品 ID{listState.data?.invalidProductIdCount ? ` · ${listState.data.invalidProductIdCount} 条流程未填写有效 ID` : ''}</p></div>{listState.data && <div className="grid gap-2"><WorkflowSyncStatus sync={listState.data.workflowSync} /><PerformanceDataSyncStatus sync={listState.data.performanceSync} /></div>}</header>
<form onSubmit={submitSearch} className="grid gap-3 border-b border-slate-200 bg-slate-50 px-5 py-4 sm:px-7 md:grid-cols-[minmax(0,1fr)_auto] md:items-end xl:grid-cols-[minmax(280px,0.9fr)_minmax(220px,1fr)_auto]">
<fieldset className="min-w-0 md:col-span-2 xl:col-span-1">
<legend className="mb-1 text-xs font-bold text-slate-600">完结日期</legend>
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2">
<label className="min-w-0"><span className="sr-only">完结开始日期</span><input type="date" value={completedFrom} max={completedTo || undefined} onChange={(event) => { setCompletedFrom(event.target.value); setFilterError(null) }} className="h-11 w-full min-w-0 rounded-lg border border-slate-300 bg-white px-2 text-sm text-slate-800 outline-none focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20" /></label>
<span className="text-xs font-semibold text-slate-500" aria-hidden="true">至</span>
<label className="min-w-0"><span className="sr-only">完结结束日期</span><input type="date" value={completedTo} min={completedFrom || undefined} onChange={(event) => { setCompletedTo(event.target.value); setFilterError(null) }} className="h-11 w-full min-w-0 rounded-lg border border-slate-300 bg-white px-2 text-sm text-slate-800 outline-none focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20" /></label>
</div>
</fieldset>
<label className="min-w-0"><span className="mb-1 block text-xs font-bold text-slate-600">关键字</span><span className="relative block"><span className="sr-only">搜索商品 ID、审批编号或流程标题</span><Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" aria-hidden="true" /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索商品 ID、审批编号或流程标题" className="h-11 w-full rounded-lg border border-slate-300 bg-white pl-10 pr-3 text-sm text-slate-900 outline-none focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20" /></span></label>
<button type="submit" className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[#1677ff] px-4 text-sm font-bold text-white hover:bg-[#0f67db] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><Search className="h-4 w-4" aria-hidden="true" />查询</button>
{filterError && <p role="alert" className="text-xs font-semibold text-red-700 md:col-span-3">{filterError}</p>}
</form>
{listState.loading && !listState.data ? <div role="status" className="px-5 py-16 text-center"><RefreshCw className="mx-auto h-7 w-7 animate-spin text-[#1677ff]" aria-hidden="true" /><p className="mt-4 text-sm font-semibold text-slate-700">正在读取美术流程商品 ID</p></div> : listState.data?.items.length ? <><WorkflowTable items={listState.data.items} detailHref={detailHref} /><div className="flex items-center justify-between border-t border-slate-200 bg-slate-50 px-5 py-3 text-xs text-slate-500 sm:px-7"><span>第 {page} 页 · 共 {listState.data.total} 项</span><div className="flex gap-2"><button type="button" aria-label="上一页" disabled={page <= 1 || listState.loading} onClick={() => { const next = page - 1; setPage(next); void loadList(selectedBrand, next, appliedFilters) }} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-300 bg-white text-slate-700 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><ChevronLeft className="h-4 w-4" aria-hidden="true" /></button><button type="button" aria-label="下一页" disabled={!listState.data.hasMore || listState.loading} onClick={() => { const next = page + 1; setPage(next); void loadList(selectedBrand, next, appliedFilters) }} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-300 bg-white text-slate-700 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><ChevronRight className="h-4 w-4" aria-hidden="true" /></button></div></div></> : <div className="px-5 py-16 text-center"><Activity className="mx-auto h-7 w-7 text-slate-400" aria-hidden="true" /><p className="mt-3 text-sm font-bold text-slate-700">{appliedFilters.query || appliedFilters.completedFrom || appliedFilters.completedTo ? '该筛选条件下没有匹配的流程商品 ID' : '该品牌暂无已完结且填写有效商品 ID 的美术流程'}</p>{Boolean(listState.data?.invalidProductIdCount) && <p className="mt-1 text-xs text-slate-500">有 {listState.data?.invalidProductIdCount} 条流程填写了空值或无效占位内容</p>}</div>}
</section>
</div>
</main>
</DepartmentShell>
)
}