refactoring and more work with clerk

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-07 23:28:55 +01:00
parent a9cbff2306
commit 86d2d0750f
61 changed files with 703 additions and 727 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { LazyChart } from '@/components/report/chart/LazyChart';
import { ReportRange } from '@/components/report/ReportRange';
import { Button } from '@/components/ui/button';

View File

@@ -1,11 +1,8 @@
import PageLayout from '@/app/(app)/page-layout';
import {
createRecentDashboard,
getDashboardById,
} from '@/server/services/dashboard.service';
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getDashboardById } from '@/server/services/dashboard.service';
import { getReportsByDashboardId } from '@/server/services/reports.service';
import { auth } from '@clerk/nextjs';
import { revalidateTag } from 'next/cache';
import { notFound } from 'next/navigation';
import { ListReports } from './list-reports';
@@ -20,22 +17,18 @@ interface PageProps {
export default async function Page({
params: { organizationId, projectId, dashboardId },
}: PageProps) {
const { userId } = auth();
const [dashboard, reports] = await Promise.all([
getDashboardById(dashboardId),
getReportsByDashboardId(dashboardId),
getExists(organizationId, projectId),
]);
const dashboard = await getDashboardById(dashboardId);
const reports = await getReportsByDashboardId(dashboardId);
if (userId && dashboard) {
await createRecentDashboard({
userId,
organizationId,
projectId,
dashboardId,
});
revalidateTag(`recentDashboards__${userId}`);
if (!dashboard) {
return notFound();
}
return (
<PageLayout title={dashboard.name}>
<PageLayout title={dashboard.name} organizationSlug={organizationId}>
<ListReports reports={reports} />
</PageLayout>
);

View File

@@ -4,13 +4,9 @@ import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import { StickyBelowHeader } from '../../../layout-sticky-below-header';
import { StickyBelowHeader } from '../layout-sticky-below-header';
interface HeaderDashboardsProps {
projectId: string;
}
export function HeaderDashboards({ projectId }: HeaderDashboardsProps) {
export function HeaderDashboards() {
return (
<StickyBelowHeader>
<div className="p-4 flex justify-between items-center">
@@ -18,9 +14,7 @@ export function HeaderDashboards({ projectId }: HeaderDashboardsProps) {
<Button
icon={PlusIcon}
onClick={() => {
pushModal('AddDashboard', {
projectId,
});
pushModal('AddDashboard');
}}
>
<span className="max-sm:hidden">Create dashboard</span>

View File

@@ -2,17 +2,19 @@
import { api, handleErrorToastOptions } from '@/app/_trpc/client';
import { Card, CardActions, CardActionsItem } from '@/components/Card';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Button } from '@/components/ui/button';
import { ToastAction } from '@/components/ui/toast';
import { toast } from '@/components/ui/use-toast';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import type { getDashboardsByProjectId } from '@/server/services/dashboard.service';
import { Pencil, Trash } from 'lucide-react';
import type { IServiceDashboards } from '@/server/services/dashboard.service';
import { LayoutPanelTopIcon, Pencil, PlusIcon, Trash } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
interface ListDashboardsProps {
dashboards: Awaited<ReturnType<typeof getDashboardsByProjectId>>;
dashboards: IServiceDashboards;
}
export function ListDashboards({ dashboards }: ListDashboardsProps) {
@@ -46,6 +48,21 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
},
});
if (dashboards.length === 0) {
return (
<FullPageEmptyState title="No dashboards" icon={LayoutPanelTopIcon}>
<p>You have not created any dashboards for this project yet</p>
<Button
onClick={() => pushModal('AddDashboard')}
className="mt-14"
icon={PlusIcon}
>
Create dashboard
</Button>
</FullPageEmptyState>
);
}
return (
<>
<div className="grid sm:grid-cols-2 gap-4 p-4">
@@ -57,9 +74,6 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
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>

View File

@@ -1,4 +1,5 @@
import PageLayout from '@/app/(app)/page-layout';
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getDashboardsByProjectId } from '@/server/services/dashboard.service';
import { HeaderDashboards } from './header-dashboards';
@@ -7,15 +8,21 @@ import { ListDashboards } from './list-dashboards';
interface PageProps {
params: {
projectId: string;
organizationId: string;
};
}
export default async function Page({ params: { projectId } }: PageProps) {
const dashboards = await getDashboardsByProjectId(projectId);
export default async function Page({
params: { projectId, organizationId },
}: PageProps) {
const [dashboards] = await Promise.all([
getDashboardsByProjectId(projectId),
await getExists(organizationId, projectId),
]);
return (
<PageLayout title="Dashboards">
<HeaderDashboards projectId={projectId} />
<PageLayout title="Dashboards" organizationSlug={organizationId}>
{dashboards.length > 0 && <HeaderDashboards />}
<ListDashboards dashboards={dashboards} />
</PageLayout>
);

View File

@@ -2,9 +2,11 @@
import { useMemo, useState } from 'react';
import { api } from '@/app/_trpc/client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination, usePagination } from '@/components/Pagination';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { GanttChartIcon } from 'lucide-react';
import { EventListItem } from './event-list-item';
@@ -47,14 +49,26 @@ export function ListEvents({ projectId }: ListEventsProps) {
</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>
{events.length === 0 ? (
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
{eventFilters.length ? (
<p>Could not find any events with your filter</p>
) : (
<p>We have not recieved any events yet</p>
)}
</FullPageEmptyState>
) : (
<>
<div className="flex flex-col gap-4">
{events.map((item) => (
<EventListItem key={item.id} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</>
)}
</div>
</>
);

View File

@@ -1,15 +1,21 @@
import PageLayout from '@/app/(app)/page-layout';
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { ListEvents } from './list-events';
interface PageProps {
params: {
projectId: string;
organizationId: string;
};
}
export default function Page({ params: { projectId } }: PageProps) {
export default async function Page({
params: { projectId, organizationId },
}: PageProps) {
await getExists(organizationId, projectId);
return (
<PageLayout title="Events">
<PageLayout title="Events" organizationSlug={organizationId}>
<ListEvents projectId={projectId} />
</PageLayout>
);

View File

@@ -1,8 +1,10 @@
'use client';
import { useEffect } from 'react';
import { useAppParams } from '@/hooks/useAppParams';
import type { IServiceRecentDashboards } from '@/server/services/dashboard.service';
import type { IServiceDashboards } from '@/server/services/dashboard.service';
import { cn } from '@/utils/cn';
import { useUser } from '@clerk/nextjs';
import {
BuildingIcon,
CogIcon,
@@ -24,49 +26,56 @@ function LinkWithIcon({
icon: Icon,
label,
active: overrideActive,
className,
}: {
href: string;
icon: React.ElementType<LucideProps>;
label: React.ReactNode;
active?: boolean;
className?: string;
}) {
const pathname = usePathname();
const active = overrideActive || href === pathname;
return (
<Link
className={cn(
'text-slate-600 flex gap-2 items-center px-3 py-3 transition-colors hover:text-white hover:bg-blue-700 leading-none rounded-lg',
active && 'bg-blue-600 text-white'
'text-slate-600 text-sm font-medium flex gap-2 items-center px-3 py-2 transition-colors hover:bg-blue-100 leading-none rounded-md transition-all',
active && 'bg-blue-50',
className
)}
href={href}
>
<Icon size={20} />
<div className="flex-1">{label}</div>
<DotIcon
size={20}
className={cn(
'transition-opacity',
active ? 'opacity-100' : 'opacity-0'
)}
/>
</Link>
);
}
interface LayoutMenuProps {
recentDashboards: IServiceRecentDashboards;
fallbackProjectId: string | null;
dashboards: IServiceDashboards;
}
export default function LayoutMenu({
recentDashboards,
fallbackProjectId,
}: LayoutMenuProps) {
export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
const { user } = useUser();
const pathname = usePathname();
const params = useAppParams();
const projectId =
!params.projectId || params.projectId === 'undefined'
? fallbackProjectId
: params.projectId;
const hasProjectId =
params.projectId &&
params.projectId !== 'null' &&
params.projectId !== 'undefined';
const projectId = hasProjectId
? params.projectId
: (user?.unsafeMetadata.projectId as string);
useEffect(() => {
if (hasProjectId) {
user?.update({
unsafeMetadata: {
projectId: params.projectId,
},
});
}
}, [params.projectId, hasProjectId]);
return (
<>
@@ -93,50 +102,51 @@ export default function LayoutMenu({
<LinkWithIcon
icon={CogIcon}
label="Settings"
href={`/${params.organizationId}/settings/organization`}
href={`/${params.organizationId}/${projectId}/settings/organization`}
/>
{pathname?.includes('/settings/') && (
<div className="pl-7">
<div className="pl-7 flex flex-col gap-1">
<LinkWithIcon
icon={BuildingIcon}
label="Organization"
href={`/${params.organizationId}/settings/organization`}
href={`/${params.organizationId}/${projectId}/settings/organization`}
/>
<LinkWithIcon
icon={WarehouseIcon}
label="Projects"
href={`/${params.organizationId}/settings/projects`}
href={`/${params.organizationId}/${projectId}/settings/projects`}
/>
<LinkWithIcon
icon={KeySquareIcon}
label="Clients"
href={`/${params.organizationId}/settings/clients`}
href={`/${params.organizationId}/${projectId}/settings/clients`}
/>
<LinkWithIcon
icon={UserIcon}
label="Profile (yours)"
href={`/${params.organizationId}/settings/profile`}
href={`/${params.organizationId}/${projectId}/settings/profile`}
/>
</div>
)}
{recentDashboards.length > 0 && (
{dashboards.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_slug}/${item.project_id}/${item.dashboard_id}`}
/>
))}
<div className="font-medium mb-2 text-sm">Your dashboards</div>
<div className="flex flex-col gap-2">
{dashboards.map((item) => (
<LinkWithIcon
className="py-1"
key={item.id}
icon={LayoutPanelTopIcon}
label={
<div className="flex flex-col gap-0.5">
<span>{item.name}</span>
<span className="text-xs">{item.project.name}</span>
</div>
}
href={`/${item.organization_slug}/${item.project_id}/dashboards/${item.id}`}
/>
))}
</div>
</div>
)}
</>

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { Logo } from '@/components/Logo';
import type { IServiceDashboards } 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';
@@ -13,14 +14,19 @@ import LayoutOrganizationSelector from './layout-organization-selector';
interface LayoutSidebarProps {
organizations: IServiceOrganization[];
dashboards: IServiceDashboards;
}
export function LayoutSidebar({ organizations }: LayoutSidebarProps) {
export function LayoutSidebar({
organizations,
dashboards,
}: LayoutSidebarProps) {
const [active, setActive] = useState(false);
const fallbackProjectId = null;
const pathname = usePathname();
useEffect(() => {
setActive(false);
}, [pathname]);
return (
<>
<button
@@ -48,10 +54,7 @@ export function LayoutSidebar({ organizations }: LayoutSidebarProps) {
</Link>
</div>
<div className="flex flex-col p-4 gap-2 flex-grow overflow-auto">
<LayoutMenu
recentDashboards={[]}
fallbackProjectId={fallbackProjectId}
/>
<LayoutMenu dashboards={dashboards} />
{/* Placeholder for LayoutOrganizationSelector */}
<div className="h-16 block shrink-0"></div>
</div>

View File

@@ -0,0 +1,28 @@
import { getDashboardsByOrganization } from '@/server/services/dashboard.service';
import { getCurrentOrganizations } from '@/server/services/organization.service';
import { LayoutSidebar } from './layout-sidebar';
interface AppLayoutProps {
children: React.ReactNode;
params: {
organizationId: string;
};
}
export default async function AppLayout({
children,
params: { organizationId },
}: AppLayoutProps) {
const [organizations, dashboards] = await Promise.all([
getCurrentOrganizations(),
getDashboardsByOrganization(organizationId),
]);
return (
<div id="dashboard">
<LayoutSidebar {...{ organizations, dashboards }} />
<div className="lg:pl-72 transition-all">{children}</div>
</div>
);
}

View File

@@ -26,7 +26,7 @@ import { cn } from '@/utils/cn';
import { Eye, FilterIcon, Globe2Icon, LockIcon, X } from 'lucide-react';
import Link from 'next/link';
import { StickyBelowHeader } from '../../layout-sticky-below-header';
import { StickyBelowHeader } from './layout-sticky-below-header';
export default function OverviewMetrics() {
const { previous, range, setRange, interval, metric, setMetric, filters } =

View File

@@ -5,16 +5,20 @@ import LayoutProjectSelector from './layout-project-selector';
interface PageLayoutProps {
children: React.ReactNode;
title: React.ReactNode;
organizationSlug: string;
}
export default async function PageLayout({ children, title }: PageLayoutProps) {
const projects = await getCurrentProjects();
export default async function PageLayout({
children,
title,
organizationSlug,
}: PageLayoutProps) {
const projects = await getCurrentProjects(organizationSlug);
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} />}
</div>
<div>{children}</div>

View File

@@ -1,10 +1,21 @@
import PageLayout from '@/app/(app)/page-layout';
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import OverviewMetrics from './overview-metrics';
export default function Page() {
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
await getExists(organizationId, projectId);
return (
<PageLayout title="Overview">
<PageLayout title="Overview" organizationSlug={organizationId}>
<OverviewMetrics />
</PageLayout>
);

View File

@@ -1,7 +1,8 @@
import PageLayout from '@/app/(app)/page-layout';
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { ListProperties } from '@/components/events/ListProperties';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { getExists } from '@/server/pageExists';
import {
getProfileById,
getProfilesByExternalId,
@@ -15,18 +16,23 @@ interface PageProps {
params: {
projectId: string;
profileId: string;
organizationId: string;
};
}
export default async function Page({
params: { projectId, profileId },
params: { projectId, profileId, organizationId },
}: PageProps) {
const profile = await getProfileById(profileId);
const [profile] = await Promise.all([
getProfileById(profileId),
getExists(organizationId, projectId),
]);
const profiles = (
await getProfilesByExternalId(profile.external_id, profile.project_id)
).filter((item) => item.id !== profile.id);
return (
<PageLayout
organizationSlug={organizationId}
title={
<div className="flex items-center gap-2">
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />

View File

@@ -2,9 +2,11 @@
import { useMemo } from 'react';
import { api } from '@/app/_trpc/client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination, usePagination } from '@/components/Pagination';
import { Input } from '@/components/ui/input';
import { UsersIcon } from 'lucide-react';
import { useQueryState } from 'nuqs';
import { ProfileListItem } from './profile-list-item';
@@ -16,7 +18,7 @@ interface ListProfilesProps {
export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
const [query, setQuery] = useQueryState('q');
const pagination = usePagination();
const eventsQuery = api.profile.list.useQuery(
const profilesQuery = api.profile.list.useQuery(
{
projectId,
query,
@@ -26,7 +28,7 @@ export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
keepPreviousData: true,
}
);
const profiles = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
const profiles = useMemo(() => profilesQuery.data ?? [], [profilesQuery]);
return (
<>
@@ -38,14 +40,28 @@ export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
/>
</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>
{profiles.length === 0 ? (
<FullPageEmptyState title="No profiles" icon={UsersIcon}>
{query ? (
<p>
No match for <strong>"{query}"</strong>
</p>
) : (
<p>We could not find any profiles on this project</p>
)}
</FullPageEmptyState>
) : (
<>
<div className="flex flex-col gap-4">
{profiles.map((item) => (
<ProfileListItem key={item.id} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</>
)}
</div>
</>
);

View File

@@ -1,4 +1,5 @@
import PageLayout from '@/app/(app)/page-layout';
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { ListProfiles } from './list-profiles';
@@ -8,11 +9,13 @@ interface PageProps {
projectId: string;
};
}
export default function Page({
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
await getExists(organizationId, projectId);
return (
<PageLayout title="Events">
<PageLayout title="Events" organizationSlug={organizationId}>
<ListProfiles projectId={projectId} organizationId={organizationId} />
</PageLayout>
);

View File

@@ -1,6 +1,9 @@
import PageLayout from '@/app/(app)/page-layout';
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { getReportById } from '@/server/services/reports.service';
import { Pencil } from 'lucide-react';
import { notFound } from 'next/navigation';
import ReportEditor from '../report-editor';
@@ -8,13 +11,25 @@ interface PageProps {
params: {
projectId: string;
reportId: string;
organizationId: string;
};
}
export default async function Page({ params: { reportId } }: PageProps) {
const report = await getReportById(reportId);
export default async function Page({
params: { reportId, organizationId, projectId },
}: PageProps) {
const [report] = await Promise.all([
getReportById(reportId),
getExists(organizationId, projectId),
]);
if (!report) {
return notFound();
}
return (
<PageLayout
organizationSlug={organizationId}
title={
<div className="flex gap-2 items-center cursor-pointer">
{report.name}

View File

@@ -1,5 +1,8 @@
import PageLayout from '@/app/(app)/page-layout';
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { Pencil } from 'lucide-react';
import { notFound } from 'next/navigation';
import ReportEditor from './report-editor';
@@ -10,9 +13,14 @@ interface PageProps {
};
}
export default function Page({ params: { organizationId } }: PageProps) {
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
await getExists(organizationId, projectId);
return (
<PageLayout
organizationSlug={organizationId}
title={
<div className="flex gap-2 items-center cursor-pointer">
Unnamed report
@@ -20,7 +28,7 @@ export default function Page({ params: { organizationId } }: PageProps) {
</div>
}
>
<ReportEditor reportId={null} />
<ReportEditor report={null} />
</PageLayout>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect } from 'react';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { Chart } from '@/components/report/chart';
import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval';

View File

@@ -1,6 +1,6 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { columns } from '@/components/clients/table';
import { DataTable } from '@/components/DataTable';
import { Button } from '@/components/ui/button';

View File

@@ -1,4 +1,5 @@
import PageLayout from '@/app/(app)/page-layout';
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getClientsByOrganizationId } from '@/server/services/clients.service';
import ListClients from './list-clients';
@@ -10,10 +11,11 @@ interface PageProps {
}
export default async function Page({ params: { organizationId } }: PageProps) {
await getExists(organizationId);
const clients = await getClientsByOrganizationId(organizationId);
return (
<PageLayout title="Clients">
<PageLayout title="Clients" organizationSlug={organizationId}>
<ListClients clients={clients} />
</PageLayout>
);

View File

@@ -0,0 +1,60 @@
import { api } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { useAppParams } from '@/hooks/useAppParams';
import { zInviteUser } from '@/utils/validation';
import { zodResolver } from '@hookform/resolvers/zod';
import { SendIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
type IForm = z.infer<typeof zInviteUser>;
export function InviteUser() {
const router = useRouter();
const { organizationId: organizationSlug } = useAppParams();
const { register, handleSubmit, formState, reset } = useForm<IForm>({
resolver: zodResolver(zInviteUser),
defaultValues: {
organizationSlug,
email: '',
role: 'org:member',
},
});
const mutation = api.organization.inviteUser.useMutation({
onSuccess() {
toast({
title: 'User invited!',
description: 'The user has been invited to the organization.',
});
reset();
router.refresh();
},
});
return (
<form
onSubmit={handleSubmit((values) => mutation.mutate(values))}
className="flex items-end gap-4"
>
<InputWithLabel
className="w-full max-w-sm"
label="Email"
placeholder="Who do you want to invite?"
{...register('email')}
/>
<Button
icon={SendIcon}
type="submit"
disabled={!formState.isDirty}
loading={mutation.isLoading}
>
Invite user
</Button>
</form>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import type { IServiceInvites } from '@/server/services/organization.service';
import { InviteUser } from './invite-user';
interface InvitedUsersProps {
invites: IServiceInvites;
}
export default function InvitedUsers({ invites }: InvitedUsersProps) {
return (
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Invites</span>
</WidgetHead>
<WidgetBody>
<InviteUser />
<div className="font-medium mt-8 mb-2">Invited users</div>
<Table className="mini">
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((item) => {
return (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.email}</TableCell>
<TableCell>{item.role}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>
{new Date(item.createdAt).toLocaleString()}
</TableCell>
</TableRow>
);
})}
{invites.length === 0 && (
<TableRow>
<TableCell colSpan={2} className="italic">
No invites
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,35 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import {
getInvites,
getOrganizationBySlug,
} from '@/server/services/organization.service';
import { clerkClient } from '@clerk/nextjs';
import { notFound } from 'next/navigation';
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 getOrganizationBySlug(organizationId);
if (!organization) {
return notFound();
}
const invites = await getInvites(organization.id);
return (
<PageLayout title={organization.name} organizationSlug={organizationId}>
<div className="p-4 grid grid-cols-1 gap-4">
<EditOrganization organization={organization} />
<InvitedUsers invites={invites} />
</div>
</PageLayout>
);
}

View File

@@ -1,8 +1,7 @@
'use client';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { signOut } from 'next-auth/react';
import { SignOutButton } from '@clerk/nextjs';
export function Logout() {
return (
@@ -14,14 +13,7 @@ export function Logout() {
<p className="mb-4">
Sometime&apos;s you need to go. See you next time
</p>
<Button
variant={'destructive'}
onClick={() => {
signOut();
}}
>
Logout 🤨
</Button>
<SignOutButton />
</WidgetBody>
</Widget>
);

View File

@@ -1,16 +1,23 @@
import PageLayout from '@/app/(app)/page-layout';
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getUserById } from '@/server/services/user.service';
import { auth } from '@clerk/nextjs';
import EditProfile from './edit-profile';
import { Logout } from './logout';
export default async function Page() {
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const { userId } = auth();
await getExists(organizationId);
const profile = await getUserById(userId!);
return (
<PageLayout title={profile.lastName}>
<PageLayout title={profile.lastName} organizationSlug={organizationId}>
<div className="p-4 flex flex-col gap-4">
<EditProfile profile={profile} />
<Logout />

View File

@@ -1,6 +1,6 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { DataTable } from '@/components/DataTable';
import { columns } from '@/components/projects/table';
import { Button } from '@/components/ui/button';

View File

@@ -1,4 +1,5 @@
import PageLayout from '@/app/(app)/page-layout';
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getProjectsByOrganizationSlug } from '@/server/services/project.service';
import ListProjects from './list-projects';
@@ -10,10 +11,11 @@ interface PageProps {
}
export default async function Page({ params: { organizationId } }: PageProps) {
await getExists(organizationId);
const projects = await getProjectsByOrganizationSlug(organizationId);
return (
<PageLayout title="Projects">
<PageLayout title="Projects" organizationSlug={organizationId}>
<ListProjects projects={projects} />
</PageLayout>
);

View File

@@ -1,49 +0,0 @@
'use client';
import { Card } from '@/components/Card';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import type { getProjectsByOrganizationId } from '@/server/services/project.service';
import { Plus } from 'lucide-react';
import Link from 'next/link';
interface ListProjectsProps {
projects: Awaited<ReturnType<typeof getProjectsByOrganizationId>>;
}
export function ListProjects({ projects }: ListProjectsProps) {
const params = useAppParams();
const organizationId = params.organizationId;
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>
</>
);
}

View File

@@ -1,7 +1,8 @@
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { getProjectWithMostEvents } from '@/server/services/project.service';
import { redirect } from 'next/navigation';
import { notFound, redirect } from 'next/navigation';
import PageLayout from '../page-layout';
import PageLayout from './[projectId]/page-layout';
interface PageProps {
params: {
@@ -10,14 +11,21 @@ interface PageProps {
}
export default async function Page({ params: { organizationId } }: PageProps) {
const project = await getProjectWithMostEvents(organizationId);
const [organization, project] = await Promise.all([
getOrganizationBySlug(organizationId),
getProjectWithMostEvents(organizationId),
]);
if (!organization) {
return notFound();
}
if (project) {
return redirect(`/${organizationId}/${project.id}`);
}
return (
<PageLayout title="Projects">
<PageLayout title="Projects" organizationSlug={organizationId}>
<div className="p-4">
<h1>Create your first project</h1>
</div>

View File

@@ -1,111 +0,0 @@
'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>
);
}

View File

@@ -1,25 +0,0 @@
import PageLayout from '@/app/(app)/page-layout';
import { getOrganizationBySlug } from '@/server/services/organization.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 getOrganizationBySlug(organizationId);
const invites = [];
return (
<PageLayout title={organization.name}>
<div className="p-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<EditOrganization organization={organization} />
<InvitedUsers invites={invites} organizationId={organizationId} />
</div>
</PageLayout>
);
}

View File

@@ -1,18 +0,0 @@
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 organizations = await getOrganizations();
return (
<div id="dashboard">
<LayoutSidebar {...{ organizations }} />
<div className="lg:pl-72 transition-all">{children}</div>
</div>
);
}

View File

@@ -1,25 +0,0 @@
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>
</>
);
}

View File

@@ -1,9 +1,9 @@
import { getOrganizations } from '@/server/services/organization.service';
import { getCurrentOrganizations } from '@/server/services/organization.service';
import { CreateOrganization } from '@clerk/nextjs';
import { redirect } from 'next/navigation';
export default async function Page() {
const organizations = await getOrganizations();
const organizations = await getCurrentOrganizations();
if (organizations.length === 0) {
return <CreateOrganization />;