Skip to content

Commit f9efa90

Browse files
authored
Merge pull request #67 from hack4impact/feature/66-admin-dashboard-users-tab
Feature/66 admin dashboard users tab
2 parents 8bd75bb + f403d3f commit f9efa90

16 files changed

Lines changed: 936 additions & 34 deletions

File tree

app/(authenticated)/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export default function AuthenticatedLayout({
2828
<SidebarProvider>
2929
<AppSidebar />
3030
<SidebarInset>
31-
<div className="flex flex-1 flex-col gap-4 p-4">
31+
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4">
3232
<Suspense fallback={null}>
3333
<AuthGate>{children}</AuthGate>
3434
</Suspense>

app/(authenticated)/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ async function HomeContent() {
3939
<div className="flex flex-row gap-4 w-full">
4040
<Card className="flex-1">
4141
<CardHeader>
42-
<CardTitle className="text-2xl">Welcome t</CardTitle>
42+
<CardTitle className="text-2xl">Welcome</CardTitle>
4343
<CardDescription>You are signed in as</CardDescription>
4444
</CardHeader>
4545
<CardContent className="space-y-4">
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"use client";
2+
3+
import { useMemo, useState } from "react";
4+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
5+
import {
6+
Select,
7+
SelectContent,
8+
SelectItem,
9+
SelectTrigger,
10+
SelectValue,
11+
} from "@/components/ui/select";
12+
import { Input } from "@/components/ui/input";
13+
import { Button } from "@/components/ui/button";
14+
import { ArrowDownAZ, ArrowUpZA, Search } from "lucide-react";
15+
import { UsersDataTable } from "./users-data-table";
16+
import { usersColumns } from "./users-columns";
17+
import type { UserRow } from "../profile-role-label";
18+
19+
const USER_SUBSCRIPTION_VIEW_TABS = [
20+
{ value: "all", label: "All Users" },
21+
{ value: "active", label: "Active" },
22+
{ value: "inactive", label: "Inactive" },
23+
] as const;
24+
25+
export type UserSubscriptionViewTab =
26+
(typeof USER_SUBSCRIPTION_VIEW_TABS)[number]["value"];
27+
28+
type SortDir = "asc" | "desc";
29+
30+
export interface RoleFilterOption {
31+
value: string;
32+
label: string;
33+
}
34+
35+
interface UsersClientProps {
36+
users: UserRow[];
37+
roleFilterOptions: RoleFilterOption[];
38+
}
39+
40+
export function UsersClient({ users, roleFilterOptions }: UsersClientProps) {
41+
const [tab, setTab] = useState<UserSubscriptionViewTab>("all");
42+
const [roleFilter, setRoleFilter] = useState<string>("all");
43+
const [nameQuery, setNameQuery] = useState<string>("");
44+
const [sortDir, setSortDir] = useState<SortDir>("asc");
45+
46+
const filtered = useMemo(() => {
47+
const query = nameQuery.trim().toLowerCase();
48+
49+
return users
50+
.filter((u) => {
51+
if (!query) return true;
52+
const name = `${u.firstName} ${u.lastName}`.toLowerCase();
53+
return (
54+
name.includes(query) || u.email.toLowerCase().includes(query)
55+
);
56+
})
57+
.filter((u) => {
58+
if (tab === "all") return true;
59+
if (tab === "active") return u.isActive;
60+
return !u.isActive;
61+
})
62+
.filter((u) => {
63+
if (roleFilter === "all") return true;
64+
return u.role === roleFilter;
65+
})
66+
.sort((a, b) => {
67+
const nameA = `${a.firstName} ${a.lastName}`.toLowerCase();
68+
const nameB = `${b.firstName} ${b.lastName}`.toLowerCase();
69+
return sortDir === "asc"
70+
? nameA.localeCompare(nameB)
71+
: nameB.localeCompare(nameA);
72+
});
73+
}, [users, nameQuery, tab, roleFilter, sortDir]);
74+
75+
return (
76+
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
77+
<Tabs
78+
value={tab}
79+
onValueChange={(v) => setTab(v as UserSubscriptionViewTab)}
80+
className="flex min-h-0 w-full min-w-0 flex-1 flex-col gap-2 overflow-hidden"
81+
>
82+
<div className="flex w-full min-w-0 shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2">
83+
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 sm:gap-3">
84+
<div className="relative p-0.5 min-w-[min(100%,10rem)] max-w-full grow sm:max-w-xs sm:grow-0 sm:basis-56">
85+
<Search className="absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
86+
<Input
87+
id="users-search"
88+
placeholder="Search by name or email..."
89+
value={nameQuery}
90+
onChange={(e) => setNameQuery(e.target.value)}
91+
className="w-full min-w-0 pl-9"
92+
/>
93+
</div>
94+
95+
<TabsList className="h-auto min-h-8 max-w-full min-w-0 flex-wrap justify-start border border-border">
96+
{USER_SUBSCRIPTION_VIEW_TABS.map(({ value, label }) => (
97+
<TabsTrigger key={value} value={value}>
98+
{label}
99+
</TabsTrigger>
100+
))}
101+
</TabsList>
102+
</div>
103+
104+
<div className="flex min-w-0 shrink-0 flex-wrap items-center justify-end gap-2">
105+
<Select value={roleFilter} onValueChange={setRoleFilter}>
106+
<SelectTrigger
107+
id="users-role-filter"
108+
className="w-[min(100%,11rem)] min-w-[8.5rem] sm:w-[140px]"
109+
>
110+
<SelectValue placeholder="All Roles" />
111+
</SelectTrigger>
112+
<SelectContent>
113+
{roleFilterOptions.map(({ value, label }) => (
114+
<SelectItem key={value} value={value}>
115+
{label}
116+
</SelectItem>
117+
))}
118+
</SelectContent>
119+
</Select>
120+
121+
<Button
122+
id="users-sort-toggle"
123+
variant="outline"
124+
size="icon"
125+
className="shrink-0"
126+
onClick={() =>
127+
setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))
128+
}
129+
aria-label={
130+
sortDir === "asc" ? "Sort Z to A" : "Sort A to Z"
131+
}
132+
>
133+
{sortDir === "asc" ? (
134+
<ArrowDownAZ className="h-4 w-4" />
135+
) : (
136+
<ArrowUpZA className="h-4 w-4" />
137+
)}
138+
</Button>
139+
</div>
140+
</div>
141+
142+
<TabsContent
143+
value={tab}
144+
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden focus-visible:outline-none"
145+
>
146+
<UsersDataTable
147+
columns={usersColumns}
148+
data={filtered}
149+
emptyMessage="No users match the current filters."
150+
rowLabel={(n) => `${n} user${n === 1 ? "" : "s"}`}
151+
/>
152+
</TabsContent>
153+
</Tabs>
154+
</div>
155+
);
156+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"use client";
2+
3+
import { ColumnDef } from "@tanstack/react-table";
4+
import { Pencil, Tag } from "lucide-react";
5+
import { Button } from "@/components/ui/button";
6+
import { Badge } from "@/components/ui/badge";
7+
import { Avatar, AvatarFallback} from "@/components/ui/avatar";
8+
import {
9+
Tooltip,
10+
TooltipContent,
11+
TooltipTrigger,
12+
} from "@/components/ui/tooltip";
13+
14+
import { profileRoleLabel, type UserRow } from "../profile-role-label";
15+
16+
export const usersColumns: ColumnDef<UserRow>[] = [
17+
{
18+
id: "profile",
19+
header: "User Profile",
20+
meta: {
21+
colWidth: "32%",
22+
tdClassName: "whitespace-normal align-middle",
23+
},
24+
cell: ({ row }) => {
25+
const u = row.original;
26+
const fullName = `${u.firstName} ${u.lastName}`;
27+
return (
28+
<div className="flex min-w-0 max-w-full items-center gap-2 sm:gap-3">
29+
<Avatar>
30+
<AvatarFallback className="bg-muted text-xs font-semibold text-muted-foreground">
31+
{`${u.firstName[0] ?? ""}${u.lastName[0] ?? ""}`.toUpperCase()}
32+
</AvatarFallback>
33+
</Avatar>
34+
<div className="flex min-w-0 flex-1 flex-col">
35+
<span className="truncate font-semibold text-sm">
36+
{fullName}
37+
</span>
38+
<span className="truncate text-xs text-muted-foreground">
39+
{u.email}
40+
</span>
41+
</div>
42+
</div>
43+
);
44+
},
45+
},
46+
{
47+
accessorKey: "role",
48+
header: "Role",
49+
meta: { colWidth: "14%" },
50+
cell: ({ row }) => (
51+
<span className="inline-flex max-w-full min-w-0 items-center rounded-full border border-border px-2 py-0.5 text-xs font-medium capitalize text-foreground">
52+
<span className="truncate">
53+
{profileRoleLabel(row.original.role)}
54+
</span>
55+
</span>
56+
),
57+
},
58+
{
59+
id: "status",
60+
header: "Status",
61+
meta: { colWidth: "14%" },
62+
cell: ({ row }) => {
63+
const active = row.original.isActive;
64+
return (
65+
<Badge variant={active ? "default" : "secondary"} className="gap-1.5">
66+
<span
67+
className={`h-2 w-2 rounded-full ${
68+
active ? "bg-green-500" : "bg-muted-foreground/50"
69+
}`}
70+
/>
71+
{active ? "Active" : "Inactive"}
72+
</Badge>
73+
);
74+
},
75+
},
76+
{
77+
id: "lastLoginAt",
78+
header: "Last Login",
79+
meta: { colWidth: "18%" },
80+
cell: ({ row }) => (
81+
<span className="block min-w-0 truncate text-sm text-muted-foreground">
82+
{new Intl.DateTimeFormat("en-CA", {
83+
year: "numeric",
84+
month: "short",
85+
day: "numeric",
86+
}).format(new Date(row.original.lastLoginAt || "Never Logged in" ))}
87+
</span>
88+
),
89+
},
90+
{
91+
id: "actions",
92+
header: () => <div className="text-right">Actions</div>,
93+
meta: {
94+
colWidth: "22%",
95+
thClassName: "text-right",
96+
tdClassName: "text-right",
97+
},
98+
cell: () => (
99+
<div className="flex items-center justify-end gap-0.5">
100+
<Tooltip>
101+
<TooltipTrigger asChild>
102+
<Button
103+
variant="ghost"
104+
size="icon-sm"
105+
aria-label="Edit user"
106+
disabled
107+
>
108+
<Pencil />
109+
</Button>
110+
</TooltipTrigger>
111+
<TooltipContent>Edit (coming soon)</TooltipContent>
112+
</Tooltip>
113+
<Tooltip>
114+
<TooltipTrigger asChild>
115+
<Button
116+
variant="ghost"
117+
size="icon-sm"
118+
aria-label="Give discount"
119+
disabled
120+
>
121+
<Tag />
122+
</Button>
123+
</TooltipTrigger>
124+
<TooltipContent>Give coupon (coming soon)</TooltipContent>
125+
</Tooltip>
126+
</div>
127+
),
128+
},
129+
];

0 commit comments

Comments
 (0)