166 lines
8.3 KiB
TypeScript
166 lines
8.3 KiB
TypeScript
import { ChevronLeft, Database, ExternalLink, Search, X } from 'lucide-react';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import type { OverviewDrillPayload } from '../model';
|
|
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
|
|
|
|
interface OverviewDrillDialogProps {
|
|
payload: OverviewDrillPayload | null;
|
|
onClose: () => void;
|
|
onPrimaryAction?: () => void;
|
|
onGroupByChange?: (groupBy: 'station' | 'customer' | 'vehicle') => void;
|
|
onRowSelect?: (row: Record<string, string | number>) => void;
|
|
}
|
|
|
|
const TONE_CLASS = {
|
|
default: 'text-slate-900',
|
|
blue: 'text-sky-600',
|
|
green: 'text-emerald-600',
|
|
amber: 'text-amber-600',
|
|
red: 'text-rose-600',
|
|
} as const;
|
|
|
|
export function OverviewDrillDialog({ payload, onClose, onPrimaryAction, onGroupByChange, onRowSelect }: OverviewDrillDialogProps) {
|
|
const [sortKey, setSortKey] = useState('');
|
|
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
|
useEffect(() => {
|
|
if (!payload) return;
|
|
const previousOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = 'hidden';
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') onClose();
|
|
};
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
document.body.style.overflow = previousOverflow;
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
};
|
|
}, [onClose, payload]);
|
|
|
|
useEffect(() => {
|
|
if (!payload?.columns.length) return;
|
|
setSortKey(payload.columns[0].key);
|
|
setSortDirection('desc');
|
|
}, [payload]);
|
|
|
|
const sortedRows = useMemo(
|
|
() => payload ? sortBy(payload.rows, sortKey, sortDirection, (row, key) => row[key]) : [],
|
|
[payload, sortDirection, sortKey],
|
|
);
|
|
const changeSort = (nextKey: string) => {
|
|
const next = toggleSort(sortKey, sortDirection, nextKey);
|
|
setSortKey(next.key);
|
|
setSortDirection(next.direction);
|
|
};
|
|
|
|
if (!payload || typeof document === 'undefined') return null;
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-950/70 p-3 backdrop-blur-[8px] md:p-5"
|
|
onMouseDown={event => {
|
|
if (event.target === event.currentTarget) onClose();
|
|
}}
|
|
role="presentation"
|
|
>
|
|
<section
|
|
className="flex max-h-[90vh] w-full max-w-[1100px] flex-col overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.35)]"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="overview-drill-title"
|
|
>
|
|
<header className="flex items-center justify-between gap-3 bg-slate-900 px-3 py-3 text-white md:px-5 md:py-4">
|
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
<button type="button" onClick={onClose} className="inline-flex h-8 shrink-0 items-center gap-1 rounded-lg border border-white/20 bg-white/10 px-2.5 text-[12px] font-bold text-slate-100 transition hover:border-sky-400 hover:bg-sky-400/15 hover:text-sky-300">
|
|
<ChevronLeft size={16} />
|
|
<span className="hidden sm:inline">返回</span>
|
|
</button>
|
|
<div className="min-w-0">
|
|
<h2 id="overview-drill-title" className="flex items-center gap-2 truncate text-[14px] font-bold text-slate-50 md:text-[16px]">
|
|
<Search size={16} className="shrink-0 text-sky-300" />
|
|
<span className="truncate">{payload.title}</span>
|
|
</h2>
|
|
<p className="mt-0.5 truncate text-[11px] font-medium text-slate-400 md:text-[12px]">{payload.subtitle}</p>
|
|
</div>
|
|
</div>
|
|
<button type="button" onClick={onClose} className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/10 text-slate-300 transition hover:bg-rose-500/80 hover:text-white" aria-label="关闭下钻弹层">
|
|
<X size={17} />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="flex-1 overflow-y-auto bg-slate-50 p-3 md:p-5">
|
|
<div className="grid grid-cols-2 gap-2 rounded-lg border border-slate-200 bg-white p-3 md:grid-cols-4 md:gap-4 md:px-[18px] md:py-[14px]">
|
|
{payload.metrics.map(metric => (
|
|
<div key={metric.label} className="min-w-0">
|
|
<div className="text-[11px] font-medium text-slate-500">{metric.label}</div>
|
|
<div className={`mt-1 truncate text-[16px] font-extrabold tabular-nums ${TONE_CLASS[metric.tone ?? 'default']}`} title={metric.value}>{metric.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{payload.groupByOptions?.length && onGroupByChange ? (
|
|
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5">
|
|
<div className="text-[11px] font-medium text-slate-500">汇总维度</div>
|
|
<div className="flex rounded-md bg-slate-100 p-0.5">
|
|
{payload.groupByOptions.map(groupBy => (
|
|
<button
|
|
key={groupBy}
|
|
type="button"
|
|
onClick={() => onGroupByChange(groupBy)}
|
|
className={`rounded px-3 py-1.5 text-[11px] font-bold transition ${payload.groupBy === groupBy ? 'bg-white text-sky-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
|
>
|
|
按{groupBy === 'customer' ? '客户' : groupBy === 'station' ? '加氢站' : '车辆'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{payload.columns.length > 0 && payload.rows.length > 0 ? (
|
|
<div className="mt-4 max-h-[440px] overflow-auto rounded-lg border border-slate-200 bg-white">
|
|
<table className="w-full min-w-[620px] border-collapse text-left">
|
|
<thead className="sticky top-0 z-10 bg-slate-100">
|
|
<tr>
|
|
{payload.columns.map(column => (
|
|
<th key={column.key} className={`border-b border-slate-200 px-3 py-2.5 text-[11px] font-bold text-slate-600 ${column.align === 'right' ? 'text-right' : 'text-left'}`}><SortableColumnHeader label={column.label} sortKey={column.key} activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align={column.align === 'right' ? 'right' : 'left'} /></th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedRows.map((row, rowIndex) => (
|
|
<tr
|
|
key={rowIndex}
|
|
className={`border-b border-slate-100 last:border-0 hover:bg-slate-50 ${onRowSelect && payload.rowActionLabel ? 'cursor-pointer' : ''}`}
|
|
onClick={() => onRowSelect?.(row)}
|
|
title={onRowSelect && payload.rowActionLabel ? payload.rowActionLabel : undefined}
|
|
>
|
|
{payload.columns.map(column => (
|
|
<td key={column.key} className={`whitespace-nowrap px-3 py-2.5 text-[12px] text-slate-700 ${column.align === 'right' ? 'text-right font-semibold tabular-nums' : 'text-left'}`}>{row[column.key]}</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="mt-4 flex min-h-[180px] flex-col items-center justify-center rounded-lg border border-dashed border-slate-300 bg-white px-5 text-center">
|
|
<span className="grid h-10 w-10 place-items-center rounded-lg bg-sky-50 text-sky-600"><Database size={19} /></span>
|
|
<div className="mt-3 text-[13px] font-bold text-slate-700">真实明细尚未接入当前总览接口</div>
|
|
<p className="mt-1 max-w-[620px] text-[11px] font-medium leading-5 text-slate-500">{payload.emptyMessage}</p>
|
|
</div>
|
|
)}
|
|
|
|
{payload.primaryActionLabel && onPrimaryAction ? (
|
|
<div className="mt-4 flex justify-end">
|
|
<button type="button" onClick={onPrimaryAction} className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-sky-600 px-3.5 text-[12px] font-bold text-white shadow-sm transition hover:bg-sky-700">
|
|
{payload.primaryActionLabel}<ExternalLink size={14} />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|