Skip to content

Commit

Permalink
feat(ui): implemented full student dashboard
Browse files Browse the repository at this point in the history
  • Loading branch information
mostafakamar2308 committed Dec 24, 2024
1 parent e49e5c6 commit 1f801c4
Show file tree
Hide file tree
Showing 28 changed files with 756 additions and 183 deletions.
3 changes: 2 additions & 1 deletion apps/dashboard/src/components/Lessons/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ const List: React.FC<{
}),
columnHelper.accessor("lesson.duration", {
header: intl("dashboard.lessons.duration"),
cell: (info) => Duration.from(info.getValue().toString()).format("ar"),
cell: (info) =>
Duration.from(info.getValue().toString()).format({ language: "ar" }),
}),
columnHelper.accessor("lesson.start", {
header: intl("dashboard.lessons.start"),
Expand Down
12 changes: 9 additions & 3 deletions apps/dashboard/src/components/Students/StatsContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,21 @@ const StatsContent: React.FC<{
},
{
label: intl("stats.student.time.total"),
value: Duration.from(data.minutes.total.toString()).format("ar"),
value: Duration.from(data.minutes.total.toString()).format({
language: "ar",
}),
},
{
label: intl("stats.student.time.ratified"),
value: Duration.from(data.minutes.ratified.toString()).format("ar"),
value: Duration.from(data.minutes.ratified.toString()).format({
language: "ar",
}),
},
{
label: intl("stats.student.time.canceled"),
value: Duration.from(data.minutes.canceled.toString()).format("ar"),
value: Duration.from(data.minutes.canceled.toString()).format({
language: "ar",
}),
},
];
}, [data, intl]);
Expand Down
4 changes: 3 additions & 1 deletion apps/dashboard/src/components/UserDetails/Content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ const Content: React.FC<{
{formatNumber(tutorStats.studentCount)}
</Detail>
<Detail label={intl("stats.tutor.teaching.hours")}>
{Duration.from(tutorStats.totalMinutes.toString()).format("ar")}
{Duration.from(tutorStats.totalMinutes.toString()).format({
language: "ar",
})}
</Detail>
</>
) : null}
Expand Down
4 changes: 3 additions & 1 deletion apps/dashboard/src/pages/ServerStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ const ServerStats: React.FC = () => {
<div>
<Typography element="body">
{intl("dashboard.server.stats.uptime", {
duration: Duration.from(uptime.toString()).format("ar"),
duration: Duration.from(uptime.toString()).format({
language: "ar",
}),
})}
</Typography>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/nova/src/components/Layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const Sidebar = () => {
return [
{
label: intl("sidebar.dashboard"),
route: Route.Root,
route: Route.Dashboard,
Icon: Home,
},
{
Expand Down
43 changes: 43 additions & 0 deletions apps/nova/src/components/dashboard/ChatSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Route } from "@/types/routes";
import { useFindUserRooms } from "@litespace/headless/chat";
import { useUser } from "@litespace/headless/context/user";
import {
ChatSummary as Summary,
type ChatSummaryProps,
} from "@litespace/luna/Chat";
import { orUndefined } from "@litespace/sol/utils";
import { IRoom } from "@litespace/types";
import dayjs from "dayjs";
import { useMemo } from "react";

function organizeRooms(
list: IRoom.FindUserRoomsApiRecord[] | null
): ChatSummaryProps["rooms"] {
if (!list) return [];

return list.map((room) => ({
id: room.roomId,
url: Route.Chat.concat("?room=", room.roomId.toString()),
name: orUndefined(room.otherMember?.name),
image: orUndefined(room.otherMember?.image),
message: room.latestMessage?.text || "TODO",
sentAt: room.latestMessage?.updatedAt || dayjs().toString(), // TODO
read: room.unreadMessagesCount === 0,
}));
}

export const ChatSummary = () => {
const { user } = useUser();
const rooms = useFindUserRooms(user?.id, { size: 4 });
const organizedRooms = useMemo(() => organizeRooms(rooms.list), [rooms.list]);

return (
<Summary
loading={rooms.query.isPending}
error={rooms.query.isError}
retry={rooms.query.refetch}
chatsUrl={Route.Chat}
rooms={organizedRooms}
/>
);
};
100 changes: 100 additions & 0 deletions apps/nova/src/components/dashboard/PastLessons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Route } from "@/types/routes";
import { useUser } from "@litespace/headless/context/user";
import { useInfiniteLessons } from "@litespace/headless/lessons";
import { useFormatMessage } from "@litespace/luna/hooks/intl";
import { PastLessonsTable } from "@litespace/luna/Lessons";
import { Typography } from "@litespace/luna/Typography";
import { ILesson, IUser } from "@litespace/types";
import { useCallback, useMemo, useState } from "react";
import BookLesson from "@/components/Lessons/BookLesson";
import { InView } from "react-intersection-observer";
import dayjs from "dayjs";
import { Loading } from "@litespace/luna/Loading";

function asLessons(list: ILesson.FindUserLessonsApiResponse["list"] | null) {
if (!list) return [];

return (
list.map((lesson) => {
const teacher = lesson.members.find(
(member) => member.role === IUser.Role.Tutor
);
return {
id: lesson.lesson.id,
start: lesson.lesson.start,
duration: lesson.lesson.duration,
tutor: {
id: teacher?.userId || 0,
name: teacher?.name || null,
imageUrl: teacher?.image || null,
},
};
}) || []
);
}

export const PastLessons = () => {
const intl = useFormatMessage();
const [tutor, setTutor] = useState<number | null>(null);

const closeRebookingDialog = useCallback(() => {
setTutor(null);
}, []);

const openRebookingDialog = useCallback((tutorId: number) => {
setTutor(tutorId);
}, []);

const { user } = useUser();
const now = useMemo(() => dayjs.utc().toISOString(), []);

const lessonsQuery = useInfiniteLessons({
users: user ? [user?.id] : [],
userOnly: true,
before: now,
});

const lessons = useMemo(
() => asLessons(lessonsQuery.list),
[lessonsQuery.list]
);

return (
<div className="grid gap-6 ">
<Typography
element="subtitle-2"
weight="bold"
className="text-natural-950 "
>
{intl("student-dashboard.previous-lessons.title")}
</Typography>

<PastLessonsTable
tutorsRoute={Route.Tutors}
onRebook={openRebookingDialog}
lessons={lessons}
loading={lessonsQuery.query.isPending}
error={lessonsQuery.query.isError}
retry={lessonsQuery.query.refetch}
/>

{!lessonsQuery.query.isFetching && lessonsQuery.query.hasNextPage ? (
<InView
as="div"
onChange={(inView) => {
if (inView && lessonsQuery.query.hasNextPage) lessonsQuery.more();
}}
/>
) : null}
{lessonsQuery.query.isFetching ? <Loading /> : null}

{tutor ? (
<BookLesson
close={closeRebookingDialog}
open={!!tutor}
tutorId={tutor}
/>
) : null}
</div>
);
};
32 changes: 32 additions & 0 deletions apps/nova/src/components/dashboard/StudentOverview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useFormatMessage } from "@litespace/luna/hooks/intl";
import { StudentOverview as Overview } from "@litespace/luna/StudentOverview";
import { Typography } from "@litespace/luna/Typography";
import { useFindPublicStudentStats } from "@litespace/headless/student";

export const StudentOverview = () => {
const intl = useFormatMessage();

const statsQuery = useFindPublicStudentStats();

return (
<div className="grid gap-6 justify-items-start w-full">
<Typography
element="subtitle-2"
weight="bold"
className="text-natural-950"
>
{intl("student-dashboard.overview.title")}
</Typography>

<Overview
tutorCount={statsQuery.data?.tutorCount || 0}
completedLessonCount={statsQuery.data?.completedLessonCount || 0}
totalLearningTime={statsQuery.data?.totalLearningTime || 0}
totalLessonCount={statsQuery.data?.completedLessonCount || 0}
loading={statsQuery.isPending}
error={statsQuery.isError}
retry={statsQuery.refetch}
/>
</div>
);
};
46 changes: 46 additions & 0 deletions apps/nova/src/components/dashboard/UpcomingLessons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Route } from "@/types/routes";
import { useUser } from "@litespace/headless/context/user";
import { useInfiniteLessons } from "@litespace/headless/lessons";
import { UpcomingLessonsSummary as Summary } from "@litespace/luna/Lessons";
import { ILesson, IUser } from "@litespace/types";
import dayjs from "dayjs";
import { useMemo } from "react";

function organizeUpcomingLessons(
list: ILesson.FindUserLessonsApiResponse["list"] | null
) {
return (
list?.map((item) => ({
start: item.lesson.start,
tutorName:
item.members.find((member) => member.role === IUser.Role.Tutor)?.name ||
null,
url: Route.Call,
})) || []
);
}

export const UpcomingLessons = () => {
const { user } = useUser();

const now = useMemo(() => dayjs.utc().toISOString(), []);
const lessonsQuery = useInfiniteLessons({
users: user ? [user?.id] : [],
userOnly: true,
after: now,
});
const lessons = useMemo(
() => organizeUpcomingLessons(lessonsQuery.list),
[lessonsQuery.list]
);
return (
<Summary
loading={lessonsQuery.query.isPending}
error={lessonsQuery.query.isError}
retry={lessonsQuery.query.refetch}
lessons={lessons}
lessonsUrl={Route.UpcomingLessons}
tutorsUrl={Route.Tutors}
/>
);
};
17 changes: 16 additions & 1 deletion apps/nova/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import React from "react";
import { ChatSummary } from "@/components/dashboard/ChatSummary";
import { UpcomingLessons } from "@/components/dashboard/UpcomingLessons";
import { PastLessons } from "@/components/dashboard/PastLessons";
import { StudentOverview } from "@/components/dashboard/StudentOverview";

const Dashboard: React.FC = () => {
return <div>Dashboard</div>;
return (
<div className="grid grid-cols-[66%,33%] p-6 gap-6 max-w-screen-3xl mx-auto">
<div className="flex flex-col gap-6">
<StudentOverview />
<PastLessons />
</div>
<div className="flex flex-col gap-6">
<UpcomingLessons />
<ChatSummary />
</div>
</div>
);
};

export default Dashboard;
29 changes: 20 additions & 9 deletions packages/assets/assets/people.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/headless/src/constants/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export enum QueryKey {
FindWithdrawalMethods = "find-withdrawal-methods",
FindUserById = "find-user-by-id",
FindStudentStats = "find-student-stats",
FindPublicStudentStats = "find-public-student-stats",
FindTopic = "find-topic",
FindPeerId = "find-peer-id",
FindAsset = "find-asset",
Expand Down
13 changes: 13 additions & 0 deletions packages/headless/src/student.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,16 @@ export function useFindStudentStats(
enabled: !!id,
});
}

export function useFindPublicStudentStats() {
const atlas = useAtlas();

const findStats = useCallback(async () => {
return await atlas.user.findPublicStudentStats();
}, [atlas.user]);

return useQuery({
queryFn: findStats,
queryKey: [QueryKey.FindPublicStudentStats],
});
}
1 change: 1 addition & 0 deletions packages/luna/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"./SidebarNav": "./dist/components/SidebarNav/index.js",
"./Slider": "./dist/components/Slider/index.js",
"./Stepper": "./dist/components/Stepper/index.js",
"./StudentOverview": "./dist/components/StudentOverview/index.js",
"./Switch": "./dist/components/Switch/index.js",
"./Textarea": "./dist/components/Textarea/index.js",
"./TextEditor": "./dist/components/TextEditor/index.js",
Expand Down
Loading

0 comments on commit 1f801c4

Please sign in to comment.