671 lines
25 KiB
TypeScript
671 lines
25 KiB
TypeScript
"use client";
|
||
|
||
// ============================================================================
|
||
// Dashboard Overview (Главная)
|
||
// ============================================================================
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { format, subDays, startOfDay, endOfDay } from "date-fns";
|
||
import { ru } from "date-fns/locale";
|
||
import {
|
||
ArrowDown,
|
||
ArrowUp,
|
||
Calendar as CalendarIcon,
|
||
BarChart3,
|
||
BarChartHorizontal,
|
||
Gauge,
|
||
Loader2,
|
||
Sparkles,
|
||
TrendingUp,
|
||
Users,
|
||
} from "lucide-react";
|
||
import {
|
||
Bar,
|
||
BarChart,
|
||
CartesianGrid,
|
||
Cell,
|
||
ReferenceLine,
|
||
XAxis,
|
||
YAxis,
|
||
} from "recharts";
|
||
type DateRangeType = {
|
||
from?: Date;
|
||
to?: Date;
|
||
} | undefined;
|
||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import {
|
||
ChartContainer,
|
||
ChartTooltip,
|
||
ChartTooltipContent,
|
||
type ChartConfig,
|
||
} from "@/components/ui/chart";
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
import { Calendar } from "@/components/ui/calendar";
|
||
import { cn } from "@/lib/utils";
|
||
import type { OverviewAnalytics } from "@/lib/types/api";
|
||
|
||
// ============================================================================
|
||
// Helpers
|
||
// ============================================================================
|
||
|
||
const formatNumber = (value: number | null | undefined, showSign = false): string => {
|
||
if (value === null || value === undefined) return "—";
|
||
const formatted = new Intl.NumberFormat("ru-RU").format(value);
|
||
if (showSign && value > 0) return `+${formatted}`;
|
||
return formatted;
|
||
};
|
||
|
||
const formatCurrency = (
|
||
value: number | null | undefined,
|
||
digits = 0
|
||
): string => {
|
||
if (value === null || value === undefined) return "—";
|
||
return new Intl.NumberFormat("ru-RU", {
|
||
style: "currency",
|
||
currency: "RUB",
|
||
minimumFractionDigits: digits,
|
||
maximumFractionDigits: digits,
|
||
}).format(value);
|
||
};
|
||
|
||
const formatDateShort = (value: string): string => {
|
||
const [year, month, day] = value.split("-");
|
||
if (!year || !month || !day) return value;
|
||
return `${day}.${month}`;
|
||
};
|
||
|
||
const DeltaPill: React.FC<{ delta?: number }> = ({ delta }) => {
|
||
if (delta === undefined || delta === null) return null;
|
||
const isPositive = delta > 0;
|
||
const isZero = delta === 0;
|
||
const color = isZero
|
||
? "bg-muted text-muted-foreground"
|
||
: isPositive
|
||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200"
|
||
: "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-200";
|
||
const Icon = isZero ? Sparkles : isPositive ? ArrowUp : ArrowDown;
|
||
const sign = delta > 0 ? "+" : "";
|
||
return (
|
||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${color}`}>
|
||
<Icon className="h-3 w-3" />
|
||
{sign}
|
||
{delta.toFixed(1)}%
|
||
</span>
|
||
);
|
||
};
|
||
|
||
// ============================================================================
|
||
// Component
|
||
// ============================================================================
|
||
|
||
export default function DashboardPage() {
|
||
const {
|
||
currentWorkspace,
|
||
isLoading: workspaceLoading,
|
||
} = useWorkspace();
|
||
|
||
const [overview, setOverview] = useState<OverviewAnalytics | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [cpfMode, setCpfMode] = useState<"top" | "worst">("top");
|
||
const [projectPage, setProjectPage] = useState(1);
|
||
const pageSize = 5;
|
||
const [dateRange, setDateRange] = useState<DateRangeType | undefined>(() => ({
|
||
from: subDays(new Date(), 29),
|
||
to: new Date(),
|
||
}));
|
||
|
||
const dateFromIso = useMemo(() => {
|
||
return dateRange?.from
|
||
? startOfDay(dateRange.from).toISOString()
|
||
: undefined;
|
||
}, [dateRange?.from]);
|
||
|
||
const dateToIso = useMemo(() => {
|
||
return dateRange?.to ? endOfDay(dateRange.to).toISOString() : undefined;
|
||
}, [dateRange?.to]);
|
||
|
||
const dateRangeLabel = useMemo(() => {
|
||
if (dateRange?.from && dateRange?.to) {
|
||
const sameYear =
|
||
dateRange.from.getFullYear() === dateRange.to.getFullYear();
|
||
const sameMonth =
|
||
sameYear && dateRange.from.getMonth() === dateRange.to.getMonth();
|
||
const fromFormat = sameMonth ? "d MMM" : "d MMM yyyy";
|
||
return `${format(dateRange.from, fromFormat, { locale: ru })} — ${format(dateRange.to, "d MMM yyyy", { locale: ru })}`;
|
||
}
|
||
if (dateRange?.from) {
|
||
return format(dateRange.from, "d MMM yyyy", { locale: ru });
|
||
}
|
||
return "За всё время";
|
||
}, [dateRange]);
|
||
|
||
useEffect(() => {
|
||
if (!currentWorkspace) {
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
const loadData = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const data = await demoAwareAnalyticsApi.overview(currentWorkspace.id, {
|
||
date_from: dateFromIso,
|
||
date_to: dateToIso,
|
||
});
|
||
setOverview(data);
|
||
setProjectPage(1);
|
||
} catch (err) {
|
||
console.error("Failed to load overview:", err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
loadData();
|
||
}, [currentWorkspace?.id, dateFromIso, dateToIso]);
|
||
|
||
const stats = useMemo(
|
||
() => [
|
||
{
|
||
title: "Сумма закупов",
|
||
value: overview?.total_cost.value,
|
||
delta: overview?.total_cost.delta_percent,
|
||
format: (v: number | null | undefined) => formatCurrency(v, 0),
|
||
icon: BarChartHorizontal,
|
||
},
|
||
{
|
||
title: "Общий охват",
|
||
value: overview?.total_reach.value,
|
||
delta: overview?.total_reach.delta_percent,
|
||
format: (v: number | null | undefined) => formatNumber(v),
|
||
icon: Users,
|
||
},
|
||
{
|
||
title: "Всего размещений",
|
||
value: overview?.placements_count.value,
|
||
delta: overview?.placements_count.delta_percent,
|
||
format: (v: number | null | undefined) => formatNumber(v),
|
||
icon: Gauge,
|
||
},
|
||
{
|
||
title: "Кол-во подписок",
|
||
value: overview?.subscriptions_count.value,
|
||
delta: overview?.subscriptions_count.delta_percent,
|
||
format: (v: number | null | undefined) => formatNumber(v),
|
||
icon: TrendingUp,
|
||
},
|
||
{
|
||
title: "Средний CPM",
|
||
value: overview?.avg_cpm.value,
|
||
delta: overview?.avg_cpm.delta_percent,
|
||
format: (v: number | null | undefined) => formatCurrency(v, 2),
|
||
icon: BarChart3,
|
||
},
|
||
{
|
||
title: "Средний CPF",
|
||
value: overview?.avg_cpf.value,
|
||
delta: overview?.avg_cpf.delta_percent,
|
||
format: (v: number | null | undefined) => formatCurrency(v, 2),
|
||
icon: Sparkles,
|
||
},
|
||
],
|
||
[overview]
|
||
);
|
||
|
||
const dailyStats = overview?.daily_stats ?? [];
|
||
|
||
const subscriptionsChartData = useMemo(
|
||
() =>
|
||
dailyStats.map((item) => ({
|
||
date: item.date,
|
||
dateLabel: formatDateShort(item.date),
|
||
value: item.subscriptions_delta,
|
||
})),
|
||
[dailyStats]
|
||
);
|
||
|
||
const costChartData = useMemo(
|
||
() =>
|
||
dailyStats.map((item) => ({
|
||
date: item.date,
|
||
dateLabel: formatDateShort(item.date),
|
||
value: item.cost,
|
||
})),
|
||
[dailyStats]
|
||
);
|
||
|
||
const cpfChartData = useMemo(
|
||
() =>
|
||
dailyStats.map((item) => ({
|
||
date: item.date,
|
||
dateLabel: formatDateShort(item.date),
|
||
value: item.cpf,
|
||
})),
|
||
[dailyStats]
|
||
);
|
||
|
||
const projectSpending = overview?.project_spending ?? [];
|
||
const totalProjectPages = Math.max(1, Math.ceil(projectSpending.length / pageSize));
|
||
const paginatedProjects = projectSpending.slice(
|
||
(projectPage - 1) * pageSize,
|
||
projectPage * pageSize
|
||
);
|
||
|
||
if (workspaceLoading) {
|
||
return (
|
||
<>
|
||
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
|
||
<div className="flex flex-1 items-center justify-center">
|
||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (!currentWorkspace) {
|
||
return (
|
||
<>
|
||
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
|
||
<div className="flex flex-1 items-center justify-center">
|
||
<div className="text-center">
|
||
<h2 className="text-2xl font-semibold mb-2">Нет доступных воркспейсов</h2>
|
||
<p className="text-muted-foreground">
|
||
Создайте новый воркспейс для начала работы
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
|
||
|
||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border/70 bg-card/40 p-3">
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">Статистика за период</p>
|
||
<p className="text-base font-semibold">{dateRangeLabel}</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
className={cn(
|
||
"min-w-[260px] justify-start text-left font-normal",
|
||
!dateRange?.from && "text-muted-foreground"
|
||
)}
|
||
>
|
||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||
{dateRange?.from ? (
|
||
dateRange.to ? (
|
||
<>
|
||
{format(dateRange.from, "d MMM yyyy", { locale: ru })} —{" "}
|
||
{format(dateRange.to, "d MMM yyyy", { locale: ru })}
|
||
</>
|
||
) : (
|
||
format(dateRange.from, "d MMM yyyy", { locale: ru })
|
||
)
|
||
) : (
|
||
<span>Выберите период</span>
|
||
)}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto p-0" align="end">
|
||
<Calendar
|
||
initialFocus
|
||
mode="range"
|
||
numberOfMonths={2}
|
||
locale={ru}
|
||
defaultMonth={dateRange?.from}
|
||
selected={dateRange as any}
|
||
onSelect={(range: any) => setDateRange(range)}
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Строка 1 — компактные показатели */}
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||
{stats.map((item) => (
|
||
<Card key={item.title} className="border-border/70">
|
||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
|
||
<div className="space-y-1">
|
||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||
{item.title}
|
||
</CardTitle>
|
||
<DeltaPill delta={item.delta} />
|
||
</div>
|
||
<item.icon className="h-4 w-4 text-muted-foreground" />
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading ? (
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
) : (
|
||
<div className="text-2xl font-bold">{item.format(item.value)}</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* Строка 2 — три диаграммы */}
|
||
<div className="grid gap-4 lg:grid-cols-3">
|
||
<Card className="border-border/70">
|
||
<CardHeader className="flex items-start justify-between space-y-0 pb-2">
|
||
<CardTitle className="text-base font-semibold">Динамика подписчиков по дням</CardTitle>
|
||
<DeltaPill delta={overview?.subscriptions_count.delta_percent} />
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading ? (
|
||
<div className="flex h-[240px] items-center justify-center">
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : (
|
||
<ChartContainer
|
||
config={{
|
||
subscriptions_delta: {
|
||
label: "Δ подписок",
|
||
},
|
||
} satisfies ChartConfig}
|
||
className="aspect-auto h-[260px] w-full"
|
||
>
|
||
<BarChart accessibilityLayer data={subscriptionsChartData}>
|
||
<CartesianGrid vertical={false} />
|
||
<XAxis
|
||
dataKey="dateLabel"
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickMargin={10}
|
||
/>
|
||
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
|
||
<ReferenceLine y={0} stroke="hsl(var(--border))" />
|
||
<ChartTooltip
|
||
cursor={false}
|
||
content={<ChartTooltipContent hideLabel hideIndicator />}
|
||
/>
|
||
<Bar dataKey="value" radius={8}>
|
||
{subscriptionsChartData.map((entry) => (
|
||
<Cell
|
||
key={`cell-${entry.date}`}
|
||
fill={
|
||
entry.value >= 0
|
||
? "var(--chart-1)"
|
||
: "var(--chart-2)"
|
||
}
|
||
/>
|
||
))}
|
||
</Bar>
|
||
</BarChart>
|
||
</ChartContainer>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-border/70">
|
||
<CardHeader className="flex items-start justify-between space-y-0 pb-2">
|
||
<CardTitle className="text-base font-semibold">Траты по дням</CardTitle>
|
||
<DeltaPill delta={overview?.total_cost.delta_percent} />
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading ? (
|
||
<div className="flex h-[240px] items-center justify-center">
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : (
|
||
<ChartContainer
|
||
config={{
|
||
cost: {
|
||
label: "Затраты",
|
||
color: "var(--chart-2)",
|
||
},
|
||
} satisfies ChartConfig}
|
||
className="aspect-auto h-[260px] w-full"
|
||
>
|
||
<BarChart accessibilityLayer data={costChartData}>
|
||
<CartesianGrid vertical={false} />
|
||
<XAxis
|
||
dataKey="dateLabel"
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickMargin={10}
|
||
/>
|
||
<YAxis
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickMargin={8}
|
||
tickFormatter={(value: number) => formatCurrency(value, 0)}
|
||
/>
|
||
<ChartTooltip
|
||
cursor={false}
|
||
content={<ChartTooltipContent hideLabel />}
|
||
/>
|
||
<Bar dataKey="value" fill="var(--color-cost)" radius={8} />
|
||
</BarChart>
|
||
</ChartContainer>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-border/70">
|
||
<CardHeader className="flex items-start justify-between space-y-0 pb-2">
|
||
<CardTitle className="text-base font-semibold">Стоимость подписчика (CPF)</CardTitle>
|
||
<DeltaPill delta={overview?.avg_cpf.delta_percent} />
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading ? (
|
||
<div className="flex h-[240px] items-center justify-center">
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : (
|
||
<ChartContainer
|
||
config={{
|
||
cpf: {
|
||
label: "CPF",
|
||
color: "var(--chart-3)",
|
||
},
|
||
} satisfies ChartConfig}
|
||
className="aspect-auto h-[260px] w-full"
|
||
>
|
||
<BarChart accessibilityLayer data={cpfChartData}>
|
||
<CartesianGrid vertical={false} />
|
||
<XAxis
|
||
dataKey="dateLabel"
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickMargin={10}
|
||
/>
|
||
<YAxis
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickMargin={8}
|
||
tickFormatter={(value: number) => formatCurrency(value, 0)}
|
||
/>
|
||
<ChartTooltip
|
||
cursor={false}
|
||
content={<ChartTooltipContent hideLabel />}
|
||
/>
|
||
<Bar dataKey="value" fill="var(--color-cpf)" radius={8} />
|
||
</BarChart>
|
||
</ChartContainer>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Строка 3 — таблицы */}
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<Card className="border-border/70">
|
||
<Tabs value={cpfMode} onValueChange={(value) => setCpfMode(value as "top" | "worst")}>
|
||
<CardHeader className="flex flex-col gap-2 pb-3">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-base font-semibold">Топ / Антитоп по CPF</CardTitle>
|
||
<TabsList className="h-8">
|
||
<TabsTrigger value="top" className="h-8 px-3 text-sm">
|
||
Топ 5
|
||
</TabsTrigger>
|
||
<TabsTrigger value="worst" className="h-8 px-3 text-sm">
|
||
Антитоп 5
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading ? (
|
||
<div className="flex h-[200px] items-center justify-center">
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : (
|
||
<>
|
||
<TabsContent value="top" className="mt-0">
|
||
{overview?.top_channels_by_cpf?.length ? (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-2/3">Канал</TableHead>
|
||
<TableHead className="text-right">CPF</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{overview.top_channels_by_cpf.slice(0, 5).map((row) => (
|
||
<TableRow key={row.channel_id}>
|
||
<TableCell className="space-y-0.5">
|
||
<div className="font-medium">{row.title}</div>
|
||
{row.username && (
|
||
<a
|
||
href={`https://t.me/${row.username}`}
|
||
className="text-xs text-primary hover:underline"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
@{row.username}
|
||
</a>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-right font-mono font-semibold">
|
||
{formatCurrency(row.cpf, 2)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">Нет данных</p>
|
||
)}
|
||
</TabsContent>
|
||
<TabsContent value="worst" className="mt-0">
|
||
{overview?.worst_channels_by_cpf?.length ? (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-2/3">Канал</TableHead>
|
||
<TableHead className="text-right">CPF</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{overview.worst_channels_by_cpf.slice(0, 5).map((row) => (
|
||
<TableRow key={row.channel_id}>
|
||
<TableCell className="space-y-0.5">
|
||
<div className="font-medium">{row.title}</div>
|
||
{row.username && (
|
||
<a
|
||
href={`https://t.me/${row.username}`}
|
||
className="text-xs text-primary hover:underline"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
@{row.username}
|
||
</a>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-right font-mono font-semibold">
|
||
{formatCurrency(row.cpf, 2)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">Нет данных</p>
|
||
)}
|
||
</TabsContent>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Tabs>
|
||
</Card>
|
||
|
||
<Card className="border-border/70">
|
||
<CardHeader className="flex items-center justify-between pb-3">
|
||
<CardTitle className="text-base font-semibold">Сумма закупов по проектам</CardTitle>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setProjectPage((p) => Math.max(1, p - 1))}
|
||
disabled={projectPage === 1}
|
||
>
|
||
Назад
|
||
</Button>
|
||
<span className="text-sm text-muted-foreground">
|
||
{projectPage}/{totalProjectPages}
|
||
</span>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setProjectPage((p) => Math.min(totalProjectPages, p + 1))}
|
||
disabled={projectPage === totalProjectPages}
|
||
>
|
||
Вперед
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading ? (
|
||
<div className="flex h-[200px] items-center justify-center">
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : paginatedProjects.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">Нет данных</p>
|
||
) : (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-2/3">Проект</TableHead>
|
||
<TableHead className="text-right">Сумма</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{paginatedProjects.map((row) => (
|
||
<TableRow key={row.project_id}>
|
||
<TableCell className="space-y-0.5">
|
||
<div className="font-medium">{row.project_title}</div>
|
||
{row.project_username && (
|
||
<span className="text-xs text-muted-foreground">@{row.project_username}</span>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-right font-mono font-semibold">
|
||
{formatCurrency(row.total_cost, 0)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|