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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 lý 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 lý 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)} triệu`;
|
||||
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 mới</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="">Tất cả giao dịch</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="">Tất cả loại 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>
|
||||
);
|
||||
|
||||
@@ -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
303
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user