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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user