Fix and restyle forecast component
This commit is contained in:
@@ -3,7 +3,7 @@ const path = require('path');
|
|||||||
require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
|
require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
// Configuration flags
|
// Set to 1 to skip product metrics and only calculate the remaining metrics
|
||||||
const SKIP_PRODUCT_METRICS = 0;
|
const SKIP_PRODUCT_METRICS = 0;
|
||||||
|
|
||||||
// Helper function to format elapsed time
|
// Helper function to format elapsed time
|
||||||
@@ -1149,8 +1149,12 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
|
|||||||
DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date
|
DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date
|
||||||
FROM (
|
FROM (
|
||||||
SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION
|
SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION
|
||||||
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 14 UNION SELECT 30 UNION
|
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION
|
||||||
SELECT 60 UNION SELECT 90
|
SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 UNION
|
||||||
|
SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 UNION
|
||||||
|
SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24 UNION
|
||||||
|
SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 UNION SELECT 29 UNION
|
||||||
|
SELECT 30
|
||||||
) numbers
|
) numbers
|
||||||
),
|
),
|
||||||
product_stats AS (
|
product_stats AS (
|
||||||
@@ -1160,7 +1164,15 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
|
|||||||
STDDEV_SAMP(ds.daily_quantity) as std_daily_quantity,
|
STDDEV_SAMP(ds.daily_quantity) as std_daily_quantity,
|
||||||
AVG(ds.daily_revenue) as avg_daily_revenue,
|
AVG(ds.daily_revenue) as avg_daily_revenue,
|
||||||
STDDEV_SAMP(ds.daily_revenue) as std_daily_revenue,
|
STDDEV_SAMP(ds.daily_revenue) as std_daily_revenue,
|
||||||
COUNT(*) as data_points
|
COUNT(*) as data_points,
|
||||||
|
-- Calculate day-of-week averages
|
||||||
|
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 1 THEN ds.daily_revenue END) as sunday_avg,
|
||||||
|
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 2 THEN ds.daily_revenue END) as monday_avg,
|
||||||
|
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 3 THEN ds.daily_revenue END) as tuesday_avg,
|
||||||
|
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 4 THEN ds.daily_revenue END) as wednesday_avg,
|
||||||
|
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 5 THEN ds.daily_revenue END) as thursday_avg,
|
||||||
|
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 6 THEN ds.daily_revenue END) as friday_avg,
|
||||||
|
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 7 THEN ds.daily_revenue END) as saturday_avg
|
||||||
FROM daily_sales ds
|
FROM daily_sales ds
|
||||||
GROUP BY ds.product_id
|
GROUP BY ds.product_id
|
||||||
)
|
)
|
||||||
@@ -1178,14 +1190,24 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
|
|||||||
))
|
))
|
||||||
) as forecast_units,
|
) as forecast_units,
|
||||||
GREATEST(0,
|
GREATEST(0,
|
||||||
ps.avg_daily_revenue *
|
CASE DAYOFWEEK(fd.forecast_date)
|
||||||
|
WHEN 1 THEN COALESCE(ps.sunday_avg, ps.avg_daily_revenue)
|
||||||
|
WHEN 2 THEN COALESCE(ps.monday_avg, ps.avg_daily_revenue)
|
||||||
|
WHEN 3 THEN COALESCE(ps.tuesday_avg, ps.avg_daily_revenue)
|
||||||
|
WHEN 4 THEN COALESCE(ps.wednesday_avg, ps.avg_daily_revenue)
|
||||||
|
WHEN 5 THEN COALESCE(ps.thursday_avg, ps.avg_daily_revenue)
|
||||||
|
WHEN 6 THEN COALESCE(ps.friday_avg, ps.avg_daily_revenue)
|
||||||
|
WHEN 7 THEN COALESCE(ps.saturday_avg, ps.avg_daily_revenue)
|
||||||
|
END *
|
||||||
(1 + COALESCE(
|
(1 + COALESCE(
|
||||||
(SELECT seasonality_factor
|
(SELECT seasonality_factor
|
||||||
FROM sales_seasonality
|
FROM sales_seasonality
|
||||||
WHERE MONTH(fd.forecast_date) = month
|
WHERE MONTH(fd.forecast_date) = month
|
||||||
LIMIT 1),
|
LIMIT 1),
|
||||||
0
|
0
|
||||||
))
|
)) *
|
||||||
|
-- Add some randomness within a small range (±5%)
|
||||||
|
(0.95 + (RAND() * 0.1))
|
||||||
) as forecast_revenue,
|
) as forecast_revenue,
|
||||||
CASE
|
CASE
|
||||||
WHEN ps.data_points >= 60 THEN 90
|
WHEN ps.data_points >= 60 THEN 90
|
||||||
@@ -1231,8 +1253,12 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
|
|||||||
DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date
|
DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date
|
||||||
FROM (
|
FROM (
|
||||||
SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION
|
SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION
|
||||||
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 14 UNION SELECT 30 UNION
|
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION
|
||||||
SELECT 60 UNION SELECT 90
|
SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 UNION
|
||||||
|
SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 UNION
|
||||||
|
SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24 UNION
|
||||||
|
SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 UNION SELECT 29 UNION
|
||||||
|
SELECT 30
|
||||||
) numbers
|
) numbers
|
||||||
),
|
),
|
||||||
category_stats AS (
|
category_stats AS (
|
||||||
@@ -1242,7 +1268,15 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
|
|||||||
STDDEV_SAMP(cds.daily_quantity) as std_daily_quantity,
|
STDDEV_SAMP(cds.daily_quantity) as std_daily_quantity,
|
||||||
AVG(cds.daily_revenue) as avg_daily_revenue,
|
AVG(cds.daily_revenue) as avg_daily_revenue,
|
||||||
STDDEV_SAMP(cds.daily_revenue) as std_daily_revenue,
|
STDDEV_SAMP(cds.daily_revenue) as std_daily_revenue,
|
||||||
COUNT(*) as data_points
|
COUNT(*) as data_points,
|
||||||
|
-- Calculate day-of-week averages
|
||||||
|
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 1 THEN cds.daily_revenue END) as sunday_avg,
|
||||||
|
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 2 THEN cds.daily_revenue END) as monday_avg,
|
||||||
|
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 3 THEN cds.daily_revenue END) as tuesday_avg,
|
||||||
|
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 4 THEN cds.daily_revenue END) as wednesday_avg,
|
||||||
|
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 5 THEN cds.daily_revenue END) as thursday_avg,
|
||||||
|
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 6 THEN cds.daily_revenue END) as friday_avg,
|
||||||
|
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 7 THEN cds.daily_revenue END) as saturday_avg
|
||||||
FROM category_daily_sales cds
|
FROM category_daily_sales cds
|
||||||
GROUP BY cds.category_id
|
GROUP BY cds.category_id
|
||||||
)
|
)
|
||||||
@@ -1260,14 +1294,24 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
|
|||||||
))
|
))
|
||||||
) as forecast_units,
|
) as forecast_units,
|
||||||
GREATEST(0,
|
GREATEST(0,
|
||||||
cs.avg_daily_revenue *
|
CASE DAYOFWEEK(fd.forecast_date)
|
||||||
|
WHEN 1 THEN COALESCE(cs.sunday_avg, cs.avg_daily_revenue)
|
||||||
|
WHEN 2 THEN COALESCE(cs.monday_avg, cs.avg_daily_revenue)
|
||||||
|
WHEN 3 THEN COALESCE(cs.tuesday_avg, cs.avg_daily_revenue)
|
||||||
|
WHEN 4 THEN COALESCE(cs.wednesday_avg, cs.avg_daily_revenue)
|
||||||
|
WHEN 5 THEN COALESCE(cs.thursday_avg, cs.avg_daily_revenue)
|
||||||
|
WHEN 6 THEN COALESCE(cs.friday_avg, cs.avg_daily_revenue)
|
||||||
|
WHEN 7 THEN COALESCE(cs.saturday_avg, cs.avg_daily_revenue)
|
||||||
|
END *
|
||||||
(1 + COALESCE(
|
(1 + COALESCE(
|
||||||
(SELECT seasonality_factor
|
(SELECT seasonality_factor
|
||||||
FROM sales_seasonality
|
FROM sales_seasonality
|
||||||
WHERE MONTH(fd.forecast_date) = month
|
WHERE MONTH(fd.forecast_date) = month
|
||||||
LIMIT 1),
|
LIMIT 1),
|
||||||
0
|
0
|
||||||
))
|
)) *
|
||||||
|
-- Add some randomness within a small range (±5%)
|
||||||
|
(0.95 + (RAND() * 0.1))
|
||||||
) as forecast_revenue,
|
) as forecast_revenue,
|
||||||
CASE
|
CASE
|
||||||
WHEN cs.data_points >= 60 THEN 90
|
WHEN cs.data_points >= 60 THEN 90
|
||||||
|
|||||||
@@ -270,14 +270,13 @@ router.get('/forecast/metrics', async (req, res) => {
|
|||||||
// Get daily forecasts
|
// Get daily forecasts
|
||||||
const [dailyForecasts] = await executeQuery(`
|
const [dailyForecasts] = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
forecast_date as date,
|
DATE(forecast_date) as date,
|
||||||
COALESCE(SUM(forecast_units), 0) as units,
|
|
||||||
COALESCE(SUM(forecast_revenue), 0) as revenue,
|
COALESCE(SUM(forecast_revenue), 0) as revenue,
|
||||||
COALESCE(AVG(confidence_level), 0) as confidence
|
COALESCE(AVG(confidence_level), 0) as confidence
|
||||||
FROM sales_forecasts
|
FROM sales_forecasts
|
||||||
WHERE forecast_date BETWEEN ? AND ?
|
WHERE forecast_date BETWEEN ? AND ?
|
||||||
GROUP BY forecast_date
|
GROUP BY DATE(forecast_date)
|
||||||
ORDER BY forecast_date
|
ORDER BY date
|
||||||
`, [startDate, endDate]);
|
`, [startDate, endDate]);
|
||||||
|
|
||||||
// Get category forecasts
|
// Get category forecasts
|
||||||
@@ -296,12 +295,11 @@ router.get('/forecast/metrics', async (req, res) => {
|
|||||||
|
|
||||||
// Format response
|
// Format response
|
||||||
const response = {
|
const response = {
|
||||||
forecastSales: parseInt(metrics.total_forecast_units) || 0,
|
forecastSales: parseInt(metrics[0].total_forecast_units) || 0,
|
||||||
forecastRevenue: parseFloat(metrics.total_forecast_revenue) || 0,
|
forecastRevenue: parseFloat(metrics[0].total_forecast_revenue) || 0,
|
||||||
confidenceLevel: parseFloat(metrics.overall_confidence) || 0,
|
confidenceLevel: parseFloat(metrics[0].overall_confidence) || 0,
|
||||||
dailyForecasts: dailyForecasts.map(d => ({
|
dailyForecasts: dailyForecasts.map(d => ({
|
||||||
date: d.date,
|
date: d.date,
|
||||||
units: parseInt(d.units) || 0,
|
|
||||||
revenue: parseFloat(d.revenue) || 0,
|
revenue: parseFloat(d.revenue) || 0,
|
||||||
confidence: parseFloat(d.confidence) || 0
|
confidence: parseFloat(d.confidence) || 0
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -6,16 +6,24 @@ import config from "@/config"
|
|||||||
import { formatCurrency } from "@/lib/utils"
|
import { formatCurrency } from "@/lib/utils"
|
||||||
import { TrendingUp, DollarSign } from "lucide-react"
|
import { TrendingUp, DollarSign } from "lucide-react"
|
||||||
import { DateRange } from "react-day-picker"
|
import { DateRange } from "react-day-picker"
|
||||||
import { addDays } from "date-fns"
|
import { addDays, format } from "date-fns"
|
||||||
import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
|
import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
|
||||||
|
|
||||||
interface ForecastData {
|
interface ForecastData {
|
||||||
forecastSales: number
|
forecastSales: number
|
||||||
forecastRevenue: number
|
forecastRevenue: number
|
||||||
dailyForecast: {
|
confidenceLevel: number
|
||||||
|
dailyForecasts: {
|
||||||
date: string
|
date: string
|
||||||
sales: number
|
units: number
|
||||||
revenue: number
|
revenue: number
|
||||||
|
confidence: number
|
||||||
|
}[]
|
||||||
|
categoryForecasts: {
|
||||||
|
category: string
|
||||||
|
units: number
|
||||||
|
revenue: number
|
||||||
|
confidence: number
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,24 +33,28 @@ export function ForecastMetrics() {
|
|||||||
to: addDays(new Date(), 30),
|
to: addDays(new Date(), 30),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data } = useQuery<ForecastData>({
|
const { data, error, isLoading } = useQuery<ForecastData>({
|
||||||
queryKey: ["forecast-metrics", dateRange],
|
queryKey: ["forecast-metrics", dateRange],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
startDate: dateRange.from?.toISOString() || "",
|
startDate: dateRange.from?.toISOString() || "",
|
||||||
endDate: dateRange.to?.toISOString() || "",
|
endDate: dateRange.to?.toISOString() || "",
|
||||||
});
|
});
|
||||||
|
console.log('Fetching forecast metrics with params:', params.toString());
|
||||||
const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics?${params}`)
|
const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics?${params}`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to fetch forecast metrics")
|
const text = await response.text();
|
||||||
|
throw new Error(`Failed to fetch forecast metrics: ${text}`);
|
||||||
}
|
}
|
||||||
return response.json()
|
const data = await response.json();
|
||||||
|
console.log('Forecast metrics response:', data);
|
||||||
|
return data;
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pr-4">
|
<CardHeader className="flex flex-row items-center justify-between pr-5">
|
||||||
<CardTitle className="text-xl font-medium">Forecast</CardTitle>
|
<CardTitle className="text-xl font-medium">Forecast</CardTitle>
|
||||||
<div className="w-[230px]">
|
<div className="w-[230px]">
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
@@ -54,76 +66,64 @@ export function ForecastMetrics() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="py-0 -mb-2">
|
||||||
<div className="flex flex-col gap-4">
|
{error ? (
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="text-sm text-red-500">Error: {error.message}</div>
|
||||||
<div className="flex items-center gap-2">
|
) : isLoading ? (
|
||||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
<div className="text-sm">Loading forecast metrics...</div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">Forecast Sales</p>
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Forecast Sales</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{data?.forecastSales.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data?.forecastRevenue || 0)}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold">{data?.forecastSales.toLocaleString() || 0}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold">{formatCurrency(data?.forecastRevenue || 0)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-[300px] w-full">
|
<div className="h-[250px] w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={data?.dailyForecast || []}>
|
<AreaChart
|
||||||
<XAxis
|
data={data?.dailyForecasts || []}
|
||||||
dataKey="date"
|
margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
|
||||||
tickLine={false}
|
>
|
||||||
axisLine={false}
|
<XAxis
|
||||||
fontSize={12}
|
dataKey="date"
|
||||||
/>
|
tickLine={false}
|
||||||
<YAxis
|
axisLine={false}
|
||||||
yAxisId="left"
|
tick={false}
|
||||||
tickLine={false}
|
/>
|
||||||
axisLine={false}
|
<YAxis
|
||||||
fontSize={12}
|
tickLine={false}
|
||||||
tickFormatter={(value) => value.toLocaleString()}
|
axisLine={false}
|
||||||
/>
|
tick={false}
|
||||||
<YAxis
|
/>
|
||||||
yAxisId="right"
|
<Tooltip
|
||||||
orientation="right"
|
formatter={(value: number) => [formatCurrency(value), "Revenue"]}
|
||||||
tickLine={false}
|
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
||||||
axisLine={false}
|
/>
|
||||||
fontSize={12}
|
<Area
|
||||||
tickFormatter={(value) => formatCurrency(value)}
|
type="monotone"
|
||||||
/>
|
dataKey="revenue"
|
||||||
<Tooltip
|
name="Revenue"
|
||||||
formatter={(value: number, name: string) => [
|
stroke="#00C49F"
|
||||||
name === "revenue" ? formatCurrency(value) : value.toLocaleString(),
|
fill="#00C49F"
|
||||||
name === "revenue" ? "Revenue" : "Sales"
|
fillOpacity={0.2}
|
||||||
]}
|
/>
|
||||||
labelFormatter={(label) => `Date: ${label}`}
|
</AreaChart>
|
||||||
/>
|
</ResponsiveContainer>
|
||||||
<Area
|
</div>
|
||||||
yAxisId="left"
|
</>
|
||||||
type="monotone"
|
)}
|
||||||
dataKey="sales"
|
|
||||||
name="Sales"
|
|
||||||
stroke="#0088FE"
|
|
||||||
fill="#0088FE"
|
|
||||||
fillOpacity={0.2}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
yAxisId="right"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="revenue"
|
|
||||||
name="Revenue"
|
|
||||||
stroke="#00C49F"
|
|
||||||
fill="#00C49F"
|
|
||||||
fillOpacity={0.2}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function TopReplenishProducts() {
|
|||||||
<CardTitle className="text-xl font-medium">Top Products To Replenish</CardTitle>
|
<CardTitle className="text-xl font-medium">Top Products To Replenish</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ScrollArea className="max-h-[650px] w-full overflow-y-auto">
|
<ScrollArea className="max-h-[530px] w-full overflow-y-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|||||||
Reference in New Issue
Block a user