migrate to app dir and ssr
This commit is contained in:
25
README.md
25
README.md
@@ -17,10 +17,23 @@ Mixan is a simple analytics tool for logging events on web and react-native. My
|
||||
|
||||
### Speed/Benchmark
|
||||
|
||||
As of today (2023-12-12) I have more then 1.2 million events in PSQL and performance is smooth as butter 🧈. Only thing that is slow (2s response time) is to get all unique events. Solved now with cache but can probably make better with `indexes` and avoid using `distinct`.
|
||||
As of today (~~2023-12-12~~ 2024-01-16) I have more then ~~1.2~~ 2.8 million events and 20 thousand profiles in postgres and performance is smooth as butter\* 🧈. Only thing that is slow (2s response time) is to get all unique events. Solved now with cache but can probably make better with `indexes` and avoid using `distinct`.
|
||||
|
||||
\* Smooth as butter is somewhat exaggerated but I would say it still fast! It takes 1.4 sec to search through all events (3 million) with advanced where clause. I think this performance is absolutly good enough.
|
||||
|
||||
### GUI
|
||||
|
||||
- [x] Fix design for report editor
|
||||
- [x] Fix profiles
|
||||
- [x] Pagination
|
||||
- [x] Filter by event name
|
||||
- [x] Fix [profileId]
|
||||
- [x] Add events
|
||||
- [x] Improve design for properties and linked profiles
|
||||
- [x] New design for events
|
||||
- [ ] Map events to convertions
|
||||
- [ ] Map ids
|
||||
- [x] Fix menu links when projectId is undefined
|
||||
- [x] Fix tables on settings
|
||||
- [x] Rename event label
|
||||
- [ ] Common web dashboard
|
||||
@@ -36,14 +49,16 @@ As of today (2023-12-12) I have more then 1.2 million events in PSQL and perform
|
||||
- [x] View events in a list
|
||||
- [x] Simple filters
|
||||
- [x] View profiles in a list
|
||||
- [ ] Invite users
|
||||
- [x] Invite users
|
||||
- [ ] Drag n Drop reports on dashboard
|
||||
- [x] Manage dashboards
|
||||
- [ ] Support more chart types
|
||||
- [x] Support more chart types
|
||||
- [x] Bar
|
||||
- [x] Histogram
|
||||
- [ ] Pie
|
||||
- [ ] Area
|
||||
- [x] Pie
|
||||
- [x] Area
|
||||
- [x] Metric
|
||||
- [x] Line
|
||||
- [ ] Support funnels
|
||||
- [ ] Support multiple breakdowns
|
||||
- [x] Aggregations (sum, average...)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm with-env next dev",
|
||||
"dev": "rm -rf .next && pnpm with-env next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
@@ -40,16 +40,19 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"hamburger-react": "^2.5.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "^0.286.0",
|
||||
"mitt": "^3.0.1",
|
||||
"next": "13.4",
|
||||
"next": "~14.0.4",
|
||||
"next-auth": "^4.23.0",
|
||||
"nuqs": "^1.15.2",
|
||||
"prisma-error-enum": "^0.1.3",
|
||||
"ramda": "^0.29.1",
|
||||
"random-animal-name": "^0.1.1",
|
||||
"react": "18.2.0",
|
||||
"react-animate-height": "^3.2.3",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.47.0",
|
||||
"react-in-viewport": "1.0.0-alpha.30",
|
||||
@@ -93,8 +96,8 @@
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@mixan/eslint-config/base",
|
||||
"@mixan/eslint-config/nextjs",
|
||||
"@mixan/eslint-config/react"
|
||||
"@mixan/eslint-config/react",
|
||||
"@mixan/eslint-config/nextjs"
|
||||
]
|
||||
},
|
||||
"prettier": "@mixan/prettier-config"
|
||||
|
||||
15
apps/web/public/logo.svg
Normal file
15
apps/web/public/logo.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="824" height="824" viewBox="0 0 824 824" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="824" height="824" fill="#007BFF" fill-opacity="0.3"/>
|
||||
<path d="M0 0L824 0V824H0L0 0Z" fill="url(#paint0_linear_0_131)"/>
|
||||
<path d="M436 220H508C520.73 220 532.939 225.057 541.941 234.059C550.943 243.061 556 255.27 556 268V604" stroke="#BED2FF" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M172 604H244" stroke="#BED2FF" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M436 604H652" stroke="#BED2FF" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M364 412V412.24" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M436 233.488V621.256C435.999 624.902 435.168 628.499 433.569 631.775C431.97 635.052 429.646 637.92 426.772 640.164C423.899 642.408 420.553 643.968 416.987 644.726C413.421 645.483 409.729 645.418 406.192 644.536L244 604V257.488C244.002 246.784 247.581 236.388 254.169 227.952C260.757 219.516 269.976 213.524 280.36 210.928L376.36 186.928C383.434 185.16 390.818 185.026 397.951 186.538C405.084 188.05 411.779 191.167 417.528 195.652C423.277 200.138 427.928 205.874 431.129 212.426C434.329 218.978 435.995 226.196 436 233.488Z" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_0_131" x1="353" y1="93.0001" x2="528" y2="747.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2563EB"/>
|
||||
<stop offset="1" stop-color="#1D54CD"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
|
||||
import { LazyChart } from '@/components/report/chart/LazyChart';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import type { getReportsByDashboardId } from '@/server/services/reports.service';
|
||||
import type { IChartRange } from '@/types';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getDefaultIntervalByRange, timeRanges } from '@/utils/constants';
|
||||
import { ChevronRight, MoreHorizontal, PlusIcon, Trash } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
|
||||
interface ListReportsProps {
|
||||
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
|
||||
}
|
||||
|
||||
export function ListReports({ reports }: ListReportsProps) {
|
||||
const router = useRouter();
|
||||
const params = useAppParams<{ dashboardId: string }>();
|
||||
const [range, setRange] = useState<null | IChartRange>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StickyBelowHeader className="p-4 items-center justify-between flex">
|
||||
<Combobox
|
||||
className="min-w-0"
|
||||
placeholder="Override range"
|
||||
value={range}
|
||||
onChange={(value) => {
|
||||
setRange((p) => (p === value ? null : value));
|
||||
}}
|
||||
items={Object.values(timeRanges).map((key) => ({
|
||||
label: key,
|
||||
value: key,
|
||||
}))}
|
||||
/>
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/${params.organizationId}/${
|
||||
params.projectId
|
||||
}/reports?${new URLSearchParams({
|
||||
dashboardId: params.dashboardId,
|
||||
}).toString()}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="max-sm:hidden">Create report</span>
|
||||
<span className="sm:hidden">Report</span>
|
||||
</Button>
|
||||
</StickyBelowHeader>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 p-4">
|
||||
{reports.map((report) => {
|
||||
const chartRange = report.range; // timeRanges[report.range];
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-border bg-white shadow"
|
||||
key={report.id}
|
||||
>
|
||||
<Link
|
||||
href={`/${params.organizationId}/${params.projectId}/reports/${report.id}`}
|
||||
className="flex border-b border-border p-4 leading-none [&_svg]:hover:opacity-100 items-center justify-between"
|
||||
shallow
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{report.name}</div>
|
||||
{chartRange !== null && (
|
||||
<div className="mt-2 text-sm flex gap-2">
|
||||
<span className={range !== null ? 'line-through' : ''}>
|
||||
{chartRange}
|
||||
</span>
|
||||
{range !== null && <span>{range}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="h-8 w-8 hover:border rounded justify-center items-center flex">
|
||||
<MoreHorizontal size={16} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(event) => {
|
||||
// event.stopPropagation();
|
||||
// deletion.mutate({
|
||||
// reportId: report.id,
|
||||
// });
|
||||
}}
|
||||
>
|
||||
<Trash size={16} className="mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ChevronRight
|
||||
className="opacity-10 transition-opacity"
|
||||
size={16}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 pl-2',
|
||||
report.chartType === 'bar' && 'overflow-auto max-h-[300px]'
|
||||
)}
|
||||
>
|
||||
<LazyChart
|
||||
{...report}
|
||||
range={range ?? report.range}
|
||||
interval={
|
||||
range ? getDefaultIntervalByRange(range) : report.interval
|
||||
}
|
||||
editMode={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import PageLayout from '@/app/(app)/page-layout';
|
||||
import { getSession } from '@/server/auth';
|
||||
import {
|
||||
createRecentDashboard,
|
||||
getDashboardById,
|
||||
} from '@/server/services/dashboard.service';
|
||||
import { getReportsByDashboardId } from '@/server/services/reports.service';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
|
||||
import { ListReports } from './list-reports';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
dashboardId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationId, projectId, dashboardId },
|
||||
}: PageProps) {
|
||||
const session = await getSession();
|
||||
const dashboard = await getDashboardById(dashboardId);
|
||||
const reports = await getReportsByDashboardId(dashboardId);
|
||||
const userId = session?.user.id;
|
||||
if (userId && dashboard) {
|
||||
await createRecentDashboard({
|
||||
userId,
|
||||
organizationId,
|
||||
projectId,
|
||||
dashboardId,
|
||||
});
|
||||
revalidateTag(`recentDashboards__${userId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout title={dashboard.name} organizationId={organizationId}>
|
||||
<ListReports reports={reports} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ActivityIcon, BotIcon, MonitorPlayIcon } from 'lucide-react';
|
||||
|
||||
const variants = cva('flex items-center justify-center shrink-0', {
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'w-6 h-6 rounded',
|
||||
default: 'w-12 h-12 rounded-xl',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
type EventIconProps = VariantProps<typeof variants> & {
|
||||
name: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const records = {
|
||||
default: { Icon: BotIcon, text: 'text-chart-0', bg: 'bg-chart-0/10' },
|
||||
screen_view: {
|
||||
Icon: MonitorPlayIcon,
|
||||
text: 'text-chart-3',
|
||||
bg: 'bg-chart-3/10',
|
||||
},
|
||||
session_start: {
|
||||
Icon: ActivityIcon,
|
||||
text: 'text-chart-2',
|
||||
bg: 'bg-chart-2/10',
|
||||
},
|
||||
};
|
||||
|
||||
export function EventIcon({ className, name, size }: EventIconProps) {
|
||||
const { Icon, text, bg } =
|
||||
name in records ? records[name as keyof typeof records] : records.default;
|
||||
|
||||
return (
|
||||
<div className={cn(variants({ size }), bg, className)}>
|
||||
<Icon size={20} className={text} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { RouterOutputs } from '@/app/_trpc/client';
|
||||
import { ListProperties } from '@/components/events/ListProperties';
|
||||
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
|
||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { round } from '@/utils/math';
|
||||
import { Activity, BotIcon, MonitorPlay } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { EventIcon } from './event-icon';
|
||||
|
||||
type EventListItemProps = RouterOutputs['event']['list'][number];
|
||||
|
||||
export function EventListItem({
|
||||
profile,
|
||||
createdAt,
|
||||
name,
|
||||
properties,
|
||||
}: EventListItemProps) {
|
||||
const params = useAppParams();
|
||||
|
||||
const bullets = useMemo(() => {
|
||||
const bullets: React.ReactNode[] = [
|
||||
<span>{formatDateTime(createdAt)}</span>,
|
||||
];
|
||||
|
||||
if (profile) {
|
||||
bullets.push(
|
||||
<Link
|
||||
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
|
||||
className="flex items-center gap-1 text-black font-medium hover:underline"
|
||||
>
|
||||
<ProfileAvatar size="xs" {...(profile ?? {})}></ProfileAvatar>
|
||||
{getProfileName(profile)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof properties.duration === 'number') {
|
||||
bullets.push(`${round(properties.duration / 1000, 1)}s`);
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'screen_view': {
|
||||
const route = (properties?.route || properties?.path) as string;
|
||||
if (route) {
|
||||
bullets.push(route);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return bullets;
|
||||
}, [name, createdAt, profile, properties, params]);
|
||||
|
||||
return (
|
||||
<ExpandableListItem
|
||||
title={name.split('_').join(' ')}
|
||||
bullets={bullets}
|
||||
image={<EventIcon name={name} />}
|
||||
>
|
||||
<ListProperties data={properties} className="rounded-none border-none" />
|
||||
</ExpandableListItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
|
||||
import { EventListItem } from './event-list-item';
|
||||
|
||||
interface ListEventsProps {
|
||||
projectId: string;
|
||||
}
|
||||
export function ListEvents({ projectId }: ListEventsProps) {
|
||||
const pagination = usePagination();
|
||||
const [eventFilters, setEventFilters] = useState<string[]>([]);
|
||||
const eventsQuery = api.event.list.useQuery(
|
||||
{
|
||||
events: eventFilters,
|
||||
projectId: projectId,
|
||||
...pagination,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
|
||||
|
||||
const filterEventsQuery = api.chart.events.useQuery({
|
||||
projectId: projectId,
|
||||
});
|
||||
const filterEvents = (filterEventsQuery.data ?? []).map((item) => ({
|
||||
value: item.name,
|
||||
label: item.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<StickyBelowHeader className="p-4 flex justify-between">
|
||||
<div>
|
||||
<ComboboxAdvanced
|
||||
items={filterEvents}
|
||||
value={eventFilters}
|
||||
onChange={setEventFilters}
|
||||
placeholder="Filter by event"
|
||||
/>
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{events.map((item) => (
|
||||
<EventListItem key={item.id} {...item} />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Pagination {...pagination} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import PageLayout from '@/app/(app)/page-layout';
|
||||
|
||||
import { ListEvents } from './list-events';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
export default function Page({
|
||||
params: { organizationId, projectId },
|
||||
}: PageProps) {
|
||||
return (
|
||||
<PageLayout title="Events" organizationId={organizationId}>
|
||||
<ListEvents projectId={projectId} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { pushModal } from '@/modals';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
|
||||
import { StickyBelowHeader } from '../../layout-sticky-below-header';
|
||||
|
||||
interface HeaderDashboardsProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function HeaderDashboards({ projectId }: HeaderDashboardsProps) {
|
||||
return (
|
||||
<StickyBelowHeader>
|
||||
<div className="p-4 flex justify-between items-center">
|
||||
<div />
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
pushModal('AddDashboard', {
|
||||
projectId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="max-sm:hidden">Create dashboard</span>
|
||||
<span className="sm:hidden">Dashboard</span>
|
||||
</Button>
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { api, handleError, handleErrorToastOptions } from '@/app/_trpc/client';
|
||||
import { Card, CardActions, CardActionsItem } from '@/components/Card';
|
||||
import { ToastAction } from '@/components/ui/toast';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { pushModal } from '@/modals';
|
||||
import type { getDashboardsByProjectId } from '@/server/services/dashboard.service';
|
||||
import { Pencil, Plus, Trash } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
|
||||
interface ListDashboardsProps {
|
||||
dashboards: Awaited<ReturnType<typeof getDashboardsByProjectId>>;
|
||||
}
|
||||
|
||||
export function ListDashboards({ dashboards }: ListDashboardsProps) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const { organizationId, projectId } = params;
|
||||
const deletion = api.dashboard.delete.useMutation({
|
||||
onError: (error, variables) => {
|
||||
return handleErrorToastOptions({
|
||||
action: (
|
||||
<ToastAction
|
||||
altText="Force delete"
|
||||
onClick={() => {
|
||||
deletion.mutate({
|
||||
forceDelete: true,
|
||||
id: variables.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Force delete
|
||||
</ToastAction>
|
||||
),
|
||||
})(error);
|
||||
},
|
||||
onSuccess() {
|
||||
router.refresh();
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Dashboard deleted.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid sm:grid-cols-2 gap-4 p-4">
|
||||
{dashboards.map((item) => (
|
||||
<Card key={item.id} hover>
|
||||
<div>
|
||||
<Link
|
||||
href={`/${organizationId}/${projectId}/${item.id}`}
|
||||
className="block p-4 flex flex-col"
|
||||
>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{item.project.name}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<CardActions>
|
||||
<CardActionsItem className="w-full" asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
pushModal('EditDashboard', item);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
Edit
|
||||
</button>
|
||||
</CardActionsItem>
|
||||
<CardActionsItem className="text-destructive w-full" asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
deletion.mutate({
|
||||
id: item.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash size={16} />
|
||||
Delete
|
||||
</button>
|
||||
</CardActionsItem>
|
||||
</CardActions>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/app/(app)/[organizationId]/[projectId]/page.tsx
Normal file
25
apps/web/src/app/(app)/[organizationId]/[projectId]/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import PageLayout from '@/app/(app)/page-layout';
|
||||
import { getDashboardsByProjectId } from '@/server/services/dashboard.service';
|
||||
|
||||
import { HeaderDashboards } from './header-dashboards';
|
||||
import { ListDashboards } from './list-dashboards';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationId, projectId },
|
||||
}: PageProps) {
|
||||
const dashboards = await getDashboardsByProjectId(projectId);
|
||||
|
||||
return (
|
||||
<PageLayout title="Dashboards" organizationId={organizationId}>
|
||||
<HeaderDashboards projectId={projectId} />
|
||||
<ListDashboards dashboards={dashboards} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
import { useEventNames } from '@/hooks/useEventNames';
|
||||
import { parseAsJson, useQueryState } from 'nuqs';
|
||||
|
||||
import { EventListItem } from '../../events/event-list-item';
|
||||
|
||||
interface ListProfileEvents {
|
||||
projectId: string;
|
||||
profileId: string;
|
||||
}
|
||||
|
||||
export default function ListProfileEvents({
|
||||
projectId,
|
||||
profileId,
|
||||
}: ListProfileEvents) {
|
||||
const pagination = usePagination();
|
||||
const [eventFilters, setEventFilters] = useQueryState(
|
||||
'events',
|
||||
parseAsJson<string[]>().withDefault([])
|
||||
);
|
||||
|
||||
const eventNames = useEventNames(projectId);
|
||||
const eventsQuery = api.event.list.useQuery(
|
||||
{
|
||||
projectId,
|
||||
profileId,
|
||||
events: eventFilters,
|
||||
...pagination,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<ComboboxAdvanced
|
||||
placeholder="Filter events"
|
||||
items={eventNames}
|
||||
value={eventFilters}
|
||||
onChange={setEventFilters}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{events.map((item) => (
|
||||
<EventListItem key={item.id} {...item} />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Pagination {...pagination} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import PageLayout from '@/app/(app)/page-layout';
|
||||
import { ListProperties } from '@/components/events/ListProperties';
|
||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
||||
import {
|
||||
getProfileById,
|
||||
getProfilesByExternalId,
|
||||
} from '@/server/services/profile.service';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
|
||||
import ListProfileEvents from './list-profile-events';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
profileId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationId, projectId, profileId },
|
||||
}: PageProps) {
|
||||
const profile = await getProfileById(profileId);
|
||||
const profiles = (
|
||||
await getProfilesByExternalId(profile.external_id, profile.project_id)
|
||||
).filter((item) => item.id !== profile.id);
|
||||
return (
|
||||
<PageLayout
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
|
||||
{getProfileName(profile)}
|
||||
</div>
|
||||
}
|
||||
organizationId={organizationId}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 mb-8">
|
||||
<Widget>
|
||||
<WidgetHead>
|
||||
<span className="title">Properties</span>
|
||||
</WidgetHead>
|
||||
<ListProperties
|
||||
data={profile.properties}
|
||||
className="rounded-none border-none"
|
||||
/>
|
||||
</Widget>
|
||||
<Widget>
|
||||
<WidgetHead>
|
||||
<span className="title">Linked profile</span>
|
||||
</WidgetHead>
|
||||
{profiles.length > 0 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{profiles.map((profile) => (
|
||||
<div key={profile.id} className="border-b border-border">
|
||||
<WidgetBody className="flex gap-4">
|
||||
<ProfileAvatar {...profile} />
|
||||
<div>
|
||||
<div className="font-medium mt-1">
|
||||
{getProfileName(profile)}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-muted-foreground text-xs">
|
||||
<span>{profile.id}</span>
|
||||
<span>{formatDateTime(profile.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
<ListProperties
|
||||
data={profile.properties}
|
||||
className="rounded-none border-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4">No linked profiles</div>
|
||||
)}
|
||||
</Widget>
|
||||
</div>
|
||||
<ListProfileEvents projectId={projectId} profileId={profileId} />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useQueryState } from 'nuqs';
|
||||
|
||||
import { ProfileListItem } from './profile-list-item';
|
||||
|
||||
interface ListProfilesProps {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
}
|
||||
export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
|
||||
const [query, setQuery] = useQueryState('q');
|
||||
const pagination = usePagination();
|
||||
const eventsQuery = api.profile.list.useQuery(
|
||||
{
|
||||
projectId,
|
||||
query,
|
||||
...pagination,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
const profiles = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StickyBelowHeader className="p-4 flex justify-between">
|
||||
<Input
|
||||
placeholder="Search by name"
|
||||
value={query ?? ''}
|
||||
onChange={(event) => setQuery(event.target.value || null)}
|
||||
/>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{profiles.map((item) => (
|
||||
<ProfileListItem key={item.id} {...item} />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Pagination {...pagination} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import PageLayout from '@/app/(app)/page-layout';
|
||||
|
||||
import { ListProfiles } from './list-profiles';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
export default function Page({
|
||||
params: { organizationId, projectId },
|
||||
}: PageProps) {
|
||||
return (
|
||||
<PageLayout title="Events" organizationId={organizationId}>
|
||||
<ListProfiles projectId={projectId} organizationId={organizationId} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { RouterOutputs } from '@/app/_trpc/client';
|
||||
import { ListProperties } from '@/components/events/ListProperties';
|
||||
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
|
||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import Link from 'next/link';
|
||||
|
||||
type ProfileListItemProps = RouterOutputs['profile']['list'][number];
|
||||
|
||||
export function ProfileListItem(props: ProfileListItemProps) {
|
||||
const { id, properties, createdAt } = props;
|
||||
const params = useAppParams();
|
||||
|
||||
const bullets = useMemo(() => {
|
||||
const bullets: React.ReactNode[] = [
|
||||
<span>{formatDateTime(createdAt)}</span>,
|
||||
<Link
|
||||
href={`/${params.organizationId}/${params.projectId}/profiles/${id}`}
|
||||
className="text-black font-medium hover:underline"
|
||||
>
|
||||
See profile
|
||||
</Link>,
|
||||
];
|
||||
|
||||
return bullets;
|
||||
}, [createdAt, id, params]);
|
||||
|
||||
return (
|
||||
<ExpandableListItem
|
||||
title={getProfileName(props)}
|
||||
bullets={bullets}
|
||||
image={<ProfileAvatar {...props} />}
|
||||
>
|
||||
<ListProperties data={properties} className="rounded-none border-none" />
|
||||
</ExpandableListItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import PageLayout from '@/app/(app)/page-layout';
|
||||
import { getReportById } from '@/server/services/reports.service';
|
||||
import { Pencil } from 'lucide-react';
|
||||
|
||||
import ReportEditor from '../report-editor';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
reportId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationId, reportId },
|
||||
}: PageProps) {
|
||||
const report = await getReportById(reportId);
|
||||
return (
|
||||
<PageLayout
|
||||
title={
|
||||
<div className="flex gap-2 items-center cursor-pointer">
|
||||
{report.name}
|
||||
<Pencil size={16} />
|
||||
</div>
|
||||
}
|
||||
organizationId={organizationId}
|
||||
>
|
||||
<ReportEditor report={report} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import PageLayout from '@/app/(app)/page-layout';
|
||||
import { Pencil } from 'lucide-react';
|
||||
|
||||
import ReportEditor from './report-editor';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page({ params: { organizationId } }: PageProps) {
|
||||
return (
|
||||
<PageLayout
|
||||
title={
|
||||
<div className="flex gap-2 items-center cursor-pointer">
|
||||
Unnamed report
|
||||
<Pencil size={16} />
|
||||
</div>
|
||||
}
|
||||
organizationId={organizationId}
|
||||
>
|
||||
<ReportEditor reportId={null} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
|
||||
import { Chart } from '@/components/report/chart';
|
||||
import { ReportChartType } from '@/components/report/ReportChartType';
|
||||
import { ReportInterval } from '@/components/report/ReportInterval';
|
||||
import { ReportLineType } from '@/components/report/ReportLineType';
|
||||
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
|
||||
import {
|
||||
changeDateRanges,
|
||||
ready,
|
||||
reset,
|
||||
setReport,
|
||||
} from '@/components/report/reportSlice';
|
||||
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import type { IServiceReport } from '@/server/services/reports.service';
|
||||
import { timeRanges } from '@/utils/constants';
|
||||
import { GanttChartSquareIcon } from 'lucide-react';
|
||||
|
||||
interface ReportEditorProps {
|
||||
report: IServiceReport | null;
|
||||
}
|
||||
|
||||
export default function ReportEditor({
|
||||
report: initialReport,
|
||||
}: ReportEditorProps) {
|
||||
const dispatch = useDispatch();
|
||||
const report = useSelector((state) => state.report);
|
||||
|
||||
// Set report if reportId exists
|
||||
useEffect(() => {
|
||||
if (initialReport) {
|
||||
dispatch(setReport(initialReport));
|
||||
} else {
|
||||
dispatch(ready());
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispatch(reset());
|
||||
};
|
||||
}, [initialReport, dispatch]);
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<StickyBelowHeader className="p-4 grid grid-cols-2 gap-2 md:grid-cols-6">
|
||||
<SheetTrigger asChild>
|
||||
<div>
|
||||
<Button icon={GanttChartSquareIcon} variant="cta">
|
||||
Pick events
|
||||
</Button>
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 col-span-4">
|
||||
<Combobox
|
||||
className="min-w-0 flex-1"
|
||||
placeholder="Range"
|
||||
value={report.range}
|
||||
onChange={(value) => {
|
||||
dispatch(changeDateRanges(value));
|
||||
}}
|
||||
items={Object.values(timeRanges).map((key) => ({
|
||||
label: key,
|
||||
value: key,
|
||||
}))}
|
||||
/>
|
||||
<ReportInterval className="min-w-0 flex-1" />
|
||||
<ReportChartType className="min-w-0 flex-1" />
|
||||
<ReportLineType className="min-w-0 flex-1" />
|
||||
</div>
|
||||
<div className="col-start-2 md:col-start-6 row-start-1 text-right">
|
||||
<ReportSaveButton />
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{report.ready && <Chart {...report} editMode />}
|
||||
</div>
|
||||
<SheetContent className="!max-w-lg w-full" side="left">
|
||||
<ReportSidebar />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
49
apps/web/src/app/(app)/[organizationId]/list-projects.tsx
Normal file
49
apps/web/src/app/(app)/[organizationId]/list-projects.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/Card';
|
||||
import { pushModal } from '@/modals';
|
||||
import type { getProjectsByOrganizationId } from '@/server/services/project.service';
|
||||
import { Plus } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
interface ListProjectsProps {
|
||||
projects: Awaited<ReturnType<typeof getProjectsByOrganizationId>>;
|
||||
}
|
||||
|
||||
export function ListProjects({ projects }: ListProjectsProps) {
|
||||
const params = useParams();
|
||||
const organizationId = params.organizationId as string;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid sm:grid-cols-2 gap-4 p-4">
|
||||
{projects.map((item) => (
|
||||
<Card key={item.id} hover>
|
||||
<div>
|
||||
<Link
|
||||
href={`/${organizationId}/${item.id}`}
|
||||
className="block p-4 flex flex-col"
|
||||
>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<Card hover className="border-dashed">
|
||||
<button
|
||||
className="flex items-center justify-between w-full p-4 font-medium leading-none"
|
||||
onClick={() => {
|
||||
pushModal('AddProject', {
|
||||
organizationId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Create new project
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
apps/web/src/app/(app)/[organizationId]/page.tsx
Normal file
17
apps/web/src/app/(app)/[organizationId]/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getFirstProjectByOrganizationId } from '@/server/services/project.service';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||
const project = await getFirstProjectByOrganizationId(organizationId);
|
||||
if (project) {
|
||||
return redirect(`/${organizationId}/${project.id}`);
|
||||
}
|
||||
|
||||
return <p>List projects maybe?</p>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
|
||||
import { columns } from '@/components/clients/table';
|
||||
import { ContentHeader } from '@/components/Content';
|
||||
import { DataTable } from '@/components/DataTable';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { pushModal } from '@/modals';
|
||||
import type { getClientsByOrganizationId } from '@/server/services/clients.service';
|
||||
import { KeySquareIcon, PlusIcon } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
interface ListClientsProps {
|
||||
clients: Awaited<ReturnType<typeof getClientsByOrganizationId>>;
|
||||
}
|
||||
export default function ListClients({ clients }: ListClientsProps) {
|
||||
const organizationId = useParams().organizationId as string;
|
||||
|
||||
return (
|
||||
<>
|
||||
<StickyBelowHeader>
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div />
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
onClick={() => pushModal('AddClient', { organizationId })}
|
||||
>
|
||||
<span className="max-sm:hidden">Create client</span>
|
||||
<span className="sm:hidden">Client</span>
|
||||
</Button>
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
<DataTable data={clients} columns={columns} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import PageLayout from '@/app/(app)/page-layout';
|
||||
import { getClientsByOrganizationId } from '@/server/services/clients.service';
|
||||
|
||||
import ListClients from './list-clients';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||
const clients = await getClientsByOrganizationId(organizationId);
|
||||
|
||||
return (
|
||||
<PageLayout title="Clients" organizationId={organizationId}>
|
||||
<ListClients clients={clients} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { api, handleError } from '@/app/_trpc/client';
|
||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
||||
import type { getOrganizationById } from '@/server/services/organization.service';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validator = z.object({
|
||||
id: z.string().min(2),
|
||||
name: z.string().min(2),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
interface EditOrganizationProps {
|
||||
organization: Awaited<ReturnType<typeof getOrganizationById>>;
|
||||
}
|
||||
export default function EditOrganization({
|
||||
organization,
|
||||
}: EditOrganizationProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { register, handleSubmit, formState, reset } = useForm<IForm>({
|
||||
defaultValues: organization,
|
||||
});
|
||||
|
||||
const mutation = api.organization.update.useMutation({
|
||||
onSuccess(res) {
|
||||
toast({
|
||||
title: 'Organization updated',
|
||||
description: 'Your organization has been updated.',
|
||||
});
|
||||
reset(res);
|
||||
router.refresh();
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
<Widget>
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Org. details</span>
|
||||
<Button size="sm" type="submit" disabled={!formState.isDirty}>
|
||||
Save
|
||||
</Button>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<InputWithLabel
|
||||
label="Name"
|
||||
{...register('name')}
|
||||
defaultValue={organization.name}
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// <ContentSection
|
||||
// title="Invite user"
|
||||
// text="Invite users to this organization. You can invite several users with (,)"
|
||||
// >
|
||||
|
||||
// </ContentSection>
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { api, handleError } from '@/app/_trpc/client';
|
||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
||||
import type { IServiceInvite } from '@/server/services/user.service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validator = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
|
||||
interface InvitedUsersProps {
|
||||
invites: IServiceInvite[];
|
||||
organizationId: string;
|
||||
}
|
||||
export default function InvitedUsers({
|
||||
invites,
|
||||
organizationId,
|
||||
}: InvitedUsersProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { register, handleSubmit, formState, reset } = useForm<IForm>({
|
||||
resolver: zodResolver(validator),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = api.user.invite.useMutation({
|
||||
onSuccess() {
|
||||
toast({
|
||||
title: 'User invited',
|
||||
description: "We have sent an invitation to the user's email",
|
||||
});
|
||||
reset();
|
||||
router.refresh();
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate({
|
||||
...values,
|
||||
organizationId,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Widget>
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Invites</span>
|
||||
<Button size="sm" type="submit" disabled={!formState.isDirty}>
|
||||
Invite
|
||||
</Button>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<InputWithLabel
|
||||
label="Email"
|
||||
placeholder="Who do you want to invite?"
|
||||
{...register('email')}
|
||||
/>
|
||||
|
||||
<div className="font-medium mt-8 mb-2">Invited users</div>
|
||||
<Table className="mini">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Accepted</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invites.map((item) => {
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{item.email}</TableCell>
|
||||
<TableCell>{item.accepted ? 'Yes' : 'No'}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
{invites.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="italic">
|
||||
No invites
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import PageLayout from '@/app/(app)/page-layout';
|
||||
import { getOrganizationById } from '@/server/services/organization.service';
|
||||
import { getInvitesByOrganizationId } from '@/server/services/user.service';
|
||||
|
||||
import EditOrganization from './edit-organization';
|
||||
import InvitedUsers from './invited-users';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||
const organization = await getOrganizationById(organizationId);
|
||||
const invites = await getInvitesByOrganizationId(organizationId);
|
||||
|
||||
return (
|
||||
<PageLayout title={organization.name} organizationId={organizationId}>
|
||||
<div className="p-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<EditOrganization organization={organization} />
|
||||
<InvitedUsers invites={invites} organizationId={organizationId} />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './organization/page';
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { api, handleError } from '@/app/_trpc/client';
|
||||
import { ContentHeader, ContentSection } from '@/components/Content';
|
||||
import { InputError } from '@/components/forms/InputError';
|
||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
||||
import type { getOrganizationById } from '@/server/services/organization.service';
|
||||
import type { getProfileById } from '@/server/services/profile.service';
|
||||
import type { getUserById } from '@/server/services/user.service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validator = z.object({
|
||||
name: z.string().min(2),
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
interface EditProfileProps {
|
||||
profile: Awaited<ReturnType<typeof getUserById>>;
|
||||
}
|
||||
export default function EditProfile({ profile }: EditProfileProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { register, handleSubmit, reset, formState } = useForm<IForm>({
|
||||
resolver: zodResolver(validator),
|
||||
defaultValues: {
|
||||
name: profile.name ?? '',
|
||||
email: profile.email ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = api.user.update.useMutation({
|
||||
onSuccess(res) {
|
||||
toast({
|
||||
title: 'Profile updated',
|
||||
description: 'Your profile has been updated.',
|
||||
});
|
||||
reset(res);
|
||||
router.refresh();
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
<Widget>
|
||||
<WidgetHead className="flex items-center justify-between">
|
||||
<span className="title">Your profile</span>
|
||||
<Button size="sm" type="submit" disabled={!formState.isDirty}>
|
||||
Save
|
||||
</Button>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="flex flex-col gap-4">
|
||||
<InputWithLabel
|
||||
label="Name"
|
||||
placeholder="Your name"
|
||||
defaultValue={profile.name ?? ''}
|
||||
{...register('name')}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Email"
|
||||
placeholder="Your email"
|
||||
defaultValue={profile.email ?? ''}
|
||||
{...register('email')}
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
export function Logout() {
|
||||
return (
|
||||
<Widget className="border-destructive">
|
||||
<WidgetHead className="border-destructive">
|
||||
<span className="title text-destructive">Sad part</span>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<p className="mb-4">
|
||||
Sometime's you need to go. See you next time
|
||||
</p>
|
||||
<Button
|
||||
variant={'destructive'}
|
||||
onClick={() => {
|
||||
signOut();
|
||||
}}
|
||||
>
|
||||
Logout 🤨
|
||||
</Button>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import PageLayout from '@/app/(app)/page-layout';
|
||||
import { getSession } from '@/server/auth';
|
||||
import { getUserById } from '@/server/services/user.service';
|
||||
|
||||
import EditProfile from './edit-profile';
|
||||
import { Logout } from './logout';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||
const session = await getSession();
|
||||
const profile = await getUserById(session?.user.id!);
|
||||
|
||||
return (
|
||||
<PageLayout title={profile.name} organizationId={organizationId}>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<EditProfile profile={profile} />
|
||||
<Logout />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
|
||||
import { DataTable } from '@/components/DataTable';
|
||||
import { columns } from '@/components/projects/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { pushModal } from '@/modals';
|
||||
import type { getProjectsByOrganizationId } from '@/server/services/project.service';
|
||||
import { PlusIcon, WarehouseIcon } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
interface ListProjectsProps {
|
||||
projects: Awaited<ReturnType<typeof getProjectsByOrganizationId>>;
|
||||
}
|
||||
export default function ListProjects({ projects }: ListProjectsProps) {
|
||||
const organizationId = useParams().organizationId as string;
|
||||
return (
|
||||
<>
|
||||
<StickyBelowHeader>
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div />
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
onClick={() =>
|
||||
pushModal('AddProject', {
|
||||
organizationId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="max-sm:hidden">Create project</span>
|
||||
<span className="sm:hidden">Project</span>
|
||||
</Button>
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4">
|
||||
<DataTable data={projects} columns={columns} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import PageLayout from '@/app/(app)/page-layout';
|
||||
import { getProjectsByOrganizationId } from '@/server/services/project.service';
|
||||
|
||||
import ListProjects from './list-projects';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||
const projects = await getProjectsByOrganizationId(organizationId);
|
||||
|
||||
return (
|
||||
<PageLayout title="Projects" organizationId={organizationId}>
|
||||
<ListProjects projects={projects} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
122
apps/web/src/app/(app)/layout-menu.tsx
Normal file
122
apps/web/src/app/(app)/layout-menu.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import type { IServiceRecentDashboards } from '@/server/services/dashboard.service';
|
||||
import {
|
||||
BuildingIcon,
|
||||
CogIcon,
|
||||
GanttChartIcon,
|
||||
KeySquareIcon,
|
||||
LayoutPanelTopIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
WarehouseIcon,
|
||||
} from 'lucide-react';
|
||||
import type { LucideProps } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
|
||||
function LinkWithIcon({
|
||||
href,
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
href: string;
|
||||
icon: React.ElementType<LucideProps>;
|
||||
label: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
className="flex gap-2 items-center px-3 py-3 transition-colors hover:bg-slate-100 leading-none rounded-lg"
|
||||
href={href}
|
||||
>
|
||||
<Icon size={20} />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
interface LayoutMenuProps {
|
||||
recentDashboards: IServiceRecentDashboards;
|
||||
fallbackProjectId: string | null;
|
||||
}
|
||||
export default function LayoutMenu({
|
||||
recentDashboards,
|
||||
fallbackProjectId,
|
||||
}: LayoutMenuProps) {
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
const projectId = (
|
||||
!params.projectId || params.projectId === 'undefined'
|
||||
? fallbackProjectId
|
||||
: params.projectId
|
||||
) as string | null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkWithIcon
|
||||
icon={LayoutPanelTopIcon}
|
||||
label="Dashboards"
|
||||
href={`/${params.organizationId}/${projectId}`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={GanttChartIcon}
|
||||
label="Events"
|
||||
href={`/${params.organizationId}/${projectId}/events`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={UsersIcon}
|
||||
label="Profiles"
|
||||
href={`/${params.organizationId}/${projectId}/profiles`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={CogIcon}
|
||||
label="Settings"
|
||||
href={`/${params.organizationId}/settings/organization`}
|
||||
/>
|
||||
{pathname.includes('/settings/') && (
|
||||
<div className="pl-7">
|
||||
<LinkWithIcon
|
||||
icon={BuildingIcon}
|
||||
label="Organization"
|
||||
href={`/${params.organizationId}/settings/organization`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={WarehouseIcon}
|
||||
label="Projects"
|
||||
href={`/${params.organizationId}/settings/projects`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={KeySquareIcon}
|
||||
label="Clients"
|
||||
href={`/${params.organizationId}/settings/clients`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={UserIcon}
|
||||
label="Profile (yours)"
|
||||
href={`/${params.organizationId}/settings/profile`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{recentDashboards.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<div className="font-medium mb-2">Recent dashboards</div>
|
||||
{recentDashboards.map((item) => (
|
||||
<LinkWithIcon
|
||||
key={item.id}
|
||||
icon={LayoutPanelTopIcon}
|
||||
label={
|
||||
<div className="flex flex-col">
|
||||
<span>{item.dashboard.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.project.name}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
href={`/${item.organization_id}/${item.project_id}/${item.dashboard_id}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
apps/web/src/app/(app)/layout-organization-selector.tsx
Normal file
30
apps/web/src/app/(app)/layout-organization-selector.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import type { IServiceOrganization } from '@/server/services/organization.service';
|
||||
import { Building } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
interface LayoutOrganizationSelectorProps {
|
||||
organizations: IServiceOrganization[];
|
||||
}
|
||||
|
||||
export default function LayoutOrganizationSelector({
|
||||
organizations,
|
||||
}: LayoutOrganizationSelectorProps) {
|
||||
const params = useParams();
|
||||
|
||||
const organization = organizations.find(
|
||||
(item) => item.id === params.organizationId
|
||||
);
|
||||
|
||||
if (!organization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border p-3 flex gap-2 rounded items-center">
|
||||
<Building size={20} />
|
||||
<span className="font-medium text-sm">{organization.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
apps/web/src/app/(app)/layout-project-selector.tsx
Normal file
46
apps/web/src/app/(app)/layout-project-selector.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import type { getProjectsByOrganizationId } from '@/server/services/project.service';
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
interface LayoutProjectSelectorProps {
|
||||
projects: Awaited<ReturnType<typeof getProjectsByOrganizationId>>;
|
||||
organizationId: string | null;
|
||||
}
|
||||
export default function LayoutProjectSelector({
|
||||
projects,
|
||||
organizationId,
|
||||
}: LayoutProjectSelectorProps) {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ projectId: string }>();
|
||||
const projectId = params?.projectId ? params.projectId : null;
|
||||
const pathname = usePathname() || '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Combobox
|
||||
className="w-auto min-w-0 max-sm:max-w-[100px]"
|
||||
placeholder={'Select project'}
|
||||
onChange={(value) => {
|
||||
// If we are on a page with only organizationId and projectId (as params)
|
||||
// we know its safe to just replace the current projectId
|
||||
// since the rest of the url is to a static page
|
||||
// e.g. /[organizationId]/[projectId]/events
|
||||
if (params && projectId && Object.keys(params).length === 2) {
|
||||
router.push(pathname.replace(projectId, value));
|
||||
} else {
|
||||
router.push(`/${organizationId}/${value}`);
|
||||
}
|
||||
}}
|
||||
value={projectId}
|
||||
items={
|
||||
projects.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})) ?? []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/app/(app)/layout-sidebar.tsx
Normal file
72
apps/web/src/app/(app)/layout-sidebar.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Logo } from '@/components/Logo';
|
||||
import type { IServiceRecentDashboards } from '@/server/services/dashboard.service';
|
||||
import type { IServiceOrganization } from '@/server/services/organization.service';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Rotate as Hamburger } from 'hamburger-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import LayoutMenu from './layout-menu';
|
||||
import LayoutOrganizationSelector from './layout-organization-selector';
|
||||
|
||||
interface LayoutSidebarProps {
|
||||
recentDashboards: IServiceRecentDashboards;
|
||||
organizations: IServiceOrganization[];
|
||||
}
|
||||
export function LayoutSidebar({
|
||||
organizations,
|
||||
recentDashboards,
|
||||
}: LayoutSidebarProps) {
|
||||
const [active, setActive] = useState(false);
|
||||
const fallbackProjectId = recentDashboards[0]?.project_id ?? null;
|
||||
const pathname = usePathname();
|
||||
useEffect(() => {
|
||||
setActive(false);
|
||||
}, [pathname]);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setActive(false)}
|
||||
className={cn(
|
||||
'fixed top-0 left-0 right-0 bottom-0 backdrop-blur-sm z-30 transition-opacity',
|
||||
active
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 left-0 h-screen border-r border-border w-72 bg-white flex flex-col z-30 transition-transform',
|
||||
'-translate-x-72 lg:-translate-x-0', // responsive
|
||||
active && 'translate-x-0' // force active on mobile
|
||||
)}
|
||||
>
|
||||
<div className="absolute -right-12 h-16 flex items-center lg:hidden">
|
||||
<Hamburger toggled={active} onToggle={setActive} size={20} />
|
||||
</div>
|
||||
<div className="h-16 border-b border-border px-4 shrink-0 flex items-center">
|
||||
<Link href="/">
|
||||
<Logo />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col p-4 gap-2 flex-grow overflow-auto">
|
||||
<LayoutMenu
|
||||
recentDashboards={recentDashboards}
|
||||
fallbackProjectId={fallbackProjectId}
|
||||
/>
|
||||
{/* Placeholder for LayoutOrganizationSelector */}
|
||||
<div className="h-16 block shrink-0"></div>
|
||||
</div>
|
||||
<div className="fixed bottom-0 left-0 right-0">
|
||||
<div className="bg-gradient-to-t from-white to-white/0 h-8 w-full"></div>
|
||||
<div className="bg-white p-4 pt-0">
|
||||
<LayoutOrganizationSelector organizations={organizations} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
apps/web/src/app/(app)/layout-sticky-below-header.tsx
Normal file
22
apps/web/src/app/(app)/layout-sticky-below-header.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface StickyBelowHeaderProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StickyBelowHeader({
|
||||
children,
|
||||
className,
|
||||
}: StickyBelowHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'md:sticky top-16 bg-white border-b border-border z-10',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
apps/web/src/app/(app)/layout.tsx
Normal file
24
apps/web/src/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getSession } from '@/server/auth';
|
||||
import { getRecentDashboardsByUserId } from '@/server/services/dashboard.service';
|
||||
import { getOrganizations } from '@/server/services/organization.service';
|
||||
|
||||
import { LayoutSidebar } from './layout-sidebar';
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function AppLayout({ children }: AppLayoutProps) {
|
||||
const session = await getSession();
|
||||
const organizations = await getOrganizations();
|
||||
const recentDashboards = session?.user.id
|
||||
? await getRecentDashboardsByUserId(session?.user.id)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LayoutSidebar {...{ organizations, recentDashboards }} />
|
||||
<div className="lg:pl-72 transition-all">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/app/(app)/list-organizations.tsx
Normal file
25
apps/web/src/app/(app)/list-organizations.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface ListOrganizationsProps {
|
||||
organizations: any[];
|
||||
}
|
||||
|
||||
export function ListOrganizations({ organizations }: ListOrganizationsProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 -mx-6">
|
||||
{organizations.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/${item.id}`}
|
||||
className="block px-6 py-3 flex items-center justify-between border-b border-border last:border-b-0 hover:bg-slate-100"
|
||||
>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<ChevronRight size={20} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
apps/web/src/app/(app)/page-layout.tsx
Normal file
34
apps/web/src/app/(app)/page-layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getProjectsByOrganizationId } from '@/server/services/project.service';
|
||||
|
||||
import LayoutProjectSelector from './layout-project-selector';
|
||||
|
||||
interface PageLayoutProps {
|
||||
children: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
organizationId: string | null;
|
||||
}
|
||||
|
||||
export default async function PageLayout({
|
||||
children,
|
||||
title,
|
||||
organizationId,
|
||||
}: PageLayoutProps) {
|
||||
const projects = organizationId
|
||||
? await getProjectsByOrganizationId(organizationId)
|
||||
: [];
|
||||
return (
|
||||
<>
|
||||
<div className="h-16 border-b border-border flex-shrink-0 sticky top-0 bg-white px-4 flex items-center justify-between z-20 pl-12 lg:pl-4">
|
||||
<div className="text-xl font-medium">{title}</div>
|
||||
|
||||
{projects.length > 0 && (
|
||||
<LayoutProjectSelector
|
||||
projects={projects}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
apps/web/src/app/(app)/page.tsx
Normal file
26
apps/web/src/app/(app)/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ModalWrapper } from '@/modals';
|
||||
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { db } from '@mixan/db';
|
||||
|
||||
import { ListOrganizations } from './list-organizations';
|
||||
|
||||
export default async function Page() {
|
||||
const organizations = await db.organization.findMany();
|
||||
|
||||
if (organizations.length === 1 && organizations[0]?.id) {
|
||||
redirect(`/${organizations[0].id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 bottom-0 bg-[rgba(0,0,0,0.2)] z-50">
|
||||
<ModalWrapper>
|
||||
<ModalContent>
|
||||
<ModalHeader title="Select organization" onClose={false} />
|
||||
<ListOrganizations organizations={organizations} />
|
||||
</ModalContent>
|
||||
</ModalWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
apps/web/src/app/_trpc/client.tsx
Normal file
40
apps/web/src/app/_trpc/client.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Toast } from '@/components/ui/use-toast';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import type { AppRouter } from '@/server/api/root';
|
||||
import type { TRPCClientErrorBase } from '@trpc/react-query';
|
||||
import { createTRPCReact } from '@trpc/react-query';
|
||||
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
|
||||
|
||||
export const api = createTRPCReact<AppRouter>({});
|
||||
|
||||
/**
|
||||
* Inference helper for inputs.
|
||||
*
|
||||
* @example type HelloInput = RouterInputs['example']['hello']
|
||||
*/
|
||||
export type RouterInputs = inferRouterInputs<AppRouter>;
|
||||
|
||||
/**
|
||||
* Inference helper for outputs.
|
||||
*
|
||||
* @example type HelloOutput = RouterOutputs['example']['hello']
|
||||
*/
|
||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
export type IChartData = RouterOutputs['chart']['chart'];
|
||||
|
||||
export function handleError(error: TRPCClientErrorBase<any>) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
export function handleErrorToastOptions(options: Toast) {
|
||||
return function (error: TRPCClientErrorBase<any>) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
}
|
||||
6
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
6
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { authOptions } from '@/server/auth';
|
||||
import NextAuth from 'next-auth/next';
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
20
apps/web/src/app/api/cookie/route.ts
Normal file
20
apps/web/src/app/api/cookie/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic'; // defaults to auto
|
||||
export function GET(req: Request) {
|
||||
const qwe = new URL(req.url);
|
||||
const item = qwe.searchParams.entries();
|
||||
const {
|
||||
value: [key, value],
|
||||
} = item.next();
|
||||
|
||||
if (key && value) {
|
||||
cookies().set(`@mixan-${key}`, JSON.stringify(value), {
|
||||
httpOnly: true,
|
||||
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ key, value });
|
||||
}
|
||||
10
apps/web/src/app/api/fml/route.ts
Normal file
10
apps/web/src/app/api/fml/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createRecentDashboard } from '@/server/services/dashboard.service';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic'; // defaults to auto
|
||||
export async function POST(req: Request) {
|
||||
await createRecentDashboard(await req.json());
|
||||
revalidatePath('/', 'layout');
|
||||
return NextResponse.json({ ok: 'qe' });
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { db } from '@/server/db';
|
||||
import { db, getId } from '@/server/db';
|
||||
import { handleError } from '@/server/exceptions';
|
||||
import { hashPassword } from '@/server/services/hash.service';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
const userName = 'Admin';
|
||||
const userPassword = 'password';
|
||||
const userEmail = 'acme@acme.com';
|
||||
const organizationName = 'Acme Inc.';
|
||||
const projectName = 'Website';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
@@ -22,29 +28,31 @@ export default async function handler(
|
||||
|
||||
const organization = await db.organization.create({
|
||||
data: {
|
||||
name: 'Acme Inc.',
|
||||
id: await getId('organization', organizationName),
|
||||
name: organizationName,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
name: 'Carl',
|
||||
password: await hashPassword('password'),
|
||||
email: 'lindesvard@gmail.com',
|
||||
name: userName,
|
||||
password: await hashPassword(userPassword),
|
||||
email: userEmail,
|
||||
organization_id: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
const project = await db.project.create({
|
||||
data: {
|
||||
name: 'Acme Website',
|
||||
id: await getId('project', projectName),
|
||||
name: projectName,
|
||||
organization_id: organization.id,
|
||||
},
|
||||
});
|
||||
const secret = randomUUID();
|
||||
const client = await db.client.create({
|
||||
data: {
|
||||
name: 'Acme Website Client',
|
||||
name: `${projectName} Client`,
|
||||
project_id: project.id,
|
||||
organization_id: organization.id,
|
||||
secret: await hashPassword(secret),
|
||||
@@ -54,6 +62,7 @@ export default async function handler(
|
||||
res.json({
|
||||
clientId: client.id,
|
||||
clientSecret: secret,
|
||||
user,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(res, error);
|
||||
18
apps/web/src/app/api/trpc/[trpc]/route.ts
Normal file
18
apps/web/src/app/api/trpc/[trpc]/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { appRouter } from '@/server/api/root';
|
||||
import { getSession } from '@/server/auth';
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
|
||||
const handler = (req: Request) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: '/api/trpc',
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: async () => {
|
||||
const session = await getSession();
|
||||
return {
|
||||
session,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
87
apps/web/src/app/auth.tsx
Normal file
87
apps/web/src/app/auth.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||
import { Logo } from '@/components/Logo';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Widget, WidgetBody } from '@/components/Widget';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { KeySquareIcon } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validator = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
export default function Auth() {
|
||||
const form = useForm<IForm>({
|
||||
resolver: zodResolver(validator),
|
||||
});
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [state, setState] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="flex items-center justify-center flex-col h-screen p-4">
|
||||
<Widget className="max-w-md w-full mb-4">
|
||||
<WidgetBody>
|
||||
<div className="flex justify-center py-8">
|
||||
<Logo />
|
||||
</div>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
const res = await signIn('credentials', {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
redirect: false,
|
||||
}).catch(() => {
|
||||
setState('Something went wrong. Please try again later');
|
||||
});
|
||||
|
||||
if (res?.ok) {
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (res?.status === 401) {
|
||||
setState('Wrong email or password. Please try again');
|
||||
}
|
||||
})}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<InputWithLabel
|
||||
label="Email"
|
||||
placeholder="Your email"
|
||||
error={form.formState.errors.email?.message}
|
||||
{...form.register('email')}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Password"
|
||||
placeholder="...and your password"
|
||||
error={form.formState.errors.password?.message}
|
||||
{...form.register('password')}
|
||||
/>
|
||||
{state !== null && (
|
||||
<Alert variant="destructive">
|
||||
<KeySquareIcon className="h-4 w-4" />
|
||||
<AlertTitle>Failed</AlertTitle>
|
||||
<AlertDescription>{state}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button type="submit">Sign in</Button>
|
||||
<Link href="/register" className="text-center text-sm">
|
||||
No account?{' '}
|
||||
<span className="font-medium text-blue-600">Sign up here!</span>
|
||||
</Link>
|
||||
</form>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<p className="text-xs">Terms & conditions</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
apps/web/src/app/cookie-provider.tsx
Normal file
24
apps/web/src/app/cookie-provider.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies';
|
||||
|
||||
type ICookies = Record<string, string | null>;
|
||||
|
||||
const context = createContext<ICookies>({});
|
||||
|
||||
export const CookieProvider = ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: RequestCookie[];
|
||||
}) => {
|
||||
const cookies = value.reduce((acc, cookie) => {
|
||||
return {
|
||||
...acc,
|
||||
[cookie.name]: cookie.value,
|
||||
};
|
||||
}, {} as ICookies);
|
||||
return <context.Provider value={cookies}>{children}</context.Provider>;
|
||||
};
|
||||
|
||||
export const useCookies = (): ICookies => useContext(context);
|
||||
52
apps/web/src/app/layout.tsx
Normal file
52
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Space_Grotesk } from 'next/font/google';
|
||||
|
||||
import Providers from './providers';
|
||||
|
||||
import '@/styles/globals.css';
|
||||
|
||||
import { getSession } from '@/server/auth';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import Auth from './auth';
|
||||
|
||||
// import { cookies } from 'next/headers';
|
||||
|
||||
const font = Space_Grotesk({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--text',
|
||||
});
|
||||
|
||||
export const metadata = {};
|
||||
|
||||
export const viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: 1,
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
|
||||
return (
|
||||
<html lang="en" className="light">
|
||||
<body
|
||||
className={cn(
|
||||
'min-h-screen font-sans antialiased grainy bg-slate-50',
|
||||
font.className
|
||||
)}
|
||||
>
|
||||
<Providers cookies={cookies().getAll()} session={session}>
|
||||
{session ? children : <Auth />}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/app/providers.tsx
Normal file
72
apps/web/src/app/providers.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { ModalProvider } from '@/modals';
|
||||
import type { AppStore } from '@/redux';
|
||||
import makeStore from '@/redux';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { httpBatchLink } from '@trpc/client';
|
||||
import type { Session } from 'next-auth';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies';
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import { CookieProvider } from './cookie-provider';
|
||||
|
||||
export default function Providers({
|
||||
children,
|
||||
session,
|
||||
cookies,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
session: Session | null;
|
||||
cookies: RequestCookie[];
|
||||
}) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const [trpcClient] = useState(() =>
|
||||
api.createClient({
|
||||
transformer: superjson,
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: 'http://localhost:3000/api/trpc',
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const storeRef = useRef<AppStore>();
|
||||
if (!storeRef.current) {
|
||||
// Create the store instance the first time this renders
|
||||
storeRef.current = makeStore();
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<ReduxProvider store={storeRef.current}>
|
||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<CookieProvider value={cookies}>{children}</CookieProvider>
|
||||
<Toaster />
|
||||
<ModalProvider />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</api.Provider>
|
||||
</ReduxProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -17,7 +19,7 @@ export function Card({ children, hover, className }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border border-border rounded relative',
|
||||
'border border-border rounded relative bg-white',
|
||||
hover && 'transition-all hover:shadow hover:border-black',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface ContentHeaderProps {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
|
||||
8
apps/web/src/components/Logo.tsx
Normal file
8
apps/web/src/components/Logo.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export function Logo() {
|
||||
return (
|
||||
<div className="text-xl flex gap-2 items-center">
|
||||
<img src="/logo.svg" className="w-8 rounded-lg" />
|
||||
<span className="relative -top-0.5">openpanel</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export function usePagination(take = 100) {
|
||||
take,
|
||||
canPrev: skip > 0,
|
||||
canNext: true,
|
||||
page: skip / take + 1,
|
||||
}),
|
||||
[skip, setSkip, take]
|
||||
);
|
||||
@@ -21,7 +22,8 @@ export type PaginationProps = ReturnType<typeof usePagination>;
|
||||
|
||||
export function Pagination(props: PaginationProps) {
|
||||
return (
|
||||
<div className="flex select-none items-center justify-end space-x-2 py-4">
|
||||
<div className="flex select-none items-center justify-end gap-2">
|
||||
<div className="font-medium text-xs">Page: {props.page}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript';
|
||||
import docco from 'react-syntax-highlighter/dist/cjs/styles/hljs/docco';
|
||||
|
||||
43
apps/web/src/components/Widget.tsx
Normal file
43
apps/web/src/components/Widget.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface WidgetHeadProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
export function WidgetHead({ children, className }: WidgetHeadProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 border-b border-border [&_.title]:font-medium',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WidgetBodyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
export function WidgetBody({ children, className }: WidgetBodyProps) {
|
||||
return <div className={cn('p-4', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface WidgetProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
export function Widget({ children, className }: WidgetProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border border-border rounded-md bg-white self-start',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useRefetchActive } from '@/hooks/useRefetchActive';
|
||||
'use client';
|
||||
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { pushModal, showConfirm } from '@/modals';
|
||||
import type { IClientWithProject } from '@/types';
|
||||
import { api } from '@/utils/api';
|
||||
import { clipboard } from '@/utils/clipboard';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
@@ -16,15 +18,16 @@ import {
|
||||
} from '../ui/dropdown-menu';
|
||||
import { toast } from '../ui/use-toast';
|
||||
|
||||
export function ClientActions({ id }: IClientWithProject) {
|
||||
const refetch = useRefetchActive();
|
||||
export function ClientActions(client: IClientWithProject) {
|
||||
const { id } = client;
|
||||
const router = useRouter();
|
||||
const deletion = api.client.remove.useMutation({
|
||||
onSuccess() {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Client revoked, incoming requests will be rejected.',
|
||||
});
|
||||
refetch();
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
return (
|
||||
@@ -42,7 +45,7 @@ export function ClientActions({ id }: IClientWithProject) {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
pushModal('EditClient', { id });
|
||||
pushModal('EditClient', client);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DataTable } from '@/components/DataTable';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import type { RouterOutputs } from '@/utils/api';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { toDots } from '@/utils/object';
|
||||
import { AvatarImage } from '@radix-ui/react-avatar';
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
import Link from 'next/link';
|
||||
|
||||
const columnHelper =
|
||||
createColumnHelper<RouterOutputs['event']['list'][number]>();
|
||||
|
||||
interface EventsTableProps {
|
||||
data: RouterOutputs['event']['list'];
|
||||
}
|
||||
|
||||
export function EventsTable({ data }: EventsTableProps) {
|
||||
const params = useOrganizationParams();
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
columnHelper.accessor((row) => row.createdAt, {
|
||||
id: 'createdAt',
|
||||
header: () => 'Created At',
|
||||
cell(info) {
|
||||
return formatDateTime(info.getValue());
|
||||
},
|
||||
footer: () => 'Created At',
|
||||
}),
|
||||
columnHelper.accessor((row) => row.name, {
|
||||
id: 'event',
|
||||
header: () => 'Event',
|
||||
cell(info) {
|
||||
return <span className="font-medium">{info.getValue()}</span>;
|
||||
},
|
||||
footer: () => 'Created At',
|
||||
}),
|
||||
columnHelper.accessor((row) => row.profile, {
|
||||
id: 'profile',
|
||||
header: () => 'Profile',
|
||||
cell(info) {
|
||||
const profile = info.getValue();
|
||||
return (
|
||||
<Link
|
||||
shallow
|
||||
href={`/${params.organization}/${params.project}/profiles/${profile?.id}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Avatar className="h-6 w-6">
|
||||
{profile?.avatar && <AvatarImage src={profile.avatar} />}
|
||||
<AvatarFallback className="text-xs">
|
||||
{profile?.first_name?.at(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{`${profile?.first_name} ${profile?.last_name ?? ''}`}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
footer: () => 'Created At',
|
||||
}),
|
||||
columnHelper.accessor((row) => row.properties, {
|
||||
id: 'properties',
|
||||
header: () => 'Properties',
|
||||
cell(info) {
|
||||
const dots = toDots(info.getValue() as Record<string, any>);
|
||||
return (
|
||||
<Table className="mini">
|
||||
<TableBody>
|
||||
{Object.keys(dots).map((key) => {
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell className="font-medium">{key}</TableCell>
|
||||
<TableCell>
|
||||
{typeof dots[key] === 'boolean'
|
||||
? dots[key]
|
||||
? 'true'
|
||||
: 'false'
|
||||
: dots[key]}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
},
|
||||
footer: () => 'Created At',
|
||||
}),
|
||||
];
|
||||
}, [params]);
|
||||
|
||||
return <DataTable data={data} columns={columns} />;
|
||||
}
|
||||
35
apps/web/src/components/events/ListProperties.tsx
Normal file
35
apps/web/src/components/events/ListProperties.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { toDots } from '@/utils/object';
|
||||
|
||||
import { Table, TableBody, TableCell, TableRow } from '../ui/table';
|
||||
|
||||
interface ListPropertiesProps {
|
||||
data: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ListProperties({
|
||||
data,
|
||||
className = 'mini',
|
||||
}: ListPropertiesProps) {
|
||||
const dots = toDots(data);
|
||||
return (
|
||||
<Table className={className}>
|
||||
<TableBody>
|
||||
{Object.keys(dots).map((key) => {
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell className="font-medium">{key}</TableCell>
|
||||
<TableCell>
|
||||
{typeof dots[key] === 'boolean'
|
||||
? dots[key]
|
||||
? 'true'
|
||||
: 'false'
|
||||
: dots[key]}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -6,15 +6,23 @@ import { Label } from '../ui/label';
|
||||
|
||||
type InputWithLabelProps = InputProps & {
|
||||
label: string;
|
||||
error?: string | undefined;
|
||||
};
|
||||
|
||||
export const InputWithLabel = forwardRef<HTMLInputElement, InputWithLabelProps>(
|
||||
({ label, className, ...props }, ref) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Label htmlFor={label} className="block mb-2">
|
||||
{label}
|
||||
</Label>
|
||||
<div className="block mb-2 flex justify-between">
|
||||
<Label className="mb-0" htmlFor={label}>
|
||||
{label}
|
||||
</Label>
|
||||
{props.error && (
|
||||
<span className="text-sm text-destructive leading-none">
|
||||
{props.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Input ref={ref} id={label} {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
54
apps/web/src/components/general/ExpandableListItem.tsx
Normal file
54
apps/web/src/components/general/ExpandableListItem.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ChevronUp } from 'lucide-react';
|
||||
import AnimateHeight from 'react-animate-height';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface ExpandableListItemProps {
|
||||
children: React.ReactNode;
|
||||
bullets: React.ReactNode[];
|
||||
title: string;
|
||||
image?: React.ReactNode;
|
||||
initialOpen?: boolean;
|
||||
}
|
||||
export function ExpandableListItem({
|
||||
title,
|
||||
bullets,
|
||||
image,
|
||||
initialOpen = false,
|
||||
children,
|
||||
}: ExpandableListItemProps) {
|
||||
const [open, setOpen] = useState(initialOpen ?? false);
|
||||
return (
|
||||
<div className="bg-white shadow rounded-xl overflow-hidden">
|
||||
<div className="p-3 sm:p-6 flex gap-4 items-start">
|
||||
<div className="flex gap-1">{image}</div>
|
||||
<div className="flex flex-col flex-1 gap-1 min-w-0">
|
||||
<span className="text-lg font-medium leading-none mb-1">{title}</span>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4 text-sm text-muted-foreground">
|
||||
{bullets.map((bullet) => (
|
||||
<span key={bullet}>{bullet}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
>
|
||||
<ChevronUp
|
||||
size={20}
|
||||
className={cn(
|
||||
'transition-transform',
|
||||
open ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<AnimateHeight duration={200} height={open ? 'auto' : 0}>
|
||||
<div className="border-t border-border">{children}</div>
|
||||
</AnimateHeight>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { MenuIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Container } from '../Container';
|
||||
import { Breadcrumbs } from '../navbar/Breadcrumbs';
|
||||
import { NavbarMenu } from '../navbar/NavbarMenu';
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MainLayout({ children, className }: MainLayoutProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<div className="h-2 w-full bg-gradient-to-r from-blue-900 to-purple-600"></div>
|
||||
<nav className="border-b border-border">
|
||||
<Container className="flex h-20 items-center justify-between ">
|
||||
<Link shallow href="/" className="text-3xl">
|
||||
mixan
|
||||
</Link>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-8 z-50',
|
||||
visible === false && 'max-sm:hidden',
|
||||
visible === true &&
|
||||
'max-sm:flex max-sm:flex-col max-sm:absolute max-sm:inset-0 max-sm:bg-white max-sm:justify-center max-sm:top-4 max-sm:shadow-lg'
|
||||
)}
|
||||
>
|
||||
<NavbarMenu />
|
||||
</div>
|
||||
<button
|
||||
className={cn(
|
||||
'px-4 sm:hidden absolute z-50 top-9 right-4 transition-all',
|
||||
visible === true && 'rotate-90'
|
||||
)}
|
||||
onClick={() => {
|
||||
setVisible((p) => !p);
|
||||
}}
|
||||
>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
</Container>
|
||||
</nav>
|
||||
<Breadcrumbs />
|
||||
<main className={cn(className, 'mb-8')}>{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { cn } from '@/utils/cn';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Container } from '../Container';
|
||||
import { PageTitle } from '../PageTitle';
|
||||
import { Sidebar, WithSidebar } from '../WithSidebar';
|
||||
import { MainLayout } from './MainLayout';
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SettingsLayout({ children, className }: SettingsLayoutProps) {
|
||||
const params = useOrganizationParams();
|
||||
const pathname = usePathname();
|
||||
const links = [
|
||||
{
|
||||
href: `/${params.organization}/settings/organization`,
|
||||
label: 'Organization',
|
||||
},
|
||||
{ href: `/${params.organization}/settings/projects`, label: 'Projects' },
|
||||
{ href: `/${params.organization}/settings/clients`, label: 'Clients' },
|
||||
{ href: `/${params.organization}/settings/profile`, label: 'Profile' },
|
||||
];
|
||||
return (
|
||||
<MainLayout>
|
||||
<Container>
|
||||
<PageTitle>Settings</PageTitle>
|
||||
<WithSidebar>
|
||||
<Sidebar>
|
||||
{links.map(({ href, label }) => (
|
||||
<Link
|
||||
shallow
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
'p-4 py-3 leading-none rounded-lg transition-colors',
|
||||
pathname.startsWith(href)
|
||||
? 'bg-slate-100'
|
||||
: 'hover:bg-slate-100'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</Sidebar>
|
||||
<div className={cn('flex flex-col', className)}>{children}</div>
|
||||
</WithSidebar>
|
||||
</Container>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { api } from '@/utils/api';
|
||||
import { ChevronRight, HomeIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
@@ -11,30 +11,30 @@ export function Breadcrumbs() {
|
||||
|
||||
const org = api.organization.get.useQuery(
|
||||
{
|
||||
slug: params.organization,
|
||||
id: params.organizationId,
|
||||
},
|
||||
{
|
||||
enabled: !!params.organization,
|
||||
enabled: !!params.organizationId,
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
|
||||
const pro = api.project.get.useQuery(
|
||||
{
|
||||
slug: params.project,
|
||||
id: params.projectId,
|
||||
},
|
||||
{
|
||||
enabled: !!params.project,
|
||||
enabled: !!params.projectId,
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
|
||||
const dashboard = api.dashboard.get.useQuery(
|
||||
{
|
||||
slug: params.dashboard,
|
||||
id: params.dashboardId,
|
||||
},
|
||||
{
|
||||
enabled: !!params.dashboard,
|
||||
enabled: !!params.dashboardId,
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
@@ -48,7 +48,7 @@ export function Breadcrumbs() {
|
||||
{org.data && (
|
||||
<>
|
||||
<HomeIcon size={14} />
|
||||
<Link shallow href={`/${org.data.slug}`}>
|
||||
<Link shallow href={`/${org.data.id}`}>
|
||||
{org.data.name}
|
||||
</Link>
|
||||
</>
|
||||
@@ -57,7 +57,7 @@ export function Breadcrumbs() {
|
||||
{org.data && pro.data && (
|
||||
<>
|
||||
<ChevronRight size={10} />
|
||||
<Link shallow href={`/${org.data.slug}/${pro.data.slug}`}>
|
||||
<Link shallow href={`/${org.data.id}/${pro.data.id}`}>
|
||||
{pro.data.name}
|
||||
</Link>
|
||||
</>
|
||||
@@ -68,7 +68,7 @@ export function Breadcrumbs() {
|
||||
<ChevronRight size={10} />
|
||||
<Link
|
||||
shallow
|
||||
href={`/${org.data.slug}/${pro.data.slug}/${dashboard.data.slug}`}
|
||||
href={`/${org.data.id}/${pro.data.id}/${dashboard.data.id}`}
|
||||
>
|
||||
{dashboard.data.name}
|
||||
</Link>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function NavbarCreate() {
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
shallow
|
||||
href={`/${params.organization}/${params.project}/reports`}
|
||||
href={`/${params.organizationId}/${params.projectId}/reports`}
|
||||
>
|
||||
<LineChart className="mr-2 h-4 w-4" />
|
||||
<span>Create a report</span>
|
||||
|
||||
@@ -26,27 +26,27 @@ export function NavbarMenu() {
|
||||
const params = useOrganizationParams();
|
||||
return (
|
||||
<div className={cn('flex gap-1 items-center text-sm', 'max-sm:flex-col')}>
|
||||
{params.project && (
|
||||
<Item href={`/${params.organization}/${params.project}`}>
|
||||
{params.projectId && (
|
||||
<Item href={`/${params.organizationId}/${params.projectId}`}>
|
||||
Dashboards
|
||||
</Item>
|
||||
)}
|
||||
{params.project && (
|
||||
<Item href={`/${params.organization}/${params.project}/events`}>
|
||||
{params.projectId && (
|
||||
<Item href={`/${params.organizationId}/${params.projectId}/events`}>
|
||||
Events
|
||||
</Item>
|
||||
)}
|
||||
{params.project && (
|
||||
<Item href={`/${params.organization}/${params.project}/profiles`}>
|
||||
{params.projectId && (
|
||||
<Item href={`/${params.organizationId}/${params.projectId}/profiles`}>
|
||||
Profiles
|
||||
</Item>
|
||||
)}
|
||||
{params.project && (
|
||||
{params.projectId && (
|
||||
<Item
|
||||
href={{
|
||||
pathname: `/${params.organization}/${params.project}/reports`,
|
||||
pathname: `/${params.organizationId}/${params.projectId}/reports`,
|
||||
query: strip({
|
||||
dashboard: params.dashboard,
|
||||
dashboardId: params.dashboardId,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,7 @@ export function NavbarUserDropdown() {
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link
|
||||
href={`/${params.organization}/settings/organization`}
|
||||
href={`/${params.organizationId}/settings/organization`}
|
||||
shallow
|
||||
>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
@@ -36,19 +36,19 @@ export function NavbarUserDropdown() {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link href={`/${params.organization}/settings/projects`} shallow>
|
||||
<Link href={`/${params.organizationId}/settings/projects`} shallow>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Projects
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link href={`/${params.organization}/settings/clients`} shallow>
|
||||
<Link href={`/${params.organizationId}/settings/clients`} shallow>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Clients
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link href={`/${params.organization}/settings/profile`} shallow>
|
||||
<Link href={`/${params.organizationId}/settings/profile`} shallow>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</Link>
|
||||
|
||||
53
apps/web/src/components/profiles/ProfileAvatar.tsx
Normal file
53
apps/web/src/components/profiles/ProfileAvatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import type { IServiceProfile } from '@/server/services/profile.service';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { AvatarImage } from '@radix-ui/react-avatar';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
import { Avatar, AvatarFallback } from '../ui/avatar';
|
||||
|
||||
interface ProfileAvatarProps
|
||||
extends VariantProps<typeof variants>,
|
||||
Partial<Pick<IServiceProfile, 'avatar' | 'first_name'>> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variants = cva('', {
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-12 w-12 rounded-xl [&>span]:rounded-xl',
|
||||
sm: 'h-6 w-6 rounded [&>span]:rounded',
|
||||
xs: 'h-4 w-4 rounded [&>span]:rounded',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
export function ProfileAvatar({
|
||||
avatar,
|
||||
first_name,
|
||||
className,
|
||||
size,
|
||||
}: ProfileAvatarProps) {
|
||||
return (
|
||||
<Avatar className={cn(variants({ className, size }), className)}>
|
||||
{avatar && <AvatarImage src={avatar} />}
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
size === 'sm'
|
||||
? 'text-xs'
|
||||
: size === 'xs'
|
||||
? 'text-[8px]'
|
||||
: 'text-base',
|
||||
'bg-slate-200 text-slate-800'
|
||||
)}
|
||||
>
|
||||
{first_name?.at(0) ?? '🫣'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useRefetchActive } from '@/hooks/useRefetchActive';
|
||||
'use client';
|
||||
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { pushModal, showConfirm } from '@/modals';
|
||||
import type { IProject } from '@/types';
|
||||
import { api } from '@/utils/api';
|
||||
import { clipboard } from '@/utils/clipboard';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
@@ -16,15 +18,16 @@ import {
|
||||
} from '../ui/dropdown-menu';
|
||||
import { toast } from '../ui/use-toast';
|
||||
|
||||
export function ProjectActions({ id }: IProject) {
|
||||
const refetch = useRefetchActive();
|
||||
export function ProjectActions(project: IProject) {
|
||||
const { id } = project;
|
||||
const router = useRouter();
|
||||
const deletion = api.project.remove.useMutation({
|
||||
onSuccess() {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Project deleted successfully.',
|
||||
});
|
||||
refetch();
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,7 +46,7 @@ export function ProjectActions({ id }: IProject) {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
pushModal('EditProject', { id });
|
||||
pushModal('EditProject', project);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IServiceProject } from '@/server/services/project.service';
|
||||
import { formatDate } from '@/utils/date';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
@@ -6,7 +7,6 @@ import type { Project as IProject } from '@mixan/db';
|
||||
import { ProjectActions } from './ProjectActions';
|
||||
|
||||
export type Project = IProject;
|
||||
|
||||
export const columns: ColumnDef<Project>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import type { IChartType } from '@/types';
|
||||
import { chartTypes } from '@/utils/constants';
|
||||
import { objectToZodEnums } from '@/utils/validation';
|
||||
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { changeChartType } from './reportSlice';
|
||||
|
||||
export function ReportChartType() {
|
||||
interface ReportChartTypeProps {
|
||||
className?: string;
|
||||
}
|
||||
export function ReportChartType({ className }: ReportChartTypeProps) {
|
||||
const dispatch = useDispatch();
|
||||
const type = useSelector((state) => state.report.chartType);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
className={className}
|
||||
placeholder="Chart type"
|
||||
onChange={(value) => {
|
||||
dispatch(changeChartType(value as IChartType));
|
||||
dispatch(changeChartType(value));
|
||||
}}
|
||||
value={type}
|
||||
items={Object.entries(chartTypes).map(([key, value]) => ({
|
||||
label: value,
|
||||
items={objectToZodEnums(chartTypes).map((key) => ({
|
||||
label: chartTypes[key],
|
||||
value: key,
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import type { IInterval } from '@/types';
|
||||
import { isMinuteIntervalEnabledByRange } from '@/utils/constants';
|
||||
import {
|
||||
isHourIntervalEnabledByRange,
|
||||
isMinuteIntervalEnabledByRange,
|
||||
} from '@/utils/constants';
|
||||
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { changeInterval } from './reportSlice';
|
||||
|
||||
export function ReportInterval() {
|
||||
interface ReportIntervalProps {
|
||||
className?: string;
|
||||
}
|
||||
export function ReportInterval({ className }: ReportIntervalProps) {
|
||||
const dispatch = useDispatch();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const range = useSelector((state) => state.report.range);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
if (chartType !== 'linear') {
|
||||
if (chartType !== 'linear' && chartType !== 'histogram') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
className={className}
|
||||
placeholder="Interval"
|
||||
onChange={(value) => {
|
||||
dispatch(changeInterval(value as IInterval));
|
||||
@@ -30,6 +37,7 @@ export function ReportInterval() {
|
||||
{
|
||||
value: 'hour',
|
||||
label: 'Hour',
|
||||
disabled: !isHourIntervalEnabledByRange(range),
|
||||
},
|
||||
{
|
||||
value: 'day',
|
||||
|
||||
34
apps/web/src/components/report/ReportLineType.tsx
Normal file
34
apps/web/src/components/report/ReportLineType.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { lineTypes } from '@/utils/constants';
|
||||
import { objectToZodEnums } from '@/utils/validation';
|
||||
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { changeLineType } from './reportSlice';
|
||||
|
||||
interface ReportLineTypeProps {
|
||||
className?: string;
|
||||
}
|
||||
export function ReportLineType({ className }: ReportLineTypeProps) {
|
||||
const dispatch = useDispatch();
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const type = useSelector((state) => state.report.lineType);
|
||||
|
||||
if (chartType != 'linear' && chartType != 'area') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
className={className}
|
||||
placeholder="Line type"
|
||||
onChange={(value) => {
|
||||
dispatch(changeLineType(value));
|
||||
}}
|
||||
value={type}
|
||||
items={objectToZodEnums(lineTypes).map((key) => ({
|
||||
label: lineTypes[key],
|
||||
value: key,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { api, handleError } from '@/app/_trpc/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { pushModal } from '@/modals';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { api, handleError } from '@/utils/api';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { useReportId } from './hooks/useReportId';
|
||||
import { resetDirty } from './reportSlice';
|
||||
|
||||
export function ReportSaveButton() {
|
||||
const { reportId } = useReportId();
|
||||
interface ReportSaveButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
export function ReportSaveButton({ className }: ReportSaveButtonProps) {
|
||||
const { reportId } = useParams();
|
||||
const dispatch = useDispatch();
|
||||
const update = api.report.update.useMutation({
|
||||
onSuccess() {
|
||||
@@ -26,11 +31,12 @@ export function ReportSaveButton() {
|
||||
if (reportId) {
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
disabled={!report.dirty}
|
||||
loading={update.isLoading}
|
||||
onClick={() => {
|
||||
update.mutate({
|
||||
reportId,
|
||||
reportId: reportId as string,
|
||||
report,
|
||||
});
|
||||
}}
|
||||
@@ -42,6 +48,7 @@ export function ReportSaveButton() {
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
disabled={!report.dirty}
|
||||
onClick={() => {
|
||||
pushModal('SaveReport', {
|
||||
|
||||
@@ -24,6 +24,9 @@ export const ChartAnimationContainer = (
|
||||
) => (
|
||||
<div
|
||||
{...props}
|
||||
className={cn('border border-border rounded-md p-8', props.className)}
|
||||
className={cn(
|
||||
'border border-border rounded-md p-8 bg-white',
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
|
||||
|
||||
104
apps/web/src/components/report/chart/ReportAreaChart.tsx
Normal file
104
apps/web/src/components/report/chart/ReportAreaChart.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { AutoSizer } from '@/components/AutoSizer';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import type { IChartLineType, IInterval } from '@/types';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { getYAxisWidth } from './chart-utils';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
||||
import { ReportTable } from './ReportTable';
|
||||
|
||||
interface ReportAreaChartProps {
|
||||
data: IChartData;
|
||||
interval: IInterval;
|
||||
lineType: IChartLineType;
|
||||
}
|
||||
|
||||
export function ReportAreaChart({
|
||||
lineType,
|
||||
interval,
|
||||
data,
|
||||
}: ReportAreaChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const rechartData = useRechartDataModel(data);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'max-sm:-mx-3',
|
||||
editMode && 'border border-border bg-white rounded-md p-4'
|
||||
)}
|
||||
>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<AreaChart
|
||||
width={width}
|
||||
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
||||
data={rechartData}
|
||||
>
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
fontSize={12}
|
||||
dataKey="date"
|
||||
tickFormatter={(m: string) => formatDate(m)}
|
||||
tickLine={false}
|
||||
allowDuplicatedCategory={false}
|
||||
/>
|
||||
<YAxis
|
||||
width={getYAxisWidth(data.metrics.max)}
|
||||
fontSize={12}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<Area
|
||||
key={serie.name}
|
||||
type={lineType}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={0}
|
||||
dataKey={`${serie.index}:count`}
|
||||
stroke={getChartColor(serie.index)}
|
||||
fill={getChartColor(serie.index)}
|
||||
stackId={'1'}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
{editMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { IChartData, RouterOutputs } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import {
|
||||
Table,
|
||||
@@ -8,8 +9,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { IChartData } from '@/types';
|
||||
import type { RouterOutputs } from '@/utils/api';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import {
|
||||
@@ -36,6 +35,7 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const [ref, { width }] = useElementSize();
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const maxCount = Math.max(...data.series.map((serie) => serie.metrics.sum));
|
||||
const table = useReactTable({
|
||||
data: useMemo(
|
||||
() => (editMode ? data.series : data.series.slice(0, 20)),
|
||||
@@ -57,7 +57,7 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
footer: (info) => info.column.id,
|
||||
size: width ? width * 0.3 : undefined,
|
||||
}),
|
||||
columnHelper.accessor((row) => row.metrics.total, {
|
||||
columnHelper.accessor((row) => row.metrics.sum, {
|
||||
id: 'totalCount',
|
||||
cell: (info) => (
|
||||
<div className="text-right font-medium">{info.getValue()}</div>
|
||||
@@ -67,15 +67,13 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
size: width ? width * 0.1 : undefined,
|
||||
enableSorting: true,
|
||||
}),
|
||||
columnHelper.accessor((row) => row.metrics.total, {
|
||||
columnHelper.accessor((row) => row.metrics.sum, {
|
||||
id: 'graph',
|
||||
cell: (info) => (
|
||||
<div
|
||||
className="shine h-4 rounded [.mini_&]:h-3"
|
||||
style={{
|
||||
width:
|
||||
(info.getValue() / info.row.original.meta.highest) * 100 +
|
||||
'%',
|
||||
width: (info.getValue() / maxCount) * 100 + '%',
|
||||
background: getChartColor(info.row.index),
|
||||
}}
|
||||
/>
|
||||
@@ -93,30 +91,10 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
// debugTable: true,
|
||||
// debugHeaders: true,
|
||||
// debugColumns: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{editMode && (
|
||||
<div className="mb-8 flex flex-wrap gap-4">
|
||||
{data.events.map((event) => {
|
||||
return (
|
||||
<div className="border border-border p-4" key={event.id}>
|
||||
<div className="flex items-center gap-2 text-lg font-medium">
|
||||
<ColorSquare>{event.id}</ColorSquare> {event.name}
|
||||
</div>
|
||||
<div className="mt-6 font-mono text-5xl font-light">
|
||||
{new Intl.NumberFormat('en-IN', {
|
||||
maximumSignificantDigits: 20,
|
||||
}).format(event.count)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
{...{
|
||||
|
||||
@@ -2,19 +2,19 @@ import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { useSelector } from '@/redux';
|
||||
import type { IToolTipProps } from '@/types';
|
||||
import { alphabetIds } from '@/utils/constants';
|
||||
|
||||
type ReportLineChartTooltipProps = IToolTipProps<{
|
||||
color: string;
|
||||
value: number;
|
||||
dataKey: string;
|
||||
payload: {
|
||||
date: Date;
|
||||
count: number;
|
||||
label: string;
|
||||
color: string;
|
||||
} & Record<string, any>;
|
||||
}>;
|
||||
|
||||
export function ReportLineChartTooltip({
|
||||
export function ReportChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: ReportLineChartTooltipProps) {
|
||||
@@ -34,31 +34,32 @@ export function ReportLineChartTooltip({
|
||||
const sorted = payload.slice(0).sort((a, b) => b.value - a.value);
|
||||
const visible = sorted.slice(0, limit);
|
||||
const hidden = sorted.slice(limit);
|
||||
const first = visible[0]!;
|
||||
const isBarChart = first.payload.count === undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl">
|
||||
{formatDate(new Date(first.payload.date))}
|
||||
{visible.map((item, index) => {
|
||||
const id = alphabetIds[index];
|
||||
// If we have a <Cell /> component, payload can be nested
|
||||
const payload = item.payload.payload ?? item.payload;
|
||||
const data = item.dataKey.includes(':')
|
||||
? payload[`${item.dataKey.split(':')[0]}:payload`]
|
||||
: payload;
|
||||
|
||||
return (
|
||||
<div key={item.payload.label} className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: item.color }}
|
||||
></div>
|
||||
<div className="flex flex-col">
|
||||
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
|
||||
{isBarChart
|
||||
? item.payload[`${id}:label`]
|
||||
: getLabel(item.payload.label)}
|
||||
</div>
|
||||
<div>
|
||||
{isBarChart ? item.payload[`${id}:count`] : item.payload.count}
|
||||
<>
|
||||
{index === 0 && data.date ? formatDate(new Date(data.date)) : null}
|
||||
<div key={item.payload.label} className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: data.color }}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
|
||||
{getLabel(data.label)}
|
||||
</div>
|
||||
<div>{data.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
{hidden.length > 0 && (
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { AutoSizer } from '@/components/AutoSizer';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import type { IChartData, IInterval } from '@/types';
|
||||
import { alphabetIds } from '@/utils/constants';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import type { IInterval } from '@/types';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor, theme } from '@/utils/theme';
|
||||
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { getYAxisWidth } from './chart-utils';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportLineChartTooltip } from './ReportLineChartTooltip';
|
||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
||||
import { ReportTable } from './ReportTable';
|
||||
|
||||
interface ReportHistogramChartProps {
|
||||
@@ -15,80 +18,61 @@ interface ReportHistogramChartProps {
|
||||
interval: IInterval;
|
||||
}
|
||||
|
||||
function BarHover(props: any) {
|
||||
const bg = theme?.colors?.slate?.['200'] as string;
|
||||
return <rect {...props} rx="8" fill={bg} fill-opacity={0.5} />;
|
||||
}
|
||||
|
||||
export function ReportHistogramChart({
|
||||
interval,
|
||||
data,
|
||||
}: ReportHistogramChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
|
||||
const ref = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!ref.current && data) {
|
||||
const max = 20;
|
||||
|
||||
setVisibleSeries(
|
||||
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
|
||||
);
|
||||
// ref.current = true;
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const rel = data.series[0]?.data.map(({ date }) => {
|
||||
return {
|
||||
date,
|
||||
...data.series.reduce((acc, serie, idx) => {
|
||||
return {
|
||||
...acc,
|
||||
...serie.data.reduce(
|
||||
(acc2, item) => {
|
||||
const id = alphabetIds[idx];
|
||||
if (item.date === date) {
|
||||
acc2[`${id}:count`] = item.count;
|
||||
acc2[`${id}:label`] = item.label;
|
||||
}
|
||||
return acc2;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
),
|
||||
};
|
||||
}, {}),
|
||||
};
|
||||
});
|
||||
const rechartData = useRechartDataModel(data);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-sm:-mx-3">
|
||||
<div
|
||||
className={cn(
|
||||
'max-sm:-mx-3',
|
||||
editMode && 'border border-border bg-white rounded-md p-4'
|
||||
)}
|
||||
>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<BarChart
|
||||
width={width}
|
||||
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
||||
data={rel}
|
||||
data={rechartData}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<Tooltip content={<ReportLineChartTooltip />} />
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<Tooltip content={<ReportChartTooltip />} cursor={<BarHover />} />
|
||||
<XAxis
|
||||
fontSize={12}
|
||||
dataKey="date"
|
||||
tickFormatter={formatDate}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
{data.series.map((serie, index) => {
|
||||
const id = alphabetIds[index];
|
||||
<YAxis
|
||||
fontSize={12}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={getYAxisWidth(data.metrics.max)}
|
||||
/>
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<>
|
||||
<YAxis dataKey={`${id}:count`} fontSize={12}></YAxis>
|
||||
<Bar
|
||||
stackId={id}
|
||||
key={serie.name}
|
||||
isAnimationActive={false}
|
||||
name={serie.name}
|
||||
dataKey={`${id}:count`}
|
||||
fill={getChartColor(index)}
|
||||
/>
|
||||
</>
|
||||
<Bar
|
||||
stackId={serie.index}
|
||||
key={serie.name}
|
||||
name={serie.name}
|
||||
dataKey={`${serie.index}:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
radius={8}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BarChart>
|
||||
@@ -98,7 +82,7 @@ export function ReportHistogramChart({
|
||||
{editMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={visibleSeries}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { AutoSizer } from '@/components/AutoSizer';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import type { IChartData, IInterval } from '@/types';
|
||||
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import type { IChartLineType, IInterval } from '@/types';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import {
|
||||
CartesianGrid,
|
||||
@@ -12,76 +16,76 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { getYAxisWidth } from './chart-utils';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportLineChartTooltip } from './ReportLineChartTooltip';
|
||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
||||
import { ReportTable } from './ReportTable';
|
||||
|
||||
interface ReportLineChartProps {
|
||||
data: IChartData;
|
||||
interval: IInterval;
|
||||
lineType: IChartLineType;
|
||||
}
|
||||
|
||||
export function ReportLineChart({ interval, data }: ReportLineChartProps) {
|
||||
export function ReportLineChart({
|
||||
lineType,
|
||||
interval,
|
||||
data,
|
||||
}: ReportLineChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
|
||||
const ref = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!ref.current && data) {
|
||||
const max = 20;
|
||||
|
||||
setVisibleSeries(
|
||||
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
|
||||
);
|
||||
// ref.current = true;
|
||||
}
|
||||
}, [data]);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const rechartData = useRechartDataModel(data);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-sm:-mx-3">
|
||||
<div
|
||||
className={cn(
|
||||
'max-sm:-mx-3',
|
||||
editMode && 'border border-border bg-white rounded-md p-4'
|
||||
)}
|
||||
>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<LineChart
|
||||
width={width}
|
||||
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
||||
data={rechartData}
|
||||
>
|
||||
<YAxis dataKey={'count'} fontSize={12}></YAxis>
|
||||
<Tooltip content={<ReportLineChartTooltip />} />
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
/>
|
||||
<YAxis
|
||||
width={getYAxisWidth(data.metrics.max)}
|
||||
fontSize={12}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
fontSize={12}
|
||||
dataKey="date"
|
||||
tickFormatter={(m: string) => {
|
||||
return formatDate(m);
|
||||
}}
|
||||
tickFormatter={(m: string) => formatDate(m)}
|
||||
tickLine={false}
|
||||
allowDuplicatedCategory={false}
|
||||
/>
|
||||
{data?.series
|
||||
.filter((serie) => {
|
||||
return visibleSeries.includes(serie.name);
|
||||
})
|
||||
.map((serie) => {
|
||||
const realIndex = data?.series.findIndex(
|
||||
(item) => item.name === serie.name
|
||||
);
|
||||
const key = serie.name;
|
||||
const strokeColor = getChartColor(realIndex);
|
||||
return (
|
||||
<Line
|
||||
type="monotone"
|
||||
key={key}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
dataKey="count"
|
||||
stroke={strokeColor}
|
||||
data={serie.data}
|
||||
name={serie.name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<Line
|
||||
type={lineType}
|
||||
key={serie.name}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
dataKey={`${serie.index}:count`}
|
||||
stroke={getChartColor(serie.index)}
|
||||
name={serie.name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
)}
|
||||
</AutoSizer>
|
||||
@@ -89,7 +93,7 @@ export function ReportLineChart({ interval, data }: ReportLineChartProps) {
|
||||
{editMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={visibleSeries}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
|
||||
79
apps/web/src/components/report/chart/ReportMetricChart.tsx
Normal file
79
apps/web/src/components/report/chart/ReportMetricChart.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { theme } from '@/utils/theme';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Area, AreaChart } from 'recharts';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
|
||||
interface ReportMetricChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportMetricChart({ data }: ReportMetricChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const { series } = useVisibleSeries(data, editMode ? undefined : 2);
|
||||
const color = theme?.colors['chart-0'];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-4',
|
||||
editMode && 'md:grid-cols-2 lg:grid-cols-3'
|
||||
)}
|
||||
>
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<div
|
||||
className="relative border border-border p-4 rounded-md bg-white overflow-hidden"
|
||||
key={serie.name}
|
||||
>
|
||||
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-50">
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<AreaChart
|
||||
width={width}
|
||||
height={height / 3}
|
||||
data={serie.data}
|
||||
style={{ marginTop: (height / 3) * 2 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="area" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={color}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
dataKey="count"
|
||||
type="monotone"
|
||||
fill="url(#area)"
|
||||
fillOpacity={1}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 text-lg font-medium">
|
||||
<ColorSquare>{serie.event.id}</ColorSquare>
|
||||
{serie.name ?? serie.event.displayName ?? serie.event.name}
|
||||
</div>
|
||||
<div className="mt-6 font-mono text-4xl font-light">
|
||||
{new Intl.NumberFormat('en', {
|
||||
maximumSignificantDigits: 20,
|
||||
}).format(serie.metrics.sum)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
apps/web/src/components/report/chart/ReportPieChart.tsx
Normal file
81
apps/web/src/components/report/chart/ReportPieChart.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { AutoSizer } from '@/components/AutoSizer';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { Cell, Pie, PieChart, Tooltip } from 'recharts';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
||||
import { ReportTable } from './ReportTable';
|
||||
|
||||
interface ReportPieChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportPieChart({ data }: ReportPieChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
|
||||
// Get max 10 series and than combine others into one
|
||||
const pieData = series.map((serie) => {
|
||||
return {
|
||||
id: serie.name,
|
||||
color: getChartColor(serie.index),
|
||||
index: serie.index,
|
||||
label: serie.name,
|
||||
count: serie.metrics.sum,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'max-sm:-mx-3',
|
||||
editMode && 'border border-border bg-white rounded-md p-4'
|
||||
)}
|
||||
>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => {
|
||||
const height = Math.min(Math.max(width * 0.5, 250), 400);
|
||||
return (
|
||||
<PieChart
|
||||
width={width}
|
||||
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
||||
>
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
<Pie
|
||||
dataKey={'count'}
|
||||
data={pieData}
|
||||
innerRadius={height / 4}
|
||||
outerRadius={height / 2 - 20}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{pieData.map((item) => {
|
||||
return (
|
||||
<Cell
|
||||
key={item.id}
|
||||
strokeWidth={2}
|
||||
stroke={item.color}
|
||||
fill={item.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
{editMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { useSelector } from '@/redux';
|
||||
import type { IChartData } from '@/types';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
interface ReportTableProps {
|
||||
data: IChartData;
|
||||
visibleSeries: string[];
|
||||
visibleSeries: IChartData['series'];
|
||||
setVisibleSeries: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
@@ -40,31 +45,23 @@ export function ReportTable({
|
||||
'bg-gray-50 text-emerald-600 font-medium border-r border-border';
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-fit max-w-full rounded-md border border-border">
|
||||
<div className="flex w-fit max-w-full rounded-md border border-border bg-white">
|
||||
{/* Labels */}
|
||||
<div className="border-r border-border">
|
||||
<div className={cn(header, row, cell)}>Name</div>
|
||||
{/* <div
|
||||
className={cn(
|
||||
'flex max-w-[200px] w-full min-w-full items-center gap-2',
|
||||
row,
|
||||
cell
|
||||
)}
|
||||
>
|
||||
<div className="font-medium min-w-0 overflow-scroll whitespace-nowrap scrollbar-hide">
|
||||
Summary
|
||||
</div>
|
||||
</div> */}
|
||||
{data.series.map((serie, index) => {
|
||||
const checked = visibleSeries.includes(serie.name);
|
||||
const checked = !!visibleSeries.find(
|
||||
(item) => item.name === serie.name
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={serie.name}
|
||||
className={cn(
|
||||
'flex max-w-[200px] w-full min-w-full items-center gap-2',
|
||||
'flex max-w-[200px] lg:max-w-[400px] xl:max-w-[600px] w-full min-w-full items-center gap-2',
|
||||
row,
|
||||
cell
|
||||
// avoid using cell since its better on the right side
|
||||
'p-2'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
@@ -81,12 +78,16 @@ export function ReportTable({
|
||||
}
|
||||
checked={checked}
|
||||
/>
|
||||
<div
|
||||
title={getLabel(serie.name)}
|
||||
className="min-w-full overflow-scroll whitespace-nowrap scrollbar-hide"
|
||||
>
|
||||
{getLabel(serie.name)}
|
||||
</div>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{getLabel(serie.name)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{getLabel(serie.name)}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -113,7 +114,7 @@ export function ReportTable({
|
||||
return (
|
||||
<div className={cn('w-max', row)} key={serie.name}>
|
||||
<div className={cn(header, value, cell, total)}>
|
||||
{serie.metrics.total}
|
||||
{serie.metrics.sum}
|
||||
</div>
|
||||
<div className={cn(header, value, cell, total)}>
|
||||
{serie.metrics.average}
|
||||
@@ -130,14 +131,22 @@ export function ReportTable({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div>Total</div>
|
||||
<div>
|
||||
{data.series.reduce((acc, serie) => serie.metrics.total + acc, 0)}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex gap-1">
|
||||
<div>Total</div>
|
||||
<div>{data.metrics.sum}</div>
|
||||
</div>
|
||||
<div>Average</div>
|
||||
<div>
|
||||
{data.series.reduce((acc, serie) => serie.metrics.average + acc, 0)}
|
||||
<div className="flex gap-1">
|
||||
<div>Average</div>
|
||||
<div>{data.metrics.averge}</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div>Min</div>
|
||||
<div>{data.metrics.min}</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div>Max</div>
|
||||
<div>{data.metrics.max}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
5
apps/web/src/components/report/chart/chart-utils.ts
Normal file
5
apps/web/src/components/report/chart/chart-utils.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { round } from '@/utils/math';
|
||||
|
||||
export function getYAxisWidth(value: number) {
|
||||
return round(value, 0).toString().length * 7.5 + 7.5;
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import type { IChartInput } from '@/types';
|
||||
import { api } from '@/utils/api';
|
||||
|
||||
import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation';
|
||||
import { withChartProivder } from './ChartProvider';
|
||||
import { ReportAreaChart } from './ReportAreaChart';
|
||||
import { ReportBarChart } from './ReportBarChart';
|
||||
import { ReportHistogramChart } from './ReportHistogramChart';
|
||||
import { ReportLineChart } from './ReportLineChart';
|
||||
import { ReportMetricChart } from './ReportMetricChart';
|
||||
import { ReportPieChart } from './ReportPieChart';
|
||||
|
||||
export type ReportChartProps = IChartInput;
|
||||
|
||||
@@ -19,8 +24,9 @@ export const Chart = memo(
|
||||
chartType,
|
||||
name,
|
||||
range,
|
||||
lineType,
|
||||
}: ReportChartProps) {
|
||||
const params = useOrganizationParams();
|
||||
const params = useAppParams();
|
||||
const hasEmptyFilters = events.some((event) =>
|
||||
event.filters.some((filter) => filter.value.length === 0)
|
||||
);
|
||||
@@ -29,13 +35,15 @@ export const Chart = memo(
|
||||
{
|
||||
interval,
|
||||
chartType,
|
||||
// dont send lineType since it does not need to be sent
|
||||
lineType: 'monotone',
|
||||
events,
|
||||
breakdowns,
|
||||
name,
|
||||
range,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
projectSlug: params.project,
|
||||
projectId: params.projectId,
|
||||
},
|
||||
{
|
||||
keepPreviousData: false,
|
||||
@@ -97,8 +105,32 @@ export const Chart = memo(
|
||||
return <ReportBarChart data={chart.data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'metric') {
|
||||
return <ReportMetricChart data={chart.data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'pie') {
|
||||
return <ReportPieChart data={chart.data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'linear') {
|
||||
return <ReportLineChart interval={interval} data={chart.data} />;
|
||||
return (
|
||||
<ReportLineChart
|
||||
lineType={lineType}
|
||||
interval={interval}
|
||||
data={chart.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (chartType === 'area') {
|
||||
return (
|
||||
<ReportAreaChart
|
||||
lineType={lineType}
|
||||
interval={interval}
|
||||
data={chart.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useQueryParams } from '@/hooks/useQueryParams';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const useReportId = () =>
|
||||
useQueryParams(
|
||||
z.object({
|
||||
reportId: z.string().optional(),
|
||||
})
|
||||
);
|
||||
@@ -2,25 +2,34 @@ import type {
|
||||
IChartBreakdown,
|
||||
IChartEvent,
|
||||
IChartInput,
|
||||
IChartLineType,
|
||||
IChartRange,
|
||||
IChartType,
|
||||
IInterval,
|
||||
} from '@/types';
|
||||
import { alphabetIds, isMinuteIntervalEnabledByRange } from '@/utils/constants';
|
||||
import {
|
||||
alphabetIds,
|
||||
getDefaultIntervalByRange,
|
||||
isHourIntervalEnabledByRange,
|
||||
isMinuteIntervalEnabledByRange,
|
||||
} from '@/utils/constants';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
type InitialState = IChartInput & {
|
||||
dirty: boolean;
|
||||
ready: boolean;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
};
|
||||
|
||||
// First approach: define the initial state using that type
|
||||
const initialState: InitialState = {
|
||||
ready: false,
|
||||
dirty: false,
|
||||
name: 'Untitled',
|
||||
chartType: 'linear',
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
breakdowns: [],
|
||||
events: [],
|
||||
@@ -42,12 +51,19 @@ export const reportSlice = createSlice({
|
||||
reset() {
|
||||
return initialState;
|
||||
},
|
||||
ready() {
|
||||
return {
|
||||
...initialState,
|
||||
ready: true,
|
||||
};
|
||||
},
|
||||
setReport(state, action: PayloadAction<IChartInput>) {
|
||||
return {
|
||||
...action.payload,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
dirty: false,
|
||||
ready: true,
|
||||
};
|
||||
},
|
||||
setName(state, action: PayloadAction<string>) {
|
||||
@@ -132,6 +148,19 @@ export const reportSlice = createSlice({
|
||||
) {
|
||||
state.interval = 'hour';
|
||||
}
|
||||
|
||||
if (
|
||||
!isHourIntervalEnabledByRange(state.range) &&
|
||||
state.interval === 'hour'
|
||||
) {
|
||||
state.interval = 'day';
|
||||
}
|
||||
},
|
||||
|
||||
// Line type
|
||||
changeLineType: (state, action: PayloadAction<IChartLineType>) => {
|
||||
state.dirty = true;
|
||||
state.lineType = action.payload;
|
||||
},
|
||||
|
||||
// Date range
|
||||
@@ -149,15 +178,7 @@ export const reportSlice = createSlice({
|
||||
changeDateRanges: (state, action: PayloadAction<IChartRange>) => {
|
||||
state.dirty = true;
|
||||
state.range = action.payload;
|
||||
if (action.payload === '30min' || action.payload === '1h') {
|
||||
state.interval = 'minute';
|
||||
} else if (action.payload === 'today' || action.payload === '24h') {
|
||||
state.interval = 'hour';
|
||||
} else if (action.payload === '7d' || action.payload === '14d') {
|
||||
state.interval = 'day';
|
||||
} else {
|
||||
state.interval = 'month';
|
||||
}
|
||||
state.interval = getDefaultIntervalByRange(action.payload);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -165,6 +186,7 @@ export const reportSlice = createSlice({
|
||||
// Action creators are generated for each case reducer function
|
||||
export const {
|
||||
reset,
|
||||
ready,
|
||||
setReport,
|
||||
setName,
|
||||
addEvent,
|
||||
@@ -176,6 +198,7 @@ export const {
|
||||
changeInterval,
|
||||
changeDateRanges,
|
||||
changeChartType,
|
||||
changeLineType,
|
||||
resetDirty,
|
||||
} = reportSlice.actions;
|
||||
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import type { IChartBreakdown } from '@/types';
|
||||
import { api } from '@/utils/api';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { addBreakdown, changeBreakdown, removeBreakdown } from '../reportSlice';
|
||||
import { ReportBreakdownMore } from './ReportBreakdownMore';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
|
||||
export function ReportBreakdowns() {
|
||||
const params = useOrganizationParams();
|
||||
const params = useParams();
|
||||
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
|
||||
const dispatch = useDispatch();
|
||||
const propertiesQuery = api.chart.properties.useQuery({
|
||||
projectSlug: params.project,
|
||||
projectId: params.projectId as string,
|
||||
});
|
||||
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
|
||||
value: item,
|
||||
@@ -43,6 +45,8 @@ export function ReportBreakdowns() {
|
||||
<div className="flex items-center gap-2 p-2 px-4">
|
||||
<ColorSquare>{index}</ColorSquare>
|
||||
<Combobox
|
||||
className="flex-1"
|
||||
searchable
|
||||
value={item.name}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
@@ -63,6 +67,7 @@ export function ReportBreakdowns() {
|
||||
|
||||
{selectedBreakdowns.length === 0 && (
|
||||
<Combobox
|
||||
searchable
|
||||
value={''}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Dispatch } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Dropdown } from '@/components/Dropdown';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
import { ComboboxMulti } from '@/components/ui/combobox-multi';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
@@ -15,16 +15,15 @@ import {
|
||||
} from '@/components/ui/command';
|
||||
import { RenderDots } from '@/components/ui/RenderDots';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { useDispatch } from '@/redux';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartEventFilter,
|
||||
IChartEventFilterValue,
|
||||
} from '@/types';
|
||||
import { api } from '@/utils/api';
|
||||
import { operators } from '@/utils/constants';
|
||||
import { CreditCard, SlidersHorizontal, Trash } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { changeEvent } from '../reportSlice';
|
||||
|
||||
@@ -39,12 +38,12 @@ export function ReportEventFilters({
|
||||
isCreating,
|
||||
setIsCreating,
|
||||
}: ReportEventFiltersProps) {
|
||||
const params = useOrganizationParams();
|
||||
const params = useParams();
|
||||
const dispatch = useDispatch();
|
||||
const propertiesQuery = api.chart.properties.useQuery(
|
||||
{
|
||||
event: event.name,
|
||||
projectSlug: params.project,
|
||||
projectId: params.projectId as string,
|
||||
},
|
||||
{
|
||||
enabled: !!event.name,
|
||||
@@ -103,13 +102,13 @@ interface FilterProps {
|
||||
}
|
||||
|
||||
function Filter({ filter, event }: FilterProps) {
|
||||
const params = useOrganizationParams();
|
||||
const params = useParams<{ organizationId: string; projectId: string }>();
|
||||
const getLabel = useMappings();
|
||||
const dispatch = useDispatch();
|
||||
const potentialValues = api.chart.values.useQuery({
|
||||
event: event.name,
|
||||
property: filter.name,
|
||||
projectSlug: params.project,
|
||||
projectId: params?.projectId!,
|
||||
});
|
||||
|
||||
const valuesCombobox =
|
||||
@@ -196,8 +195,8 @@ function Filter({ filter, event }: FilterProps) {
|
||||
</Dropdown>
|
||||
<ComboboxAdvanced
|
||||
items={valuesCombobox}
|
||||
selected={filter.value}
|
||||
setSelected={(setFn) => {
|
||||
value={filter.value}
|
||||
onChange={(setFn) => {
|
||||
changeFilterValue(
|
||||
typeof setFn === 'function' ? setFn(filter.value) : setFn
|
||||
);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Dropdown } from '@/components/Dropdown';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useDebounceFn } from '@/hooks/useDebounceFn';
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import type { IChartEvent } from '@/types';
|
||||
import { api } from '@/utils/api';
|
||||
import { Filter, GanttChart, Users } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { addEvent, changeEvent, removeEvent } from '../reportSlice';
|
||||
import { ReportEventFilters } from './ReportEventFilters';
|
||||
@@ -19,9 +21,9 @@ export function ReportEvents() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const selectedEvents = useSelector((state) => state.report.events);
|
||||
const dispatch = useDispatch();
|
||||
const params = useOrganizationParams();
|
||||
const params = useParams();
|
||||
const eventsQuery = api.chart.events.useQuery({
|
||||
projectSlug: params.project,
|
||||
projectId: String(params.projectId),
|
||||
});
|
||||
const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({
|
||||
value: item.name,
|
||||
@@ -56,6 +58,8 @@ export function ReportEvents() {
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
<ColorSquare>{event.id}</ColorSquare>
|
||||
<Combobox
|
||||
className="flex-1"
|
||||
searchable
|
||||
value={event.name}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Asterisk, ChevronRight } from 'lucide-react';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
59
apps/web/src/components/ui/alert.tsx
Normal file
59
apps/web/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive:
|
||||
'border-destructive text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = 'Alert';
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-sm [&_p]:leading-relaxed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user