diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/list-reports.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/list-reports.tsx index acd8ecb6..12fa2c41 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/list-reports.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/list-reports.tsx @@ -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'; diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/page.tsx index 9f639d4a..759abf69 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/page.tsx @@ -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 ( - + ); diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/header-dashboards.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/header-dashboards.tsx index a717fd48..62e50b99 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/header-dashboards.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/header-dashboards.tsx @@ -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 (
@@ -18,9 +14,7 @@ export function HeaderDashboards({ projectId }: HeaderDashboardsProps) { + + ); + } + return ( <>
@@ -57,9 +74,6 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) { className="block p-4 flex flex-col" > {item.name} - - {item.project.name} -
diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/page.tsx index 07033f02..c516815b 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/page.tsx @@ -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 ( - - + + {dashboards.length > 0 && } ); diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/list-events.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/list-events.tsx index 515d7ea7..9f6df1e4 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/list-events.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/list-events.tsx @@ -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) {
-
- {events.map((item) => ( - - ))} -
-
- -
+ {events.length === 0 ? ( + + {eventFilters.length ? ( +

Could not find any events with your filter

+ ) : ( +

We have not recieved any events yet

+ )} +
+ ) : ( + <> +
+ {events.map((item) => ( + + ))} +
+
+ +
+ + )}
); diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx index 8a411603..b29ef420 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx @@ -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 ( - + ); diff --git a/apps/web/src/app/(app)/layout-menu.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-menu.tsx similarity index 50% rename from apps/web/src/app/(app)/layout-menu.tsx rename to apps/web/src/app/(app)/[organizationId]/[projectId]/layout-menu.tsx index f17d1f5d..2d37b9fc 100644 --- a/apps/web/src/app/(app)/layout-menu.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-menu.tsx @@ -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; label: React.ReactNode; active?: boolean; + className?: string; }) { const pathname = usePathname(); const active = overrideActive || href === pathname; return (
{label}
- ); } 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({ {pathname?.includes('/settings/') && ( -
+
)} - {recentDashboards.length > 0 && ( + {dashboards.length > 0 && (
-
Recent dashboards
- {recentDashboards.map((item) => ( - - {item.dashboard.name} - - {item.project.name} - -
- } - href={`/${item.organization_slug}/${item.project_id}/${item.dashboard_id}`} - /> - ))} +
Your dashboards
+
+ {dashboards.map((item) => ( + + {item.name} + {item.project.name} +
+ } + href={`/${item.organization_slug}/${item.project_id}/dashboards/${item.id}`} + /> + ))} +
)} diff --git a/apps/web/src/app/(app)/layout-organization-selector.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-organization-selector.tsx similarity index 100% rename from apps/web/src/app/(app)/layout-organization-selector.tsx rename to apps/web/src/app/(app)/[organizationId]/[projectId]/layout-organization-selector.tsx diff --git a/apps/web/src/app/(app)/layout-project-selector.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-project-selector.tsx similarity index 100% rename from apps/web/src/app/(app)/layout-project-selector.tsx rename to apps/web/src/app/(app)/[organizationId]/[projectId]/layout-project-selector.tsx diff --git a/apps/web/src/app/(app)/layout-sidebar.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-sidebar.tsx similarity index 89% rename from apps/web/src/app/(app)/layout-sidebar.tsx rename to apps/web/src/app/(app)/[organizationId]/[projectId]/layout-sidebar.tsx index 47ab3501..f928e337 100644 --- a/apps/web/src/app/(app)/layout-sidebar.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-sidebar.tsx @@ -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 ( <> + + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/organization/invited-users.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/organization/invited-users.tsx new file mode 100644 index 00000000..cf598941 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/organization/invited-users.tsx @@ -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 ( + + + Invites + + + + +
Invited users
+ + + + Email + Role + Status + Created + + + + {invites.map((item) => { + return ( + + {item.email} + {item.role} + {item.status} + + {new Date(item.createdAt).toLocaleString()} + + + ); + })} + + {invites.length === 0 && ( + + + No invites + + + )} + +
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/organization/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/organization/page.tsx new file mode 100644 index 00000000..4d161a1f --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/organization/page.tsx @@ -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 ( + +
+ + +
+
+ ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/settings/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/page.tsx similarity index 100% rename from apps/web/src/app/(app)/[organizationId]/settings/page.tsx rename to apps/web/src/app/(app)/[organizationId]/[projectId]/settings/page.tsx diff --git a/apps/web/src/app/(app)/[organizationId]/settings/profile/edit-profile.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/profile/edit-profile.tsx similarity index 100% rename from apps/web/src/app/(app)/[organizationId]/settings/profile/edit-profile.tsx rename to apps/web/src/app/(app)/[organizationId]/[projectId]/settings/profile/edit-profile.tsx diff --git a/apps/web/src/app/(app)/[organizationId]/settings/profile/logout.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/profile/logout.tsx similarity index 64% rename from apps/web/src/app/(app)/[organizationId]/settings/profile/logout.tsx rename to apps/web/src/app/(app)/[organizationId]/[projectId]/settings/profile/logout.tsx index af14ce10..e4e14826 100644 --- a/apps/web/src/app/(app)/[organizationId]/settings/profile/logout.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/profile/logout.tsx @@ -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() {

Sometime's you need to go. See you next time

- + ); diff --git a/apps/web/src/app/(app)/[organizationId]/settings/profile/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/profile/page.tsx similarity index 51% rename from apps/web/src/app/(app)/[organizationId]/settings/profile/page.tsx rename to apps/web/src/app/(app)/[organizationId]/[projectId]/settings/profile/page.tsx index aff39d9c..8572024b 100644 --- a/apps/web/src/app/(app)/[organizationId]/settings/profile/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/profile/page.tsx @@ -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 ( - +
diff --git a/apps/web/src/app/(app)/[organizationId]/settings/projects/list-projects.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/projects/list-projects.tsx similarity index 92% rename from apps/web/src/app/(app)/[organizationId]/settings/projects/list-projects.tsx rename to apps/web/src/app/(app)/[organizationId]/[projectId]/settings/projects/list-projects.tsx index 81f865ff..9534ba20 100644 --- a/apps/web/src/app/(app)/[organizationId]/settings/projects/list-projects.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/projects/list-projects.tsx @@ -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'; diff --git a/apps/web/src/app/(app)/[organizationId]/settings/projects/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/projects/page.tsx similarity index 65% rename from apps/web/src/app/(app)/[organizationId]/settings/projects/page.tsx rename to apps/web/src/app/(app)/[organizationId]/[projectId]/settings/projects/page.tsx index 6e57d49c..2417af46 100644 --- a/apps/web/src/app/(app)/[organizationId]/settings/projects/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/projects/page.tsx @@ -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 ( - + ); diff --git a/apps/web/src/app/(app)/[organizationId]/list-projects.tsx b/apps/web/src/app/(app)/[organizationId]/list-projects.tsx deleted file mode 100644 index 710188c4..00000000 --- a/apps/web/src/app/(app)/[organizationId]/list-projects.tsx +++ /dev/null @@ -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>; -} - -export function ListProjects({ projects }: ListProjectsProps) { - const params = useAppParams(); - const organizationId = params.organizationId; - - return ( - <> -
- {projects.map((item) => ( - -
- - {item.name} - -
-
- ))} - - - -
- - ); -} diff --git a/apps/web/src/app/(app)/[organizationId]/page.tsx b/apps/web/src/app/(app)/[organizationId]/page.tsx index e507c37c..3b96d59b 100644 --- a/apps/web/src/app/(app)/[organizationId]/page.tsx +++ b/apps/web/src/app/(app)/[organizationId]/page.tsx @@ -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 ( - +

Create your first project

diff --git a/apps/web/src/app/(app)/[organizationId]/settings/organization/invited-users.tsx b/apps/web/src/app/(app)/[organizationId]/settings/organization/invited-users.tsx deleted file mode 100644 index cc579f9d..00000000 --- a/apps/web/src/app/(app)/[organizationId]/settings/organization/invited-users.tsx +++ /dev/null @@ -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; - -interface InvitedUsersProps { - invites: IServiceInvite[]; - organizationId: string; -} -export default function InvitedUsers({ - invites, - organizationId, -}: InvitedUsersProps) { - const router = useRouter(); - - const { register, handleSubmit, formState, reset } = useForm({ - 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 ( -
{ - mutation.mutate({ - ...values, - organizationId, - }); - })} - > - - - Invites - - - - - -
Invited users
- - - - Email - Accepted - - - - {invites.map((item) => { - return ( - - {item.email} - {item.accepted ? 'Yes' : 'No'} - - ); - })} - - {invites.length === 0 && ( - - - No invites - - - )} - -
-
-
-
- ); -} diff --git a/apps/web/src/app/(app)/[organizationId]/settings/organization/page.tsx b/apps/web/src/app/(app)/[organizationId]/settings/organization/page.tsx deleted file mode 100644 index a5d1cfa0..00000000 --- a/apps/web/src/app/(app)/[organizationId]/settings/organization/page.tsx +++ /dev/null @@ -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 ( - -
- - -
-
- ); -} diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx deleted file mode 100644 index 8b305657..00000000 --- a/apps/web/src/app/(app)/layout.tsx +++ /dev/null @@ -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 ( -
- -
{children}
-
- ); -} diff --git a/apps/web/src/app/(app)/list-organizations.tsx b/apps/web/src/app/(app)/list-organizations.tsx deleted file mode 100644 index 79efa892..00000000 --- a/apps/web/src/app/(app)/list-organizations.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { ChevronRight } from 'lucide-react'; -import Link from 'next/link'; - -interface ListOrganizationsProps { - organizations: any[]; -} - -export function ListOrganizations({ organizations }: ListOrganizationsProps) { - return ( - <> -
- {organizations.map((item) => ( - - {item.name} - - - ))} -
- - ); -} diff --git a/apps/web/src/app/(app)/page.tsx b/apps/web/src/app/(app)/page.tsx index 2be0f2ac..51cb12cc 100644 --- a/apps/web/src/app/(app)/page.tsx +++ b/apps/web/src/app/(app)/page.tsx @@ -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 ; diff --git a/apps/web/src/components/FullPageEmptyState.tsx b/apps/web/src/components/FullPageEmptyState.tsx new file mode 100644 index 00000000..a998d330 --- /dev/null +++ b/apps/web/src/components/FullPageEmptyState.tsx @@ -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 ( +
+
+
+ +
+ +

{title}

+ + {children} +
+
+ ); +} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 0ab333b1..97f4872b 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -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', }, } ); diff --git a/apps/web/src/components/ui/combobox-advanced.tsx b/apps/web/src/components/ui/combobox-advanced.tsx index 8a608580..c23af75f 100644 --- a/apps/web/src/components/ui/combobox-advanced.tsx +++ b/apps/web/src/components/ui/combobox-advanced.tsx @@ -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 ( { e.preventDefault(); e.stopPropagation(); @@ -71,52 +78,40 @@ export function ComboboxAdvanced({ }; return ( - - - {open && ( -
-
- -
- setInputValue(event.target.value)} - /> -
- {inputValue === '' - ? value.map(renderUnknownItem) - : renderItem({ - value: inputValue, - label: `Pick "${inputValue}"`, - })} - {selectables.map(renderItem)} -
+ + +
- )} - + + + + + + + {inputValue === '' + ? value.map(renderUnknownItem) + : renderItem({ + value: inputValue, + label: `Pick "${inputValue}"`, + })} + {selectables.map(renderItem)} + + + + ); } diff --git a/apps/web/src/components/ui/combobox-multi.tsx b/apps/web/src/components/ui/combobox-multi.tsx deleted file mode 100644 index 34a132a9..00000000 --- a/apps/web/src/components/ui/combobox-multi.tsx +++ /dev/null @@ -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>; - items: Item[]; - placeholder: string; -} - -export function ComboboxMulti({ - items, - selected, - setSelected, - placeholder, - ...props -}: ComboboxMultiProps) { - const inputRef = React.useRef(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) => { - 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 field - if (e.key === 'Escape') { - input.blur(); - } - } - }, - [] - ); - - const selectables = items.filter( - (item) => !selected.find((s) => s.value === item.value) - ); - - return ( - -
-
- {selected.map((item) => { - return ( - - {item.label} - - - ); - })} - {/* Avoid having the "Search" Icon */} - setOpen(false)} - onFocus={() => setOpen(true)} - placeholder={placeholder} - className="ml-2 bg-transparent outline-none placeholder:text-muted-foreground flex-1" - /> -
-
-
- {open && selectables.length > 0 ? ( -
- - {selectables.map((item) => { - return ( - { - e.preventDefault(); - e.stopPropagation(); - }} - onSelect={(value) => { - setInputValue(''); - setSelected((prev) => [...prev, item]); - }} - className={'cursor-pointer'} - > - {item.label} - - ); - })} - -
- ) : null} -
-
- ); -} diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index a939345a..54b7ea12 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -55,7 +55,6 @@ export function Combobox({ searchable, icon: Icon, size, - label, }: ComboboxProps) { const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(''); diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 3a6d2553..d47063a0 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -13,7 +13,7 @@ const Input = React.forwardRef( ; -export default function AddDashboard({ projectId }: AddDashboardProps) { +export default function AddDashboard() { + const { projectId, organizationId: organizationSlug } = useAppParams(); const router = useRouter(); const { register, handleSubmit, formState } = useForm({ @@ -54,6 +52,7 @@ export default function AddDashboard({ projectId }: AddDashboardProps) { mutation.mutate({ name, projectId, + organizationSlug, }); })} > diff --git a/apps/web/src/server/api/routers/dashboard.ts b/apps/web/src/server/api/routers/dashboard.ts index c493301a..a7d24581 100644 --- a/apps/web/src/server/api/routers/dashboard.ts +++ b/apps/web/src/server/api/routers/dashboard.ts @@ -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, }, }); diff --git a/apps/web/src/server/api/routers/organization.ts b/apps/web/src/server/api/routers/organization.ts index d77cc1ee..e454417a 100644 --- a/apps/web/src/server/api/routers/organization.ts +++ b/apps/web/src/server/api/routers/organization.ts @@ -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, + }); + }), }); diff --git a/apps/web/src/server/api/routers/user.ts b/apps/web/src/server/api/routers/user.ts index 3357ae6d..6c18fc3a 100644 --- a/apps/web/src/server/api/routers/user.ts +++ b/apps/web/src/server/api/routers/user.ts @@ -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, - }, - }); - }), }); diff --git a/apps/web/src/server/db.ts b/apps/web/src/server/db.ts index f56cc6ed..e3216689 100644 --- a/apps/web/src/server/db.ts +++ b/apps/web/src/server/db.ts @@ -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'); diff --git a/apps/web/src/server/pageExists.tsx b/apps/web/src/server/pageExists.tsx new file mode 100644 index 00000000..e1db5ad3 --- /dev/null +++ b/apps/web/src/server/pageExists.tsx @@ -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[] = [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 + >, + project: results[1] as Awaited>, + }; +} diff --git a/apps/web/src/server/services/dashboard.service.ts b/apps/web/src/server/services/dashboard.service.ts index ec0f52ec..83909215 100644 --- a/apps/web/src/server/services/dashboard.service.ts +++ b/apps/web/src/server/services/dashboard.service.ts @@ -1,20 +1,40 @@ -import { unstable_cache } from 'next/cache'; - import { db } from '../db'; -export type IServiceRecentDashboards = Awaited< - ReturnType ->; export type IServiceDashboard = Awaited>; -export type IServiceDashboardWithProject = Awaited< +export type IServiceDashboards = Awaited< ReturnType ->[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, - }, - }); -} diff --git a/apps/web/src/server/services/organization.service.ts b/apps/web/src/server/services/organization.service.ts index d03b8faa..7ba2876d 100644 --- a/apps/web/src/server/services/organization.service.ts +++ b/apps/web/src/server/services/organization.service.ts @@ -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 + ReturnType >[number]; +export type IServiceInvites = Awaited>; + 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)); +} diff --git a/apps/web/src/server/services/project.service.ts b/apps/web/src/server/services/project.service.ts index 923544ef..bc6570a8 100644 --- a/apps/web/src/server/services/project.service.ts +++ b/apps/web/src/server/services/project.service.ts @@ -1,7 +1,4 @@ -import { unstable_cache } from 'next/cache'; - import { db } from '../db'; -import { getCurrentOrganization } from './organization.service'; export type IServiceProject = Awaited>; @@ -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, }, }); } diff --git a/apps/web/src/server/services/reports.service.ts b/apps/web/src/server/services/reports.service.ts index 1b203638..6725ae90 100644 --- a/apps/web/src/server/services/reports.service.ts +++ b/apps/web/src/server/services/reports.service.ts @@ -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); } diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 9278d1a1..c51a0887 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -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; } diff --git a/apps/web/src/utils/validation.ts b/apps/web/src/utils/validation.ts index 3b57c8bd..ad5912d1 100644 --- a/apps/web/src/utils/validation.ts +++ b/apps/web/src/utils/validation.ts @@ -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']), +}); diff --git a/packages/db/prisma/migrations/20240207201711_add_org_slug_on_dashboards/migration.sql b/packages/db/prisma/migrations/20240207201711_add_org_slug_on_dashboards/migration.sql new file mode 100644 index 00000000..8797e86f --- /dev/null +++ b/packages/db/prisma/migrations/20240207201711_add_org_slug_on_dashboards/migration.sql @@ -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"; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 05375f90..b7a25b7d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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") }