From 86d1c8859db9160aa13689afa0323e8fc84192a7 Mon Sep 17 00:00:00 2001 From: Chneemann Date: Tue, 18 Aug 2026 12:01:57 +0200 Subject: [PATCH] feat(member): integrate dynamic member list with server-side data and utility helpers --- app/(app)/member/MemberHeader.tsx | 30 +++++++++ app/(app)/member/list/MemberItem.tsx | 95 ++++++++++++++++++++++++++++ app/(app)/member/list/MemberList.tsx | 54 ++++++++++++++++ app/(app)/member/page.tsx | 23 +++++++ app/components/layout/Navbar.tsx | 9 ++- services/user.service.ts | 27 +++++++- types/user.ts | 4 ++ utils/user.ts | 70 +++++++++++++++++++- 8 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 app/(app)/member/MemberHeader.tsx create mode 100644 app/(app)/member/list/MemberItem.tsx create mode 100644 app/(app)/member/list/MemberList.tsx create mode 100644 app/(app)/member/page.tsx 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"; +}