Add dashboard
This commit is contained in:
@@ -1,85 +1,223 @@
|
||||
import { AreaChart, Area, XAxis, YAxis, Tooltip } from "recharts";
|
||||
|
||||
const data = [
|
||||
{
|
||||
name: "Page A",
|
||||
uv: 4000,
|
||||
pv: 2400,
|
||||
amt: 2400,
|
||||
},
|
||||
{
|
||||
name: "Page B",
|
||||
uv: 3000,
|
||||
pv: 1398,
|
||||
amt: 2210,
|
||||
},
|
||||
{
|
||||
name: "Page C",
|
||||
uv: 2000,
|
||||
pv: 9800,
|
||||
amt: 2290,
|
||||
},
|
||||
{
|
||||
name: "Page D",
|
||||
uv: 2780,
|
||||
pv: 3908,
|
||||
amt: 2000,
|
||||
},
|
||||
{
|
||||
name: "Page E",
|
||||
uv: 1890,
|
||||
pv: 4800,
|
||||
amt: 2181,
|
||||
},
|
||||
{
|
||||
name: "Page F",
|
||||
uv: 2390,
|
||||
pv: 3800,
|
||||
amt: 2500,
|
||||
},
|
||||
{
|
||||
name: "Page G",
|
||||
uv: 3490,
|
||||
pv: 4300,
|
||||
amt: 2100,
|
||||
},
|
||||
];
|
||||
import React from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { EmailStatusIcon } from "../emails/email-status-badge";
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
import { api } from "~/trpc/react";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@unsend/ui/src/tabs";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
|
||||
export default function DashboardChart() {
|
||||
const [days, setDays] = useUrlState("days", "7");
|
||||
const statusQuery = api.email.dashboard.useQuery({ days: Number(days) });
|
||||
|
||||
return (
|
||||
<AreaChart
|
||||
width={900}
|
||||
height={250}
|
||||
data={data}
|
||||
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8884d8" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#8884d8" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorPv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#82ca9d" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="uv"
|
||||
stroke="#8884d8"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUv)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="pv"
|
||||
stroke="#82ca9d"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorPv)"
|
||||
/>
|
||||
</AreaChart>
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="font-bold text-lg">Dashboard</h1>
|
||||
<Tabs
|
||||
value={days || "7"}
|
||||
onValueChange={(value) => setDays(value)}
|
||||
className=""
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="7">7 Days</TabsTrigger>
|
||||
<TabsTrigger value="30">30 Days</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8 mt-8">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!statusQuery.isLoading && statusQuery.data ? (
|
||||
<>
|
||||
<DashboardItemCard
|
||||
status={"total"}
|
||||
count={statusQuery.data.totalCount}
|
||||
percentage={100}
|
||||
/>
|
||||
<DashboardItemCard
|
||||
status={EmailStatus.DELIVERED}
|
||||
count={statusQuery.data.emailStatusCounts.DELIVERED.count}
|
||||
percentage={
|
||||
statusQuery.data.emailStatusCounts.DELIVERED.percentage
|
||||
}
|
||||
/>
|
||||
<DashboardItemCard
|
||||
status={EmailStatus.BOUNCED}
|
||||
count={statusQuery.data.emailStatusCounts.BOUNCED.count}
|
||||
percentage={
|
||||
statusQuery.data.emailStatusCounts.BOUNCED.percentage
|
||||
}
|
||||
/>
|
||||
<DashboardItemCard
|
||||
status={EmailStatus.COMPLAINED}
|
||||
count={statusQuery.data.emailStatusCounts.COMPLAINED.count}
|
||||
percentage={
|
||||
statusQuery.data.emailStatusCounts.COMPLAINED.percentage
|
||||
}
|
||||
/>
|
||||
<DashboardItemCard
|
||||
status={EmailStatus.CLICKED}
|
||||
count={statusQuery.data.emailStatusCounts.CLICKED.count}
|
||||
percentage={
|
||||
statusQuery.data.emailStatusCounts.CLICKED.percentage
|
||||
}
|
||||
/>
|
||||
<DashboardItemCard
|
||||
status={EmailStatus.OPENED}
|
||||
count={statusQuery.data.emailStatusCounts.OPENED.count}
|
||||
percentage={
|
||||
statusQuery.data.emailStatusCounts.OPENED.percentage
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
|
||||
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
|
||||
</div>
|
||||
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
|
||||
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
|
||||
</div>
|
||||
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
|
||||
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
|
||||
</div>
|
||||
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
|
||||
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
|
||||
</div>
|
||||
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
|
||||
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!statusQuery.isLoading && statusQuery.data ? (
|
||||
<div className="w-full h-[400px] border rounded-lg p-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
width={900}
|
||||
height={300}
|
||||
data={statusQuery.data.emailDailyStatusCounts}
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
{/* <CartesianGrid strokeDasharray="3 3" /> */}
|
||||
<XAxis dataKey="name" fontSize={12} />
|
||||
<YAxis fontSize={12} />
|
||||
<Tooltip
|
||||
content={({ payload }) => {
|
||||
const data = payload?.[0]?.payload as Record<
|
||||
EmailStatus,
|
||||
number
|
||||
> & { name: string };
|
||||
|
||||
if (
|
||||
!data ||
|
||||
(!data.BOUNCED &&
|
||||
!data.COMPLAINED &&
|
||||
!data.DELIVERED &&
|
||||
!data.OPENED &&
|
||||
!data.CLICKED)
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div className=" bg-black border shadow-lg p-2 rounded flex flex-col gap-4 px-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.name}
|
||||
</p>
|
||||
{data.DELIVERED ? (
|
||||
<div>
|
||||
<p className="text-sm text-[#10b981]">Delivered</p>
|
||||
<p className="text-xs">{data.DELIVERED} emails</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.BOUNCED ? (
|
||||
<div>
|
||||
<p className="text-sm text-[#ef4444]">Bounced</p>
|
||||
<p className="text-xs">{data.BOUNCED} emails</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.COMPLAINED ? (
|
||||
<div>
|
||||
<p className="text-sm text-[#eab308]">Complained</p>
|
||||
<p className="text-xs">{data.COMPLAINED} emails</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.OPENED ? (
|
||||
<div>
|
||||
<p className="text-sm text-[#6366f1]">Opened</p>
|
||||
<p className="text-xs">{data.OPENED} emails</p>
|
||||
</div>
|
||||
) : null}
|
||||
{data.CLICKED ? (
|
||||
<div>
|
||||
<p className="text-sm text-[#06b6d4]">Clicked</p>
|
||||
<p className="text-xs">{data.CLICKED} emails</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
cursor={false}
|
||||
/>
|
||||
{/* <Legend /> */}
|
||||
<Bar
|
||||
barSize={8}
|
||||
dataKey="DELIVERED"
|
||||
stackId="a"
|
||||
fill="#10b981"
|
||||
/>
|
||||
<Bar dataKey="BOUNCED" stackId="a" fill="#ef4444" />
|
||||
<Bar dataKey="COMPLAINED" stackId="a" fill="#eab308" />
|
||||
<Bar dataKey="OPENED" stackId="a" fill="#6366f1" />
|
||||
<Bar dataKey="CLICKED" stackId="a" fill="#06b6d4" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DashboardItemCardProps = {
|
||||
status: EmailStatus | "total";
|
||||
count: number;
|
||||
percentage: number;
|
||||
};
|
||||
|
||||
const DashboardItemCard: React.FC<DashboardItemCardProps> = ({
|
||||
status,
|
||||
count,
|
||||
percentage,
|
||||
}) => {
|
||||
return (
|
||||
<div className="h-[100px] w-[16%] min-w-[170px] bg-secondary/10 border rounded-lg p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{status !== "total" ? <EmailStatusIcon status={status} /> : null}
|
||||
<div className=" capitalize">{status.toLowerCase()}</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="text-primary font-light text-2xl font-mono">
|
||||
{count}
|
||||
</div>
|
||||
{status !== "total" ? (
|
||||
<div className="text-sm pb-1">{percentage}%</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -5,8 +5,7 @@ import DashboardChart from "./dashboard-chart";
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div>
|
||||
Dashboard
|
||||
<div className="mx-auto flex justify-center item-center mt-[30vh]">
|
||||
<div className="w-full ">
|
||||
<DashboardChart />
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -69,10 +69,13 @@ export default async function AuthenticatedDashboardLayout({
|
||||
<div className="flex min-h-screen w-full h-full">
|
||||
<div className="hidden bg-muted/20 md:block md:w-[280px]">
|
||||
<div className="flex h-full max-h-screen flex-col gap-2">
|
||||
<div className="flex h-14 items-center px-4 lg:h-[60px] lg:px-6">
|
||||
<div className="flex h-14 gap-4 items-center px-4 lg:h-[60px] lg:px-6">
|
||||
<Link href="/" className="flex items-center gap-2 font-semibold">
|
||||
<span className=" text-lg">Unsend</span>
|
||||
</Link>
|
||||
<span className="text-[10px] text-muted-foreground bg-muted p-0.5 px-2 rounded-full">
|
||||
Early access
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<nav className="grid items-start px-2 text-sm font-medium lg:px-4">
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
import { format, subDays } from "date-fns";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
|
||||
@@ -51,6 +52,111 @@ export const emailRouter = createTRPCRouter({
|
||||
return { emails, totalPage: Math.ceil(count / limit) };
|
||||
}),
|
||||
|
||||
dashboard: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
days: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { team } = ctx;
|
||||
const daysInMs = (input.days || 7) * 24 * 60 * 60 * 1000;
|
||||
|
||||
const rawEmailStatusCounts = await db.email.findMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
createdAt: {
|
||||
gt: new Date(Date.now() - daysInMs),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
latestStatus: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const totalCount = rawEmailStatusCounts.length;
|
||||
|
||||
const emailStatusCounts = rawEmailStatusCounts.reduce(
|
||||
(acc, cur) => {
|
||||
acc[cur.latestStatus] = {
|
||||
count: (acc[cur.latestStatus]?.count || 0) + 1,
|
||||
percentage:
|
||||
(((acc[cur.latestStatus]?.count || 0) + 1) / totalCount) * 100,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
DELIVERED: { count: 0, percentage: 0 },
|
||||
COMPLAINED: { count: 0, percentage: 0 },
|
||||
OPENED: { count: 0, percentage: 0 },
|
||||
CLICKED: { count: 0, percentage: 0 },
|
||||
BOUNCED: { count: 0, percentage: 0 },
|
||||
} as Record<EmailStatus, { count: number; percentage: number }>
|
||||
);
|
||||
|
||||
const dateRecord: Record<
|
||||
string,
|
||||
Record<
|
||||
"DELIVERED" | "COMPLAINED" | "OPENED" | "CLICKED" | "BOUNCED",
|
||||
number
|
||||
>
|
||||
> = {};
|
||||
|
||||
const currentDate = new Date();
|
||||
|
||||
for (let i = 0; i < (input.days || 7); i++) {
|
||||
const actualDate = subDays(currentDate, i);
|
||||
dateRecord[format(actualDate, "MMM dd")] = {
|
||||
DELIVERED: 0,
|
||||
COMPLAINED: 0,
|
||||
OPENED: 0,
|
||||
CLICKED: 0,
|
||||
BOUNCED: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const _emailDailyStatusCounts = rawEmailStatusCounts.reduce(
|
||||
(acc, { latestStatus, createdAt }) => {
|
||||
const day = format(createdAt, "MMM dd");
|
||||
|
||||
console.log(day);
|
||||
if (
|
||||
!day ||
|
||||
![
|
||||
"DELIVERED",
|
||||
"COMPLAINED",
|
||||
"OPENED",
|
||||
"CLICKED",
|
||||
"BOUNCED",
|
||||
].includes(latestStatus)
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[day]![
|
||||
latestStatus as
|
||||
| "DELIVERED"
|
||||
| "COMPLAINED"
|
||||
| "OPENED"
|
||||
| "CLICKED"
|
||||
| "BOUNCED"
|
||||
]++;
|
||||
return acc;
|
||||
},
|
||||
dateRecord
|
||||
);
|
||||
|
||||
const emailDailyStatusCounts = Object.entries(_emailDailyStatusCounts)
|
||||
.reverse()
|
||||
.map(([date, counts]) => ({
|
||||
name: date,
|
||||
...counts,
|
||||
}));
|
||||
|
||||
return { emailStatusCounts, totalCount, emailDailyStatusCounts };
|
||||
}),
|
||||
|
||||
getEmail: teamProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
|
Reference in New Issue
Block a user