From a2c479c56569e9692031e024667f4035a90bbc4a Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 2 May 2025 12:45:38 -0400 Subject: [PATCH 1/6] refactor(layout): enhance layout structure and styling for improved responsiveness - Updated layout components to use flexbox for better alignment and spacing. - Adjusted Sidebar and Header components for consistent styling and layout. - Implemented Suspense for loading states in the HomePage component. - Refactored Overview component to handle user memberships and organization data more effectively. - Improved user menu to display initials based on user name or email. --- .../(home)/components/NoAccessMessage.tsx | 18 ++ .../components/OrganizationDashboard.tsx | 61 +++++++ .../(app)/(home)/components/Overview.tsx | 159 +++++++++++------- .../src/app/[locale]/(app)/(home)/layout.tsx | 8 +- .../src/app/[locale]/(app)/(home)/page.tsx | 27 ++- apps/portal/src/app/[locale]/(app)/layout.tsx | 12 +- apps/portal/src/app/components/header.tsx | 2 +- apps/portal/src/app/components/logout.tsx | 2 +- apps/portal/src/app/components/otp-input.tsx | 10 +- apps/portal/src/app/components/sidebar.tsx | 15 +- apps/portal/src/app/components/user-menu.tsx | 28 ++- yarn.lock | 29 ++-- 12 files changed, 269 insertions(+), 102 deletions(-) create mode 100644 apps/portal/src/app/[locale]/(app)/(home)/components/NoAccessMessage.tsx create mode 100644 apps/portal/src/app/[locale]/(app)/(home)/components/OrganizationDashboard.tsx diff --git a/apps/portal/src/app/[locale]/(app)/(home)/components/NoAccessMessage.tsx b/apps/portal/src/app/[locale]/(app)/(home)/components/NoAccessMessage.tsx new file mode 100644 index 0000000000..f2f14316ad --- /dev/null +++ b/apps/portal/src/app/[locale]/(app)/(home)/components/NoAccessMessage.tsx @@ -0,0 +1,18 @@ +import { Alert, AlertDescription, AlertTitle } from "@comp/ui/alert"; +import { AlertTriangle } from "lucide-react"; + +interface NoAccessMessageProps { + message?: string; +} + +export function NoAccessMessage({ message }: NoAccessMessageProps) { + return ( + + + Access Denied + + {message ?? "You do not have access to the employee portal with this account, or you are not currently assigned to an organization. Please contact your administrator if you believe this is an error."} + + + ); +} \ No newline at end of file diff --git a/apps/portal/src/app/[locale]/(app)/(home)/components/OrganizationDashboard.tsx b/apps/portal/src/app/[locale]/(app)/(home)/components/OrganizationDashboard.tsx new file mode 100644 index 0000000000..0493692e48 --- /dev/null +++ b/apps/portal/src/app/[locale]/(app)/(home)/components/OrganizationDashboard.tsx @@ -0,0 +1,61 @@ +import { db } from "@comp/db"; +import type { + Member, + User, + Policy, + EmployeeTrainingVideoCompletion, + Organization +} from "@prisma/client"; +import { EmployeeTasksList } from "./EmployeeTasksList"; + +// Define the type for the member prop passed from Overview +interface MemberWithUserOrg extends Member { + user: User; + organization: Organization; +} + +interface OrganizationDashboardProps { + organizationId: string; + member: MemberWithUserOrg; // Pass the full member object for user info etc. +} + +export async function OrganizationDashboard({ organizationId, member }: OrganizationDashboardProps) { + + // Fetch policies specific to the selected organization + const policies = await db.policy.findMany({ + where: { + organizationId: organizationId, + isRequiredToSign: true, // Keep original logic for required policies + }, + }); + + // Fetch training video completions specific to the member + // Note: The original fetched *all* completions for the member, regardless of org + // If videos are org-specific, the schema/query might need adjustment + const trainingVideos = await db.employeeTrainingVideoCompletion.findMany({ + where: { + memberId: member.id, + // Add organizationId filter if EmployeeTrainingVideoCompletion has it + // organizationId: organizationId, + }, + // Include video details if needed by EmployeeTasksList + // include: { trainingVideo: true } + }); + + // Display welcome message and tasks + return ( +
+
+ {/* Use organization name if available and needed */} +

Organization: {member.organization.name}

+

Welcome back, {member.user.name}

+

Please complete the following tasks for {member.organization.name}:

+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/portal/src/app/[locale]/(app)/(home)/components/Overview.tsx b/apps/portal/src/app/[locale]/(app)/(home)/components/Overview.tsx index 4412cb1bee..daeaa7b75a 100644 --- a/apps/portal/src/app/[locale]/(app)/(home)/components/Overview.tsx +++ b/apps/portal/src/app/[locale]/(app)/(home)/components/Overview.tsx @@ -1,91 +1,136 @@ import { auth } from "@/app/lib/auth"; import { db } from "@comp/db"; -import { cache } from "react"; +// Import types directly from @prisma/client +import type { + Member, + User, + Policy, + EmployeeTrainingVideoCompletion, + Organization, +} from "@prisma/client"; import { headers } from "next/headers"; -import { EmployeeTasksList } from "./EmployeeTasksList"; - -export async function Overview() { - const policies = await getPolicies(); - const trainingVideos = await getTrainingVideos(); - const member = await getMember(); - - return ( -
-
-

Welcome back, {member.user.name}

-

Please complete the following tasks

-
- -
- ); +import { redirect } from "next/navigation"; +// Removed EmployeeTasksList import as it's not used directly here +import { NoAccessMessage } from "./NoAccessMessage"; +// Removed OrganizationSelector import +import { OrganizationDashboard } from "./OrganizationDashboard"; + +// Define the type for the member prop including the user and organization relations +interface MemberWithUserOrg extends Member { + user: User; + organization: Organization; } -const getMember = cache(async () => { +// Removed OverviewProps interface and searchParams prop +// export async function Overview({ searchParams }: OverviewProps) { +export async function Overview() { const session = await auth.api.getSession({ headers: await headers(), }); if (!session?.user) { - throw new Error("Unauthorized"); + redirect("/login"); // Or appropriate login/auth route } - const member = await db.member.findFirst({ + // Fetch all memberships for the user, including organization details + const memberships = await db.member.findMany({ where: { userId: session.user.id, - role: "employee", + // We might want to filter by role if needed, but let's see all memberships first + // role: "employee", // Keep commented unless needed }, include: { user: true, + organization: true, // Include organization details }, }); - if (!member) { - throw new Error("Unauthorized"); + // Case 1: No memberships found + if (memberships.length === 0) { + return ; } - return member; -}); - -const getPolicies = cache(async () => { - const member = await getMember(); - const organizationId = member.organizationId; + // Filter memberships to only those with valid organization data + const validMemberships = memberships.filter( + (member): member is MemberWithUserOrg & { organization: Organization } => + Boolean(member.organization) + ); - if (!organizationId) { - throw new Error("Unauthorized"); + // If after filtering, there are no valid memberships with organizations + if (validMemberships.length === 0) { + // This case might indicate memberships exist but lack organization links + console.warn("User has memberships but none with associated organizations.", { userId: session.user.id }); + return ; } - const policies = await db.policy.findMany({ - where: { - organizationId, - isRequiredToSign: true, - }, - }); - return policies; -}); + // Render a dashboard for each valid membership + return ( +
{/* Added a wrapper div with spacing */} + {validMemberships.map((member) => ( + + ))} +
+ ); + + // Removed the logic for OrganizationSelector and single/selected org handling + /* + // Extract unique organizations + const organizations = memberships.reduce((acc, member) => { + if (member.organization && !acc.some(org => org.id === member.organizationId)) { + acc.push(member.organization); + } + return acc; + }, [] as Organization[]); + + + const selectedOrgId = searchParams?.orgId as string | undefined; -const getTrainingVideos = cache(async () => { - const member = await getMember(); + // Case 2: Multiple organizations, and none selected yet OR selected is invalid + if (organizations.length > 1) { + const isValidSelection = selectedOrgId && organizations.some(org => org.id === selectedOrgId); - if (!member) { - throw new Error("Unauthorized"); + if (!isValidSelection) { + // If multiple orgs and no valid selection, show selector + return ; + } + // If valid selection, proceed to find member and render dashboard (handled below) } - const organizationId = member.organizationId; + // Case 3: Exactly one organization OR multiple orgs with a valid selection + let targetOrgId: string | undefined = undefined; + let targetMember: MemberWithUserOrg | undefined = undefined; + + if (organizations.length === 1) { + targetOrgId = organizations[0].id; + // Find the specific membership for this single organization + targetMember = memberships.find(m => m.organizationId === targetOrgId); + } else if (selectedOrgId) { + // Already validated that selectedOrgId is one of the user's orgs + targetOrgId = selectedOrgId; + targetMember = memberships.find(m => m.organizationId === targetOrgId); + } - if (!organizationId) { - throw new Error("Unauthorized"); + // If we have a target organization and member, render the dashboard + if (targetOrgId && targetMember) { + // We need the full MemberWithUserOrg type here potentially + // Ensure targetMember is correctly typed if OrganizationDashboard expects more + return ; } - const trainingVideos = await db.employeeTrainingVideoCompletion.findMany({ - where: { - memberId: member.id, - }, - }); + // Fallback case (should ideally not be reached with the logic above) + // If multiple orgs but somehow didn't render selector or dashboard + if (organizations.length > 1) { + return ; + } - return trainingVideos; -}); + // If single org but couldn't find member (data inconsistency?) + // Or some other unexpected state + console.error("Unexpected state in Overview component", { userId: session.user.id, memberships }); + return ; // Or a more specific error + */ +} diff --git a/apps/portal/src/app/[locale]/(app)/(home)/layout.tsx b/apps/portal/src/app/[locale]/(app)/(home)/layout.tsx index 1b20eac612..f3af69c550 100644 --- a/apps/portal/src/app/[locale]/(app)/(home)/layout.tsx +++ b/apps/portal/src/app/[locale]/(app)/(home)/layout.tsx @@ -9,10 +9,12 @@ export default async function Layout({ const t = await getI18n(); return ( -
+ <> -
{children}
-
+
+ {children} +
+ ); } diff --git a/apps/portal/src/app/[locale]/(app)/(home)/page.tsx b/apps/portal/src/app/[locale]/(app)/(home)/page.tsx index 159c056853..15cca506b7 100644 --- a/apps/portal/src/app/[locale]/(app)/(home)/page.tsx +++ b/apps/portal/src/app/[locale]/(app)/(home)/page.tsx @@ -2,16 +2,27 @@ import { getI18n } from "@/app/locales/server"; import type { Metadata } from "next"; import { setStaticParamsLocale } from "next-international/server"; import { Overview } from "./components/Overview"; +import { Suspense } from 'react' -export default async function Portal({ - params, -}: { - params: Promise<{ locale: string }>; -}) { - const { locale } = await params; - setStaticParamsLocale(locale); +interface HomePageProps { + params: { locale: string } + searchParams: { [key: string]: string | string[] | undefined } +} + +export default function HomePage({ params, searchParams }: HomePageProps) { + // Ensure locale is handled if needed by Overview or child components + // const { locale } = params; - return ; + return ( +
+ {/* Add loading states later if Overview becomes complex */} + Loading overview...
}> + {/* Pass searchParams to Overview */} + + + {/* Other home page sections can go here */} + + ) } export async function generateMetadata({ diff --git a/apps/portal/src/app/[locale]/(app)/layout.tsx b/apps/portal/src/app/[locale]/(app)/layout.tsx index 6d3bf83d4a..bf4dbc5eaf 100644 --- a/apps/portal/src/app/[locale]/(app)/layout.tsx +++ b/apps/portal/src/app/[locale]/(app)/layout.tsx @@ -18,12 +18,16 @@ export default async function Layout({ } return ( -
+
-
-
-
{children}
+
+
+
+
+
+ {children} +
); diff --git a/apps/portal/src/app/components/header.tsx b/apps/portal/src/app/components/header.tsx index b6da9287f1..65698406f0 100644 --- a/apps/portal/src/app/components/header.tsx +++ b/apps/portal/src/app/components/header.tsx @@ -5,7 +5,7 @@ import { MobileMenu } from "./mobile-menu"; export async function Header() { return ( -
+
diff --git a/apps/portal/src/app/components/logout.tsx b/apps/portal/src/app/components/logout.tsx index 2ac96dfc44..84afcd23c8 100644 --- a/apps/portal/src/app/components/logout.tsx +++ b/apps/portal/src/app/components/logout.tsx @@ -16,7 +16,7 @@ export function Logout() { await authClient.signOut({ fetchOptions: { onSuccess: () => { - router.push("/login"); // redirect to login page + router.push("/auth"); // Redirect to /auth instead of /login }, }, }); diff --git a/apps/portal/src/app/components/otp-input.tsx b/apps/portal/src/app/components/otp-input.tsx index 617d1ba6b3..26c330c34d 100644 --- a/apps/portal/src/app/components/otp-input.tsx +++ b/apps/portal/src/app/components/otp-input.tsx @@ -20,13 +20,19 @@ export const OtpStyledInput = ({ return ( ( )} - containerStyle={`flex justify-center items-center flex-wrap text-2xl font-bold ${ + containerStyle={`flex justify-center items-center text-2xl font-bold ${ props.renderSeparator ? "gap-1" : "gap-x-3 gap-y-2" }`} /> diff --git a/apps/portal/src/app/components/sidebar.tsx b/apps/portal/src/app/components/sidebar.tsx index 187be13f66..c89dfaf547 100644 --- a/apps/portal/src/app/components/sidebar.tsx +++ b/apps/portal/src/app/components/sidebar.tsx @@ -1,19 +1,14 @@ import { Icons } from "@comp/ui/icons"; -import { cookies } from "next/headers"; import Link from "next/link"; import { MainMenu } from "./main-menu"; export async function Sidebar() { return ( -