refactoring and more work with clerk
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
@@ -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>
|
||||
@@ -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 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 } =
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
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's you need to go. See you next time
|
||||
</p>
|
||||
<Button
|
||||
variant={'destructive'}
|
||||
onClick={() => {
|
||||
signOut();
|
||||
}}
|
||||
>
|
||||
Logout 🤨
|
||||
</Button>
|
||||
<SignOutButton />
|
||||
</WidgetBody>
|
||||
</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 { 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 />
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 { 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>
|
||||
|
||||
@@ -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 { redirect } from 'next/navigation';
|
||||
|
||||
export default async function Page() {
|
||||
const organizations = await getOrganizations();
|
||||
const organizations = await getCurrentOrganizations();
|
||||
|
||||
if (organizations.length === 0) {
|
||||
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: {
|
||||
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',
|
||||
icon: 'h-10 w-10',
|
||||
icon: 'h-8 w-8',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
size: 'sm',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
import * as React from 'react';
|
||||
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 { Button } from './button';
|
||||
import { Checkbox } from './checkbox';
|
||||
import { Input } from './input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
|
||||
type IValue = any;
|
||||
type IItem = Record<'value' | 'label', IValue>;
|
||||
@@ -43,7 +51,6 @@ export function ComboboxAdvanced({
|
||||
const checked = !!value.find((s) => s === item.value);
|
||||
return (
|
||||
<CommandItem
|
||||
key={String(item.value)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -71,52 +78,40 @@ export function ComboboxAdvanced({
|
||||
};
|
||||
|
||||
return (
|
||||
<Command className="overflow-visible bg-white" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className="group border border-input px-3 py-2 text-sm ring-offset-background rounded-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{value.length === 0 && placeholder}
|
||||
{value.slice(0, 2).map((value) => {
|
||||
const item = items.find((item) => item.value === value) ?? {
|
||||
value,
|
||||
label: value,
|
||||
};
|
||||
return (
|
||||
<Badge key={String(item.value)} variant="secondary">
|
||||
{item.label}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{value.length > 2 && (
|
||||
<Badge variant="secondary">+{value.length - 2} more</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="relative top-2">
|
||||
<div className="max-h-80 min-w-[300px] absolute w-full z-10 top-0 rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
|
||||
<CommandGroup className="max-h-80 overflow-auto">
|
||||
<div className="p-1 mb-2">
|
||||
<Input
|
||||
placeholder="Type to search"
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{inputValue === ''
|
||||
? value.map(renderUnknownItem)
|
||||
: renderItem({
|
||||
value: inputValue,
|
||||
label: `Pick "${inputValue}"`,
|
||||
})}
|
||||
{selectables.map(renderItem)}
|
||||
</CommandGroup>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={'outline'} onClick={() => setOpen((prev) => !prev)}>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{value.length === 0 && placeholder}
|
||||
{value.slice(0, 2).map((value) => {
|
||||
const item = items.find((item) => item.value === value) ?? {
|
||||
value,
|
||||
label: value,
|
||||
};
|
||||
return <Badge key={String(item.value)}>{item.label}</Badge>;
|
||||
})}
|
||||
{value.length > 2 && <Badge>+{value.length - 2} more</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full max-w-md p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search"
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
/>
|
||||
<CommandGroup>
|
||||
{inputValue === ''
|
||||
? value.map(renderUnknownItem)
|
||||
: renderItem({
|
||||
value: inputValue,
|
||||
label: `Pick "${inputValue}"`,
|
||||
})}
|
||||
{selectables.map(renderItem)}
|
||||
</CommandGroup>
|
||||
</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,
|
||||
icon: Icon,
|
||||
size,
|
||||
label,
|
||||
}: ComboboxProps<T>) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState('');
|
||||
|
||||
@@ -13,7 +13,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
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,
|
||||
!!error && 'border-destructive'
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ButtonContainer } from '@/components/ButtonContainer';
|
||||
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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -13,17 +14,14 @@ import { z } from 'zod';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
|
||||
interface AddDashboardProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const validator = z.object({
|
||||
name: z.string().min(1, 'Required'),
|
||||
});
|
||||
|
||||
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 { register, handleSubmit, formState } = useForm<IForm>({
|
||||
@@ -54,6 +52,7 @@ export default function AddDashboard({ projectId }: AddDashboardProps) {
|
||||
mutation.mutate({
|
||||
name,
|
||||
projectId,
|
||||
organizationSlug,
|
||||
});
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { z } from 'zod';
|
||||
|
||||
@@ -61,12 +61,15 @@ export const dashboardRouter = createTRPCRouter({
|
||||
z.object({
|
||||
name: z.string(),
|
||||
projectId: z.string(),
|
||||
organizationSlug: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input: { projectId, name } }) => {
|
||||
.mutation(async ({ input: { organizationSlug, projectId, name } }) => {
|
||||
return db.dashboard.create({
|
||||
data: {
|
||||
id: await getId('dashboard', name),
|
||||
project_id: projectId,
|
||||
organization_slug: organizationSlug,
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||
import {
|
||||
getCurrentOrganization,
|
||||
getOrganizationBySlug,
|
||||
} from '@/server/services/organization.service';
|
||||
import { getOrganizationBySlug } from '@/server/services/organization.service';
|
||||
import { zInviteUser } from '@/utils/validation';
|
||||
import { clerkClient } from '@clerk/nextjs';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -10,7 +8,7 @@ export const organizationRouter = createTRPCRouter({
|
||||
list: protectedProcedure.query(() => {
|
||||
return clerkClient.organizations.getOrganizationList();
|
||||
}),
|
||||
first: protectedProcedure.query(() => getCurrentOrganization()),
|
||||
// first: protectedProcedure.query(() => getCurrentOrganization()),
|
||||
get: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -32,4 +30,20 @@ export const organizationRouter = createTRPCRouter({
|
||||
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);
|
||||
}),
|
||||
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 async function getId(
|
||||
tableName: 'project' | 'organization' | 'dashboard',
|
||||
name: string
|
||||
) {
|
||||
export async function getId(tableName: 'project' | 'dashboard', name: string) {
|
||||
const newId = slug(name);
|
||||
if (!db[tableName]) {
|
||||
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';
|
||||
|
||||
export type IServiceRecentDashboards = Awaited<
|
||||
ReturnType<typeof getRecentDashboardsByUserId>
|
||||
>;
|
||||
export type IServiceDashboard = Awaited<ReturnType<typeof getDashboardById>>;
|
||||
export type IServiceDashboardWithProject = Awaited<
|
||||
export type IServiceDashboards = Awaited<
|
||||
ReturnType<typeof getDashboardsByProjectId>
|
||||
>[number];
|
||||
>;
|
||||
|
||||
export function getDashboardById(id: string) {
|
||||
return db.dashboard.findUniqueOrThrow({
|
||||
export async function getDashboardById(id: string) {
|
||||
const dashboard = await db.dashboard.findUnique({
|
||||
where: {
|
||||
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 type { Organization } from '@clerk/nextjs/dist/types/server';
|
||||
import type {
|
||||
Organization,
|
||||
OrganizationInvitation,
|
||||
} from '@clerk/nextjs/dist/types/server';
|
||||
|
||||
import { db } from '../db';
|
||||
|
||||
export type IServiceOrganization = Awaited<
|
||||
ReturnType<typeof getOrganizations>
|
||||
ReturnType<typeof getCurrentOrganizations>
|
||||
>[number];
|
||||
|
||||
export type IServiceInvites = Awaited<ReturnType<typeof getInvites>>;
|
||||
|
||||
function transformOrganization(org: Organization) {
|
||||
return {
|
||||
id: org.id,
|
||||
@@ -15,28 +20,19 @@ function transformOrganization(org: Organization) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOrganizations() {
|
||||
const orgs = await clerkClient.organizations.getOrganizationList();
|
||||
return orgs.map(transformOrganization);
|
||||
}
|
||||
|
||||
export async function getCurrentOrganization() {
|
||||
export async function getCurrentOrganizations() {
|
||||
const session = auth();
|
||||
if (!session?.orgSlug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const organization = await clerkClient.organizations.getOrganization({
|
||||
slug: session.orgSlug,
|
||||
const organizations = await clerkClient.users.getOrganizationMembershipList({
|
||||
userId: session.userId!,
|
||||
});
|
||||
|
||||
return transformOrganization(organization);
|
||||
return organizations.map((item) => transformOrganization(item.organization));
|
||||
}
|
||||
|
||||
export function getOrganizationBySlug(slug: string) {
|
||||
return clerkClient.organizations
|
||||
.getOrganization({ slug })
|
||||
.then(transformOrganization);
|
||||
.then(transformOrganization)
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
export async function getOrganizationByProjectId(projectId: string) {
|
||||
@@ -50,3 +46,22 @@ export async function getOrganizationByProjectId(projectId: string) {
|
||||
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 { getCurrentOrganization } from './organization.service';
|
||||
|
||||
export type IServiceProject = Awaited<ReturnType<typeof getProjectById>>;
|
||||
|
||||
@@ -13,12 +10,10 @@ export function getProjectById(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCurrentProjects() {
|
||||
const organization = await getCurrentOrganization();
|
||||
if (!organization?.slug) return [];
|
||||
export async function getCurrentProjects(organizationSlug: string) {
|
||||
return await db.project.findMany({
|
||||
where: {
|
||||
organization_slug: organization.slug,
|
||||
organization_slug: organizationSlug,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,12 +70,16 @@ export function getReportsByDashboardId(dashboardId: string) {
|
||||
.then((reports) => reports.map(transformReport));
|
||||
}
|
||||
|
||||
export function getReportById(id: string) {
|
||||
return db.report
|
||||
.findUniqueOrThrow({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
.then(transformReport);
|
||||
export async function getReportById(id: string) {
|
||||
const report = await db.report.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!report) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return transformReport(report);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
--border: 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;
|
||||
}
|
||||
|
||||
@@ -73,3 +73,9 @@ export const zChartInput = z.object({
|
||||
startDate: 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";
|
||||
@@ -19,11 +19,10 @@ model Project {
|
||||
profiles Profile[]
|
||||
clients Client[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
reports Report[]
|
||||
dashboards Dashboard[]
|
||||
RecentDashboards RecentDashboards[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
reports Report[]
|
||||
dashboards Dashboard[]
|
||||
|
||||
@@map("projects")
|
||||
}
|
||||
@@ -67,15 +66,6 @@ model Profile {
|
||||
@@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 {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
name String
|
||||
@@ -91,19 +81,6 @@ model Client {
|
||||
@@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 {
|
||||
hour
|
||||
day
|
||||
@@ -121,15 +98,15 @@ enum ChartType {
|
||||
}
|
||||
|
||||
model Dashboard {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
name String
|
||||
project_id String
|
||||
project Project @relation(fields: [project_id], references: [id])
|
||||
reports Report[]
|
||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||
name String
|
||||
organization_slug String
|
||||
project_id String
|
||||
project Project @relation(fields: [project_id], references: [id])
|
||||
reports Report[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
RecentDashboards RecentDashboards[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@map("dashboards")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user