506 lines
20 KiB
TypeScript
506 lines
20 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import {
|
|
ChevronsUp,
|
|
} from 'lucide-react';
|
|
import type {
|
|
MileageSourceGroup,
|
|
MonitoringVehicle,
|
|
MonitoringStats,
|
|
MonitoringFilters,
|
|
} from './types';
|
|
import { fetchMonitoring } from './api';
|
|
import { exportMileageXlsx } from './xlsx-export';
|
|
import VehicleDetailModal from './VehicleDetailModal';
|
|
import MileageLoadingOverlay from './MileageLoadingOverlay';
|
|
import {
|
|
defaultMileageDate,
|
|
getRangePreset,
|
|
normalizeRangeLabel,
|
|
type RangePreset,
|
|
} from './monitoring/date-range';
|
|
import {
|
|
buildExportMonitoringQuery,
|
|
buildFullscreenMonitoringQuery,
|
|
buildInitialMonitoringQuery,
|
|
buildNextPageMonitoringQuery,
|
|
type MonitoringQueryModel,
|
|
} from './monitoring/query-model';
|
|
import ActiveFilterTags from './monitoring/components/ActiveFilterTags';
|
|
import FilterPanel from './monitoring/components/FilterPanel';
|
|
import FullscreenMonitoring from './monitoring/components/FullscreenMonitoring';
|
|
import MonitoringSummary from './monitoring/components/MonitoringSummary';
|
|
import MonitoringToolbar from './monitoring/components/MonitoringToolbar';
|
|
import VehicleList from './monitoring/components/VehicleList';
|
|
|
|
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
|
|
'交投40辆4.5T普货',
|
|
'交投190辆4.5T冷链车',
|
|
]);
|
|
const HIGH_MILEAGE_ALERT_KM = 800;
|
|
|
|
export default function MonitoringView() {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [filterDept, setFilterDept] = useState('All');
|
|
const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>('today');
|
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
|
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
|
|
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
|
const [fullscreenRefresh, setFullscreenRefresh] = useState(0);
|
|
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
|
|
|
// New filters from image
|
|
const [filterPlates, setFilterPlates] = useState<string[]>([]);
|
|
const [filterCustomer, setFilterCustomer] = useState('All');
|
|
const [filterProject, setFilterProject] = useState('All');
|
|
const [filterEntity, setFilterEntity] = useState('All');
|
|
const [filterRentStatus, setFilterRentStatus] = useState('All');
|
|
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
|
|
const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]);
|
|
const [filterRegion, setFilterRegion] = useState('All');
|
|
const [filterBrands, setFilterBrands] = useState<string[]>([]);
|
|
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
|
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
|
const [exporting, setExporting] = useState(false);
|
|
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
|
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
|
|
const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
|
|
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(['instrument']);
|
|
|
|
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
|
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
|
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [], brands: [] });
|
|
const [rangeDailyTotals, setRangeDailyTotals] = useState<{ date: string; totalKm: number }[]>([]);
|
|
const [effectiveRange, setEffectiveRange] = useState<{ start: string; end: string }>(() => ({ start: defaultMileageDate(), end: defaultMileageDate() }));
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [pageLoading, setPageLoading] = useState(true);
|
|
const [manualRefreshing, setManualRefreshing] = useState(false);
|
|
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
|
|
const [showBackToTop, setShowBackToTop] = useState(false);
|
|
const [relativeNow, setRelativeNow] = useState(() => Date.now());
|
|
const PAGE_SIZE = 50;
|
|
|
|
const monitoringQuery = useMemo<MonitoringQueryModel>(() => ({
|
|
sortBy,
|
|
sortOrder,
|
|
searchTerm,
|
|
department: filterDept,
|
|
customer: filterCustomer,
|
|
project: filterProject,
|
|
entity: filterEntity,
|
|
rentStatus: filterRentStatus,
|
|
platePrefix: filterPlatePrefix,
|
|
targetNames: filterTargetNames,
|
|
region: filterRegion,
|
|
plates: filterPlates,
|
|
mileageRange: appliedMileageRange,
|
|
rangeStart,
|
|
rangeEnd,
|
|
brands: filterBrands,
|
|
sourcePriority,
|
|
}), [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
|
|
|
|
// Keep this model and dependency list separate: fullscreen historically omits
|
|
// project, entity and mileage-range, so adding those states would change both
|
|
// its request parameters and refresh triggers.
|
|
const fullscreenQuery = useMemo<MonitoringQueryModel>(() => ({
|
|
sortBy,
|
|
sortOrder,
|
|
searchTerm,
|
|
department: filterDept,
|
|
customer: filterCustomer,
|
|
project: 'All',
|
|
entity: 'All',
|
|
rentStatus: filterRentStatus,
|
|
platePrefix: filterPlatePrefix,
|
|
targetNames: filterTargetNames,
|
|
region: filterRegion,
|
|
plates: filterPlates,
|
|
mileageRange: { min: '', max: '' },
|
|
rangeStart,
|
|
rangeEnd,
|
|
brands: filterBrands,
|
|
sourcePriority,
|
|
}), [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, filterBrands, sourcePriority]);
|
|
|
|
useEffect(() => {
|
|
const timer = setInterval(() => setRelativeNow(Date.now()), 5000);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
const departments = filterOptions.departments;
|
|
const plateNumbers = filterOptions.plates;
|
|
const rangeLabel = normalizeRangeLabel(effectiveRange.start, effectiveRange.end);
|
|
const isRangeMode = !!effectiveRange.start && !!effectiveRange.end && effectiveRange.start !== effectiveRange.end;
|
|
const applyRangePreset = useCallback((preset: RangePreset) => {
|
|
const range = getRangePreset(preset);
|
|
setRangeStart(range.start);
|
|
setRangeEnd(range.end);
|
|
}, []);
|
|
|
|
const isHighMileageAlert = useCallback((v: MonitoringVehicle) => {
|
|
const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name))
|
|
|| filterTargetNames.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name));
|
|
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
|
|
}, [filterTargetNames]);
|
|
|
|
const queryRequestRef = useRef(0);
|
|
|
|
// 加载首页数据
|
|
const loadFirstPage = useCallback((showPageLoading = true) => {
|
|
const requestId = ++queryRequestRef.current;
|
|
if (showPageLoading) setPageLoading(true);
|
|
return fetchMonitoring(buildInitialMonitoringQuery(monitoringQuery, PAGE_SIZE)).then(d => {
|
|
if (requestId !== queryRequestRef.current) return;
|
|
setVehicles(d.vehicles);
|
|
setStats(d.stats);
|
|
setFilterOptions(d.filters);
|
|
setRangeDailyTotals(d.rangeDailyTotals || []);
|
|
setEffectiveRange(d.dateRange || { start: rangeStart, end: rangeEnd });
|
|
setTotal(d.total);
|
|
setPage(1);
|
|
setHasMore(d.page < d.totalPages);
|
|
setLastRefreshedAt(new Date());
|
|
}).catch(() => {}).finally(() => {
|
|
if (requestId === queryRequestRef.current) setPageLoading(false);
|
|
});
|
|
}, [monitoringQuery, rangeStart, rangeEnd]);
|
|
|
|
const handleManualRefresh = useCallback(async () => {
|
|
if (manualRefreshing || pageLoading) return;
|
|
setManualRefreshing(true);
|
|
try {
|
|
await loadFirstPage(false);
|
|
} finally {
|
|
setManualRefreshing(false);
|
|
}
|
|
}, [loadFirstPage, manualRefreshing, pageLoading]);
|
|
|
|
// 加载更多
|
|
const loadMore = useCallback(() => {
|
|
if (pageLoading || loadingMore || !hasMore) return;
|
|
const requestId = queryRequestRef.current;
|
|
const nextPage = page + 1;
|
|
setLoadingMore(true);
|
|
fetchMonitoring(buildNextPageMonitoringQuery(monitoringQuery, PAGE_SIZE, nextPage)).then(d => {
|
|
if (requestId !== queryRequestRef.current) return;
|
|
setVehicles(prev => [...prev, ...d.vehicles]);
|
|
setPage(nextPage);
|
|
setHasMore(nextPage < d.totalPages);
|
|
}).catch(() => {}).finally(() => setLoadingMore(false));
|
|
}, [monitoringQuery, page, pageLoading, loadingMore, hasMore]);
|
|
|
|
// 筛选/排序变化时重新加载
|
|
useEffect(() => {
|
|
loadFirstPage();
|
|
}, [loadFirstPage]);
|
|
|
|
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
|
|
useEffect(() => {
|
|
if (filterPlates.length === 0) return;
|
|
const valid = new Set(filterOptions.plates);
|
|
const next = filterPlates.filter(p => valid.has(p));
|
|
if (next.length !== filterPlates.length) setFilterPlates(next);
|
|
}, [filterOptions.plates, filterPlates]);
|
|
|
|
// 下载当前筛选结果为 xlsx
|
|
const handleDownload = useCallback(async () => {
|
|
if (exporting) return;
|
|
setExporting(true);
|
|
try {
|
|
const d = await fetchMonitoring(buildExportMonitoringQuery(monitoringQuery));
|
|
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
|
|
} catch (err) {
|
|
console.error('export failed', err);
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
}, [exporting, monitoringQuery, rangeStart, rangeEnd, sortBy]);
|
|
|
|
// 每分钟自动刷新
|
|
useEffect(() => {
|
|
const timer = setInterval(() => { void loadFirstPage(false); }, 60 * 1000);
|
|
return () => clearInterval(timer);
|
|
}, [loadFirstPage]);
|
|
|
|
// 触底检测:用 IntersectionObserver 监听哨兵元素
|
|
const loadMoreRef = useRef(loadMore);
|
|
loadMoreRef.current = loadMore;
|
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const sentinel = sentinelRef.current;
|
|
if (!sentinel) return;
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0].isIntersecting) {
|
|
loadMoreRef.current();
|
|
}
|
|
},
|
|
{ rootMargin: '200px' }
|
|
);
|
|
observer.observe(sentinel);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
// 回到顶部按钮:用 IntersectionObserver 检测顶部哨兵是否离开视口
|
|
const topSentinelRef = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
const el = topSentinelRef.current;
|
|
if (!el) return;
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => setShowBackToTop(!entry.isIntersecting),
|
|
);
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
const scrollToTop = () => {
|
|
window.scrollTo(0, 0);
|
|
document.documentElement.scrollTop = 0;
|
|
document.body.scrollTop = 0;
|
|
};
|
|
|
|
const filteredVehicles = vehicles;
|
|
|
|
const toggleFullscreen = () => {
|
|
setIsFullscreen(!isFullscreen);
|
|
};
|
|
|
|
// 全屏时加载全部数据(无分页),筛选变化时重新加载
|
|
useEffect(() => {
|
|
if (!isFullscreen) return;
|
|
setFullscreenLoading(true);
|
|
fetchMonitoring(buildFullscreenMonitoringQuery(fullscreenQuery)).then(d => {
|
|
setFullscreenVehicles(d.vehicles);
|
|
setFullscreenStats(d.stats);
|
|
setFilterOptions(d.filters);
|
|
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
|
}, [isFullscreen, fullscreenQuery, fullscreenRefresh]);
|
|
|
|
// 全屏时禁止背景滚动
|
|
useEffect(() => {
|
|
if (isFullscreen) {
|
|
document.body.style.overflow = 'hidden';
|
|
} else {
|
|
document.body.style.overflow = '';
|
|
}
|
|
return () => { document.body.style.overflow = ''; };
|
|
}, [isFullscreen]);
|
|
|
|
// 检测是否在小程序 webview 中(微信/抖音/支付宝等),且当前是竖屏
|
|
// 小程序 webview 无法调用系统旋转 API,只能用 CSS rotate 强制横屏
|
|
const forceLandscape = useMemo(() => {
|
|
if (typeof window === 'undefined') return false;
|
|
const ua = navigator.userAgent || '';
|
|
const isMiniProgram =
|
|
/miniProgram/i.test(ua) ||
|
|
/toutiaomicroapp/i.test(ua) ||
|
|
/AlipayClient/i.test(ua) ||
|
|
(window as any).__wxjs_environment === 'miniprogram';
|
|
const isPortrait = window.innerHeight > window.innerWidth;
|
|
return isMiniProgram && isPortrait;
|
|
}, [isFullscreen]);
|
|
|
|
return (
|
|
<>
|
|
<AnimatePresence>
|
|
{pageLoading && <MileageLoadingOverlay />}
|
|
</AnimatePresence>
|
|
{/* 顶部哨兵:离开视口时显示回到顶部按钮 */}
|
|
<div ref={topSentinelRef} className="h-0" />
|
|
|
|
{/* Fullscreen Landscape View Overlay */}
|
|
<AnimatePresence>
|
|
{isFullscreen && (
|
|
<FullscreenMonitoring
|
|
forceLandscape={forceLandscape}
|
|
fullscreenStats={fullscreenStats}
|
|
sortBy={sortBy}
|
|
sourcePriority={sourcePriority}
|
|
onRefresh={() => { setFullscreenRefresh(n => n + 1); }}
|
|
fullscreenLoading={fullscreenLoading}
|
|
onToggleFullscreen={toggleFullscreen}
|
|
filterTargetNames={filterTargetNames}
|
|
onFilterTargetNamesChange={setFilterTargetNames}
|
|
filterOptions={filterOptions}
|
|
filterPlates={filterPlates}
|
|
filterCustomer={filterCustomer}
|
|
onFilterCustomerChange={setFilterCustomer}
|
|
filterBrands={filterBrands}
|
|
onFilterBrandsChange={setFilterBrands}
|
|
filterRentStatus={filterRentStatus}
|
|
onFilterRentStatusChange={setFilterRentStatus}
|
|
filterDept={filterDept}
|
|
onFilterDeptChange={setFilterDept}
|
|
departments={departments}
|
|
sortOrder={sortOrder}
|
|
onSortByChange={setSortBy}
|
|
onSortOrderChange={setSortOrder}
|
|
fullscreenVehicles={fullscreenVehicles}
|
|
isHighMileageAlert={isHighMileageAlert}
|
|
relativeNow={relativeNow}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<MonitoringToolbar
|
|
onToggleFullscreen={toggleFullscreen}
|
|
onDownload={handleDownload}
|
|
exporting={exporting}
|
|
lastRefreshedAt={lastRefreshedAt}
|
|
onManualRefresh={handleManualRefresh}
|
|
manualRefreshing={manualRefreshing}
|
|
sortBy={sortBy}
|
|
onSortByChange={setSortBy}
|
|
sortOrder={sortOrder}
|
|
onSortOrderChange={setSortOrder}
|
|
sourcePriority={sourcePriority}
|
|
onSourcePriorityChange={setSourcePriority}
|
|
filterOptions={filterOptions}
|
|
filterTargetNames={filterTargetNames}
|
|
onFilterTargetNamesChange={setFilterTargetNames}
|
|
filterRegion={filterRegion}
|
|
onFilterRegionChange={setFilterRegion}
|
|
plateNumbers={plateNumbers}
|
|
filterPlates={filterPlates}
|
|
onFilterPlatesChange={setFilterPlates}
|
|
isFilterOpen={isFilterOpen}
|
|
onFilterOpenChange={setIsFilterOpen}
|
|
searchTerm={searchTerm}
|
|
filterDept={filterDept}
|
|
filterCustomer={filterCustomer}
|
|
filterRentStatus={filterRentStatus}
|
|
filterProject={filterProject}
|
|
filterBrands={filterBrands}
|
|
rangeStart={rangeStart}
|
|
rangeEnd={rangeEnd}
|
|
onApplyRangePreset={applyRangePreset}
|
|
rangeLabel={rangeLabel}
|
|
/>
|
|
|
|
{/* Expandable Filter Panel */}
|
|
<AnimatePresence>
|
|
{isFilterOpen && (
|
|
<FilterPanel
|
|
rangeStart={rangeStart}
|
|
rangeEnd={rangeEnd}
|
|
onRangeStartChange={setRangeStart}
|
|
onRangeEndChange={setRangeEnd}
|
|
onApplyRangePreset={applyRangePreset}
|
|
departments={departments}
|
|
filterDept={filterDept}
|
|
onFilterDeptChange={setFilterDept}
|
|
filterOptions={filterOptions}
|
|
filterCustomer={filterCustomer}
|
|
onFilterCustomerChange={setFilterCustomer}
|
|
filterBrands={filterBrands}
|
|
onFilterBrandsChange={setFilterBrands}
|
|
filterProject={filterProject}
|
|
onFilterProjectChange={setFilterProject}
|
|
filterRentStatus={filterRentStatus}
|
|
onFilterRentStatusChange={setFilterRentStatus}
|
|
filterEntity={filterEntity}
|
|
onFilterEntityChange={setFilterEntity}
|
|
filterPlatePrefix={filterPlatePrefix}
|
|
onFilterPlatePrefixChange={setFilterPlatePrefix}
|
|
filterMileageRange={filterMileageRange}
|
|
onFilterMileageRangeChange={setFilterMileageRange}
|
|
onSearchTermChange={setSearchTerm}
|
|
onFilterPlatesChange={setFilterPlates}
|
|
onFilterTargetNamesChange={setFilterTargetNames}
|
|
onFilterRegionChange={setFilterRegion}
|
|
onAppliedMileageRangeChange={setAppliedMileageRange}
|
|
onFilterOpenChange={setIsFilterOpen}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Active Filter Tags */}
|
|
<ActiveFilterTags
|
|
filterTargetNames={filterTargetNames}
|
|
filterOptions={filterOptions}
|
|
onFilterTargetNamesChange={setFilterTargetNames}
|
|
filterRegion={filterRegion}
|
|
onFilterRegionChange={setFilterRegion}
|
|
filterPlates={filterPlates}
|
|
onFilterPlatesChange={setFilterPlates}
|
|
filterRentStatus={filterRentStatus}
|
|
onFilterRentStatusChange={setFilterRentStatus}
|
|
filterDept={filterDept}
|
|
onFilterDeptChange={setFilterDept}
|
|
filterCustomer={filterCustomer}
|
|
onFilterCustomerChange={setFilterCustomer}
|
|
filterBrands={filterBrands}
|
|
onFilterBrandsChange={setFilterBrands}
|
|
filterProject={filterProject}
|
|
onFilterProjectChange={setFilterProject}
|
|
filterEntity={filterEntity}
|
|
onFilterEntityChange={setFilterEntity}
|
|
searchTerm={searchTerm}
|
|
onSearchTermChange={setSearchTerm}
|
|
onFilterMileageRangeChange={setFilterMileageRange}
|
|
appliedMileageRange={appliedMileageRange}
|
|
onAppliedMileageRangeChange={setAppliedMileageRange}
|
|
filterPlatePrefix={filterPlatePrefix}
|
|
onFilterPlatePrefixChange={setFilterPlatePrefix}
|
|
rangeStart={rangeStart}
|
|
rangeEnd={rangeEnd}
|
|
onRangeStartChange={setRangeStart}
|
|
onRangeEndChange={setRangeEnd}
|
|
/>
|
|
|
|
{/* Sticky header: KPI + 清单标题 */}
|
|
<MonitoringSummary
|
|
pageLoading={pageLoading}
|
|
sortBy={sortBy}
|
|
isRangeMode={isRangeMode}
|
|
stats={stats}
|
|
rangeLabel={rangeLabel}
|
|
rangeDailyTotals={rangeDailyTotals}
|
|
total={total}
|
|
/>
|
|
|
|
{/* Vehicle List */}
|
|
<VehicleList
|
|
pageLoading={pageLoading}
|
|
vehicles={filteredVehicles}
|
|
isHighMileageAlert={isHighMileageAlert}
|
|
relativeNow={relativeNow}
|
|
isRangeMode={isRangeMode}
|
|
onVehicleClick={setDetailVehicle}
|
|
loadingMore={loadingMore}
|
|
hasMore={hasMore}
|
|
total={total}
|
|
sentinelRef={sentinelRef}
|
|
/>
|
|
|
|
<VehicleDetailModal
|
|
vehicle={detailVehicle}
|
|
onClose={() => setDetailVehicle(null)}
|
|
sourcePriority={sourcePriority}
|
|
/>
|
|
|
|
{/* 回到顶部按钮 */}
|
|
<AnimatePresence>
|
|
{showBackToTop && (
|
|
<motion.button
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.8 }}
|
|
onClick={scrollToTop}
|
|
className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-40 w-10 h-10 bg-blue-600 text-white rounded-full shadow-lg shadow-blue-200 flex items-center justify-center active:scale-95 transition-transform"
|
|
>
|
|
<ChevronsUp size={18} />
|
|
</motion.button>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|