diff --git a/app/(app)/member/MemberHeader.tsx b/app/(app)/member/MemberHeader.tsx
new file mode 100644
index 0000000..91d4d6f
--- /dev/null
+++ b/app/(app)/member/MemberHeader.tsx
@@ -0,0 +1,30 @@
+/**
+ * @file member/MemberHeader.tsx
+ * @description Client component rendering the header section for the team members page.
+ */
+
+"use client";
+
+import { UsersRound } from "lucide-react";
+
+/**
+ * Renders the members page header featuring title details, workspace subtitle, and an indicator icon.
+ *
+ * @returns {JSX.Element} The rendered members header component.
+ */
+export default function MemberHeader() {
+ return (
+
+
+
+
+ Team
+
+
Member
+
+ Manage your team members and collaborate efficiently.
+
+
+
+ );
+}
diff --git a/app/(app)/member/list/MemberItem.tsx b/app/(app)/member/list/MemberItem.tsx
new file mode 100644
index 0000000..d4680c8
--- /dev/null
+++ b/app/(app)/member/list/MemberItem.tsx
@@ -0,0 +1,95 @@
+/**
+ * @file member/list/MemberItem.tsx
+ * @description Client component rendering an individual member item card displaying avatar initials, full name, role badge, email address, online status indicator, and last active timestamp.
+ */
+
+"use client";
+
+import {
+ getFullName,
+ getInitials,
+ getStatusColor,
+ formatTimeAgo,
+} from "@/utils/user";
+import { Mail } from "lucide-react";
+import { UserListItem } from "@/types/user";
+
+/**
+ * Properties for the MemberItem component.
+ *
+ * @interface MemberItemProps
+ * @property {UserListItem} member - The member user object containing profile and status information.
+ */
+interface MemberItemProps {
+ member: UserListItem;
+}
+
+/**
+ * Renders a member list item displaying user credentials, email, dynamic color accent stripe based on online state, and activity status.
+ *
+ * @param {MemberItemProps} props - The component props.
+ * @returns {JSX.Element} The rendered member item component.
+ */
+export default function MemberItem({ member }: MemberItemProps) {
+ return (
+
+ {/* Dynamic colored accent stripe */}
+
+
+ {/* --- Left Side --- */}
+
+
+ {getInitials(member.firstName, member.lastName)}
+
+
+
+
+
+ {getFullName(member.firstName, member.lastName)}
+
+
+ Member
+
+
+
+
+
+
+ {member.email}
+
+
+
+
+
+ {/* --- Right Side --- */}
+
+
+
+
+ {member.isOnline ? "Online" : "Offline"}
+
+
+
+
+ {formatTimeAgo(member.lastLogin)}
+
+
+
+ );
+}
diff --git a/app/(app)/member/list/MemberList.tsx b/app/(app)/member/list/MemberList.tsx
new file mode 100644
index 0000000..9bcfc41
--- /dev/null
+++ b/app/(app)/member/list/MemberList.tsx
@@ -0,0 +1,54 @@
+/**
+ * @file member/list/MemberList.tsx
+ * @description Client component rendering the list of team members along with the section header and an empty state fallback.
+ */
+
+"use client";
+
+import MemberHeader from "../MemberHeader";
+import { UserListItem } from "@/types/user";
+import MemberItem from "./MemberItem";
+
+/**
+ * Properties for the MembersList component.
+ *
+ * @interface MemberListProps
+ * @property {UserListItem[]} members - Array of user items to display in the list.
+ */
+interface MemberListProps {
+ members: UserListItem[];
+}
+
+/**
+ * Renders the member directory section including the section header, a responsive list of team members,
+ * or an empty state message if no members are available.
+ *
+ * @param {MembersListProps} props - The component props.
+ * @returns {JSX.Element} The rendered members list section component.
+ */
+export default function MemberList({ members }: MemberListProps) {
+ return (
+
+
+
+ {members.length === 0 ? (
+
+
+ No team members found.
+
+
+ There are no users registered yet.
+
+
+ ) : (
+
+ {members.map((member) => (
+ -
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/(app)/member/page.tsx b/app/(app)/member/page.tsx
new file mode 100644
index 0000000..bae5983
--- /dev/null
+++ b/app/(app)/member/page.tsx
@@ -0,0 +1,23 @@
+/**
+ * @file member/page.tsx
+ * @description Server component rendering the members directory page by fetching all registered users and passing them to the MemberList component.
+ */
+
+import MemberList from "./list/MemberList";
+import { UserService } from "@/services/user.service";
+
+/**
+ * Renders the member management page displaying all registered users in a list container.
+ *
+ * @async
+ * @returns {Promise} The rendered member page component.
+ */
+export default async function MemberPage() {
+ const users = await UserService.findAllUsers();
+
+ return (
+
+
+
+ );
+}
diff --git a/app/components/layout/Navbar.tsx b/app/components/layout/Navbar.tsx
index 4fb7784..efc70e0 100644
--- a/app/components/layout/Navbar.tsx
+++ b/app/components/layout/Navbar.tsx
@@ -7,7 +7,13 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
-import { LayoutDashboard, FileText, Trash2, FilePenLine } from "lucide-react";
+import {
+ LayoutDashboard,
+ FileText,
+ Trash2,
+ FilePenLine,
+ Users,
+} from "lucide-react";
import useSWR from "swr";
import MobileLegalMenu from "./MobileLegalMenu";
@@ -26,6 +32,7 @@ const navItems = [
{ name: "Summary", href: "/summary", icon: FileText },
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Add Task", href: "/tasks", icon: FilePenLine },
+ { name: "Member", href: "/member", icon: Users },
{ name: "Trash", href: "/trash", icon: Trash2 },
];
diff --git a/services/user.service.ts b/services/user.service.ts
index bcb3c62..3bc0f29 100644
--- a/services/user.service.ts
+++ b/services/user.service.ts
@@ -5,18 +5,18 @@
import { db } from "@/db";
import { usersTable } from "@/db/schema";
-import { eq } from "drizzle-orm";
+import { eq, desc, sql } from "drizzle-orm";
/**
* Service class for handling user operations and database interactions.
*/
export class UserService {
/**
- * Retrieves specific profile information (firstName, lastName, color, online status) for a user by ID.
+ * Retrieves specific profile information (firstName, lastName, color, online status, last login) for a user by ID.
*
* @async
* @param {string} userId - The unique identifier of the user.
- * @returns {Promise<{ firstName: string; lastName: string; color: string; isOnline: boolean; lastLogin: Date } | null>} The user profile data or null.
+ * @returns {Promise<{ firstName: string; lastName: string; color: string; isOnline: boolean; lastLogin: Date } | null>} The user profile data or null if not found.
*/
static async findProfileById(userId: string) {
const [user] = await db
@@ -32,4 +32,25 @@ export class UserService {
return user || null;
}
+
+ /**
+ * Retrieves a list of all users sorted by their last login date in descending order, putting null values last.
+ *
+ * @async
+ * @returns {Promise>} An array of user list items.
+ */
+ static async findAllUsers() {
+ return await db
+ .select({
+ id: usersTable.id,
+ firstName: usersTable.firstName,
+ lastName: usersTable.lastName,
+ email: usersTable.email,
+ color: usersTable.color,
+ lastLogin: usersTable.lastLogin,
+ isOnline: usersTable.isOnline,
+ })
+ .from(usersTable)
+ .orderBy(sql`${usersTable.lastLogin} DESC NULLS LAST`);
+ }
}
diff --git a/types/user.ts b/types/user.ts
index ae505d6..3f94910 100644
--- a/types/user.ts
+++ b/types/user.ts
@@ -4,6 +4,7 @@
*/
import { type User as DbUser } from "@/db/schema";
+import { UserService } from "@/services/user.service";
// ==========================================
// Types
@@ -11,6 +12,9 @@ import { type User as DbUser } from "@/db/schema";
export type { DbUser };
export type UserColor = (typeof AVAILABLE_COLORS)[number];
+export type UserListItem = Awaited<
+ ReturnType
+>[number];
// ==========================================
// UI Configurations
diff --git a/utils/user.ts b/utils/user.ts
index 88e104c..58a4138 100644
--- a/utils/user.ts
+++ b/utils/user.ts
@@ -1,17 +1,83 @@
/**
* @file utils/user.ts
- * @description Utility functions for user formatting and initials generation.
+ * @description Utility functions for user formatting, initials generation, and name capitalization.
*/
+/**
+ * Generates the full name with properly capitalized first and last names.
+ *
+ * @param {string} [firstName] - The user's first name.
+ * @param {string} [lastName] - The user's last name.
+ * @returns {string} The formatted full name.
+ */
+export function getFullName(firstName?: string, lastName?: string): string {
+ const format = (str?: string) =>
+ str ? str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() : "";
+
+ return `${format(firstName)} ${format(lastName)}`.trim();
+}
+
/**
* Generates uppercase initials from a user's first and last name.
*
* @param {string} [firstName] - The user's first name.
* @param {string} [lastName] - The user's last name.
- * @returns {string} The computed initials (e.g., "JD") or a question mark if neither is provided.
+ * @returns {string} The computed initials (e.g., "JD") or a fallback question mark "?" if neither is provided.
*/
export function getInitials(firstName?: string, lastName?: string): string {
const first = firstName?.[0] || "";
const last = lastName?.[0] || "";
return `${first}${last}`.toUpperCase() || "?";
}
+
+/**
+ * Returns the CSS classes for the user's online status indicator.
+ *
+ * @param {boolean} isOnline - Whether the user is currently online.
+ * @returns {string} Tailwind CSS classes representing the status dot indicator.
+ */
+export function getStatusColor(isOnline: boolean): string {
+ return isOnline
+ ? "bg-emerald-500 shadow-emerald-500/50"
+ : "bg-slate-600 shadow-none";
+}
+
+/**
+ * Formats a given login date into a human-readable relative time string (e.g., "2 days ago").
+ *
+ * @param {Date | string | null} [dateInput] - The login date to format.
+ * @returns {string} A relative time string formatted via Intl.RelativeTimeFormat, or a fallback message if no date is provided.
+ */
+export function formatTimeAgo(dateInput?: Date | string | null): string {
+ if (!dateInput) return "Never logged in";
+
+ const date = new Date(dateInput);
+ const now = new Date();
+ const diffInSeconds = Math.round((date.getTime() - now.getTime()) / 1000);
+
+ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
+
+ const intervals: { limit: number; unit: Intl.RelativeTimeFormatUnit }[] = [
+ { limit: 60, unit: "second" },
+ { limit: 3600, unit: "minute" },
+ { limit: 86400, unit: "hour" },
+ { limit: 2592000, unit: "day" },
+ { limit: 31536000, unit: "month" },
+ { limit: Infinity, unit: "year" },
+ ];
+
+ const divisors = [1, 60, 3600, 86400, 2592000, 31536000];
+
+ for (let i = 0; i < intervals.length; i++) {
+ const divisor = divisors[i];
+ if (
+ Math.abs(diffInSeconds) < intervals[i].limit ||
+ i === intervals.length - 1
+ ) {
+ const value = Math.round(diffInSeconds / divisor);
+ return rtf.format(value, intervals[i].unit);
+ }
+ }
+
+ return "Never logged in";
+}