Skip to content

Commit

Permalink
Merge pull request #10 from timia2109/feature/admin-dashboard
Browse files Browse the repository at this point in the history
Feature/admin dashboard
  • Loading branch information
timia2109 committed Aug 7, 2024
2 parents 86a92b0 + 5c1c997 commit f5c4687
Show file tree
Hide file tree
Showing 24 changed files with 580 additions and 7 deletions.
2 changes: 2 additions & 0 deletions prisma/migrations/20240807154225_add_user_role/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `User` ADD COLUMN `role` ENUM('User', 'Admin') NOT NULL DEFAULT 'User';
10 changes: 8 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,22 @@ model User {
email String? @unique
emailVerified DateTime?
image String?
Session Session[]
Account Account[]
role UserRole @default(User)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Session Session[]
Account Account[]
mealPlanInvites MealPlanInvite[]
mealPlanAssignments MealPlanAssignment[]
}

enum UserRole {
User
Admin
}

model Account {
id String @id @default(cuid()) @db.Char(25)
userId String @db.Char(25)
Expand Down
22 changes: 22 additions & 0 deletions src/app/[locale]/(userArea)/admin/KpiContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Kpi } from "@/functions/admin/kpiTools";
import type { FC } from "react";

type Props = {
kpis: Kpi[];
title: string;
};

export const KpiContainer: FC<Props> = ({ kpis, title }) => (
<div>
<p className="my-4 text-2xl font-bold">{title}</p>
<div className="stats stats-vertical shadow lg:stats-horizontal">
{kpis.map((kpi, index) => (
<div className="stat" key={index}>
<div className="stat-title">{kpi.title}</div>
<div className="stat-value">{kpi.value}</div>
<div className="stat-desc">{kpi.subtitle}</div>
</div>
))}
</div>
</div>
);
36 changes: 36 additions & 0 deletions src/app/[locale]/(userArea)/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ActiveLink } from "@/components/common/ActiveLink";
import { Heading } from "@/components/common/Heading";
import { ensureIsAdmin } from "@/functions/user/ensureIsAdmin";
import { getScopedI18n } from "@/locales/server";
import { getRoute } from "@/routes";
import type { ReactElement } from "react";

export default async function AdminLayout({
children,
}: {
children: ReactElement;
}) {
await ensureIsAdmin();
const t = await getScopedI18n("admin");

return (
<div className="container mx-1 md:mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3">
<div className="mb-3 md:mb-0">
<Heading>{t("title")}</Heading>
<ul className="menu w-56 rounded-box bg-base-200">
<li>
<ActiveLink href={getRoute("admin")}>{t("kpis")}</ActiveLink>
</li>
<li>
<ActiveLink href={getRoute("adminUsers")}>
{t("users")}
</ActiveLink>
</li>
</ul>
</div>
<div className="col-span-2">{children}</div>
</div>
</div>
);
}
44 changes: 44 additions & 0 deletions src/app/[locale]/(userArea)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
getGeneralKpis,
getMealPlansKpis,
getUserKpis,
} from "@/functions/admin/kpiTools";
import { getScopedI18n } from "@/locales/server";
import { Suspense } from "react";
import { KpiContainer } from "./KpiContainer";

async function GeneralKpis() {
const generalKpis = await getGeneralKpis();
const t = await getScopedI18n("admin");

return <KpiContainer kpis={generalKpis} title={t("generalKpis")} />;
}

async function MealPlanKpis() {
const mealPlanKpis = await getMealPlansKpis();
const t = await getScopedI18n("admin");

return <KpiContainer kpis={mealPlanKpis} title={t("mealPlanKpis")} />;
}

async function UsersKpis() {
const usersKpis = await getUserKpis();
const t = await getScopedI18n("admin");

return <KpiContainer kpis={usersKpis} title={t("usersKpis")} />;
}

export default function AdminPage() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<GeneralKpis />
</Suspense>
<Suspense fallback={<div>Loading...</div>}></Suspense>
<MealPlanKpis />
<Suspense fallback={<div>Loading...</div>}>
<UsersKpis />
</Suspense>
</div>
);
}
77 changes: 77 additions & 0 deletions src/app/[locale]/(userArea)/admin/users/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import PagingComponent from "@/components/common/PagingComponent";
import { ProfileImage } from "@/components/common/ProfileImage";
import { getUsers } from "@/dal/admin/getUsers";
import { getCurrentLocale, getScopedI18n } from "@/locales/server";
import type { User } from "@prisma/client";

const pageSize = 50;

type Props = {
searchParams: {
query: string | undefined;
skip: string | undefined;
};
};

async function UserComponent({ user }: { user: User }) {
const t = await getScopedI18n("admin");
const locale = getCurrentLocale();

return (
<div
className="border-e border-s border-t border-accent p-3
first:rounded-t last:rounded-b last:border-b"
>
<div className="flex items-center justify-start gap-3">
<ProfileImage user={user} />
<p>{user.name}</p>
</div>
<div>
<p>
{t("userEmail")}: {user.email}
</p>
<p>
{t("userCreatedAt")}: {user.createdAt.toLocaleString(locale)}
</p>
</div>
</div>
);
}

export default async function UsersPage({ searchParams }: Props) {
const t = await getScopedI18n("admin");
const skip = searchParams.skip ? parseInt(searchParams.skip) : 0;
const { data: users, total } = await getUsers(
searchParams.query,
skip,
pageSize
);

return (
<div>
<div className="mb-3">
<form method="get">
<div className="join w-full">
<input
className="input join-item input-bordered w-full"
name="query"
defaultValue={searchParams.query}
placeholder={t("usersQuery")}
/>
<button type="submit" className="btn join-item rounded-r-full">
{t("usersSearch")}
</button>
</div>
</form>
</div>

<div>
{users.map((user) => (
<UserComponent key={user.id} user={user} />
))}
</div>

<PagingComponent total={total} pageSize={pageSize} skip={skip} />
</div>
);
}
11 changes: 9 additions & 2 deletions src/auth.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { UserRole } from "@prisma/client";
import type { NextAuthConfig } from "next-auth";
import type { Provider } from "next-auth/providers";
import Facebook from "next-auth/providers/facebook";
import Google from "next-auth/providers/google";
import { env } from "./env/server.mjs";
import type { AppSession } from "./functions/user/getUserId";

const providers: Provider[] = [];

Expand Down Expand Up @@ -36,8 +38,13 @@ export const authConfig: NextAuthConfig = {
callbacks: {
async session(props) {
const { session, token } = props;
session.userId = token.id as string;
return session;
const appSession: AppSession = {
...session,
userId: token.id as string,
role: token.role as UserRole,
};

return appSession;
},
},
providers,
Expand Down
4 changes: 3 additions & 1 deletion src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import type { User } from "@prisma/client";
import NextAuth from "next-auth";
import { authConfig } from "./auth.config";
import { onCreateUser } from "./functions/user/onCreateUser";
Expand All @@ -16,8 +17,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
jwt: async (props) => {
const { token, user } = props;
if (user) {
// Add the user id to the token
const appUser = user as User;
token.id = user.id;
token.role = appUser.role;
}
return token;
},
Expand Down
23 changes: 23 additions & 0 deletions src/components/common/ActiveLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { FC, PropsWithChildren } from "react";

type Props = {
href: string;
};

/** A (Route)Link that knows if it's active
*/
export const ActiveLink: FC<PropsWithChildren<Props>> = ({
href,
children,
}) => {
const pathName = usePathname();

return (
<Link href={href} className={pathName === href ? "active" : ""}>
{children}
</Link>
);
};
8 changes: 7 additions & 1 deletion src/components/common/NavBar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { auth } from "@/auth";
import { getMealPlans } from "@/dal/mealPlans/getMealPlans";
import { getMealPlanLabel } from "@/functions/user/getMealPlanLabel";
import { getUserId } from "@/functions/user/getUserId";
import { getUserId, getUserRole } from "@/functions/user/getUserId";
import { getI18n } from "@/locales/server";
import { getRoute, redirectRoute } from "@/routes";
import Link from "next/link";
Expand All @@ -11,6 +11,7 @@ import { ProfileImage } from "./ProfileImage";
async function InnerMenu() {
const mealPlans = await getMealPlans(await getUserId(true));
const t = await getI18n();
const role = await getUserRole();

return (
<>
Expand All @@ -37,6 +38,11 @@ async function InnerMenu() {
<li>
<Link href={getRoute("manage")}>{t("manageMealPlans.manage")}</Link>
</li>
{role === "Admin" && (
<li>
<Link href={getRoute("admin")}>{t("landing.admin")}</Link>
</li>
)}
</>
);
}
Expand Down
32 changes: 32 additions & 0 deletions src/components/common/PagingComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
type Props = {
total: number;
pageSize: number;
skip: number;
};

export default function PagingComponent({ total, pageSize, skip }: Props) {
const pages = Math.ceil(total / pageSize);
const currentPage = Math.floor(skip / pageSize) + 1;

return (
<div className="mt-3 flex justify-end">
<form method="get">
<div className="join">
{Array.from({ length: pages }, (_, i) => i + 1).map((page) => (
<button
key={page}
name="skip"
value={(page - 1) * pageSize}
type="submit"
className={`btn join-item ${
page === currentPage ? "btn-active" : ""
}`}
>
{page}
</button>
))}
</div>
</form>
</div>
);
}
17 changes: 17 additions & 0 deletions src/dal/admin/_getRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { DateTime } from "luxon";

/**
* Generates a range of time based on the type and plus
* @param type Type of time unit
* @param plus Unit to add
* @returns Prisma ready Range
*/
export function getRange(type: "day" | "week" | "month", plus: number) {
const end = DateTime.now();
const start = end.minus({ [type]: plus + 1 });

return {
gte: start.toJSDate(),
lt: end.toJSDate(),
};
}
23 changes: 23 additions & 0 deletions src/dal/admin/getKpisFromDb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { prisma } from "@/server/db";

/** Return summarized entries from db */
export async function getKpisFromDb() {
const users = await prisma.user.count();
const mealPlans = await prisma.mealPlan.count();
const mealEntries = await prisma.mealEntry.count();

const invitations = await prisma.mealPlanInvite.count({
where: {
expiresAt: {
lt: new Date(),
},
},
});

return {
mealPlans,
mealEntries,
invitations,
users,
};
}
Loading

0 comments on commit f5c4687

Please sign in to comment.