From 78345be66283beb2f16a2cd8830be3396eec3110 Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Wed, 7 Aug 2024 17:43:02 +0200 Subject: [PATCH 1/8] feature: add role to user --- prisma/schema.prisma | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 45fa328..de68bc5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) From 54d07e64db0a23070f0d99b932dcccbf68bc749b Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Wed, 7 Aug 2024 17:43:09 +0200 Subject: [PATCH 2/8] feature: add migration --- prisma/migrations/20240807154225_add_user_role/migration.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 prisma/migrations/20240807154225_add_user_role/migration.sql diff --git a/prisma/migrations/20240807154225_add_user_role/migration.sql b/prisma/migrations/20240807154225_add_user_role/migration.sql new file mode 100644 index 0000000..926ca3c --- /dev/null +++ b/prisma/migrations/20240807154225_add_user_role/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `User` ADD COLUMN `role` ENUM('User', 'Admin') NOT NULL DEFAULT 'User'; From f596d1766b85110b59b9bbcefe29eec42471014c Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Wed, 7 Aug 2024 17:56:41 +0200 Subject: [PATCH 3/8] feat: Add role to session --- src/auth.config.ts | 11 +++++++++-- src/auth.ts | 4 +++- src/functions/user/getUserId.ts | 11 ++++++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/auth.config.ts b/src/auth.config.ts index 46132c5..bcb33f6 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -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 { AppSession } from "./functions/user/getUserId"; const providers: Provider[] = []; @@ -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, diff --git a/src/auth.ts b/src/auth.ts index f977d8d..fe2f90f 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -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"; @@ -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; }, diff --git a/src/functions/user/getUserId.ts b/src/functions/user/getUserId.ts index c25cab6..cfe69b1 100644 --- a/src/functions/user/getUserId.ts +++ b/src/functions/user/getUserId.ts @@ -1,9 +1,11 @@ import { auth } from "@/auth"; +import type { UserRole } from "@prisma/client"; import type { Session } from "next-auth"; import { redirectRoute } from "../../routes"; -type AppSession = Session & { +export type AppSession = Session & { userId: string; + role: UserRole; }; /** Gets the current UserId (or null if there is no user) */ @@ -23,3 +25,10 @@ export async function getUserId(withRedirection: boolean | null = false) { return (user as AppSession).userId; } + +export async function getUserRole(): Promise { + const user = await auth(); + if (user == null) return "User"; + + return (user as AppSession).role; +} From d0609707cf48555eebda195730fae2baf4ce7cf9 Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Wed, 7 Aug 2024 17:56:52 +0200 Subject: [PATCH 4/8] feat: add ensureIsAdmin function --- src/functions/user/ensureIsAdmin.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/functions/user/ensureIsAdmin.ts diff --git a/src/functions/user/ensureIsAdmin.ts b/src/functions/user/ensureIsAdmin.ts new file mode 100644 index 0000000..65f51fa --- /dev/null +++ b/src/functions/user/ensureIsAdmin.ts @@ -0,0 +1,11 @@ +import { notFound } from "next/navigation"; +import { getUserRole } from "./getUserId"; + +/** + * Ensures that the current user is an Admin, else it will throw a 404 error. + */ +export async function ensureIsAdmin(): Promise { + const role = await getUserRole(); + + if (role != "Admin") notFound(); +} From 80e9c6a5abce4a959056fbca926c9fac705b3c6c Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Wed, 7 Aug 2024 17:57:24 +0200 Subject: [PATCH 5/8] fix: typing error --- src/auth.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth.config.ts b/src/auth.config.ts index bcb33f6..b8e2661 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -4,7 +4,7 @@ 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 { AppSession } from "./functions/user/getUserId"; +import type { AppSession } from "./functions/user/getUserId"; const providers: Provider[] = []; From d179766e78dc502746323aa3dced793edd03027b Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Wed, 7 Aug 2024 21:03:45 +0200 Subject: [PATCH 6/8] feat: add admin page --- src/app/[locale]/(userArea)/admin/layout.tsx | 33 ++++++++++++++++++++ src/components/common/NavBar.tsx | 8 ++++- src/routes.ts | 1 + 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 src/app/[locale]/(userArea)/admin/layout.tsx diff --git a/src/app/[locale]/(userArea)/admin/layout.tsx b/src/app/[locale]/(userArea)/admin/layout.tsx new file mode 100644 index 0000000..2536975 --- /dev/null +++ b/src/app/[locale]/(userArea)/admin/layout.tsx @@ -0,0 +1,33 @@ +import { Heading } from "@/components/common/Heading"; +import { ensureIsAdmin } from "@/functions/user/ensureIsAdmin"; +import { getScopedI18n } from "@/locales/server"; +import { getRoute } from "@/routes"; +import Link from "next/link"; +import type { ReactElement } from "react"; + +export default async function AdminLayout({ + children, +}: { + children: ReactElement; +}) { + await ensureIsAdmin(); + const t = await getScopedI18n("admin"); + + return ( +
+
+
+ {t("title")} +
    +
  • + + {t("kpis")} + +
  • +
+
+
{children}
+
+
+ ); +} diff --git a/src/components/common/NavBar.tsx b/src/components/common/NavBar.tsx index 8368c9c..21ea5d2 100644 --- a/src/components/common/NavBar.tsx +++ b/src/components/common/NavBar.tsx @@ -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"; @@ -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 ( <> @@ -37,6 +38,11 @@ async function InnerMenu() {
  • {t("manageMealPlans.manage")}
  • + {role === "Admin" && ( +
  • + {t("landing.admin")} +
  • + )} ); } diff --git a/src/routes.ts b/src/routes.ts index 400b226..6a046fd 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -24,6 +24,7 @@ const routes = { join: (invitationCode: string) => withLocale(`/mealPlan/join/${invitationCode}`), profile: () => withLocale("/profile"), + admin: () => withLocale("/admin"), }; /** Adds the locale to the route */ From 103893ad8b7915716effb99c4747cb0e938f2798 Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Wed, 7 Aug 2024 21:03:56 +0200 Subject: [PATCH 7/8] feat: add different KPIs in admin --- .../(userArea)/admin/KpiContainer.tsx | 22 +++++ src/app/[locale]/(userArea)/admin/page.tsx | 44 ++++++++++ src/dal/admin/_getRange.ts | 17 ++++ src/dal/admin/getKpisFromDb.ts | 23 ++++++ src/dal/admin/getMealEntriesKpisFromDb.ts | 36 +++++++++ src/dal/admin/getUserKpisFromDb.ts | 35 ++++++++ src/functions/admin/kpiTools.ts | 81 +++++++++++++++++++ src/functions/formatKpiNumber.ts | 16 ++++ src/locales/de.ts | 16 ++++ src/locales/en.ts | 16 ++++ 10 files changed, 306 insertions(+) create mode 100644 src/app/[locale]/(userArea)/admin/KpiContainer.tsx create mode 100644 src/app/[locale]/(userArea)/admin/page.tsx create mode 100644 src/dal/admin/_getRange.ts create mode 100644 src/dal/admin/getKpisFromDb.ts create mode 100644 src/dal/admin/getMealEntriesKpisFromDb.ts create mode 100644 src/dal/admin/getUserKpisFromDb.ts create mode 100644 src/functions/admin/kpiTools.ts create mode 100644 src/functions/formatKpiNumber.ts diff --git a/src/app/[locale]/(userArea)/admin/KpiContainer.tsx b/src/app/[locale]/(userArea)/admin/KpiContainer.tsx new file mode 100644 index 0000000..15dd397 --- /dev/null +++ b/src/app/[locale]/(userArea)/admin/KpiContainer.tsx @@ -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 = ({ kpis, title }) => ( +
    +

    {title}

    +
    + {kpis.map((kpi, index) => ( +
    +
    {kpi.title}
    +
    {kpi.value}
    +
    {kpi.subtitle}
    +
    + ))} +
    +
    +); diff --git a/src/app/[locale]/(userArea)/admin/page.tsx b/src/app/[locale]/(userArea)/admin/page.tsx new file mode 100644 index 0000000..40f0583 --- /dev/null +++ b/src/app/[locale]/(userArea)/admin/page.tsx @@ -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 ; +} + +async function MealPlanKpis() { + const mealPlanKpis = await getMealPlansKpis(); + const t = await getScopedI18n("admin"); + + return ; +} + +async function UsersKpis() { + const usersKpis = await getUserKpis(); + const t = await getScopedI18n("admin"); + + return ; +} + +export default function AdminPage() { + return ( +
    + Loading...
    }> + + + Loading...}> + + Loading...}> + + + + ); +} diff --git a/src/dal/admin/_getRange.ts b/src/dal/admin/_getRange.ts new file mode 100644 index 0000000..0ddff50 --- /dev/null +++ b/src/dal/admin/_getRange.ts @@ -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(), + }; +} diff --git a/src/dal/admin/getKpisFromDb.ts b/src/dal/admin/getKpisFromDb.ts new file mode 100644 index 0000000..80bf151 --- /dev/null +++ b/src/dal/admin/getKpisFromDb.ts @@ -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, + }; +} diff --git a/src/dal/admin/getMealEntriesKpisFromDb.ts b/src/dal/admin/getMealEntriesKpisFromDb.ts new file mode 100644 index 0000000..5c29b38 --- /dev/null +++ b/src/dal/admin/getMealEntriesKpisFromDb.ts @@ -0,0 +1,36 @@ +import { prisma } from "@/server/db"; +import { getRange } from "./_getRange"; + +/** Gets KPIs to meal entries in different time units */ +export async function getMealEntriesKpisFromDb() { + const mealEntriesThisMonth = await prisma.mealEntry.count({ + where: { + updatedAt: getRange("month", 0), + }, + }); + + const mealEntriesLastMonth = await prisma.mealEntry.count({ + where: { + updatedAt: getRange("month", -1), + }, + }); + + const mealEntriesToday = await prisma.mealEntry.count({ + where: { + updatedAt: getRange("day", 0), + }, + }); + + const mealEntriesYesterday = await prisma.mealEntry.count({ + where: { + updatedAt: getRange("day", -1), + }, + }); + + return { + mealEntriesThisMonth, + mealEntriesLastMonth, + mealEntriesToday, + mealEntriesYesterday, + }; +} diff --git a/src/dal/admin/getUserKpisFromDb.ts b/src/dal/admin/getUserKpisFromDb.ts new file mode 100644 index 0000000..91a83f9 --- /dev/null +++ b/src/dal/admin/getUserKpisFromDb.ts @@ -0,0 +1,35 @@ +import { prisma } from "@/server/db"; +import { getRange } from "./_getRange"; + +export async function getUserKpisFromDb() { + const newUsersToday = await prisma.user.count({ + where: { + createdAt: getRange("day", 0), + }, + }); + + const newUsersYesterday = await prisma.user.count({ + where: { + createdAt: getRange("day", -1), + }, + }); + + const newUsersThisMonth = await prisma.user.count({ + where: { + createdAt: getRange("month", 0), + }, + }); + + const newUsersLastMonth = await prisma.user.count({ + where: { + createdAt: getRange("month", -1), + }, + }); + + return { + newUsersToday, + newUsersYesterday, + newUsersThisMonth, + newUsersLastMonth, + }; +} diff --git a/src/functions/admin/kpiTools.ts b/src/functions/admin/kpiTools.ts new file mode 100644 index 0000000..c1040f4 --- /dev/null +++ b/src/functions/admin/kpiTools.ts @@ -0,0 +1,81 @@ +import { getKpisFromDb } from "@/dal/admin/getKpisFromDb"; +import { getMealEntriesKpisFromDb } from "@/dal/admin/getMealEntriesKpisFromDb"; +import { getUserKpisFromDb } from "@/dal/admin/getUserKpisFromDb"; +import { getScopedI18n } from "@/locales/server"; +import { formatKpiNumber } from "../formatKpiNumber"; + +export type Kpi = { + title: string; + subtitle?: string; + value: number | string; +}; + +export function getComparingKpi( + title: string, + currentValue: number, + previousValue: number +): Kpi { + const diff = currentValue - previousValue; + const percentage = (diff / previousValue) * 100; + const isPositive = diff > 0; + + return { + title, + subtitle: `${isPositive ? "↗︎" : "↘︎"} ${diff} (${percentage.toFixed(1)}%)`, + value: formatKpiNumber(currentValue), + }; +} + +export async function getGeneralKpis(): Promise { + const t = await getScopedI18n("admin"); + const { invitations, mealEntries, mealPlans, users } = await getKpisFromDb(); + + return [ + { title: t("users"), value: users }, + { title: t("invitations"), value: invitations }, + { title: t("mealEntries"), value: mealEntries }, + { title: t("mealPlans"), value: mealPlans }, + ]; +} + +export async function getMealPlansKpis(): Promise { + const t = await getScopedI18n("admin"); + const { + mealEntriesLastMonth, + mealEntriesThisMonth, + mealEntriesToday, + mealEntriesYesterday, + } = await getMealEntriesKpisFromDb(); + + return [ + getComparingKpi( + t("mealEntriesThisMonth"), + mealEntriesThisMonth, + mealEntriesLastMonth + ), + getComparingKpi( + t("mealEntriesToday"), + mealEntriesToday, + mealEntriesYesterday + ), + ]; +} + +export async function getUserKpis(): Promise { + const t = await getScopedI18n("admin"); + const { + newUsersLastMonth, + newUsersThisMonth, + newUsersToday, + newUsersYesterday, + } = await getUserKpisFromDb(); + + return [ + getComparingKpi( + t("newUsersThisMonth"), + newUsersThisMonth, + newUsersLastMonth + ), + getComparingKpi(t("newUsersToday"), newUsersToday, newUsersYesterday), + ]; +} diff --git a/src/functions/formatKpiNumber.ts b/src/functions/formatKpiNumber.ts new file mode 100644 index 0000000..e853322 --- /dev/null +++ b/src/functions/formatKpiNumber.ts @@ -0,0 +1,16 @@ +/** + * Formats a number to a KPI format + * @param num Number to format + * @returns KPI formatted number + */ +export const formatKpiNumber = (num: number) => { + if (num < 1000) { + return num.toString(); + } else if (num < 1_000_000) { + return (num / 1000).toFixed(1) + "K"; + } else if (num < 1_000_000_0000) { + return (num / 1000000).toFixed(1) + "M"; + } else { + return (num / 1000000000).toFixed(1) + "B"; + } +}; diff --git a/src/locales/de.ts b/src/locales/de.ts index b8e7b1e..6241f68 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -9,6 +9,7 @@ export default { myMealPlans: "Meine Essenspläne", logout: "Abmelden", profile: "Profile", + admin: "Admin", }, features: { featureA: "Einfache Essensplanung", @@ -96,4 +97,19 @@ export default { deviceSettings: "Diese Einstellungen werden als Cookie gespeichert und gelten nur für dieses Gerät.", }, + admin: { + title: "Admin", + kpis: "Statistiken", + invitations: "Einladungen", + mealPlans: "Essenspläne", + mealEntries: "Mahlzeiten", + mealEntriesThisMonth: "Mahlzeiten diesen Monat", + mealEntriesToday: "Mahlzeiten heute", + users: "Nutzer", + generalKpis: "Generelle Statistiken", + mealPlanKpis: "Essensplan Statistiken", + newUsersThisMonth: "Neue Nutzer diesen Monat", + newUsersToday: "Neue Nutzer heute", + usersKpis: "Nutzer Statistiken", + }, } as const; diff --git a/src/locales/en.ts b/src/locales/en.ts index d1cc6c1..c4b04a6 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -9,6 +9,7 @@ export default { myMealPlans: "My Meal Plans", logout: "Logout", profile: "Profile", + admin: "Admin", }, features: { featureA: "Simple Meal Planning", @@ -94,4 +95,19 @@ export default { deviceSettings: "This settings are saved as cookies and only affecting the current device.", }, + admin: { + title: "Admin", + kpis: "KPIs", + invitations: "Invitations", + mealEntries: "Meal Entries", + mealPlans: "Meal Plans", + mealEntriesThisMonth: "Meal Entries this month", + mealEntriesToday: "Meal Entries today", + users: "Users", + mealPlanKpis: "Meal Plan KPIs", + generalKpis: "General KPIs", + newUsersThisMonth: "New Users this month", + newUsersToday: "New Users today", + usersKpis: "Users KPIs", + }, } as const; From 5c1c997fab5ac8c27da4d85071d5810ecf9a45ec Mon Sep 17 00:00:00 2001 From: Tim Ittermann Date: Wed, 7 Aug 2024 21:42:16 +0200 Subject: [PATCH 8/8] feat: add first users page --- src/app/[locale]/(userArea)/admin/layout.tsx | 11 ++- .../[locale]/(userArea)/admin/users/page.tsx | 77 +++++++++++++++++++ src/components/common/ActiveLink.tsx | 23 ++++++ src/components/common/PagingComponent.tsx | 32 ++++++++ src/dal/admin/getUsers.ts | 40 ++++++++++ src/locales/de.ts | 4 + src/locales/en.ts | 4 + src/routes.ts | 1 + src/types/PagingResult.ts | 6 ++ 9 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 src/app/[locale]/(userArea)/admin/users/page.tsx create mode 100644 src/components/common/ActiveLink.tsx create mode 100644 src/components/common/PagingComponent.tsx create mode 100644 src/dal/admin/getUsers.ts create mode 100644 src/types/PagingResult.ts diff --git a/src/app/[locale]/(userArea)/admin/layout.tsx b/src/app/[locale]/(userArea)/admin/layout.tsx index 2536975..9372eb6 100644 --- a/src/app/[locale]/(userArea)/admin/layout.tsx +++ b/src/app/[locale]/(userArea)/admin/layout.tsx @@ -1,8 +1,8 @@ +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 Link from "next/link"; import type { ReactElement } from "react"; export default async function AdminLayout({ @@ -20,9 +20,12 @@ export default async function AdminLayout({ {t("title")}
    • - - {t("kpis")} - + {t("kpis")} +
    • +
    • + + {t("users")} +
    diff --git a/src/app/[locale]/(userArea)/admin/users/page.tsx b/src/app/[locale]/(userArea)/admin/users/page.tsx new file mode 100644 index 0000000..2f6dac1 --- /dev/null +++ b/src/app/[locale]/(userArea)/admin/users/page.tsx @@ -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 ( +
    +
    + +

    {user.name}

    +
    +
    +

    + {t("userEmail")}: {user.email} +

    +

    + {t("userCreatedAt")}: {user.createdAt.toLocaleString(locale)} +

    +
    +
    + ); +} + +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 ( +
    +
    +
    +
    + + +
    +
    +
    + +
    + {users.map((user) => ( + + ))} +
    + + +
    + ); +} diff --git a/src/components/common/ActiveLink.tsx b/src/components/common/ActiveLink.tsx new file mode 100644 index 0000000..ef3f679 --- /dev/null +++ b/src/components/common/ActiveLink.tsx @@ -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> = ({ + href, + children, +}) => { + const pathName = usePathname(); + + return ( + + {children} + + ); +}; diff --git a/src/components/common/PagingComponent.tsx b/src/components/common/PagingComponent.tsx new file mode 100644 index 0000000..e03aa14 --- /dev/null +++ b/src/components/common/PagingComponent.tsx @@ -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 ( +
    +
    +
    + {Array.from({ length: pages }, (_, i) => i + 1).map((page) => ( + + ))} +
    +
    +
    + ); +} diff --git a/src/dal/admin/getUsers.ts b/src/dal/admin/getUsers.ts new file mode 100644 index 0000000..02b562b --- /dev/null +++ b/src/dal/admin/getUsers.ts @@ -0,0 +1,40 @@ +import { prisma } from "@/server/db"; +import type { PagingResult } from "@/types/PagingResult"; +import type { User } from "@prisma/client"; + +/** + * Searches for users (or returns all) + * @param query SearchQuery for Email or Name + * @param skip Items to skip + * @param take Items to take (per page) + * @returns List of users + */ +export async function getUsers( + query: string | undefined, + skip: number, + take: number +): Promise> { + const dbQuery: Parameters[0] = { + skip, + take, + }; + + if (query) { + dbQuery.where = { + OR: [{ email: { contains: query } }, { name: { contains: query } }], + }; + } + + const total = await prisma.user.count({ where: dbQuery.where }); + + const users = await prisma.user.findMany({ + ...dbQuery, + orderBy: { name: "asc" }, + }); + + return { + data: users, + total, + skip, + }; +} diff --git a/src/locales/de.ts b/src/locales/de.ts index 6241f68..bae49e0 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -111,5 +111,9 @@ export default { newUsersThisMonth: "Neue Nutzer diesen Monat", newUsersToday: "Neue Nutzer heute", usersKpis: "Nutzer Statistiken", + userEmail: "E-Mail", + userCreatedAt: "Erstellt am", + usersQuery: "Suche nach Name oder E-Mail", + usersSearch: "Suchen", }, } as const; diff --git a/src/locales/en.ts b/src/locales/en.ts index c4b04a6..e64d04e 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -109,5 +109,9 @@ export default { newUsersThisMonth: "New Users this month", newUsersToday: "New Users today", usersKpis: "Users KPIs", + userEmail: "Email", + userCreatedAt: "Created at", + usersQuery: "Search for name or email", + usersSearch: "Search", }, } as const; diff --git a/src/routes.ts b/src/routes.ts index 6a046fd..df91f27 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -25,6 +25,7 @@ const routes = { withLocale(`/mealPlan/join/${invitationCode}`), profile: () => withLocale("/profile"), admin: () => withLocale("/admin"), + adminUsers: () => withLocale("/admin/users"), }; /** Adds the locale to the route */ diff --git a/src/types/PagingResult.ts b/src/types/PagingResult.ts new file mode 100644 index 0000000..a452fdf --- /dev/null +++ b/src/types/PagingResult.ts @@ -0,0 +1,6 @@ +/** A limited result */ +export type PagingResult = { + skip: number; + total: number; + data: T[]; +};