refactoring and more work with clerk
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
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 { LazyChart } from '@/components/report/chart/LazyChart';
|
||||||
import { ReportRange } from '@/components/report/ReportRange';
|
import { ReportRange } from '@/components/report/ReportRange';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import PageLayout from '@/app/(app)/page-layout';
|
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||||
import {
|
import { getExists } from '@/server/pageExists';
|
||||||
createRecentDashboard,
|
import { getDashboardById } from '@/server/services/dashboard.service';
|
||||||
getDashboardById,
|
|
||||||
} from '@/server/services/dashboard.service';
|
|
||||||
import { getReportsByDashboardId } from '@/server/services/reports.service';
|
import { getReportsByDashboardId } from '@/server/services/reports.service';
|
||||||
import { auth } from '@clerk/nextjs';
|
import { notFound } from 'next/navigation';
|
||||||
import { revalidateTag } from 'next/cache';
|
|
||||||
|
|
||||||
import { ListReports } from './list-reports';
|
import { ListReports } from './list-reports';
|
||||||
|
|
||||||
@@ -20,22 +17,18 @@ interface PageProps {
|
|||||||
export default async function Page({
|
export default async function Page({
|
||||||
params: { organizationId, projectId, dashboardId },
|
params: { organizationId, projectId, dashboardId },
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
const { userId } = auth();
|
const [dashboard, reports] = await Promise.all([
|
||||||
|
getDashboardById(dashboardId),
|
||||||
|
getReportsByDashboardId(dashboardId),
|
||||||
|
getExists(organizationId, projectId),
|
||||||
|
]);
|
||||||
|
|
||||||
const dashboard = await getDashboardById(dashboardId);
|
if (!dashboard) {
|
||||||
const reports = await getReportsByDashboardId(dashboardId);
|
return notFound();
|
||||||
if (userId && dashboard) {
|
|
||||||
await createRecentDashboard({
|
|
||||||
userId,
|
|
||||||
organizationId,
|
|
||||||
projectId,
|
|
||||||
dashboardId,
|
|
||||||
});
|
|
||||||
revalidateTag(`recentDashboards__${userId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title={dashboard.name}>
|
<PageLayout title={dashboard.name} organizationSlug={organizationId}>
|
||||||
<ListReports reports={reports} />
|
<ListReports reports={reports} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,13 +4,9 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import { PlusIcon } from 'lucide-react';
|
import { PlusIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { StickyBelowHeader } from '../../../layout-sticky-below-header';
|
import { StickyBelowHeader } from '../layout-sticky-below-header';
|
||||||
|
|
||||||
interface HeaderDashboardsProps {
|
export function HeaderDashboards() {
|
||||||
projectId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeaderDashboards({ projectId }: HeaderDashboardsProps) {
|
|
||||||
return (
|
return (
|
||||||
<StickyBelowHeader>
|
<StickyBelowHeader>
|
||||||
<div className="p-4 flex justify-between items-center">
|
<div className="p-4 flex justify-between items-center">
|
||||||
@@ -18,9 +14,7 @@ export function HeaderDashboards({ projectId }: HeaderDashboardsProps) {
|
|||||||
<Button
|
<Button
|
||||||
icon={PlusIcon}
|
icon={PlusIcon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
pushModal('AddDashboard', {
|
pushModal('AddDashboard');
|
||||||
projectId,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="max-sm:hidden">Create dashboard</span>
|
<span className="max-sm:hidden">Create dashboard</span>
|
||||||
|
|||||||
@@ -2,17 +2,19 @@
|
|||||||
|
|
||||||
import { api, handleErrorToastOptions } from '@/app/_trpc/client';
|
import { api, handleErrorToastOptions } from '@/app/_trpc/client';
|
||||||
import { Card, CardActions, CardActionsItem } from '@/components/Card';
|
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 { ToastAction } from '@/components/ui/toast';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
import { toast } from '@/components/ui/use-toast';
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import type { getDashboardsByProjectId } from '@/server/services/dashboard.service';
|
import type { IServiceDashboards } from '@/server/services/dashboard.service';
|
||||||
import { Pencil, Trash } from 'lucide-react';
|
import { LayoutPanelTopIcon, Pencil, PlusIcon, Trash } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
interface ListDashboardsProps {
|
interface ListDashboardsProps {
|
||||||
dashboards: Awaited<ReturnType<typeof getDashboardsByProjectId>>;
|
dashboards: IServiceDashboards;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListDashboards({ dashboards }: ListDashboardsProps) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid sm:grid-cols-2 gap-4 p-4">
|
<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"
|
className="block p-4 flex flex-col"
|
||||||
>
|
>
|
||||||
<span className="font-medium">{item.name}</span>
|
<span className="font-medium">{item.name}</span>
|
||||||
<span className="text-muted-foreground text-sm">
|
|
||||||
{item.project.name}
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { getDashboardsByProjectId } from '@/server/services/dashboard.service';
|
||||||
|
|
||||||
import { HeaderDashboards } from './header-dashboards';
|
import { HeaderDashboards } from './header-dashboards';
|
||||||
@@ -7,15 +8,21 @@ import { ListDashboards } from './list-dashboards';
|
|||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
organizationId: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({ params: { projectId } }: PageProps) {
|
export default async function Page({
|
||||||
const dashboards = await getDashboardsByProjectId(projectId);
|
params: { projectId, organizationId },
|
||||||
|
}: PageProps) {
|
||||||
|
const [dashboards] = await Promise.all([
|
||||||
|
getDashboardsByProjectId(projectId),
|
||||||
|
await getExists(organizationId, projectId),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="Dashboards">
|
<PageLayout title="Dashboards" organizationSlug={organizationId}>
|
||||||
<HeaderDashboards projectId={projectId} />
|
{dashboards.length > 0 && <HeaderDashboards />}
|
||||||
<ListDashboards dashboards={dashboards} />
|
<ListDashboards dashboards={dashboards} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { api } from '@/app/_trpc/client';
|
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 { Pagination, usePagination } from '@/components/Pagination';
|
||||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||||
|
import { GanttChartIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { EventListItem } from './event-list-item';
|
import { EventListItem } from './event-list-item';
|
||||||
|
|
||||||
@@ -47,6 +49,16 @@ export function ListEvents({ projectId }: ListEventsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</StickyBelowHeader>
|
</StickyBelowHeader>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
{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">
|
<div className="flex flex-col gap-4">
|
||||||
{events.map((item) => (
|
{events.map((item) => (
|
||||||
<EventListItem key={item.id} {...item} />
|
<EventListItem key={item.id} {...item} />
|
||||||
@@ -55,6 +67,8 @@ export function ListEvents({ projectId }: ListEventsProps) {
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Pagination {...pagination} />
|
<Pagination {...pagination} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
import { ListEvents } from './list-events';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: {
|
||||||
projectId: string;
|
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 (
|
return (
|
||||||
<PageLayout title="Events">
|
<PageLayout title="Events" organizationSlug={organizationId}>
|
||||||
<ListEvents projectId={projectId} />
|
<ListEvents projectId={projectId} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
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 { cn } from '@/utils/cn';
|
||||||
|
import { useUser } from '@clerk/nextjs';
|
||||||
import {
|
import {
|
||||||
BuildingIcon,
|
BuildingIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
@@ -24,49 +26,56 @@ function LinkWithIcon({
|
|||||||
icon: Icon,
|
icon: Icon,
|
||||||
label,
|
label,
|
||||||
active: overrideActive,
|
active: overrideActive,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
href: string;
|
href: string;
|
||||||
icon: React.ElementType<LucideProps>;
|
icon: React.ElementType<LucideProps>;
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const active = overrideActive || href === pathname;
|
const active = overrideActive || href === pathname;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
className={cn(
|
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',
|
'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-600 text-white'
|
active && 'bg-blue-50',
|
||||||
|
className
|
||||||
)}
|
)}
|
||||||
href={href}
|
href={href}
|
||||||
>
|
>
|
||||||
<Icon size={20} />
|
<Icon size={20} />
|
||||||
<div className="flex-1">{label}</div>
|
<div className="flex-1">{label}</div>
|
||||||
<DotIcon
|
|
||||||
size={20}
|
|
||||||
className={cn(
|
|
||||||
'transition-opacity',
|
|
||||||
active ? 'opacity-100' : 'opacity-0'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutMenuProps {
|
interface LayoutMenuProps {
|
||||||
recentDashboards: IServiceRecentDashboards;
|
dashboards: IServiceDashboards;
|
||||||
fallbackProjectId: string | null;
|
|
||||||
}
|
}
|
||||||
export default function LayoutMenu({
|
export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
||||||
recentDashboards,
|
const { user } = useUser();
|
||||||
fallbackProjectId,
|
|
||||||
}: LayoutMenuProps) {
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useAppParams();
|
const params = useAppParams();
|
||||||
const projectId =
|
const hasProjectId =
|
||||||
!params.projectId || params.projectId === 'undefined'
|
params.projectId &&
|
||||||
? fallbackProjectId
|
params.projectId !== 'null' &&
|
||||||
: params.projectId;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -93,51 +102,52 @@ export default function LayoutMenu({
|
|||||||
<LinkWithIcon
|
<LinkWithIcon
|
||||||
icon={CogIcon}
|
icon={CogIcon}
|
||||||
label="Settings"
|
label="Settings"
|
||||||
href={`/${params.organizationId}/settings/organization`}
|
href={`/${params.organizationId}/${projectId}/settings/organization`}
|
||||||
/>
|
/>
|
||||||
{pathname?.includes('/settings/') && (
|
{pathname?.includes('/settings/') && (
|
||||||
<div className="pl-7">
|
<div className="pl-7 flex flex-col gap-1">
|
||||||
<LinkWithIcon
|
<LinkWithIcon
|
||||||
icon={BuildingIcon}
|
icon={BuildingIcon}
|
||||||
label="Organization"
|
label="Organization"
|
||||||
href={`/${params.organizationId}/settings/organization`}
|
href={`/${params.organizationId}/${projectId}/settings/organization`}
|
||||||
/>
|
/>
|
||||||
<LinkWithIcon
|
<LinkWithIcon
|
||||||
icon={WarehouseIcon}
|
icon={WarehouseIcon}
|
||||||
label="Projects"
|
label="Projects"
|
||||||
href={`/${params.organizationId}/settings/projects`}
|
href={`/${params.organizationId}/${projectId}/settings/projects`}
|
||||||
/>
|
/>
|
||||||
<LinkWithIcon
|
<LinkWithIcon
|
||||||
icon={KeySquareIcon}
|
icon={KeySquareIcon}
|
||||||
label="Clients"
|
label="Clients"
|
||||||
href={`/${params.organizationId}/settings/clients`}
|
href={`/${params.organizationId}/${projectId}/settings/clients`}
|
||||||
/>
|
/>
|
||||||
<LinkWithIcon
|
<LinkWithIcon
|
||||||
icon={UserIcon}
|
icon={UserIcon}
|
||||||
label="Profile (yours)"
|
label="Profile (yours)"
|
||||||
href={`/${params.organizationId}/settings/profile`}
|
href={`/${params.organizationId}/${projectId}/settings/profile`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{recentDashboards.length > 0 && (
|
{dashboards.length > 0 && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<div className="font-medium mb-2">Recent dashboards</div>
|
<div className="font-medium mb-2 text-sm">Your dashboards</div>
|
||||||
{recentDashboards.map((item) => (
|
<div className="flex flex-col gap-2">
|
||||||
|
{dashboards.map((item) => (
|
||||||
<LinkWithIcon
|
<LinkWithIcon
|
||||||
|
className="py-1"
|
||||||
key={item.id}
|
key={item.id}
|
||||||
icon={LayoutPanelTopIcon}
|
icon={LayoutPanelTopIcon}
|
||||||
label={
|
label={
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span>{item.dashboard.name}</span>
|
<span>{item.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs">{item.project.name}</span>
|
||||||
{item.project.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
href={`/${item.organization_slug}/${item.project_id}/${item.dashboard_id}`}
|
href={`/${item.organization_slug}/${item.project_id}/dashboards/${item.id}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Logo } from '@/components/Logo';
|
import { Logo } from '@/components/Logo';
|
||||||
|
import type { IServiceDashboards } from '@/server/services/dashboard.service';
|
||||||
import type { IServiceOrganization } from '@/server/services/organization.service';
|
import type { IServiceOrganization } from '@/server/services/organization.service';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { Rotate as Hamburger } from 'hamburger-react';
|
import { Rotate as Hamburger } from 'hamburger-react';
|
||||||
@@ -13,14 +14,19 @@ import LayoutOrganizationSelector from './layout-organization-selector';
|
|||||||
|
|
||||||
interface LayoutSidebarProps {
|
interface LayoutSidebarProps {
|
||||||
organizations: IServiceOrganization[];
|
organizations: IServiceOrganization[];
|
||||||
|
dashboards: IServiceDashboards;
|
||||||
}
|
}
|
||||||
export function LayoutSidebar({ organizations }: LayoutSidebarProps) {
|
export function LayoutSidebar({
|
||||||
|
organizations,
|
||||||
|
dashboards,
|
||||||
|
}: LayoutSidebarProps) {
|
||||||
const [active, setActive] = useState(false);
|
const [active, setActive] = useState(false);
|
||||||
const fallbackProjectId = null;
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActive(false);
|
setActive(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -48,10 +54,7 @@ export function LayoutSidebar({ organizations }: LayoutSidebarProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col p-4 gap-2 flex-grow overflow-auto">
|
<div className="flex flex-col p-4 gap-2 flex-grow overflow-auto">
|
||||||
<LayoutMenu
|
<LayoutMenu dashboards={dashboards} />
|
||||||
recentDashboards={[]}
|
|
||||||
fallbackProjectId={fallbackProjectId}
|
|
||||||
/>
|
|
||||||
{/* Placeholder for LayoutOrganizationSelector */}
|
{/* Placeholder for LayoutOrganizationSelector */}
|
||||||
<div className="h-16 block shrink-0"></div>
|
<div className="h-16 block shrink-0"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ import { cn } from '@/utils/cn';
|
|||||||
import { Eye, FilterIcon, Globe2Icon, LockIcon, X } from 'lucide-react';
|
import { Eye, FilterIcon, Globe2Icon, LockIcon, X } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { StickyBelowHeader } from '../../layout-sticky-below-header';
|
import { StickyBelowHeader } from './layout-sticky-below-header';
|
||||||
|
|
||||||
export default function OverviewMetrics() {
|
export default function OverviewMetrics() {
|
||||||
const { previous, range, setRange, interval, metric, setMetric, filters } =
|
const { previous, range, setRange, interval, metric, setMetric, filters } =
|
||||||
|
|||||||
@@ -5,16 +5,20 @@ import LayoutProjectSelector from './layout-project-selector';
|
|||||||
interface PageLayoutProps {
|
interface PageLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
|
organizationSlug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function PageLayout({ children, title }: PageLayoutProps) {
|
export default async function PageLayout({
|
||||||
const projects = await getCurrentProjects();
|
children,
|
||||||
|
title,
|
||||||
|
organizationSlug,
|
||||||
|
}: PageLayoutProps) {
|
||||||
|
const projects = await getCurrentProjects(organizationSlug);
|
||||||
|
|
||||||
return (
|
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="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>
|
<div className="text-xl font-medium">{title}</div>
|
||||||
|
|
||||||
{projects.length > 0 && <LayoutProjectSelector projects={projects} />}
|
{projects.length > 0 && <LayoutProjectSelector projects={projects} />}
|
||||||
</div>
|
</div>
|
||||||
<div>{children}</div>
|
<div>{children}</div>
|
||||||
@@ -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';
|
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 (
|
return (
|
||||||
<PageLayout title="Overview">
|
<PageLayout title="Overview" organizationSlug={organizationId}>
|
||||||
<OverviewMetrics />
|
<OverviewMetrics />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { ListProperties } from '@/components/events/ListProperties';
|
||||||
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
|
||||||
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
||||||
|
import { getExists } from '@/server/pageExists';
|
||||||
import {
|
import {
|
||||||
getProfileById,
|
getProfileById,
|
||||||
getProfilesByExternalId,
|
getProfilesByExternalId,
|
||||||
@@ -15,18 +16,23 @@ interface PageProps {
|
|||||||
params: {
|
params: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
|
organizationId: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({
|
export default async function Page({
|
||||||
params: { projectId, profileId },
|
params: { projectId, profileId, organizationId },
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
const profile = await getProfileById(profileId);
|
const [profile] = await Promise.all([
|
||||||
|
getProfileById(profileId),
|
||||||
|
getExists(organizationId, projectId),
|
||||||
|
]);
|
||||||
const profiles = (
|
const profiles = (
|
||||||
await getProfilesByExternalId(profile.external_id, profile.project_id)
|
await getProfilesByExternalId(profile.external_id, profile.project_id)
|
||||||
).filter((item) => item.id !== profile.id);
|
).filter((item) => item.id !== profile.id);
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
|
organizationSlug={organizationId}
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
|
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { api } from '@/app/_trpc/client';
|
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 { Pagination, usePagination } from '@/components/Pagination';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { UsersIcon } from 'lucide-react';
|
||||||
import { useQueryState } from 'nuqs';
|
import { useQueryState } from 'nuqs';
|
||||||
|
|
||||||
import { ProfileListItem } from './profile-list-item';
|
import { ProfileListItem } from './profile-list-item';
|
||||||
@@ -16,7 +18,7 @@ interface ListProfilesProps {
|
|||||||
export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
|
export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
|
||||||
const [query, setQuery] = useQueryState('q');
|
const [query, setQuery] = useQueryState('q');
|
||||||
const pagination = usePagination();
|
const pagination = usePagination();
|
||||||
const eventsQuery = api.profile.list.useQuery(
|
const profilesQuery = api.profile.list.useQuery(
|
||||||
{
|
{
|
||||||
projectId,
|
projectId,
|
||||||
query,
|
query,
|
||||||
@@ -26,7 +28,7 @@ export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
|
|||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const profiles = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
|
const profiles = useMemo(() => profilesQuery.data ?? [], [profilesQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -38,6 +40,18 @@ export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
|
|||||||
/>
|
/>
|
||||||
</StickyBelowHeader>
|
</StickyBelowHeader>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
{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">
|
<div className="flex flex-col gap-4">
|
||||||
{profiles.map((item) => (
|
{profiles.map((item) => (
|
||||||
<ProfileListItem key={item.id} {...item} />
|
<ProfileListItem key={item.id} {...item} />
|
||||||
@@ -46,6 +60,8 @@ export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Pagination {...pagination} />
|
<Pagination {...pagination} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
import { ListProfiles } from './list-profiles';
|
||||||
|
|
||||||
@@ -8,11 +9,13 @@ interface PageProps {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export default function Page({
|
export default async function Page({
|
||||||
params: { organizationId, projectId },
|
params: { organizationId, projectId },
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
|
await getExists(organizationId, projectId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="Events">
|
<PageLayout title="Events" organizationSlug={organizationId}>
|
||||||
<ListProfiles projectId={projectId} organizationId={organizationId} />
|
<ListProfiles projectId={projectId} organizationId={organizationId} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { getReportById } from '@/server/services/reports.service';
|
||||||
import { Pencil } from 'lucide-react';
|
import { Pencil } from 'lucide-react';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import ReportEditor from '../report-editor';
|
import ReportEditor from '../report-editor';
|
||||||
|
|
||||||
@@ -8,13 +11,25 @@ interface PageProps {
|
|||||||
params: {
|
params: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
reportId: string;
|
reportId: string;
|
||||||
|
organizationId: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({ params: { reportId } }: PageProps) {
|
export default async function Page({
|
||||||
const report = await getReportById(reportId);
|
params: { reportId, organizationId, projectId },
|
||||||
|
}: PageProps) {
|
||||||
|
const [report] = await Promise.all([
|
||||||
|
getReportById(reportId),
|
||||||
|
getExists(organizationId, projectId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
|
organizationSlug={organizationId}
|
||||||
title={
|
title={
|
||||||
<div className="flex gap-2 items-center cursor-pointer">
|
<div className="flex gap-2 items-center cursor-pointer">
|
||||||
{report.name}
|
{report.name}
|
||||||
|
|||||||
@@ -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 { Pencil } from 'lucide-react';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import ReportEditor from './report-editor';
|
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 (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
|
organizationSlug={organizationId}
|
||||||
title={
|
title={
|
||||||
<div className="flex gap-2 items-center cursor-pointer">
|
<div className="flex gap-2 items-center cursor-pointer">
|
||||||
Unnamed report
|
Unnamed report
|
||||||
@@ -20,7 +28,7 @@ export default function Page({ params: { organizationId } }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ReportEditor reportId={null} />
|
<ReportEditor report={null} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
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 { Chart } from '@/components/report/chart';
|
||||||
import { ReportChartType } from '@/components/report/ReportChartType';
|
import { ReportChartType } from '@/components/report/ReportChartType';
|
||||||
import { ReportInterval } from '@/components/report/ReportInterval';
|
import { ReportInterval } from '@/components/report/ReportInterval';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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 { columns } from '@/components/clients/table';
|
||||||
import { DataTable } from '@/components/DataTable';
|
import { DataTable } from '@/components/DataTable';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -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 { getClientsByOrganizationId } from '@/server/services/clients.service';
|
||||||
|
|
||||||
import ListClients from './list-clients';
|
import ListClients from './list-clients';
|
||||||
@@ -10,10 +11,11 @@ interface PageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||||
|
await getExists(organizationId);
|
||||||
const clients = await getClientsByOrganizationId(organizationId);
|
const clients = await getClientsByOrganizationId(organizationId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="Clients">
|
<PageLayout title="Clients" organizationSlug={organizationId}>
|
||||||
<ListClients clients={clients} />
|
<ListClients clients={clients} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
||||||
import { signOut } from 'next-auth/react';
|
import { SignOutButton } from '@clerk/nextjs';
|
||||||
|
|
||||||
export function Logout() {
|
export function Logout() {
|
||||||
return (
|
return (
|
||||||
@@ -14,14 +13,7 @@ export function Logout() {
|
|||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Sometime's you need to go. See you next time
|
Sometime's you need to go. See you next time
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<SignOutButton />
|
||||||
variant={'destructive'}
|
|
||||||
onClick={() => {
|
|
||||||
signOut();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Logout 🤨
|
|
||||||
</Button>
|
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
);
|
);
|
||||||
@@ -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 { getUserById } from '@/server/services/user.service';
|
||||||
import { auth } from '@clerk/nextjs';
|
import { auth } from '@clerk/nextjs';
|
||||||
|
|
||||||
import EditProfile from './edit-profile';
|
import EditProfile from './edit-profile';
|
||||||
import { Logout } from './logout';
|
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();
|
const { userId } = auth();
|
||||||
|
await getExists(organizationId);
|
||||||
const profile = await getUserById(userId!);
|
const profile = await getUserById(userId!);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title={profile.lastName}>
|
<PageLayout title={profile.lastName} organizationSlug={organizationId}>
|
||||||
<div className="p-4 flex flex-col gap-4">
|
<div className="p-4 flex flex-col gap-4">
|
||||||
<EditProfile profile={profile} />
|
<EditProfile profile={profile} />
|
||||||
<Logout />
|
<Logout />
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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 { DataTable } from '@/components/DataTable';
|
||||||
import { columns } from '@/components/projects/table';
|
import { columns } from '@/components/projects/table';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -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 { getProjectsByOrganizationSlug } from '@/server/services/project.service';
|
||||||
|
|
||||||
import ListProjects from './list-projects';
|
import ListProjects from './list-projects';
|
||||||
@@ -10,10 +11,11 @@ interface PageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||||
|
await getExists(organizationId);
|
||||||
const projects = await getProjectsByOrganizationSlug(organizationId);
|
const projects = await getProjectsByOrganizationSlug(organizationId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="Projects">
|
<PageLayout title="Projects" organizationSlug={organizationId}>
|
||||||
<ListProjects projects={projects} />
|
<ListProjects projects={projects} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { getOrganizationBySlug } from '@/server/services/organization.service';
|
||||||
import { getProjectWithMostEvents } from '@/server/services/project.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 {
|
interface PageProps {
|
||||||
params: {
|
params: {
|
||||||
@@ -10,14 +11,21 @@ interface PageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({ params: { organizationId } }: 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) {
|
if (project) {
|
||||||
return redirect(`/${organizationId}/${project.id}`);
|
return redirect(`/${organizationId}/${project.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="Projects">
|
<PageLayout title="Projects" organizationSlug={organizationId}>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h1>Create your first project</h1>
|
<h1>Create your first project</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { getOrganizations } from '@/server/services/organization.service';
|
import { getCurrentOrganizations } from '@/server/services/organization.service';
|
||||||
import { CreateOrganization } from '@clerk/nextjs';
|
import { CreateOrganization } from '@clerk/nextjs';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const organizations = await getOrganizations();
|
const organizations = await getCurrentOrganizations();
|
||||||
|
|
||||||
if (organizations.length === 0) {
|
if (organizations.length === 0) {
|
||||||
return <CreateOrganization />;
|
return <CreateOrganization />;
|
||||||
|
|||||||
27
apps/web/src/components/FullPageEmptyState.tsx
Normal file
27
apps/web/src/components/FullPageEmptyState.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface FullPageEmptyStateProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FullPageEmptyState({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: FullPageEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 flex items-center justify-center">
|
||||||
|
<div className="p-8 w-full max-w-xl flex flex-col items-center justify-center">
|
||||||
|
<div className="w-24 h-24 bg-white shadow-sm rounded-full flex justify-center items-center mb-6">
|
||||||
|
<Icon size={60} strokeWidth={1} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-xl font-medium mb-1">{title}</h1>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,14 +26,14 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-10 px-4 py-2',
|
default: 'h-10 px-4 py-2',
|
||||||
sm: 'h-9 rounded-md px-3',
|
sm: 'h-8 rounded-md px-2',
|
||||||
lg: 'h-11 rounded-md px-8',
|
lg: 'h-11 rounded-md px-8',
|
||||||
icon: 'h-10 w-10',
|
icon: 'h-8 w-8',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: 'default',
|
||||||
size: 'default',
|
size: 'sm',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,11 +2,19 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Command, CommandGroup, CommandItem } from '@/components/ui/command';
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
} from '@/components/ui/command';
|
||||||
import { useOnClickOutside } from 'usehooks-ts';
|
import { useOnClickOutside } from 'usehooks-ts';
|
||||||
|
|
||||||
|
import { Button } from './button';
|
||||||
import { Checkbox } from './checkbox';
|
import { Checkbox } from './checkbox';
|
||||||
import { Input } from './input';
|
import { Input } from './input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||||
|
|
||||||
type IValue = any;
|
type IValue = any;
|
||||||
type IItem = Record<'value' | 'label', IValue>;
|
type IItem = Record<'value' | 'label', IValue>;
|
||||||
@@ -43,7 +51,6 @@ export function ComboboxAdvanced({
|
|||||||
const checked = !!value.find((s) => s === item.value);
|
const checked = !!value.find((s) => s === item.value);
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={String(item.value)}
|
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -71,12 +78,9 @@ export function ComboboxAdvanced({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Command className="overflow-visible bg-white" ref={ref}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<button
|
<PopoverTrigger asChild>
|
||||||
type="button"
|
<Button variant={'outline'} onClick={() => setOpen((prev) => !prev)}>
|
||||||
className="group border border-input px-3 py-2 text-sm ring-offset-background rounded-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
|
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
|
||||||
>
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{value.length === 0 && placeholder}
|
{value.length === 0 && placeholder}
|
||||||
{value.slice(0, 2).map((value) => {
|
{value.slice(0, 2).map((value) => {
|
||||||
@@ -84,28 +88,20 @@ export function ComboboxAdvanced({
|
|||||||
value,
|
value,
|
||||||
label: value,
|
label: value,
|
||||||
};
|
};
|
||||||
return (
|
return <Badge key={String(item.value)}>{item.label}</Badge>;
|
||||||
<Badge key={String(item.value)} variant="secondary">
|
|
||||||
{item.label}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
{value.length > 2 && (
|
{value.length > 2 && <Badge>+{value.length - 2} more</Badge>}
|
||||||
<Badge variant="secondary">+{value.length - 2} more</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Button>
|
||||||
{open && (
|
</PopoverTrigger>
|
||||||
<div className="relative top-2">
|
<PopoverContent className="w-full max-w-md p-0" align="start">
|
||||||
<div className="max-h-80 min-w-[300px] absolute w-full z-10 top-0 rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
|
<Command>
|
||||||
<CommandGroup className="max-h-80 overflow-auto">
|
<CommandInput
|
||||||
<div className="p-1 mb-2">
|
placeholder="Search"
|
||||||
<Input
|
|
||||||
placeholder="Type to search"
|
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(event) => setInputValue(event.target.value)}
|
onValueChange={setInputValue}
|
||||||
/>
|
/>
|
||||||
</div>
|
<CommandGroup>
|
||||||
{inputValue === ''
|
{inputValue === ''
|
||||||
? value.map(renderUnknownItem)
|
? value.map(renderUnknownItem)
|
||||||
: renderItem({
|
: renderItem({
|
||||||
@@ -114,9 +110,8 @@ export function ComboboxAdvanced({
|
|||||||
})}
|
})}
|
||||||
{selectables.map(renderItem)}
|
{selectables.map(renderItem)}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Command>
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Command, CommandGroup, CommandItem } from '@/components/ui/command';
|
|
||||||
import { Command as CommandPrimitive } from 'cmdk';
|
|
||||||
import { X } from 'lucide-react';
|
|
||||||
|
|
||||||
type Item = Record<'value' | 'label', string>;
|
|
||||||
|
|
||||||
interface ComboboxMultiProps {
|
|
||||||
selected: Item[];
|
|
||||||
setSelected: React.Dispatch<React.SetStateAction<Item[]>>;
|
|
||||||
items: Item[];
|
|
||||||
placeholder: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ComboboxMulti({
|
|
||||||
items,
|
|
||||||
selected,
|
|
||||||
setSelected,
|
|
||||||
placeholder,
|
|
||||||
...props
|
|
||||||
}: ComboboxMultiProps) {
|
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
const [open, setOpen] = React.useState(false);
|
|
||||||
const [inputValue, setInputValue] = React.useState('');
|
|
||||||
|
|
||||||
const handleUnselect = React.useCallback((item: Item) => {
|
|
||||||
setSelected((prev) => prev.filter((s) => s.value !== item.value));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleKeyDown = React.useCallback(
|
|
||||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
const input = inputRef.current;
|
|
||||||
if (input) {
|
|
||||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
||||||
if (input.value === '') {
|
|
||||||
setSelected((prev) => {
|
|
||||||
const newSelected = [...prev];
|
|
||||||
newSelected.pop();
|
|
||||||
return newSelected;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// This is not a default behaviour of the <input /> field
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
input.blur();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectables = items.filter(
|
|
||||||
(item) => !selected.find((s) => s.value === item.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Command onKeyDown={handleKeyDown} className="overflow-visible bg-white">
|
|
||||||
<div className="group border border-input px-3 py-2 text-sm ring-offset-background rounded-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
|
||||||
{selected.map((item) => {
|
|
||||||
return (
|
|
||||||
<Badge key={item.value} variant="secondary">
|
|
||||||
{item.label}
|
|
||||||
<button
|
|
||||||
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
handleUnselect(item);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
onClick={() => handleUnselect(item)}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{/* Avoid having the "Search" Icon */}
|
|
||||||
<CommandPrimitive.Input
|
|
||||||
ref={inputRef}
|
|
||||||
value={inputValue}
|
|
||||||
onValueChange={setInputValue}
|
|
||||||
onBlur={() => setOpen(false)}
|
|
||||||
onFocus={() => setOpen(true)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="ml-2 bg-transparent outline-none placeholder:text-muted-foreground flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative mt-2">
|
|
||||||
{open && selectables.length > 0 ? (
|
|
||||||
<div className="absolute w-full z-10 top-0 rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
|
|
||||||
<CommandGroup className="h-full overflow-auto">
|
|
||||||
{selectables.map((item) => {
|
|
||||||
return (
|
|
||||||
<CommandItem
|
|
||||||
key={item.value}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
onSelect={(value) => {
|
|
||||||
setInputValue('');
|
|
||||||
setSelected((prev) => [...prev, item]);
|
|
||||||
}}
|
|
||||||
className={'cursor-pointer'}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</CommandItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandGroup>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Command>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -55,7 +55,6 @@ export function Combobox<T extends string>({
|
|||||||
searchable,
|
searchable,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
size,
|
size,
|
||||||
label,
|
|
||||||
}: ComboboxProps<T>) {
|
}: ComboboxProps<T>) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [search, setSearch] = React.useState('');
|
const [search, setSearch] = React.useState('');
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
!!error && 'border-destructive'
|
!!error && 'border-destructive'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ButtonContainer } from '@/components/ButtonContainer';
|
|||||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
import { toast } from '@/components/ui/use-toast';
|
||||||
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -13,17 +14,14 @@ import { z } from 'zod';
|
|||||||
import { popModal } from '.';
|
import { popModal } from '.';
|
||||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||||
|
|
||||||
interface AddDashboardProps {
|
|
||||||
projectId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validator = z.object({
|
const validator = z.object({
|
||||||
name: z.string().min(1, 'Required'),
|
name: z.string().min(1, 'Required'),
|
||||||
});
|
});
|
||||||
|
|
||||||
type IForm = z.infer<typeof validator>;
|
type IForm = z.infer<typeof validator>;
|
||||||
|
|
||||||
export default function AddDashboard({ projectId }: AddDashboardProps) {
|
export default function AddDashboard() {
|
||||||
|
const { projectId, organizationId: organizationSlug } = useAppParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { register, handleSubmit, formState } = useForm<IForm>({
|
const { register, handleSubmit, formState } = useForm<IForm>({
|
||||||
@@ -54,6 +52,7 @@ export default function AddDashboard({ projectId }: AddDashboardProps) {
|
|||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
name,
|
name,
|
||||||
projectId,
|
projectId,
|
||||||
|
organizationSlug,
|
||||||
});
|
});
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||||
import { db } from '@/server/db';
|
import { db, getId } from '@/server/db';
|
||||||
import { PrismaError } from 'prisma-error-enum';
|
import { PrismaError } from 'prisma-error-enum';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -61,12 +61,15 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
z.object({
|
z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
|
organizationSlug: z.string(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input: { projectId, name } }) => {
|
.mutation(async ({ input: { organizationSlug, projectId, name } }) => {
|
||||||
return db.dashboard.create({
|
return db.dashboard.create({
|
||||||
data: {
|
data: {
|
||||||
|
id: await getId('dashboard', name),
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
|
organization_slug: organizationSlug,
|
||||||
name,
|
name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||||
import {
|
import { getOrganizationBySlug } from '@/server/services/organization.service';
|
||||||
getCurrentOrganization,
|
import { zInviteUser } from '@/utils/validation';
|
||||||
getOrganizationBySlug,
|
|
||||||
} from '@/server/services/organization.service';
|
|
||||||
import { clerkClient } from '@clerk/nextjs';
|
import { clerkClient } from '@clerk/nextjs';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -10,7 +8,7 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
list: protectedProcedure.query(() => {
|
list: protectedProcedure.query(() => {
|
||||||
return clerkClient.organizations.getOrganizationList();
|
return clerkClient.organizations.getOrganizationList();
|
||||||
}),
|
}),
|
||||||
first: protectedProcedure.query(() => getCurrentOrganization()),
|
// first: protectedProcedure.query(() => getCurrentOrganization()),
|
||||||
get: protectedProcedure
|
get: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -32,4 +30,20 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
inviteUser: protectedProcedure
|
||||||
|
.input(zInviteUser)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const organization = await getOrganizationBySlug(input.organizationSlug);
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw new Error('Organization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return clerkClient.organizations.createOrganizationInvitation({
|
||||||
|
organizationId: organization.id,
|
||||||
|
emailAddress: input.email,
|
||||||
|
role: input.role,
|
||||||
|
inviterUserId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,19 +28,4 @@ export const userRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.then(transformUser);
|
.then(transformUser);
|
||||||
}),
|
}),
|
||||||
invite: protectedProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
email: z.string().email(),
|
|
||||||
organizationId: z.string(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
await db.invite.create({
|
|
||||||
data: {
|
|
||||||
organization_id: input.organizationId,
|
|
||||||
email: input.email,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import { db } from '@mixan/db';
|
|||||||
|
|
||||||
export { db } from '@mixan/db';
|
export { db } from '@mixan/db';
|
||||||
|
|
||||||
export async function getId(
|
export async function getId(tableName: 'project' | 'dashboard', name: string) {
|
||||||
tableName: 'project' | 'organization' | 'dashboard',
|
|
||||||
name: string
|
|
||||||
) {
|
|
||||||
const newId = slug(name);
|
const newId = slug(name);
|
||||||
if (!db[tableName]) {
|
if (!db[tableName]) {
|
||||||
throw new Error('Table does not exists');
|
throw new Error('Table does not exists');
|
||||||
|
|||||||
25
apps/web/src/server/pageExists.tsx
Normal file
25
apps/web/src/server/pageExists.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getOrganizationBySlug } from './services/organization.service';
|
||||||
|
import { getProjectById } from './services/project.service';
|
||||||
|
|
||||||
|
export async function getExists(organizationSlug: string, projectId?: string) {
|
||||||
|
const promises: Promise<any>[] = [getOrganizationBySlug(organizationSlug)];
|
||||||
|
|
||||||
|
if (projectId) {
|
||||||
|
promises.push(getProjectById(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
if (results.some((res) => !res)) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
organization: results[0] as Awaited<
|
||||||
|
ReturnType<typeof getOrganizationBySlug>
|
||||||
|
>,
|
||||||
|
project: results[1] as Awaited<ReturnType<typeof getProjectById>>,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,20 +1,40 @@
|
|||||||
import { unstable_cache } from 'next/cache';
|
|
||||||
|
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
|
|
||||||
export type IServiceRecentDashboards = Awaited<
|
|
||||||
ReturnType<typeof getRecentDashboardsByUserId>
|
|
||||||
>;
|
|
||||||
export type IServiceDashboard = Awaited<ReturnType<typeof getDashboardById>>;
|
export type IServiceDashboard = Awaited<ReturnType<typeof getDashboardById>>;
|
||||||
export type IServiceDashboardWithProject = Awaited<
|
export type IServiceDashboards = Awaited<
|
||||||
ReturnType<typeof getDashboardsByProjectId>
|
ReturnType<typeof getDashboardsByProjectId>
|
||||||
>[number];
|
>;
|
||||||
|
|
||||||
export function getDashboardById(id: string) {
|
export async function getDashboardById(id: string) {
|
||||||
return db.dashboard.findUniqueOrThrow({
|
const dashboard = await db.dashboard.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
project: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dashboard) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardsByOrganization(organizationSlug: string) {
|
||||||
|
return db.dashboard.findMany({
|
||||||
|
where: {
|
||||||
|
organization_slug: organizationSlug,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
project: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
reports: {
|
||||||
|
_count: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,59 +48,3 @@ export function getDashboardsByProjectId(projectId: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecentDashboardsByUserId(userId: string) {
|
|
||||||
const tag = `recentDashboards_${userId}`;
|
|
||||||
|
|
||||||
return unstable_cache(
|
|
||||||
async (userId: string) => {
|
|
||||||
return db.recentDashboards.findMany({
|
|
||||||
where: {
|
|
||||||
user_id: userId,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc',
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
project: true,
|
|
||||||
dashboard: true,
|
|
||||||
},
|
|
||||||
take: 5,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
tag.split('_'),
|
|
||||||
{
|
|
||||||
revalidate: 3600,
|
|
||||||
tags: [tag],
|
|
||||||
}
|
|
||||||
)(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createRecentDashboard({
|
|
||||||
organizationId,
|
|
||||||
projectId,
|
|
||||||
dashboardId,
|
|
||||||
userId,
|
|
||||||
}: {
|
|
||||||
organizationId: string;
|
|
||||||
projectId: string;
|
|
||||||
dashboardId: string;
|
|
||||||
userId: string;
|
|
||||||
}) {
|
|
||||||
await db.recentDashboards.deleteMany({
|
|
||||||
where: {
|
|
||||||
user_id: userId,
|
|
||||||
project_id: projectId,
|
|
||||||
dashboard_id: dashboardId,
|
|
||||||
organization_slug: organizationId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return db.recentDashboards.create({
|
|
||||||
data: {
|
|
||||||
user_id: userId,
|
|
||||||
organization_slug: organizationId,
|
|
||||||
project_id: projectId,
|
|
||||||
dashboard_id: dashboardId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { auth, clerkClient } from '@clerk/nextjs';
|
import { auth, clerkClient } from '@clerk/nextjs';
|
||||||
import type { Organization } from '@clerk/nextjs/dist/types/server';
|
import type {
|
||||||
|
Organization,
|
||||||
|
OrganizationInvitation,
|
||||||
|
} from '@clerk/nextjs/dist/types/server';
|
||||||
|
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
|
|
||||||
export type IServiceOrganization = Awaited<
|
export type IServiceOrganization = Awaited<
|
||||||
ReturnType<typeof getOrganizations>
|
ReturnType<typeof getCurrentOrganizations>
|
||||||
>[number];
|
>[number];
|
||||||
|
|
||||||
|
export type IServiceInvites = Awaited<ReturnType<typeof getInvites>>;
|
||||||
|
|
||||||
function transformOrganization(org: Organization) {
|
function transformOrganization(org: Organization) {
|
||||||
return {
|
return {
|
||||||
id: org.id,
|
id: org.id,
|
||||||
@@ -15,28 +20,19 @@ function transformOrganization(org: Organization) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrganizations() {
|
export async function getCurrentOrganizations() {
|
||||||
const orgs = await clerkClient.organizations.getOrganizationList();
|
|
||||||
return orgs.map(transformOrganization);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCurrentOrganization() {
|
|
||||||
const session = auth();
|
const session = auth();
|
||||||
if (!session?.orgSlug) {
|
const organizations = await clerkClient.users.getOrganizationMembershipList({
|
||||||
return null;
|
userId: session.userId!,
|
||||||
}
|
|
||||||
|
|
||||||
const organization = await clerkClient.organizations.getOrganization({
|
|
||||||
slug: session.orgSlug,
|
|
||||||
});
|
});
|
||||||
|
return organizations.map((item) => transformOrganization(item.organization));
|
||||||
return transformOrganization(organization);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOrganizationBySlug(slug: string) {
|
export function getOrganizationBySlug(slug: string) {
|
||||||
return clerkClient.organizations
|
return clerkClient.organizations
|
||||||
.getOrganization({ slug })
|
.getOrganization({ slug })
|
||||||
.then(transformOrganization);
|
.then(transformOrganization)
|
||||||
|
.catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrganizationByProjectId(projectId: string) {
|
export async function getOrganizationByProjectId(projectId: string) {
|
||||||
@@ -50,3 +46,22 @@ export async function getOrganizationByProjectId(projectId: string) {
|
|||||||
slug: project.organization_slug,
|
slug: project.organization_slug,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function transformInvite(invite: OrganizationInvitation) {
|
||||||
|
return {
|
||||||
|
id: invite.id,
|
||||||
|
email: invite.emailAddress,
|
||||||
|
role: invite.role,
|
||||||
|
status: invite.status,
|
||||||
|
createdAt: invite.createdAt,
|
||||||
|
updatedAt: invite.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvites(organizationId: string) {
|
||||||
|
return await clerkClient.organizations
|
||||||
|
.getOrganizationInvitationList({
|
||||||
|
organizationId,
|
||||||
|
})
|
||||||
|
.then((invites) => invites.map(transformInvite));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { unstable_cache } from 'next/cache';
|
|
||||||
|
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { getCurrentOrganization } from './organization.service';
|
|
||||||
|
|
||||||
export type IServiceProject = Awaited<ReturnType<typeof getProjectById>>;
|
export type IServiceProject = Awaited<ReturnType<typeof getProjectById>>;
|
||||||
|
|
||||||
@@ -13,12 +10,10 @@ export function getProjectById(id: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentProjects() {
|
export async function getCurrentProjects(organizationSlug: string) {
|
||||||
const organization = await getCurrentOrganization();
|
|
||||||
if (!organization?.slug) return [];
|
|
||||||
return await db.project.findMany({
|
return await db.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
organization_slug: organization.slug,
|
organization_slug: organizationSlug,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,12 +70,16 @@ export function getReportsByDashboardId(dashboardId: string) {
|
|||||||
.then((reports) => reports.map(transformReport));
|
.then((reports) => reports.map(transformReport));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReportById(id: string) {
|
export async function getReportById(id: string) {
|
||||||
return db.report
|
const report = await db.report.findUnique({
|
||||||
.findUniqueOrThrow({
|
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
.then(transformReport);
|
|
||||||
|
if (!report) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformReport(report);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 214.3 31.8% 91.4%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 214.3 31.8% 91.4%;
|
||||||
--ring: 222.2 84% 4.9%;
|
--ring: 212.73deg 26.83% 83.92%;
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,3 +73,9 @@ export const zChartInput = z.object({
|
|||||||
startDate: z.string().nullish(),
|
startDate: z.string().nullish(),
|
||||||
endDate: z.string().nullish(),
|
endDate: z.string().nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const zInviteUser = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
organizationSlug: z.string(),
|
||||||
|
role: z.enum(['admin', 'org:member']),
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `event_failed` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `recent_dashboards` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- Added the required column `organization_slug` to the `dashboards` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "recent_dashboards" DROP CONSTRAINT "recent_dashboards_dashboard_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "recent_dashboards" DROP CONSTRAINT "recent_dashboards_project_id_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "dashboards" ADD COLUMN "organization_slug" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "event_failed";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "recent_dashboards";
|
||||||
@@ -23,7 +23,6 @@ model Project {
|
|||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
reports Report[]
|
reports Report[]
|
||||||
dashboards Dashboard[]
|
dashboards Dashboard[]
|
||||||
RecentDashboards RecentDashboards[]
|
|
||||||
|
|
||||||
@@map("projects")
|
@@map("projects")
|
||||||
}
|
}
|
||||||
@@ -67,15 +66,6 @@ model Profile {
|
|||||||
@@map("profiles")
|
@@map("profiles")
|
||||||
}
|
}
|
||||||
|
|
||||||
model EventFailed {
|
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
|
||||||
data Json
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
|
||||||
|
|
||||||
@@map("event_failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Client {
|
model Client {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
name String
|
name String
|
||||||
@@ -91,19 +81,6 @@ model Client {
|
|||||||
@@map("clients")
|
@@map("clients")
|
||||||
}
|
}
|
||||||
|
|
||||||
model RecentDashboards {
|
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
|
||||||
project_id String
|
|
||||||
project Project @relation(fields: [project_id], references: [id])
|
|
||||||
organization_slug String
|
|
||||||
dashboard_id String
|
|
||||||
dashboard Dashboard @relation(fields: [dashboard_id], references: [id])
|
|
||||||
user_id String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@map("recent_dashboards")
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Interval {
|
enum Interval {
|
||||||
hour
|
hour
|
||||||
day
|
day
|
||||||
@@ -123,13 +100,13 @@ enum ChartType {
|
|||||||
model Dashboard {
|
model Dashboard {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||||
name String
|
name String
|
||||||
|
organization_slug String
|
||||||
project_id String
|
project_id String
|
||||||
project Project @relation(fields: [project_id], references: [id])
|
project Project @relation(fields: [project_id], references: [id])
|
||||||
reports Report[]
|
reports Report[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
RecentDashboards RecentDashboards[]
|
|
||||||
|
|
||||||
@@map("dashboards")
|
@@map("dashboards")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user