feat(web): build agent dashboard with analytics charts and listing management

- Dashboard overview: stats cards (listings, views, inquiries, market avg price), Recharts bar chart for district pricing, recent listings feed with engagement metrics
- Analytics page: tabbed layout (overview/trends/districts), interactive bar chart for district comparison, line chart for price trend over quarters with dual Y-axis, clickable heatmap cards
- Listings management: grid/table view toggle, status filter, stats summary cards, table view with thumbnails and engagement data

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 05:20:28 +07:00
parent b6bb422d33
commit 00d2f26e25
5 changed files with 1293 additions and 262 deletions

View File

@@ -3,15 +3,30 @@
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
analyticsApi,
type MarketReportDistrict,
type HeatmapDataPoint,
type DistrictStats,
type PriceTrendPoint,
} from '@/lib/analytics-api';
import {
BarChart,
Bar,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang'];
const CURRENT_PERIOD = '2026-Q1';
const TREND_PERIODS = ['2025-Q1', '2025-Q2', '2025-Q3', '2025-Q4', '2026-Q1'];
function formatPrice(priceStr: string): string {
const num = Number(priceStr);
@@ -29,19 +44,26 @@ function YoYBadge({ value }: { value: number | null }) {
if (value === null) return <span className="text-xs text-muted-foreground">N/A</span>;
const isPositive = value >= 0;
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${isPositive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{isPositive ? '+' : ''}{value.toFixed(1)}%
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${isPositive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}
>
{isPositive ? '+' : ''}
{value.toFixed(1)}%
</span>
);
}
export default function AnalyticsPage() {
const [city, setCity] = useState(CITIES[0]);
const [city, setCity] = useState<string>(CITIES[0] ?? 'Ho Chi Minh');
const [period] = useState(CURRENT_PERIOD);
const [tab, setTab] = useState('overview');
const [marketReport, setMarketReport] = useState<MarketReportDistrict[]>([]);
const [heatmap, setHeatmap] = useState<HeatmapDataPoint[]>([]);
const [districtStats, setDistrictStats] = useState<DistrictStats[]>([]);
const [priceTrend, setPriceTrend] = useState<PriceTrendPoint[]>([]);
const [trendDistrict, setTrendDistrict] = useState<string>('');
const [loading, setLoading] = useState(true);
const [trendLoading, setTrendLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -49,30 +71,73 @@ export default function AnalyticsPage() {
setError(null);
Promise.all([
analyticsApi.getMarketReport(city, period).catch(() => ({ districts: [] as MarketReportDistrict[] })),
analyticsApi.getHeatmap(city, period).catch(() => ({ dataPoints: [] as HeatmapDataPoint[] })),
analyticsApi.getDistrictStats(city, period).catch(() => ({ districts: [] as DistrictStats[] })),
analyticsApi
.getMarketReport(city, period)
.catch(() => ({ districts: [] as MarketReportDistrict[] })),
analyticsApi
.getHeatmap(city, period)
.catch(() => ({ dataPoints: [] as HeatmapDataPoint[] })),
analyticsApi
.getDistrictStats(city, period)
.catch(() => ({ districts: [] as DistrictStats[] })),
])
.then(([report, heatmapData, stats]) => {
setMarketReport(report.districts);
setHeatmap(heatmapData.dataPoints);
setDistrictStats(stats.districts);
// Auto-select first district for trend
const firstDistrict = report.districts[0]?.district ?? '';
if (firstDistrict && !trendDistrict) {
setTrendDistrict(firstDistrict);
}
})
.catch(() => setError('Khong the tai du lieu phan tich'))
.finally(() => setLoading(false));
}, [city, period]);
// Load price trend when district changes
useEffect(() => {
if (!trendDistrict || !city) return;
setTrendLoading(true);
analyticsApi
.getPriceTrend(trendDistrict, city, 'APARTMENT', TREND_PERIODS)
.then((res) => setPriceTrend(res.trend))
.catch(() => setPriceTrend([]))
.finally(() => setTrendLoading(false));
}, [trendDistrict, city]);
const totalListings = marketReport.reduce((sum, d) => sum + d.totalListings, 0);
const avgDaysOnMarket = marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.daysOnMarket, 0) / marketReport.length
: 0;
const avgPriceM2 = marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.avgPriceM2, 0) / marketReport.length
: 0;
const avgDaysOnMarket =
marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.daysOnMarket, 0) / marketReport.length
: 0;
const avgPriceM2 =
marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.avgPriceM2, 0) / marketReport.length
: 0;
const uniqueDistricts = [...new Set(marketReport.map((d) => d.district))];
// Chart data for bar chart
const barChartData = heatmap
.sort((a, b) => b.avgPriceM2 - a.avgPriceM2)
.map((p) => ({
district: p.district.replace(/^Quan\s*/i, 'Q.').replace(/^Huyen\s*/i, 'H.'),
price: Math.round(p.avgPriceM2 / 1_000_000),
listings: p.totalListings,
}));
// Chart data for line chart
const trendChartData = priceTrend.map((p) => ({
period: p.period,
'Gia/m2': Math.round(p.avgPriceM2 / 1_000_000),
'Tin dang': p.totalListings,
}));
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold">Phan tich thi truong</h1>
<p className="mt-2 text-muted-foreground">
@@ -93,163 +158,375 @@ export default function AnalyticsPage() {
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 p-4 text-red-700">{error}</div>
)}
{error && <div className="rounded-md bg-red-50 p-4 text-red-700">{error}</div>}
{/* Summary Cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Tong tin dang</CardDescription>
<CardTitle className="text-2xl">{loading ? '...' : totalListings.toLocaleString('vi-VN')}</CardTitle>
<CardTitle className="text-2xl">
{loading ? '...' : totalListings.toLocaleString('vi-VN')}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Gia TB/m2</CardDescription>
<CardTitle className="text-2xl">{loading ? '...' : formatPriceM2(avgPriceM2)}</CardTitle>
<CardTitle className="text-2xl">
{loading ? '...' : formatPriceM2(avgPriceM2)}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Ngay trung binh de ban</CardDescription>
<CardTitle className="text-2xl">{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngay`}</CardTitle>
<CardTitle className="text-2xl">
{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngay`}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>So quan/huyen</CardDescription>
<CardTitle className="text-2xl">{loading ? '...' : new Set(marketReport.map(d => d.district)).size}</CardTitle>
<CardTitle className="text-2xl">
{loading ? '...' : new Set(marketReport.map((d) => d.district)).size}
</CardTitle>
</CardHeader>
</Card>
</div>
{/* Heatmap - Price by District */}
<Card>
<CardHeader>
<CardTitle>Ban do gia theo quan</CardTitle>
<CardDescription>So sanh gia trung binh/m2 giua cac quan tai {city}</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">Dang tai...</div>
) : heatmap.length === 0 ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">Chua co du lieu</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{heatmap
.sort((a, b) => b.avgPriceM2 - a.avgPriceM2)
.map((point) => {
const maxPrice = heatmap[0] ? Math.max(...heatmap.map(h => h.avgPriceM2)) : 1;
const intensity = Math.round((point.avgPriceM2 / maxPrice) * 100);
return (
<div
key={point.district}
className="rounded-lg border p-3"
style={{
background: `linear-gradient(135deg, hsl(${120 - intensity * 1.2}, 70%, 95%), hsl(${120 - intensity * 1.2}, 70%, 85%))`,
}}
>
<div className="font-medium">{point.district}</div>
<div className="text-sm font-semibold">{formatPriceM2(point.avgPriceM2)}</div>
<div className="text-xs text-muted-foreground">{point.totalListings} tin dang</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Tabs */}
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="overview">Tong quan</TabsTrigger>
<TabsTrigger value="trends">Xu huong gia</TabsTrigger>
<TabsTrigger value="districts">Chi tiet quan</TabsTrigger>
</TabsList>
{/* District Stats Table */}
<Card>
<CardHeader>
<CardTitle>Thong ke chi tiet theo quan</CardTitle>
<CardDescription>Du lieu thi truong bat dong san tai {city} - {period}</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">Dang tai...</div>
) : districtStats.length === 0 ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">Chua co du lieu</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="pb-2 pr-4 font-medium">Quan</th>
<th className="pb-2 pr-4 font-medium">Loai BDS</th>
<th className="pb-2 pr-4 font-medium text-right">Gia trung vi</th>
<th className="pb-2 pr-4 font-medium text-right">Gia/m2</th>
<th className="pb-2 pr-4 font-medium text-right">Tin dang</th>
<th className="pb-2 pr-4 font-medium text-right">Ngay ban</th>
<th className="pb-2 font-medium text-right">YoY</th>
</tr>
</thead>
<tbody>
{districtStats.map((stat, i) => (
<tr key={`${stat.district}-${stat.propertyType}-${i}`} className="border-b last:border-0">
<td className="py-2 pr-4">{stat.district}</td>
<td className="py-2 pr-4 text-xs text-muted-foreground">{stat.propertyType}</td>
<td className="py-2 pr-4 text-right font-medium">{formatPrice(stat.medianPrice)}</td>
<td className="py-2 pr-4 text-right">{formatPriceM2(stat.avgPriceM2)}</td>
<td className="py-2 pr-4 text-right">{stat.totalListings}</td>
<td className="py-2 pr-4 text-right">{stat.daysOnMarket.toFixed(0)}</td>
<td className="py-2 text-right"><YoYBadge value={stat.yoyChange} /></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Market Report Summary */}
<Card>
<CardHeader>
<CardTitle>Bao cao thi truong</CardTitle>
<CardDescription>Tong hop chi so thi truong theo tung quan</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">Dang tai...</div>
) : marketReport.length === 0 ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">Chua co du lieu</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[...new Map(marketReport.map(d => [d.district, d])).values()].map((district) => (
<div key={district.district} className="rounded-lg border p-4">
<h3 className="font-semibold">{district.district}</h3>
<div className="mt-2 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Gia trung vi</span>
<span className="font-medium">{formatPrice(district.medianPrice)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Gia/m2</span>
<span>{formatPriceM2(district.avgPriceM2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tin dang</span>
<span>{district.totalListings}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Ton kho</span>
<span>{district.inventoryLevel}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Thay doi YoY</span>
<YoYBadge value={district.yoyChange} />
</div>
{/* Overview Tab */}
<TabsContent value="overview">
<div className="mt-4 grid gap-6 lg:grid-cols-2">
{/* Bar Chart - Price by District */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Gia trung binh theo quan</CardTitle>
<CardDescription>Trieu VND/m2 tai {city}</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Dang tai...
</div>
</div>
) : barChartData.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Chua co du lieu
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={barChartData}
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="district"
tick={{ fontSize: 11 }}
angle={-30}
textAnchor="end"
height={60}
className="fill-muted-foreground"
/>
<YAxis tick={{ fontSize: 12 }} className="fill-muted-foreground" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '0.5rem',
fontSize: '0.875rem',
}}
formatter={(value, name) => [
name === 'price' ? `${value} tr/m2` : value,
name === 'price' ? 'Gia' : 'Tin dang',
]}
/>
<Bar dataKey="price" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Heatmap - Card Grid */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Ban do gia theo quan</CardTitle>
<CardDescription>So sanh gia trung binh/m2 tai {city}</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Dang tai...
</div>
) : heatmap.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Chua co du lieu
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{heatmap
.sort((a, b) => b.avgPriceM2 - a.avgPriceM2)
.slice(0, 8)
.map((point) => {
const maxPrice = Math.max(...heatmap.map((h) => h.avgPriceM2));
const intensity = Math.round((point.avgPriceM2 / maxPrice) * 100);
return (
<div
key={point.district}
className="cursor-pointer rounded-lg border p-3 transition-shadow hover:shadow-md"
style={{
background: `linear-gradient(135deg, hsl(${120 - intensity * 1.2}, 70%, 95%), hsl(${120 - intensity * 1.2}, 70%, 85%))`,
}}
onClick={() => {
setTrendDistrict(point.district);
setTab('trends');
}}
>
<div className="font-medium">{point.district}</div>
<div className="text-sm font-semibold">
{formatPriceM2(point.avgPriceM2)}
</div>
<div className="text-xs text-muted-foreground">
{point.totalListings} tin dang
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Trends Tab */}
<TabsContent value="trends">
<div className="mt-4 space-y-6">
{/* District selector */}
<div className="flex flex-wrap gap-2">
{uniqueDistricts.map((d) => (
<Button
key={d}
variant={trendDistrict === d ? 'default' : 'outline'}
size="sm"
onClick={() => setTrendDistrict(d)}
>
{d}
</Button>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">
Xu huong gia - {trendDistrict || 'Chon quan'}
</CardTitle>
<CardDescription>
Bien dong gia trung binh/m2 qua cac quy (Can ho)
</CardDescription>
</CardHeader>
<CardContent>
{trendLoading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Dang tai...
</div>
) : trendChartData.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Chua co du lieu xu huong
</div>
) : (
<ResponsiveContainer width="100%" height={350}>
<LineChart
data={trendChartData}
margin={{ top: 5, right: 30, left: 0, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="period"
tick={{ fontSize: 12 }}
className="fill-muted-foreground"
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 12 }}
className="fill-muted-foreground"
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 12 }}
className="fill-muted-foreground"
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '0.5rem',
fontSize: '0.875rem',
}}
formatter={(value, name) => [
name === 'Gia/m2' ? `${value} tr/m2` : value,
name,
]}
/>
<Legend />
<Line
yAxisId="left"
type="monotone"
dataKey="Gia/m2"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="Tin dang"
stroke="hsl(var(--muted-foreground))"
strokeWidth={1}
strokeDasharray="5 5"
dot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* District Stats Tab */}
<TabsContent value="districts">
<div className="mt-4 space-y-6">
{/* Stats Table */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Thong ke chi tiet theo quan</CardTitle>
<CardDescription>
Du lieu thi truong bat dong san tai {city} - {period}
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">
Dang tai...
</div>
) : districtStats.length === 0 ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">
Chua co du lieu
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="pb-2 pr-4 font-medium">Quan</th>
<th className="pb-2 pr-4 font-medium">Loai BDS</th>
<th className="pb-2 pr-4 font-medium text-right">Gia trung vi</th>
<th className="pb-2 pr-4 font-medium text-right">Gia/m2</th>
<th className="pb-2 pr-4 font-medium text-right">Tin dang</th>
<th className="pb-2 pr-4 font-medium text-right">Ngay ban</th>
<th className="pb-2 font-medium text-right">YoY</th>
</tr>
</thead>
<tbody>
{districtStats.map((stat, i) => (
<tr
key={`${stat.district}-${stat.propertyType}-${i}`}
className="border-b last:border-0"
>
<td className="py-2 pr-4">{stat.district}</td>
<td className="py-2 pr-4 text-xs text-muted-foreground">
{stat.propertyType}
</td>
<td className="py-2 pr-4 text-right font-medium">
{formatPrice(stat.medianPrice)}
</td>
<td className="py-2 pr-4 text-right">
{formatPriceM2(stat.avgPriceM2)}
</td>
<td className="py-2 pr-4 text-right">{stat.totalListings}</td>
<td className="py-2 pr-4 text-right">
{stat.daysOnMarket.toFixed(0)}
</td>
<td className="py-2 text-right">
<YoYBadge value={stat.yoyChange} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Market Report Cards */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Bao cao thi truong</CardTitle>
<CardDescription>Tong hop chi so thi truong theo tung quan</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">
Dang tai...
</div>
) : marketReport.length === 0 ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">
Chua co du lieu
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[...new Map(marketReport.map((d) => [d.district, d])).values()].map(
(district) => (
<div key={district.district} className="rounded-lg border p-4">
<h3 className="font-semibold">{district.district}</h3>
<div className="mt-2 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Gia trung vi</span>
<span className="font-medium">
{formatPrice(district.medianPrice)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Gia/m2</span>
<span>{formatPriceM2(district.avgPriceM2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tin dang</span>
<span>{district.totalListings}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Ton kho</span>
<span>{district.inventoryLevel}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Thay doi YoY</span>
<YoYBadge value={district.yoyChange} />
</div>
</div>
</div>
),
)}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,54 +1,322 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
import {
analyticsApi,
type MarketReportDistrict,
type HeatmapDataPoint,
} from '@/lib/analytics-api';
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
const CITY = 'Ho Chi Minh';
const PERIOD = '2026-Q1';
function formatPrice(priceStr: string): string {
const num = Number(priceStr);
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} ty`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`;
return num.toLocaleString('vi-VN');
}
function formatPriceM2(price: number): string {
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m2`;
return `${price.toLocaleString('vi-VN')} d/m2`;
}
interface StatCardProps {
title: string;
value: string;
description?: string;
trend?: number | null;
}
function StatCard({ title, value, description, trend }: StatCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardDescription>{title}</CardDescription>
<CardTitle className="text-2xl">{value}</CardTitle>
</CardHeader>
{(description || trend != null) && (
<CardContent>
<div className="flex items-center gap-2">
{trend != null && (
<span
className={`text-xs font-medium ${trend >= 0 ? 'text-green-600' : 'text-red-600'}`}
>
{trend >= 0 ? '+' : ''}
{trend.toFixed(1)}%
</span>
)}
{description && (
<span className="text-xs text-muted-foreground">{description}</span>
)}
</div>
</CardContent>
)}
</Card>
);
}
export default function DashboardPage() {
const [marketReport, setMarketReport] = useState<MarketReportDistrict[]>([]);
const [heatmap, setHeatmap] = useState<HeatmapDataPoint[]>([]);
const [listings, setListings] = useState<PaginatedResult<ListingDetail> | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
Promise.all([
analyticsApi.getMarketReport(CITY, PERIOD).catch(() => ({ districts: [] as MarketReportDistrict[] })),
analyticsApi.getHeatmap(CITY, PERIOD).catch(() => ({ dataPoints: [] as HeatmapDataPoint[] })),
listingsApi.search({ page: 1, limit: 6 }).catch(() => null),
])
.then(([report, heatmapData, listingsResult]) => {
setMarketReport(report.districts);
setHeatmap(heatmapData.dataPoints);
setListings(listingsResult);
})
.finally(() => setLoading(false));
}, []);
const totalListings = marketReport.reduce((sum, d) => sum + d.totalListings, 0);
const avgPriceM2 =
marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.avgPriceM2, 0) / marketReport.length
: 0;
const avgDaysOnMarket =
marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.daysOnMarket, 0) / marketReport.length
: 0;
const avgYoy =
marketReport.filter((d) => d.yoyChange != null).length > 0
? marketReport
.filter((d) => d.yoyChange != null)
.reduce((sum, d) => sum + (d.yoyChange ?? 0), 0) /
marketReport.filter((d) => d.yoyChange != null).length
: null;
const myListingsCount = listings?.total ?? 0;
const totalViews = listings?.data.reduce((s, l) => s + l.viewCount, 0) ?? 0;
const totalInquiries = listings?.data.reduce((s, l) => s + l.inquiryCount, 0) ?? 0;
const chartData = heatmap
.sort((a, b) => b.avgPriceM2 - a.avgPriceM2)
.slice(0, 8)
.map((p) => ({
district: p.district.replace(/^Quan\s*/i, 'Q.').replace(/^Huyen\s*/i, 'H.'),
'Gia/m2': Math.round(p.avgPriceM2 / 1_000_000),
listings: p.totalListings,
}));
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold">Bảng điều khiển</h1>
<p className="mt-2 text-muted-foreground">
Quản tin đăng bất đng sản của bạn
</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Bang dieu khien</h1>
<p className="mt-2 text-muted-foreground">
Tong quan thi truong va tin dang cua ban
</p>
</div>
<Link href="/listings/new">
<Button>Dang tin moi</Button>
</Link>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Card>
{/* Stats overview */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Tin dang cua toi"
value={loading ? '...' : myListingsCount.toLocaleString('vi-VN')}
description="Tong so tin da dang"
/>
<StatCard
title="Luot xem"
value={loading ? '...' : totalViews.toLocaleString('vi-VN')}
description="Tren tat ca tin dang"
/>
<StatCard
title="Lien he"
value={loading ? '...' : totalInquiries.toLocaleString('vi-VN')}
description="Yeu cau tu khach hang"
/>
<StatCard
title="Gia TB thi truong"
value={loading ? '...' : formatPriceM2(avgPriceM2)}
trend={avgYoy}
description="YoY"
/>
</div>
{/* Market overview + quick stats */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Price chart */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Đăng tin mới</CardTitle>
<CardDescription>Tạo tin đăng bán hoặc cho thuê bất đng sản</CardDescription>
<CardTitle className="text-lg">Gia trung binh theo quan</CardTitle>
<CardDescription>{CITY} - {PERIOD} (trieu VND/m2)</CardDescription>
</CardHeader>
<CardContent>
<Link href="/listings/new">
<Button className="w-full">Đăng tin ngay</Button>
</Link>
{loading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Dang tai...
</div>
) : chartData.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Chua co du lieu
</div>
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="district"
tick={{ fontSize: 12 }}
className="fill-muted-foreground"
/>
<YAxis tick={{ fontSize: 12 }} className="fill-muted-foreground" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '0.5rem',
fontSize: '0.875rem',
}}
formatter={(value) => [`${value} tr/m2`, 'Gia']}
/>
<Bar dataKey="Gia/m2" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Market summary */}
<Card>
<CardHeader>
<CardTitle>Tin đăng của tôi</CardTitle>
<CardDescription>Quản các tin đăng đã tạo</CardDescription>
<CardTitle className="text-lg">Thi truong {CITY}</CardTitle>
<CardDescription>Chi so chinh - {PERIOD}</CardDescription>
</CardHeader>
<CardContent>
<Link href="/listings">
<Button variant="outline" className="w-full">Xem danh sách</Button>
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tìm kiếm</CardTitle>
<CardDescription>Tìm bất đng sản phù hợp nhu cầu</CardDescription>
</CardHeader>
<CardContent>
<Link href="/search">
<Button variant="outline" className="w-full">Tìm kiếm</Button>
</Link>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tong tin dang</span>
<span className="font-semibold">
{loading ? '...' : totalListings.toLocaleString('vi-VN')}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Gia TB/m2</span>
<span className="font-semibold">
{loading ? '...' : formatPriceM2(avgPriceM2)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Ngay TB de ban</span>
<span className="font-semibold">
{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngay`}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">So quan</span>
<span className="font-semibold">
{loading ? '...' : new Set(marketReport.map((d) => d.district)).size}
</span>
</div>
<div className="pt-2">
<Link href="/analytics">
<Button variant="outline" size="sm" className="w-full">
Xem phan tich chi tiet
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
{/* Recent listings */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg">Tin dang gan day</CardTitle>
<CardDescription>Danh sach tin dang moi nhat cua ban</CardDescription>
</div>
<Link href="/listings">
<Button variant="outline" size="sm">
Xem tat ca
</Button>
</Link>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-32 items-center justify-center text-muted-foreground">
Dang tai...
</div>
) : !listings || listings.data.length === 0 ? (
<div className="flex h-32 flex-col items-center justify-center text-muted-foreground">
<p>Chua co tin dang nao</p>
<Link href="/listings/new" className="mt-2">
<Button variant="outline" size="sm">
Dang tin dau tien
</Button>
</Link>
</div>
) : (
<div className="space-y-3">
{listings.data.slice(0, 5).map((listing) => (
<Link
key={listing.id}
href={`/listings/${listing.id}`}
className="flex items-center gap-4 rounded-lg border p-3 transition-colors hover:bg-accent"
>
<div className="h-12 w-16 flex-shrink-0 overflow-hidden rounded bg-muted">
{listing.property.media.length > 0 ? (
<img
src={listing.property.media[0]?.url}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
N/A
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{listing.property.title}</p>
<p className="text-sm text-muted-foreground">
{listing.property.district}, {listing.property.city}
</p>
</div>
<div className="text-right">
<p className="font-semibold text-primary">
{formatPrice(listing.priceVND)}
</p>
<ListingStatusBadge status={listing.status} />
</div>
<div className="hidden sm:flex sm:gap-3 sm:text-sm sm:text-muted-foreground">
<span>{listing.viewCount} luot xem</span>
<span>{listing.inquiryCount} lien he</span>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -2,35 +2,57 @@
import * as React from 'react';
import Link from 'next/link';
import { Card, CardContent } from '@/components/ui/card';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select } from '@/components/ui/select';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
import {
listingsApi,
type ListingDetail,
type ListingStatus,
type PaginatedResult,
} from '@/lib/listings-api';
import { PROPERTY_TYPES, TRANSACTION_TYPES, LISTING_STATUSES } from '@/lib/validations/listings';
import { useAuthStore } from '@/lib/auth-store';
function formatPrice(priceVND: string): string {
const num = Number(priceVND);
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} t`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triu`;
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} ty`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`;
return num.toLocaleString('vi-VN');
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return 'N/A';
return new Date(dateStr).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
type ViewMode = 'grid' | 'table';
export default function ListingsPage() {
const { tokens } = useAuthStore();
const [result, setResult] = React.useState<PaginatedResult<ListingDetail> | null>(null);
const [loading, setLoading] = React.useState(true);
const [viewMode, setViewMode] = React.useState<ViewMode>('grid');
const [filters, setFilters] = React.useState({
transactionType: '',
propertyType: '',
status: '' as string,
page: 1,
});
React.useEffect(() => {
const fetchListings = React.useCallback(() => {
setLoading(true);
const params: Record<string, string | number> = { page: filters.page, limit: 12 };
if (filters.transactionType) params['transactionType'] = filters.transactionType;
if (filters.propertyType) params['propertyType'] = filters.propertyType;
if (filters.status) params['status'] = filters.status;
listingsApi
.search(params)
@@ -39,23 +61,78 @@ export default function ListingsPage() {
.finally(() => setLoading(false));
}, [filters]);
React.useEffect(() => {
fetchListings();
}, [fetchListings]);
// Stats from current page data
const stats = React.useMemo(() => {
if (!result) return { total: 0, active: 0, pending: 0, views: 0 };
return {
total: result.total,
active: result.data.filter((l) => l.status === 'ACTIVE').length,
pending: result.data.filter((l) => l.status === 'PENDING_REVIEW').length,
views: result.data.reduce((s, l) => s + l.viewCount, 0),
};
}, [result]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-bold">Tin đăng</h1>
<div>
<h1 className="text-2xl font-bold">Quan ly tin dang</h1>
<p className="text-sm text-muted-foreground">
Quan ly, theo doi va cap nhat cac tin dang cua ban
</p>
</div>
<Link href="/listings/new">
<Button>Đăng tin mi</Button>
<Button>Dang tin moi</Button>
</Link>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3">
{/* Stats */}
<div className="grid gap-3 sm:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Tong tin dang</CardDescription>
<CardTitle className="text-xl">{loading ? '...' : stats.total}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Dang hoat dong</CardDescription>
<CardTitle className="text-xl text-green-600">
{loading ? '...' : stats.active}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Cho duyet</CardDescription>
<CardTitle className="text-xl text-yellow-600">
{loading ? '...' : stats.pending}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Tong luot xem</CardDescription>
<CardTitle className="text-xl">{loading ? '...' : stats.views}</CardTitle>
</CardHeader>
</Card>
</div>
{/* Filters + View Toggle */}
<div className="flex flex-wrap items-center gap-3">
<Select
value={filters.transactionType}
onChange={(e) => setFilters((f) => ({ ...f, transactionType: e.target.value, page: 1 }))}
onChange={(e) =>
setFilters((f) => ({ ...f, transactionType: e.target.value, page: 1 }))
}
className="w-40"
>
<option value="">Tt c giao dch</option>
<option value="">Tat ca giao dich</option>
{TRANSACTION_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
@@ -64,108 +141,217 @@ export default function ListingsPage() {
</Select>
<Select
value={filters.propertyType}
onChange={(e) => setFilters((f) => ({ ...f, propertyType: e.target.value, page: 1 }))}
onChange={(e) =>
setFilters((f) => ({ ...f, propertyType: e.target.value, page: 1 }))
}
className="w-44"
>
<option value="">Tt c loi BĐS</option>
<option value="">Tat ca loai BDS</option>
{PROPERTY_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
<Select
value={filters.status}
onChange={(e) => setFilters((f) => ({ ...f, status: e.target.value, page: 1 }))}
className="w-40"
>
<option value="">Tat ca trang thai</option>
{Object.entries(LISTING_STATUSES).map(([key, { label }]) => (
<option key={key} value={key}>
{label}
</option>
))}
</Select>
<div className="ml-auto flex gap-1">
<Button
variant={viewMode === 'grid' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('grid')}
>
Luoi
</Button>
<Button
variant={viewMode === 'table' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('table')}
>
Bang
</Button>
</div>
</div>
{/* Listing grid */}
{/* Content */}
{loading ? (
<div className="flex min-h-[300px] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : !result || result.data.length === 0 ? (
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
<p>Chưa có tin đăng nào</p>
<p>Chua co tin dang nao</p>
<Link href="/listings/new" className="mt-2">
<Button variant="outline" size="sm">
Đăng tin đu tiên
Dang tin dau tien
</Button>
</Link>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{result.data.map((listing) => (
<Link key={listing.id} href={`/listings/${listing.id}`}>
<Card className="h-full overflow-hidden transition-shadow hover:shadow-md">
<div className="relative aspect-[4/3] bg-muted">
{listing.property.media.length > 0 ? (
<img
src={listing.property.media[0]?.url}
alt={listing.property.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
Chưa có nh
</div>
)}
<div className="absolute left-2 top-2">
<ListingStatusBadge status={listing.status} />
) : viewMode === 'grid' ? (
/* Grid View */
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{result.data.map((listing) => (
<Link key={listing.id} href={`/listings/${listing.id}`}>
<Card className="h-full overflow-hidden transition-shadow hover:shadow-md">
<div className="relative aspect-[4/3] bg-muted">
{listing.property.media.length > 0 ? (
<img
src={listing.property.media[0]?.url}
alt={listing.property.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
Chua co anh
</div>
)}
<div className="absolute left-2 top-2">
<ListingStatusBadge status={listing.status} />
</div>
<CardContent className="p-4">
<p className="text-lg font-bold text-primary">
{formatPrice(listing.priceVND)} VNĐ
</p>
<h3 className="mt-1 line-clamp-1 font-medium">{listing.property.title}</h3>
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
{listing.property.district}, {listing.property.city}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
</div>
<CardContent className="p-4">
<p className="text-lg font-bold text-primary">
{formatPrice(listing.priceVND)} VND
</p>
<h3 className="mt-1 line-clamp-1 font-medium">{listing.property.title}</h3>
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
{listing.property.district}, {listing.property.city}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<Badge variant="secondary" className="text-xs">
{listing.property.areaM2} m2
</Badge>
{listing.property.bedrooms != null && (
<Badge variant="secondary" className="text-xs">
{listing.property.areaM2} m²
{listing.property.bedrooms} PN
</Badge>
{listing.property.bedrooms != null && (
<Badge variant="secondary" className="text-xs">
{listing.property.bedrooms} PN
</Badge>
)}
{listing.property.bathrooms != null && listing.property.bathrooms > 0 && (
<Badge variant="secondary" className="text-xs">
{listing.property.bathrooms} PT
</Badge>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
{/* Pagination */}
{result.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={filters.page <= 1}
onClick={() => setFilters((f) => ({ ...f, page: f.page - 1 }))}
>
Trước
</Button>
<span className="text-sm text-muted-foreground">
Trang {result.page} / {result.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={filters.page >= result.totalPages}
onClick={() => setFilters((f) => ({ ...f, page: f.page + 1 }))}
>
Tiếp
</Button>
)}
{listing.property.bathrooms != null && listing.property.bathrooms > 0 && (
<Badge variant="secondary" className="text-xs">
{listing.property.bathrooms} PT
</Badge>
)}
</div>
<div className="mt-3 flex gap-3 text-xs text-muted-foreground">
<span>{listing.viewCount} luot xem</span>
<span>{listing.inquiryCount} lien he</span>
<span>{listing.saveCount} da luu</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
/* Table View */
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="p-3 font-medium">Tin dang</th>
<th className="p-3 font-medium">Loai</th>
<th className="p-3 font-medium text-right">Gia</th>
<th className="p-3 font-medium text-right">Dien tich</th>
<th className="p-3 font-medium text-center">Trang thai</th>
<th className="p-3 font-medium text-right">Luot xem</th>
<th className="p-3 font-medium text-right">Lien he</th>
<th className="p-3 font-medium text-right">Ngay dang</th>
</tr>
</thead>
<tbody>
{result.data.map((listing) => (
<tr
key={listing.id}
className="border-b last:border-0 transition-colors hover:bg-accent/50"
>
<td className="p-3">
<Link
href={`/listings/${listing.id}`}
className="group flex items-center gap-3"
>
<div className="h-10 w-14 flex-shrink-0 overflow-hidden rounded bg-muted">
{listing.property.media.length > 0 ? (
<img
src={listing.property.media[0]?.url}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
N/A
</div>
)}
</div>
<div className="min-w-0">
<p className="truncate font-medium group-hover:text-primary">
{listing.property.title}
</p>
<p className="truncate text-xs text-muted-foreground">
{listing.property.district}, {listing.property.city}
</p>
</div>
</Link>
</td>
<td className="p-3 text-xs text-muted-foreground">
{listing.property.propertyType}
</td>
<td className="p-3 text-right font-medium text-primary">
{formatPrice(listing.priceVND)}
</td>
<td className="p-3 text-right">{listing.property.areaM2} m2</td>
<td className="p-3 text-center">
<ListingStatusBadge status={listing.status} />
</td>
<td className="p-3 text-right">{listing.viewCount}</td>
<td className="p-3 text-right">{listing.inquiryCount}</td>
<td className="p-3 text-right text-xs text-muted-foreground">
{formatDate(listing.publishedAt ?? listing.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
</CardContent>
</Card>
)}
{/* Pagination */}
{result && result.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={filters.page <= 1}
onClick={() => setFilters((f) => ({ ...f, page: f.page - 1 }))}
>
Truoc
</Button>
<span className="text-sm text-muted-foreground">
Trang {result.page} / {result.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={filters.page >= result.totalPages}
onClick={() => setFilters((f) => ({ ...f, page: f.page + 1 }))}
>
Tiep
</Button>
</div>
)}
</div>
);

View File

@@ -19,6 +19,7 @@
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.72.1",
"recharts": "^3.8.1",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.12"

303
pnpm-lock.yaml generated
View File

@@ -241,6 +241,9 @@ importers:
react-hook-form:
specifier: ^7.72.1
version: 7.72.1(react@18.3.1)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@19.2.4)(react@18.3.1)(redux@5.0.1)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
@@ -249,7 +252,7 @@ importers:
version: 4.3.6
zustand:
specifier: ^5.0.12
version: 5.0.12(@types/react@18.3.28)(react@18.3.1)
version: 5.0.12(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
devDependencies:
'@types/mapbox-gl':
specifier: ^3.5.0
@@ -1306,6 +1309,17 @@ packages:
'@protobufjs/utf8@1.1.0':
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rollup/rollup-android-arm-eabi@4.60.1':
resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==}
cpu: [arm]
@@ -1725,6 +1739,33 @@ packages:
'@types/cookiejar@2.1.5':
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -1833,6 +1874,9 @@ packages:
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
@@ -2524,6 +2568,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
@@ -2540,6 +2628,9 @@ packages:
supports-color:
optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
@@ -2690,6 +2781,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es-toolkit@1.45.1:
resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==}
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
@@ -3196,6 +3290,12 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.4:
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -3211,6 +3311,10 @@ packages:
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
interpret@3.1.1:
resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==}
engines: {node: '>=10.13.0'}
@@ -4036,6 +4140,21 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-is@19.2.4:
resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==}
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
@@ -4059,6 +4178,14 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
recharts@3.8.1:
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
rechoir@0.8.0:
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
engines: {node: '>= 10.13.0'}
@@ -4071,6 +4198,14 @@ packages:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@@ -4086,6 +4221,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -4452,6 +4590,9 @@ packages:
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -4594,6 +4735,11 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -4621,6 +4767,9 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -6089,6 +6238,18 @@ snapshots:
'@protobufjs/utf8@1.1.0':
optional: true
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.4
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 18.3.1
react-redux: 9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1)
'@rollup/rollup-android-arm-eabi@4.60.1':
optional: true
@@ -6572,6 +6733,30 @@ snapshots:
'@types/cookiejar@2.1.5': {}
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/deep-eql@4.0.2': {}
'@types/eslint-scope@3.7.7':
@@ -6710,6 +6895,8 @@ snapshots:
'@types/tough-cookie@4.0.5':
optional: true
'@types/use-sync-external-store@0.0.6': {}
'@types/validator@13.15.10': {}
'@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
@@ -7411,6 +7598,44 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
data-uri-to-buffer@4.0.1: {}
dateformat@4.6.3: {}
@@ -7419,6 +7644,8 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js-light@2.5.1: {}
deep-eql@5.0.2: {}
deep-is@0.1.4: {}
@@ -7568,6 +7795,8 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.2
es-toolkit@1.45.1: {}
esbuild@0.27.7:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
@@ -8230,6 +8459,10 @@ snapshots:
ignore@7.0.5: {}
immer@10.2.0: {}
immer@11.1.4: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -8241,6 +8474,8 @@ snapshots:
ini@4.1.1: {}
internmap@2.0.3: {}
interpret@3.1.1: {}
ioredis@5.10.1:
@@ -9033,6 +9268,17 @@ snapshots:
dependencies:
react: 18.3.1
react-is@19.2.4: {}
react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 18.3.1
use-sync-external-store: 1.6.0(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.28
redux: 5.0.1
react@18.3.1:
dependencies:
loose-envify: 1.4.0
@@ -9055,6 +9301,26 @@ snapshots:
real-require@0.2.0: {}
recharts@3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@19.2.4)(react@18.3.1)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.45.1
eventemitter3: 5.0.4
immer: 10.2.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-is: 19.2.4
react-redux: 9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@18.3.1)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
rechoir@0.8.0:
dependencies:
resolve: 1.22.11
@@ -9065,6 +9331,12 @@ snapshots:
dependencies:
redis-errors: 1.2.0
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect-metadata@0.2.2: {}
regexp-tree@0.1.27: {}
@@ -9074,6 +9346,8 @@ snapshots:
require-from-string@2.0.2: {}
reselect@5.1.1: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -9523,6 +9797,8 @@ snapshots:
dependencies:
real-require: 0.2.0
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@@ -9682,6 +9958,10 @@ snapshots:
dependencies:
punycode: 2.3.1
use-sync-external-store@1.6.0(react@18.3.1):
dependencies:
react: 18.3.1
util-deprecate@1.0.2: {}
utils-merge@1.0.1: {}
@@ -9698,6 +9978,23 @@ snapshots:
vary@1.1.2: {}
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite-node@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
cac: 6.7.14
@@ -9908,7 +10205,9 @@ snapshots:
zod@4.3.6: {}
zustand@5.0.12(@types/react@18.3.28)(react@18.3.1):
zustand@5.0.12(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)):
optionalDependencies:
'@types/react': 18.3.28
immer: 11.1.4
react: 18.3.1
use-sync-external-store: 1.6.0(react@18.3.1)