save, create and view reports in dashboard
This commit is contained in:
@@ -20,7 +20,8 @@ const config = {
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Interval" AS ENUM ('hour', 'day', 'month');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ChartType" AS ENUM ('linear', 'bar', 'pie', 'metric', 'area');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dashboards" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"name" TEXT NOT NULL,
|
||||
"project_id" UUID NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "dashboards_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "reports" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"interval" "Interval" NOT NULL,
|
||||
"range" INTEGER NOT NULL,
|
||||
"chart_type" "ChartType" NOT NULL,
|
||||
"breakdowns" JSONB NOT NULL,
|
||||
"events" JSONB NOT NULL,
|
||||
"project_id" UUID NOT NULL,
|
||||
"dashboard_id" UUID NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "reports_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reports" ADD CONSTRAINT "reports_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reports" ADD CONSTRAINT "reports_dashboard_id_fkey" FOREIGN KEY ("dashboard_id") REFERENCES "dashboards"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `name` to the `reports` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "reports" ADD COLUMN "name" TEXT NOT NULL;
|
||||
@@ -31,8 +31,10 @@ model Project {
|
||||
profiles Profile[]
|
||||
clients Client[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
reports Report[]
|
||||
dashboards Dashboard[]
|
||||
|
||||
@@map("projects")
|
||||
}
|
||||
@@ -97,3 +99,50 @@ model Client {
|
||||
|
||||
@@map("clients")
|
||||
}
|
||||
|
||||
enum Interval {
|
||||
hour
|
||||
day
|
||||
month
|
||||
}
|
||||
|
||||
enum ChartType {
|
||||
linear
|
||||
bar
|
||||
pie
|
||||
metric
|
||||
area
|
||||
}
|
||||
|
||||
model Dashboard {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
name String
|
||||
project_id String @db.Uuid
|
||||
project Project @relation(fields: [project_id], references: [id])
|
||||
reports Report[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@map("dashboards")
|
||||
}
|
||||
|
||||
model Report {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
name String
|
||||
interval Interval
|
||||
range Int
|
||||
chart_type ChartType
|
||||
breakdowns Json
|
||||
events Json
|
||||
project_id String @db.Uuid
|
||||
project Project @relation(fields: [project_id], references: [id])
|
||||
|
||||
dashboard_id String @db.Uuid
|
||||
dashboard Dashboard @relation(fields: [dashboard_id], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@map("reports")
|
||||
}
|
||||
|
||||
8
apps/web/src/components/Container.tsx
Normal file
8
apps/web/src/components/Container.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { type HtmlProps } from "@/types";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
export function Container({className,...props}: HtmlProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("mx-auto w-full max-w-4xl", className)} {...props} />
|
||||
);
|
||||
}
|
||||
10
apps/web/src/components/Navbar.tsx
Normal file
10
apps/web/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<div className="flex gap-4 text-sm uppercase">
|
||||
<Link href="/">Dashboards</Link>
|
||||
<Link href="/reports">Reports</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import {
|
||||
Cloud,
|
||||
CreditCard,
|
||||
Github,
|
||||
Keyboard,
|
||||
LifeBuoy,
|
||||
LogOut,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
PlusCircle,
|
||||
Settings,
|
||||
User,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useState } from "react"
|
||||
|
||||
export function ReportFilterPicker() {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">Open</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
<span>Billing</span>
|
||||
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Keyboard className="mr-2 h-4 w-4" />
|
||||
<span>Keyboard shortcuts</span>
|
||||
<DropdownMenuShortcut>⌘K</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
<span>Team</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
<span>Invite users</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
<span>Email</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
<span>Message</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
<span>More...</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<span>New Team</span>
|
||||
<DropdownMenuShortcut>⌘+T</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
<span>GitHub</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<LifeBuoy className="mr-2 h-4 w-4" />
|
||||
<span>Support</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
<span>API</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
34
apps/web/src/components/UserDropdown.tsx
Normal file
34
apps/web/src/components/UserDropdown.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { User } from "lucide-react";
|
||||
|
||||
export function UserDropdown() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button>
|
||||
<Avatar className="text-black">
|
||||
<AvatarFallback>CL</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Organization
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
35
apps/web/src/components/layouts/Main.tsx
Normal file
35
apps/web/src/components/layouts/Main.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import Head from "next/head";
|
||||
import { UserDropdown } from "../UserDropdown";
|
||||
import { Navbar } from "../Navbar";
|
||||
|
||||
type MainLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MainLayout({ children, className }: MainLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Create T3 App</title>
|
||||
<meta name="description" content="Generated by create-t3-app" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<nav className="flex h-20 items-center justify-between border-b border-border bg-black px-8 text-white">
|
||||
<div className="flex flex-col [&_*]:leading-tight">
|
||||
<span className="text-2xl font-extrabold">MIXAN</span>
|
||||
<span className="text-xs text-muted">v0.0.1</span>
|
||||
</div>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div>
|
||||
<UserDropdown />
|
||||
</div>
|
||||
</nav>
|
||||
<main className={className}>
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
89
apps/web/src/components/report/ReportDateRange.tsx
Normal file
89
apps/web/src/components/report/ReportDateRange.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useDispatch, useSelector } from "@/redux";
|
||||
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
|
||||
import { changeDateRanges, changeInterval } from "./reportSlice";
|
||||
import { Combobox } from "../ui/combobox";
|
||||
import { type IInterval } from "@/types";
|
||||
|
||||
export function ReportDateRange() {
|
||||
const dispatch = useDispatch();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RadioGroup>
|
||||
<RadioGroupItem
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(1));
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(7));
|
||||
}}
|
||||
>
|
||||
7 days
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(14));
|
||||
}}
|
||||
>
|
||||
14 days
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(30));
|
||||
}}
|
||||
>
|
||||
1 month
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(90));
|
||||
}}
|
||||
>
|
||||
3 month
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(180));
|
||||
}}
|
||||
>
|
||||
6 month
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(356));
|
||||
}}
|
||||
>
|
||||
1 year
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
<div className="w-full max-w-[200px]">
|
||||
<Combobox
|
||||
placeholder="Interval"
|
||||
onChange={(value) => {
|
||||
dispatch(changeInterval(value as IInterval));
|
||||
}}
|
||||
value={interval}
|
||||
items={[
|
||||
{
|
||||
label: "Hour",
|
||||
value: "hour",
|
||||
},
|
||||
{
|
||||
label: "Day",
|
||||
value: "day",
|
||||
},
|
||||
{
|
||||
label: "Month",
|
||||
value: "month",
|
||||
},
|
||||
]}
|
||||
></Combobox>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
Tooltip,
|
||||
@@ -10,22 +9,13 @@ import {
|
||||
} from "recharts";
|
||||
import { ReportLineChartTooltip } from "./ReportLineChartTooltop";
|
||||
import { useFormatDateInterval } from "@/hooks/useFormatDateInterval";
|
||||
import {
|
||||
type IChartBreakdown,
|
||||
type IChartEvent,
|
||||
type IInterval,
|
||||
} from "@/types";
|
||||
import { type IChartInput } from "@/types";
|
||||
import { getChartColor } from "@/utils/theme";
|
||||
import { ReportTable } from "./ReportTable";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AutoSizer } from "@/components/AutoSizer";
|
||||
|
||||
type ReportLineChartProps = {
|
||||
interval: IInterval;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
events: IChartEvent[];
|
||||
breakdowns: IChartBreakdown[];
|
||||
type ReportLineChartProps = IChartInput & {
|
||||
showTable?: boolean;
|
||||
};
|
||||
|
||||
@@ -36,16 +26,20 @@ export function ReportLineChart({
|
||||
events,
|
||||
breakdowns,
|
||||
showTable,
|
||||
chartType,
|
||||
name,
|
||||
}: ReportLineChartProps) {
|
||||
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
|
||||
|
||||
const chart = api.chartMeta.chart.useQuery(
|
||||
{
|
||||
interval,
|
||||
chartType: "linear",
|
||||
chartType,
|
||||
startDate,
|
||||
endDate,
|
||||
events,
|
||||
breakdowns,
|
||||
name,
|
||||
},
|
||||
{
|
||||
enabled: events.length > 0,
|
||||
@@ -58,25 +52,23 @@ export function ReportLineChart({
|
||||
useEffect(() => {
|
||||
if (!ref.current && chart.data) {
|
||||
const max = 20;
|
||||
|
||||
|
||||
setVisibleSeries(
|
||||
chart.data?.series?.slice(0, max).map((serie) => serie.name) ?? [],
|
||||
);
|
||||
// ref.current = true;
|
||||
}
|
||||
}, [chart.data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
);
|
||||
// ref.current = true;
|
||||
}
|
||||
}, [chart.data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{chart.isSuccess && chart.data?.series?.[0]?.data && (
|
||||
<>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<LineChart width={width} height={width * 0.5}>
|
||||
{/* <Legend /> */}
|
||||
<LineChart width={width} height={Math.min(width * 0.5, 400)}>
|
||||
<YAxis dataKey={"count"}></YAxis>
|
||||
<Tooltip content={<ReportLineChartTooltip />} />
|
||||
{/* <Tooltip /> */}
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
|
||||
9
apps/web/src/components/report/hooks/useReportId.ts
Normal file
9
apps/web/src/components/report/hooks/useReportId.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useQueryParams } from "@/hooks/useQueryParams";
|
||||
import { z } from "zod";
|
||||
|
||||
export const useReportId = () =>
|
||||
useQueryParams(
|
||||
z.object({
|
||||
reportId: z.string().optional(),
|
||||
}),
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type IChartInput,
|
||||
type IChartBreakdown,
|
||||
type IChartEvent,
|
||||
type IInterval,
|
||||
@@ -6,32 +7,17 @@ import {
|
||||
import { getDaysOldDate } from "@/utils/date";
|
||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||
|
||||
type InitialState = {
|
||||
events: IChartEvent[];
|
||||
breakdowns: IChartBreakdown[];
|
||||
interval: IInterval;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
type InitialState = IChartInput;
|
||||
|
||||
// First approach: define the initial state using that type
|
||||
const initialState: InitialState = {
|
||||
name: "",
|
||||
chartType: "linear",
|
||||
startDate: getDaysOldDate(7),
|
||||
endDate: new Date(),
|
||||
interval: "day",
|
||||
breakdowns: [
|
||||
{
|
||||
id: "A",
|
||||
name: 'properties.id'
|
||||
}
|
||||
],
|
||||
events: [
|
||||
{
|
||||
id: "A",
|
||||
name: "sign_up",
|
||||
filters: []
|
||||
},
|
||||
],
|
||||
breakdowns: [],
|
||||
events: [],
|
||||
};
|
||||
|
||||
const IDS = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"] as const;
|
||||
@@ -40,6 +26,12 @@ export const reportSlice = createSlice({
|
||||
name: "counter",
|
||||
initialState,
|
||||
reducers: {
|
||||
reset() {
|
||||
return initialState
|
||||
},
|
||||
setReport(state, action: PayloadAction<IChartInput>) {
|
||||
return action.payload
|
||||
},
|
||||
// Events
|
||||
addEvent: (state, action: PayloadAction<Omit<IChartEvent, "id">>) => {
|
||||
state.events.push({
|
||||
@@ -111,21 +103,23 @@ export const reportSlice = createSlice({
|
||||
},
|
||||
|
||||
changeDateRanges: (state, action: PayloadAction<number>) => {
|
||||
if(action.payload === 1) {
|
||||
if (action.payload === 1) {
|
||||
state.interval = "hour";
|
||||
} else if(action.payload <= 30) {
|
||||
} else if (action.payload <= 30) {
|
||||
state.interval = "day";
|
||||
} else {
|
||||
state.interval = "month";
|
||||
}
|
||||
state.startDate = getDaysOldDate(action.payload);
|
||||
state.endDate = new Date();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Action creators are generated for each case reducer function
|
||||
export const {
|
||||
reset,
|
||||
setReport,
|
||||
addEvent,
|
||||
removeEvent,
|
||||
changeEvent,
|
||||
|
||||
36
apps/web/src/components/report/sidebar/ReportSave.tsx
Normal file
36
apps/web/src/components/report/sidebar/ReportSave.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useReportId } from "../hooks/useReportId";
|
||||
import { api } from "@/utils/api";
|
||||
import { useSelector } from "@/redux";
|
||||
|
||||
export function ReportSave() {
|
||||
const { reportId } = useReportId();
|
||||
const save = api.report.save.useMutation();
|
||||
const update = api.report.update.useMutation();
|
||||
const report = useSelector((state) => state.report);
|
||||
|
||||
if (reportId) {
|
||||
return <Button onClick={() => {
|
||||
update.mutate({
|
||||
reportId,
|
||||
report,
|
||||
dashboardId: "9227feb4-ad59-40f3-b887-3501685733dd",
|
||||
projectId: "f7eabf0c-e0b0-4ac0-940f-1589715b0c3d",
|
||||
});
|
||||
}}>Update</Button>;
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
save.mutate({
|
||||
report,
|
||||
dashboardId: "9227feb4-ad59-40f3-b887-3501685733dd",
|
||||
projectId: "f7eabf0c-e0b0-4ac0-940f-1589715b0c3d",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ReportEvents } from "./ReportEvents";
|
||||
import { ReportBreakdowns } from "./ReportBreakdowns";
|
||||
import { ReportSave } from "./ReportSave";
|
||||
|
||||
export function ReportSidebar() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<ReportEvents />
|
||||
<ReportBreakdowns />
|
||||
<ReportSave />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
35
apps/web/src/hooks/useQueryParams.ts
Normal file
35
apps/web/src/hooks/useQueryParams.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { type z } from "zod";
|
||||
|
||||
export function useQueryParams<Z extends z.ZodTypeAny = z.ZodNever>(zod: Z) {
|
||||
const router = useRouter();
|
||||
const value = zod.safeParse(router.query);
|
||||
|
||||
return useMemo(() => {
|
||||
function setQueryParams(newValue: Partial<z.infer<Z>>) {
|
||||
return router
|
||||
.replace({
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
...newValue,
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
|
||||
if (value.success) {
|
||||
return { ...value.data, setQueryParams } as z.infer<Z> & {
|
||||
setQueryParams: typeof setQueryParams;
|
||||
};
|
||||
}
|
||||
return { ...router.query, setQueryParams } as z.infer<Z> & {
|
||||
setQueryParams: typeof setQueryParams;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router.asPath, value.success]);
|
||||
}
|
||||
21
apps/web/src/hooks/useRouterBeforeLeave.ts
Normal file
21
apps/web/src/hooks/useRouterBeforeLeave.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useRouterBeforeLeave(callback: () => void) {
|
||||
const router = useRouter();
|
||||
const prevUrl = useRef(router.asPath);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRouteChange = (url: string) => {
|
||||
if (prevUrl.current !== url) {
|
||||
callback()
|
||||
}
|
||||
prevUrl.current = url;
|
||||
};
|
||||
|
||||
router.events.on("routeChangeStart", handleRouteChange);
|
||||
return () => {
|
||||
router.events.off("routeChangeStart", handleRouteChange);
|
||||
};
|
||||
}, [router, callback]);
|
||||
}
|
||||
@@ -1,174 +1,33 @@
|
||||
import Head from "next/head";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ReportSidebar } from "@/components/report/sidebar/ReportSidebar";
|
||||
import { ReportLineChart } from "@/components/report/chart/ReportLineChart";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useDispatch, useSelector } from "@/redux";
|
||||
import {
|
||||
changeDateRanges,
|
||||
changeInterval,
|
||||
} from "@/components/report/reportSlice";
|
||||
import { type IInterval } from "@/types";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuShortcut, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { User } from "lucide-react";
|
||||
import { DropdownMenuSeparator } from "@radix-ui/react-dropdown-menu";
|
||||
import { MainLayout } from "@/components/layouts/Main";
|
||||
import { Container } from "@/components/Container";
|
||||
import { api } from "@/utils/api";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
const dispatch = useDispatch();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const events = useSelector((state) => state.report.events);
|
||||
const breakdowns = useSelector((state) => state.report.breakdowns);
|
||||
const startDate = useSelector((state) => state.report.startDate);
|
||||
const endDate = useSelector((state) => state.report.endDate);
|
||||
const reportsQuery = api.report.getDashboard.useQuery({
|
||||
projectId: 'f7eabf0c-e0b0-4ac0-940f-1589715b0c3d',
|
||||
dashboardId: '9227feb4-ad59-40f3-b887-3501685733dd',
|
||||
}, {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
const reports = reportsQuery.data ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Create T3 App</title>
|
||||
<meta name="description" content="Generated by create-t3-app" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<nav className="flex h-20 items-center border-b border-border px-8 justify-between bg-black text-white">
|
||||
<div className="flex flex-col [&_*]:leading-tight">
|
||||
<span className="text-2xl font-extrabold">MIXAN</span>
|
||||
<span className="text-xs text-muted">v0.0.1</span>
|
||||
</div>
|
||||
<div className="flex gap-4 uppercase text-sm">
|
||||
<a href="#">Dashboards</a>
|
||||
<a href="#">Reports</a>
|
||||
<a href="#">Events</a>
|
||||
<a href="#">Users</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button>
|
||||
<Avatar className="text-black">
|
||||
<AvatarFallback>CL</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Organization
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Logout
|
||||
<DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="grid min-h-screen grid-cols-[400px_minmax(0,1fr)] divide-x">
|
||||
<div>
|
||||
<ReportSidebar />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex gap-4">
|
||||
<RadioGroup>
|
||||
<RadioGroupItem
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(1));
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(1));
|
||||
}}
|
||||
>
|
||||
7 days
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(14));
|
||||
}}
|
||||
>
|
||||
14 days
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(30));
|
||||
}}
|
||||
>
|
||||
1 month
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(90));
|
||||
}}
|
||||
>
|
||||
3 month
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(180));
|
||||
}}
|
||||
>
|
||||
6 month
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(356));
|
||||
}}
|
||||
>
|
||||
1 year
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
<div className="w-full max-w-[200px]">
|
||||
<Combobox
|
||||
placeholder="Interval"
|
||||
onChange={(value) => {
|
||||
dispatch(changeInterval(value as IInterval));
|
||||
}}
|
||||
value={interval}
|
||||
items={[
|
||||
{
|
||||
label: "Hour",
|
||||
value: "hour",
|
||||
},
|
||||
{
|
||||
label: "Day",
|
||||
value: "day",
|
||||
},
|
||||
{
|
||||
label: "Month",
|
||||
value: "month",
|
||||
},
|
||||
]}
|
||||
></Combobox>
|
||||
<MainLayout className="bg-slate-50 py-8">
|
||||
<Container className="flex flex-col gap-8">
|
||||
{reports.map((report) => (
|
||||
<div
|
||||
className="rounded-xl border border-border bg-white shadow"
|
||||
key={report.id}
|
||||
>
|
||||
<Link href={`/reports/${report.id}`} className="block border-b border-border p-4 font-bold hover:underline">{report.name}</Link>
|
||||
<div className="p-4">
|
||||
<ReportLineChart {...report} showTable={false} />
|
||||
</div>
|
||||
</div>
|
||||
{startDate && endDate && (
|
||||
<ReportLineChart
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
events={events}
|
||||
breakdowns={breakdowns}
|
||||
interval={interval}
|
||||
showTable
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
))}
|
||||
</Container>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
1
apps/web/src/pages/reports/[reportId].tsx
Normal file
1
apps/web/src/pages/reports/[reportId].tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./index";
|
||||
46
apps/web/src/pages/reports/index.tsx
Normal file
46
apps/web/src/pages/reports/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ReportSidebar } from "@/components/report/sidebar/ReportSidebar";
|
||||
import { ReportLineChart } from "@/components/report/chart/ReportLineChart";
|
||||
import { useDispatch, useSelector } from "@/redux";
|
||||
import { MainLayout } from "@/components/layouts/Main";
|
||||
import { ReportDateRange } from "@/components/report/ReportDateRange";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { reset, setReport } from "@/components/report/reportSlice";
|
||||
import { useReportId } from "@/components/report/hooks/useReportId";
|
||||
import { api } from "@/utils/api";
|
||||
import { useRouterBeforeLeave } from "@/hooks/useRouterBeforeLeave";
|
||||
|
||||
export default function Page() {
|
||||
const { reportId } = useReportId();
|
||||
const dispatch = useDispatch();
|
||||
const report = useSelector((state) => state.report);
|
||||
const reportQuery = api.report.get.useQuery({ id: String(reportId) }, {
|
||||
enabled: Boolean(reportId),
|
||||
})
|
||||
|
||||
// Reset report state before leaving
|
||||
useRouterBeforeLeave(useCallback(() => {
|
||||
dispatch(reset())
|
||||
}, [dispatch]))
|
||||
|
||||
// Set report if reportId exists
|
||||
useEffect(() => {
|
||||
if(reportId && reportQuery.data) {
|
||||
dispatch(setReport(reportQuery.data))
|
||||
}
|
||||
}, [reportId, reportQuery.data, dispatch])
|
||||
|
||||
return (
|
||||
<MainLayout className="grid min-h-screen grid-cols-[400px_minmax(0,1fr)] divide-x">
|
||||
<div>
|
||||
<ReportSidebar />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex gap-4">
|
||||
<ReportDateRange />
|
||||
</div>
|
||||
|
||||
<ReportLineChart {...report} showTable />
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { exampleRouter } from "@/server/api/routers/example";
|
||||
import { createTRPCRouter } from "@/server/api/trpc";
|
||||
import { chartMetaRouter } from "./routers/chartMeta";
|
||||
import { reportRouter } from "./routers/report";
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@@ -9,7 +10,8 @@ import { chartMetaRouter } from "./routers/chartMeta";
|
||||
*/
|
||||
export const appRouter = createTRPCRouter({
|
||||
example: exampleRouter,
|
||||
chartMeta: chartMetaRouter
|
||||
chartMeta: chartMetaRouter,
|
||||
report: reportRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -5,12 +5,7 @@ import { db } from "@/server/db";
|
||||
import { map, path, pipe, sort, uniq } from "ramda";
|
||||
import { toDots } from "@/utils/object";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import {
|
||||
zChartBreakdowns,
|
||||
zChartEvents,
|
||||
zChartType,
|
||||
zTimeInterval,
|
||||
} from "@/utils/validation";
|
||||
import { zChartInput } from "@/utils/validation";
|
||||
import { type IChartBreakdown, type IChartEvent } from "@/types";
|
||||
|
||||
type ResultItem = {
|
||||
@@ -21,17 +16,25 @@ type ResultItem = {
|
||||
|
||||
function propertyNameToSql(name: string) {
|
||||
if (name.includes(".")) {
|
||||
return name
|
||||
const str = name
|
||||
.split(".")
|
||||
.map((item, index) => (index === 0 ? item : `'${item}'`))
|
||||
.join("->");
|
||||
const findLastOf = "->"
|
||||
const lastArrow = str.lastIndexOf(findLastOf);
|
||||
if(lastArrow === -1) {
|
||||
return str;
|
||||
}
|
||||
const first = str.slice(0, lastArrow);
|
||||
const last = str.slice(lastArrow + findLastOf.length);
|
||||
return `${first}->>${last}`
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
function getEventLegend(event: IChartEvent) {
|
||||
return `${event.name} (${event.id})`
|
||||
return `${event.name} (${event.id})`;
|
||||
}
|
||||
|
||||
function getTotalCount(arr: ResultItem[]) {
|
||||
@@ -84,7 +87,7 @@ async function getChartData({
|
||||
filters.forEach((filter) => {
|
||||
const { name, value } = filter;
|
||||
if (name.includes(".")) {
|
||||
where.push(`${propertyNameToSql(name)} = '"${value}"'`);
|
||||
where.push(`${propertyNameToSql(name)} = '${value}'`);
|
||||
} else {
|
||||
where.push(`${name} = '${value}'`);
|
||||
}
|
||||
@@ -119,9 +122,8 @@ async function getChartData({
|
||||
GROUP BY ${groupBy.join(", ")}
|
||||
ORDER BY ${orderBy.join(", ")}
|
||||
`;
|
||||
console.log(sql);
|
||||
|
||||
const result = await db.$queryRawUnsafe<ResultItem[]>(sql);
|
||||
const result = await db.$queryRawUnsafe<ResultItem[]>(sql);
|
||||
|
||||
// group by sql label
|
||||
const series = result.reduce(
|
||||
@@ -129,7 +131,7 @@ async function getChartData({
|
||||
// item.label can be null when using breakdowns on a property
|
||||
// that doesn't exist on all events
|
||||
// fallback on event legend
|
||||
const label = item.label?.trim() ?? getEventLegend(event)
|
||||
const label = item.label?.trim() ?? getEventLegend(event);
|
||||
if (label) {
|
||||
if (acc[label]) {
|
||||
acc[label]?.push(item);
|
||||
@@ -147,22 +149,19 @@ async function getChartData({
|
||||
|
||||
return Object.keys(series).map((key) => {
|
||||
const legend = breakdowns.length ? key : getEventLegend(event);
|
||||
const data = series[key] ?? []
|
||||
const data = series[key] ?? [];
|
||||
return {
|
||||
name: legend,
|
||||
totalCount: getTotalCount(data),
|
||||
data: fillEmptySpotsInTimeline(
|
||||
data,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
).map((item) => {
|
||||
return {
|
||||
label: legend,
|
||||
count: item.count,
|
||||
date: new Date(item.date).toISOString(),
|
||||
};
|
||||
}),
|
||||
data: fillEmptySpotsInTimeline(data, interval, startDate, endDate).map(
|
||||
(item) => {
|
||||
return {
|
||||
label: legend,
|
||||
count: item.count,
|
||||
date: new Date(item.date).toISOString(),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -237,16 +236,7 @@ export const chartMetaRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
chart: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
startDate: z.date().nullish(),
|
||||
endDate: z.date().nullish(),
|
||||
chartType: zChartType,
|
||||
interval: zTimeInterval,
|
||||
events: zChartEvents,
|
||||
breakdowns: zChartBreakdowns,
|
||||
}),
|
||||
)
|
||||
.input(zChartInput)
|
||||
.query(
|
||||
async ({
|
||||
input: { chartType, events, breakdowns, interval, ...input },
|
||||
@@ -285,12 +275,17 @@ function fillEmptySpotsInTimeline(
|
||||
endDate: Date,
|
||||
) {
|
||||
const result = [];
|
||||
const currentDate = new Date(startDate);
|
||||
currentDate.setHours(2, 0, 0, 0);
|
||||
const modifiedEndDate = new Date(endDate);
|
||||
modifiedEndDate.setHours(2, 0, 0, 0);
|
||||
|
||||
while (currentDate.getTime() <= modifiedEndDate.getTime()) {
|
||||
const clonedStartDate = new Date(startDate);
|
||||
const clonedEndDate = new Date(endDate);
|
||||
if(interval === 'hour') {
|
||||
clonedStartDate.setMinutes(0, 0, 0);
|
||||
clonedEndDate.setMinutes(0, 0, 0)
|
||||
} else {
|
||||
clonedStartDate.setHours(2, 0, 0, 0);
|
||||
clonedEndDate.setHours(2, 0, 0, 0);
|
||||
}
|
||||
|
||||
while (clonedStartDate.getTime() <= clonedEndDate.getTime()) {
|
||||
const getYear = (date: Date) => date.getFullYear();
|
||||
const getMonth = (date: Date) => date.getMonth();
|
||||
const getDay = (date: Date) => date.getDate();
|
||||
@@ -302,32 +297,32 @@ function fillEmptySpotsInTimeline(
|
||||
|
||||
if (interval === "month") {
|
||||
return (
|
||||
getYear(date) === getYear(currentDate) &&
|
||||
getMonth(date) === getMonth(currentDate)
|
||||
getYear(date) === getYear(clonedStartDate) &&
|
||||
getMonth(date) === getMonth(clonedStartDate)
|
||||
);
|
||||
}
|
||||
if (interval === "day") {
|
||||
return (
|
||||
getYear(date) === getYear(currentDate) &&
|
||||
getMonth(date) === getMonth(currentDate) &&
|
||||
getDay(date) === getDay(currentDate)
|
||||
getYear(date) === getYear(clonedStartDate) &&
|
||||
getMonth(date) === getMonth(clonedStartDate) &&
|
||||
getDay(date) === getDay(clonedStartDate)
|
||||
);
|
||||
}
|
||||
if (interval === "hour") {
|
||||
return (
|
||||
getYear(date) === getYear(currentDate) &&
|
||||
getMonth(date) === getMonth(currentDate) &&
|
||||
getDay(date) === getDay(currentDate) &&
|
||||
getHour(date) === getHour(currentDate)
|
||||
getYear(date) === getYear(clonedStartDate) &&
|
||||
getMonth(date) === getMonth(clonedStartDate) &&
|
||||
getDay(date) === getDay(clonedStartDate) &&
|
||||
getHour(date) === getHour(clonedStartDate)
|
||||
);
|
||||
}
|
||||
if (interval === "minute") {
|
||||
return (
|
||||
getYear(date) === getYear(currentDate) &&
|
||||
getMonth(date) === getMonth(currentDate) &&
|
||||
getDay(date) === getDay(currentDate) &&
|
||||
getHour(date) === getHour(currentDate) &&
|
||||
getMinute(date) === getMinute(currentDate)
|
||||
getYear(date) === getYear(clonedStartDate) &&
|
||||
getMonth(date) === getMonth(clonedStartDate) &&
|
||||
getDay(date) === getDay(clonedStartDate) &&
|
||||
getHour(date) === getHour(clonedStartDate) &&
|
||||
getMinute(date) === getMinute(clonedStartDate)
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -336,7 +331,7 @@ function fillEmptySpotsInTimeline(
|
||||
result.push(item);
|
||||
} else {
|
||||
result.push({
|
||||
date: currentDate.toISOString(),
|
||||
date: clonedStartDate.toISOString(),
|
||||
count: 0,
|
||||
label: null,
|
||||
});
|
||||
@@ -344,19 +339,19 @@ function fillEmptySpotsInTimeline(
|
||||
|
||||
switch (interval) {
|
||||
case "day": {
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
clonedStartDate.setDate(clonedStartDate.getDate() + 1);
|
||||
break;
|
||||
}
|
||||
case "hour": {
|
||||
currentDate.setHours(currentDate.getHours() + 1);
|
||||
clonedStartDate.setHours(clonedStartDate.getHours() + 1);
|
||||
break;
|
||||
}
|
||||
case "minute": {
|
||||
currentDate.setMinutes(currentDate.getMinutes() + 1);
|
||||
clonedStartDate.setMinutes(clonedStartDate.getMinutes() + 1);
|
||||
break;
|
||||
}
|
||||
case "month": {
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
clonedStartDate.setMonth(clonedStartDate.getMonth() + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
108
apps/web/src/server/api/routers/report.ts
Normal file
108
apps/web/src/server/api/routers/report.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import { zChartInput } from "@/utils/validation";
|
||||
import { dateDifferanceInDays, getDaysOldDate } from "@/utils/date";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
type IChartInput,
|
||||
type IChartBreakdown,
|
||||
type IChartEvent,
|
||||
} from "@/types";
|
||||
import { type Report as DbReport } from "@prisma/client";
|
||||
|
||||
function transform(report: DbReport): IChartInput & { id: string } {
|
||||
return {
|
||||
id: report.id,
|
||||
events: report.events as IChartEvent[],
|
||||
breakdowns: report.breakdowns as IChartBreakdown[],
|
||||
startDate: getDaysOldDate(report.range),
|
||||
endDate: new Date(),
|
||||
chartType: report.chart_type,
|
||||
interval: report.interval,
|
||||
name: report.name,
|
||||
};
|
||||
}
|
||||
|
||||
export const reportRouter = createTRPCRouter({
|
||||
get: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(({ input: { id } }) => {
|
||||
return db.report
|
||||
.findUniqueOrThrow({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
.then(transform);
|
||||
}),
|
||||
getDashboard: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
dashboardId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input: { projectId, dashboardId } }) => {
|
||||
const reports = await db.report.findMany({
|
||||
where: {
|
||||
project_id: projectId,
|
||||
dashboard_id: dashboardId,
|
||||
},
|
||||
});
|
||||
|
||||
return reports.map(transform);
|
||||
}),
|
||||
save: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
report: zChartInput,
|
||||
projectId: z.string(),
|
||||
dashboardId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ input: { report, projectId, dashboardId } }) => {
|
||||
return db.report.create({
|
||||
data: {
|
||||
project_id: projectId,
|
||||
dashboard_id: dashboardId,
|
||||
name: report.name,
|
||||
events: report.events,
|
||||
interval: report.interval,
|
||||
breakdowns: report.breakdowns,
|
||||
chart_type: report.chartType,
|
||||
range: dateDifferanceInDays(report.endDate, report.startDate),
|
||||
},
|
||||
});
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
reportId: z.string(),
|
||||
report: zChartInput,
|
||||
projectId: z.string(),
|
||||
dashboardId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ input: { report, projectId, dashboardId, reportId } }) => {
|
||||
return db.report.update({
|
||||
where: {
|
||||
id: reportId,
|
||||
},
|
||||
data: {
|
||||
project_id: projectId,
|
||||
dashboard_id: dashboardId,
|
||||
name: report.name,
|
||||
events: report.events,
|
||||
interval: report.interval,
|
||||
breakdowns: report.breakdowns,
|
||||
chart_type: report.chartType,
|
||||
range: dateDifferanceInDays(report.endDate, report.startDate),
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { type zTimeInterval, type zChartBreakdown, type zChartEvent } from "@/utils/validation";
|
||||
import { type zTimeInterval, type zChartBreakdown, type zChartEvent, type zChartInput } from "@/utils/validation";
|
||||
import { type TooltipProps } from "recharts";
|
||||
import { type z } from "zod";
|
||||
|
||||
export type HtmlProps<T> = React.DetailedHTMLProps<React.HTMLAttributes<T>, T>;
|
||||
|
||||
export type IChartInput = z.infer<typeof zChartInput>
|
||||
export type IChartEvent = z.infer<typeof zChartEvent>
|
||||
export type IChartEventFilter = IChartEvent['filters'][number]
|
||||
export type IChartBreakdown = z.infer<typeof zChartBreakdown>
|
||||
@@ -10,4 +13,4 @@ export type IInterval = z.infer<typeof zTimeInterval>
|
||||
|
||||
export type IToolTipProps<T> = Omit<TooltipProps<number, string>, 'payload'> & {
|
||||
payload?: Array<T>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ const getBaseUrl = () => {
|
||||
export const api = createTRPCNext<AppRouter>({
|
||||
config() {
|
||||
return {
|
||||
queryClientConfig: {
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Transformer used for data de-serialization from the server.
|
||||
*
|
||||
|
||||
@@ -2,4 +2,10 @@ export function getDaysOldDate(days: number) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - days);
|
||||
return date;
|
||||
}
|
||||
|
||||
export function dateDifferanceInDays(date1: Date, date2: Date) {
|
||||
const diffTime = Math.abs(date2.getTime() - date1.getTime());
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ChartType } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
export const zChartEvent = z.object({
|
||||
@@ -19,6 +20,16 @@ export const zChartBreakdown = z.object({
|
||||
export const zChartEvents = z.array(zChartEvent);
|
||||
export const zChartBreakdowns = z.array(zChartBreakdown);
|
||||
|
||||
export const zChartType = z.enum(["bar", "linear"]);
|
||||
export const zChartType = z.enum(['linear', 'bar', 'pie', 'metric', 'area']);
|
||||
|
||||
export const zTimeInterval = z.enum(["day", "hour", "month"]);
|
||||
|
||||
export const zChartInput = z.object({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
endDate: z.date(),
|
||||
chartType: zChartType,
|
||||
interval: zTimeInterval,
|
||||
events: zChartEvents,
|
||||
breakdowns: zChartBreakdowns,
|
||||
})
|
||||
@@ -7,6 +7,9 @@
|
||||
"license": "ISC",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "cd apps/web && bun dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
"semver": "^7.5.4"
|
||||
|
||||
Reference in New Issue
Block a user