migrate to app dir and ssr
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
@@ -7,11 +9,12 @@ import type { LucideIcon } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'flex-shrink-0 inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
cta: 'bg-blue-600 text-primary-foreground hover:bg-blue-500',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Command, CommandGroup, CommandItem } from '@/components/ui/command';
|
||||
import { useOnClickOutside } from 'usehooks-ts';
|
||||
|
||||
import { Checkbox } from './checkbox';
|
||||
import { Input } from './input';
|
||||
@@ -9,23 +12,25 @@ type IValue = any;
|
||||
type IItem = Record<'value' | 'label', IValue>;
|
||||
|
||||
interface ComboboxAdvancedProps {
|
||||
selected: IValue[];
|
||||
setSelected: React.Dispatch<React.SetStateAction<IValue[]>>;
|
||||
value: IValue[];
|
||||
onChange: React.Dispatch<React.SetStateAction<IValue[]>>;
|
||||
items: IItem[];
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export function ComboboxAdvanced({
|
||||
items,
|
||||
selected,
|
||||
setSelected,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: ComboboxAdvancedProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
const ref = React.useRef<HTMLDivElement | null>(null);
|
||||
useOnClickOutside(ref, () => setOpen(false));
|
||||
|
||||
const selectables = items
|
||||
.filter((item) => !selected.find((s) => s === item.value))
|
||||
.filter((item) => !value.find((s) => s === item.value))
|
||||
.filter(
|
||||
(item) =>
|
||||
(typeof item.label === 'string' &&
|
||||
@@ -35,7 +40,7 @@ export function ComboboxAdvanced({
|
||||
);
|
||||
|
||||
const renderItem = (item: IItem) => {
|
||||
const checked = !!selected.find((s) => s === item.value);
|
||||
const checked = !!value.find((s) => s === item.value);
|
||||
return (
|
||||
<CommandItem
|
||||
key={String(item.value)}
|
||||
@@ -45,7 +50,7 @@ export function ComboboxAdvanced({
|
||||
}}
|
||||
onSelect={() => {
|
||||
setInputValue('');
|
||||
setSelected((prev) => {
|
||||
onChange((prev) => {
|
||||
if (prev.includes(item.value)) {
|
||||
return prev.filter((s) => s !== item.value);
|
||||
}
|
||||
@@ -66,15 +71,15 @@ export function ComboboxAdvanced({
|
||||
};
|
||||
|
||||
return (
|
||||
<Command className="overflow-visible bg-white">
|
||||
<Command className="overflow-visible bg-white" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className="group border border-input px-3 py-2 text-sm ring-offset-background rounded-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{selected.length === 0 && placeholder}
|
||||
{selected.slice(0, 2).map((value) => {
|
||||
{value.length === 0 && placeholder}
|
||||
{value.slice(0, 2).map((value) => {
|
||||
const item = items.find((item) => item.value === value) ?? {
|
||||
value,
|
||||
label: value,
|
||||
@@ -85,13 +90,13 @@ export function ComboboxAdvanced({
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{selected.length > 2 && (
|
||||
<Badge variant="secondary">+{selected.length - 2} more</Badge>
|
||||
{value.length > 2 && (
|
||||
<Badge variant="secondary">+{value.length - 2} more</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="relative mt-2">
|
||||
{open && (
|
||||
{open && (
|
||||
<div className="relative top-2">
|
||||
<div className="max-h-80 min-w-[300px] absolute w-full z-10 top-0 rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
|
||||
<CommandGroup className="max-h-80 overflow-auto">
|
||||
<div className="p-1 mb-2">
|
||||
@@ -102,13 +107,16 @@ export function ComboboxAdvanced({
|
||||
/>
|
||||
</div>
|
||||
{inputValue === ''
|
||||
? selected.map(renderUnknownItem)
|
||||
: renderUnknownItem(inputValue)}
|
||||
? value.map(renderUnknownItem)
|
||||
: renderItem({
|
||||
value: inputValue,
|
||||
label: `Pick "${inputValue}"`,
|
||||
})}
|
||||
{selectables.map(renderItem)}
|
||||
</CommandGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user