feat(member): integrate dynamic member list with server-side data and utility helpers
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 49s
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 49s
This commit is contained in:
parent
ca01a3b44e
commit
86d1c8859d
8 changed files with 306 additions and 6 deletions
30
app/(app)/member/MemberHeader.tsx
Normal file
30
app/(app)/member/MemberHeader.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-primary font-medium text-xs uppercase tracking-wider mb-1">
|
||||
<UsersRound size={14} />
|
||||
Team
|
||||
</div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">Member</h1>
|
||||
<p className="text-sm text-foreground-muted mt-1">
|
||||
Manage your team members and collaborate efficiently.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
app/(app)/member/list/MemberItem.tsx
Normal file
95
app/(app)/member/list/MemberItem.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="group relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 bg-card/40 border border-border/80 rounded-2xl hover:border-primary/40 hover:bg-card/70 transition-all duration-200 shadow-sm overflow-hidden">
|
||||
{/* Dynamic colored accent stripe */}
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 w-1 transition-colors ${
|
||||
member.isOnline
|
||||
? "bg-emerald-500/60 group-hover:bg-emerald-500"
|
||||
: "bg-slate-600/60 group-hover:bg-slate-500"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* --- Left Side --- */}
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div
|
||||
className={`w-11 h-11 rounded-xl flex items-center justify-center text-sm font-black tracking-wider text-white shadow-md border-2 border-card ring-2 ring-border/50 ${member.color}`}
|
||||
style={{ textShadow: "0 1px 2px rgba(0, 0, 0, 0.8)" }}
|
||||
>
|
||||
{getInitials(member.firstName, member.lastName)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h3 className="font-semibold text-base text-foreground group-hover:text-primary transition-colors tracking-tight">
|
||||
{getFullName(member.firstName, member.lastName)}
|
||||
</h3>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-md font-semibold border border-border/60 bg-card/60 text-foreground-muted">
|
||||
Member
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-foreground-muted font-mono">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Mail size={12} className="text-foreground-muted/70" />
|
||||
{member.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Right Side --- */}
|
||||
<div className="flex sm:flex-col flex-row sm:items-end items-center justify-between sm:justify-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 px-2.5 py-1 rounded-full bg-card/60 border border-border/60 shadow-xs"
|
||||
title={member.isOnline ? "Online" : "Offline"}
|
||||
>
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full shadow-lg ${getStatusColor(
|
||||
member.isOnline,
|
||||
)}`}
|
||||
/>
|
||||
<span className="text-xs font-medium text-foreground-muted">
|
||||
{member.isOnline ? "Online" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-[11px] text-foreground-muted/70 font-mono">
|
||||
{formatTimeAgo(member.lastLogin)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
app/(app)/member/list/MemberList.tsx
Normal file
54
app/(app)/member/list/MemberList.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-6 mx-auto">
|
||||
<MemberHeader />
|
||||
|
||||
{members.length === 0 ? (
|
||||
<div className="text-center py-20 border-2 border-dashed border-border/50 rounded-3xl bg-card/20 space-y-2">
|
||||
<p className="text-sm font-medium text-foreground-muted">
|
||||
No team members found.
|
||||
</p>
|
||||
<p className="text-xs text-foreground-muted/65">
|
||||
There are no users registered yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid gap-4 list-none">
|
||||
{members.map((member) => (
|
||||
<li key={member.id}>
|
||||
<MemberItem member={member} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
app/(app)/member/page.tsx
Normal file
23
app/(app)/member/page.tsx
Normal file
|
|
@ -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<JSX.Element>} The rendered member page component.
|
||||
*/
|
||||
export default async function MemberPage() {
|
||||
const users = await UserService.findAllUsers();
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-7xl mx-auto pb-12">
|
||||
<MemberList members={users} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Array<{ id: string; firstName: string; lastName: string; email: string; color: string; lastLogin: Date; isOnline: boolean }>>} 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`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof UserService.findAllUsers>
|
||||
>[number];
|
||||
|
||||
// ==========================================
|
||||
// UI Configurations
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue