diff --git a/app/components/dashboard/announcement-bar.tsx b/app/components/dashboard/announcement-bar.tsx new file mode 100644 index 000000000..18bcb6ba4 --- /dev/null +++ b/app/components/dashboard/announcement-bar.tsx @@ -0,0 +1,27 @@ +import { useLoaderData } from "@remix-run/react"; +import type { loader } from "~/routes/_layout+/dashboard"; +import { StarsIcon } from "../icons"; +import { MarkdownViewer } from "../markdown"; +import { Button } from "../shared/button"; + +export default function AnnouncementBar() { + const { announcement } = useLoaderData(); + return announcement ? ( +
+
+ +
+
+
+ +
+
+ + {/* */} +
+ ) : null; +} diff --git a/app/components/dashboard/assets-by-category-chart.tsx b/app/components/dashboard/assets-by-category-chart.tsx new file mode 100644 index 000000000..f8c47e4f5 --- /dev/null +++ b/app/components/dashboard/assets-by-category-chart.tsx @@ -0,0 +1,100 @@ +import { useLoaderData } from "@remix-run/react"; +import type { Color } from "@tremor/react"; +import { DonutChart } from "@tremor/react"; +import { ClientOnly } from "remix-utils/client-only"; +import type { loader } from "~/routes/_layout+/dashboard"; +import { EmptyState } from "./empty-state"; +import { Badge, Button } from "../shared"; +import { InfoTooltip } from "../shared/info-tooltip"; + +export default function AssetsByCategoryChart() { + const { assetsByCategory } = useLoaderData(); + const chartColors: Color[] = [ + "slate", + "sky", + "rose", + "orange", + "red", + "purple", + ]; + + const correspondingChartColorsHex: string[] = [ + "#64748b", + "#0ea5e9", + "#f43f5e", + "#f97316", + "#ef4444", + "#a855f7", + ]; + + return ( + + {() => ( +
+
+
+ Assets by category (top 6) +
+
+ +
Assets by Category
+

+ Below graph shows how many percent of assets are in which + category{" "} +

+ + } + /> +
+
+
+ {assetsByCategory.length > 0 ? ( +
+ +
+
    + {assetsByCategory.map((cd, i) => ( +
  • + +
  • + ))} +
  • + +
  • +
+
+
+ ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/app/components/dashboard/assets-by-status-chart.tsx b/app/components/dashboard/assets-by-status-chart.tsx new file mode 100644 index 000000000..4d069dcd8 --- /dev/null +++ b/app/components/dashboard/assets-by-status-chart.tsx @@ -0,0 +1,81 @@ +import { useLoaderData } from "@remix-run/react"; +import { DonutChart } from "@tremor/react"; +import { ClientOnly } from "remix-utils/client-only"; +import type { loader } from "~/routes/_layout+/dashboard"; +import { EmptyState } from "./empty-state"; +import { Badge } from "../shared"; +import { InfoTooltip } from "../shared/info-tooltip"; + +export default function AssetsByStatusChart() { + const { assetsByStatus } = useLoaderData(); + + const { chartData, availableAssets, inCustodyAssets } = assetsByStatus; + + return ( + + {() => ( +
+
+
+ Assets by status +
+
+ +
Assets by Status
+

+ Below graph shows how many percent of assets are in which + status{" "} +

+ + } + /> +
+
+
+ {chartData?.length > 0 ? ( +
+ +
+
    +
  • + + + + {availableAssets} + {" "} + Available + + +
  • +
  • + + + + {inCustodyAssets} + {" "} + In Custody + + +
  • +
+
+
+ ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/app/components/dashboard/assets-for-each-month.tsx b/app/components/dashboard/assets-for-each-month.tsx new file mode 100644 index 000000000..78d8d827e --- /dev/null +++ b/app/components/dashboard/assets-for-each-month.tsx @@ -0,0 +1,51 @@ +import { useLoaderData } from "@remix-run/react"; +import { AreaChart, Card, Title } from "@tremor/react"; +import { ClientOnly } from "remix-utils/client-only"; +import type { loader } from "~/routes/_layout+/dashboard"; +import { InfoTooltip } from "../shared/info-tooltip"; + +export default function AssetsForEachMonth() { + const { totalAssetsAtEndOfEachMonth, totalAssets } = + useLoaderData(); + return ( + + {() => ( + + + <div className="flex justify-between"> + <div> + <span className="mb-2 block text-[14px] font-medium"> + Total inventory + </span> + <span className="block text-[30px] font-semibold text-gray-900"> + {totalAssets} assets + </span> + </div> + <InfoTooltip + content={ + <> + <h6>Total inventory</h6> + <p> + Below graph shows the total assets you have created in the + last year + </p> + </> + } + /> + </div> + + + + )} + + ); +} diff --git a/app/components/dashboard/custodians.tsx b/app/components/dashboard/custodians.tsx new file mode 100644 index 000000000..f39050500 --- /dev/null +++ b/app/components/dashboard/custodians.tsx @@ -0,0 +1,95 @@ +import { useLoaderData } from "@remix-run/react"; +import type { TeamMemberWithUser } from "~/modules/team-member/types"; +import type { loader } from "~/routes/_layout+/dashboard"; +import { EmptyState } from "./empty-state"; +import { InfoTooltip } from "../shared/info-tooltip"; +import { Table, Td, Tr } from "../table"; + +export default function CustodiansList() { + const { custodiansData } = useLoaderData(); + + return ( + <> +
+
+
+ Custodians +
+
+ +
Custodians
+

Below listed custodians hold the most assets

+ + } + /> +
+
+
+ + {custodiansData.length > 0 ? ( + + + {custodiansData.map((cd) => ( + + {/** + * @TODO this needs to be resolved. Its because of the createdAt & updatedAt fields. + * We need a global solution for this as it happens everywhere + * @ts-ignore */} + + + ))} + {custodiansData.length < 5 && + Array(5 - custodiansData.length) + .fill(null) + .map((_d, i) => ( + + {""} + + ))} + +
+ ) : ( +
+ +
+ )} + + ); +} + +function Row({ + custodian, + count, +}: { + custodian: TeamMemberWithUser; + count: number; +}) { + return ( + <> + +
+ +
+ {`${custodian.name}'s +
+ {custodian.name} + {count} Assets +
+
+
+
+ + {""} + + ); +} diff --git a/app/components/dashboard/empty-state.tsx b/app/components/dashboard/empty-state.tsx new file mode 100644 index 000000000..54c6dfda1 --- /dev/null +++ b/app/components/dashboard/empty-state.tsx @@ -0,0 +1,12 @@ +export function EmptyState({ text }: { text: string }) { + return ( +
+ Empty state +
{text}
+
+ ); +} diff --git a/app/components/dashboard/most-scanned-assets.tsx b/app/components/dashboard/most-scanned-assets.tsx new file mode 100644 index 000000000..a89779572 --- /dev/null +++ b/app/components/dashboard/most-scanned-assets.tsx @@ -0,0 +1,106 @@ +import type { Asset } from "@prisma/client"; +import { useLoaderData } from "@remix-run/react"; +import type { loader } from "~/routes/_layout+/dashboard"; +import { userFriendlyAssetStatus } from "~/utils"; +import { EmptyState } from "./empty-state"; +import { AssetImage } from "../assets/asset-image"; +import { Badge } from "../shared"; +import { InfoTooltip } from "../shared/info-tooltip"; +import { Td, Table, Tr } from "../table"; + +export default function MostScannedAssets() { + const { mostScannedAssets } = useLoaderData(); + return ( + <> +
+
+
+ Most scanned assets +
+
+ +
Most scanned assets
+

+ Below listed assets were the most scanned among your all + assets +

+ + } + /> +
+
+
+ {mostScannedAssets.length > 0 ? ( + + + {mostScannedAssets.map((asset) => ( + + {/* @TODO resolve this issue + @ts-ignore */} + + + ))} + {mostScannedAssets.length < 5 && + Array(5 - mostScannedAssets.length) + .fill(null) + .map((_d, i) => ( + + {""} + + ))} + +
+ ) : ( +
+ +
+ )} + + ); +} + +const Row = ({ + item, +}: { + item: Asset & { + scanCount?: number; + }; +}) => ( + <> + {/* Item */} + +
+
+
+ +
+
+ + {item.title} + +
+ + {userFriendlyAssetStatus(item.status)} + +
+
+
+
+ + + {/* Category */} + {item.scanCount} scans + +); diff --git a/app/components/dashboard/most-scanned-categories.tsx b/app/components/dashboard/most-scanned-categories.tsx new file mode 100644 index 000000000..06c1c0a4d --- /dev/null +++ b/app/components/dashboard/most-scanned-categories.tsx @@ -0,0 +1,89 @@ +import type { Category } from "@prisma/client"; +import { useLoaderData } from "@remix-run/react"; +import type { loader } from "~/routes/_layout+/dashboard"; +import { EmptyState } from "./empty-state"; +import { InfoTooltip } from "../shared/info-tooltip"; +import { Td, Table, Tr } from "../table"; + +export default function MostScannedCategories() { + const { mostScannedCategories } = useLoaderData(); + return ( + <> +
+
+
+ Most scanned categories +
+
+ +
Most scanned categories
+

+ Below listed categories were the most scanned among your all + categories +

+ + } + /> +
+
+
+ {mostScannedCategories.length > 0 ? ( + + + {mostScannedCategories.map((category, i) => ( + + {/* @TODO resolve this issue + @ts-ignore */} + + + ))} + {mostScannedCategories.length < 5 && + Array(5 - mostScannedCategories.length) + .fill(null) + .map((_d, i) => ( + + {""} + + ))} + +
+ ) : ( +
+ +
+ )} + + ); +} + +const Row = ({ + item, +}: { + item: Category & { + scanCount?: number; + assetCount?: number; + }; +}) => ( + <> + {/* Item */} + +
+
+
+ + {item.name} + + + {item.assetCount} Assets + +
+
+
+ + + {/* Category */} + {item.scanCount} scans + +); diff --git a/app/components/dashboard/newest-assets.tsx b/app/components/dashboard/newest-assets.tsx new file mode 100644 index 000000000..fde7b93aa --- /dev/null +++ b/app/components/dashboard/newest-assets.tsx @@ -0,0 +1,116 @@ +import type { Asset, Category } from "@prisma/client"; +import { useLoaderData } from "@remix-run/react"; +import type { loader } from "~/routes/_layout+/dashboard"; +import { userFriendlyAssetStatus } from "~/utils"; +import { EmptyState } from "./empty-state"; +import { AssetImage } from "../assets/asset-image"; +import { Badge } from "../shared"; +import { InfoTooltip } from "../shared/info-tooltip"; +import { Td, Table, Tr } from "../table"; + +export default function NewestAssets() { + const { newAssets } = useLoaderData(); + return ( + <> +
+
+
+ Newest Assets +
+
+ +
Newest Assets
+

Below listed assets were created recently

+ + } + /> +
+
+
+ {newAssets.length > 0 ? ( + + + {newAssets.map((asset) => ( + + {/* @TODO resolve this issue + @ts-ignore */} + + + ))} + {newAssets.length < 5 && + Array(5 - newAssets.length) + .fill(null) + .map((_d, i) => ( + + {""} + + ))} + +
+ ) : ( +
+ +
+ )} + + ); +} + +const Row = ({ + item, +}: { + item: Asset & { + category?: Category; + }; +}) => { + const { category } = item; + return ( + <> + {/* Item */} + +
+
+
+ +
+
+ + {item.title} + +
+ + {userFriendlyAssetStatus(item.status)} + +
+
+
+
+ + + {/* Category */} + + {category ? ( + + {category.name} + + ) : ( + + {"Uncategorized"} + + )} + + + ); +}; diff --git a/app/components/icons/library.tsx b/app/components/icons/library.tsx index e81897742..cdb42f77e 100644 --- a/app/components/icons/library.tsx +++ b/app/components/icons/library.tsx @@ -940,7 +940,7 @@ export const InfoIcon = (props: SVGProps) => ( > ) => ( /> ); + +export const GraphIcon = (props: SVGProps) => ( + + + +); + +export const StarsIcon = (props: SVGProps) => ( + + + +); diff --git a/app/components/layout/sidebar/menu-items.tsx b/app/components/layout/sidebar/menu-items.tsx index dc32d1656..af3a6a5de 100644 --- a/app/components/layout/sidebar/menu-items.tsx +++ b/app/components/layout/sidebar/menu-items.tsx @@ -5,6 +5,7 @@ import { useAtom } from "jotai"; import { AssetsIcon, CategoriesIcon, + GraphIcon, LocationMarkerIcon, QuestionsIcon, SettingsIcon, @@ -18,6 +19,11 @@ import { toggleMobileNavAtom } from "./atoms"; import { ChatWithAnExpert } from "./chat-with-an-expert"; const menuItemsTop = [ + { + icon: , + to: "dashboard", + label: "Dashboard", + }, { icon: , to: "assets", @@ -64,7 +70,7 @@ const MenuItems = ({ fetcher }: { fetcher: FetcherWithComponents }) => { isActive ? "active bg-primary-50 text-primary-600" : "" ) } - to={"/admin-dashboard"} + to={"/admin-dashboard/users"} onClick={toggleMobileNav} title={"Admin dashboard"} > diff --git a/app/components/shared/badge.tsx b/app/components/shared/badge.tsx index 94943afdf..58f04dfdf 100644 --- a/app/components/shared/badge.tsx +++ b/app/components/shared/badge.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { tw } from "~/utils"; export const Badge = ({ @@ -6,7 +7,7 @@ export const Badge = ({ noBg = false, withDot = true, }: { - children: string; + children: string | ReactNode; color: string; noBg?: boolean; withDot?: boolean; diff --git a/app/components/shared/info-tooltip.tsx b/app/components/shared/info-tooltip.tsx new file mode 100644 index 000000000..65624999a --- /dev/null +++ b/app/components/shared/info-tooltip.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; +import { InfoIcon } from "~/components/icons"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/shared/tooltip"; + +export const InfoTooltip = ({ content }: { content: ReactNode }) => ( + + + + + + + + +
+ {content} +
+
+
+
+); diff --git a/app/database/migrations/20231114082502_create_announcement_model/migration.sql b/app/database/migrations/20231114082502_create_announcement_model/migration.sql new file mode 100644 index 000000000..9327be639 --- /dev/null +++ b/app/database/migrations/20231114082502_create_announcement_model/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "Announcement" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "content" TEXT NOT NULL, + "link" TEXT, + "linkText" TEXT, + "published" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Announcement_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "Announcement" ENABLE row level security; diff --git a/app/database/schema.prisma b/app/database/schema.prisma index 29e456b06..aebf7e3e3 100644 --- a/app/database/schema.prisma +++ b/app/database/schema.prisma @@ -497,3 +497,19 @@ model Invite { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + + +model Announcement { + id String @id @default(cuid()) + + name String + content String + + link String? + linkText String? + published Boolean @default(false) + + // Datetime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/app/modules/category/service.server.ts b/app/modules/category/service.server.ts index 09c90b61d..37d97197f 100644 --- a/app/modules/category/service.server.ts +++ b/app/modules/category/service.server.ts @@ -68,6 +68,11 @@ export async function getCategories({ take, where, orderBy: { updatedAt: "desc" }, + include: { + _count: { + select: { assets: true }, + }, + }, }), /** Count them */ diff --git a/app/modules/team-member/types.ts b/app/modules/team-member/types.ts new file mode 100644 index 000000000..f76dafb45 --- /dev/null +++ b/app/modules/team-member/types.ts @@ -0,0 +1,7 @@ +import type { Prisma } from "@prisma/client"; + +export type TeamMemberWithUser = Prisma.TeamMemberGetPayload<{ + include: { + user: true; + }; +}>; diff --git a/app/routes/_layout+/admin-dashboard.$userId.tsx b/app/routes/_layout+/admin-dashboard+/$userId.tsx similarity index 100% rename from app/routes/_layout+/admin-dashboard.$userId.tsx rename to app/routes/_layout+/admin-dashboard+/$userId.tsx diff --git a/app/routes/_layout+/admin-dashboard.tsx b/app/routes/_layout+/admin-dashboard+/_layout.tsx similarity index 64% rename from app/routes/_layout+/admin-dashboard.tsx rename to app/routes/_layout+/admin-dashboard+/_layout.tsx index 8187a33dc..794db824d 100644 --- a/app/routes/_layout+/admin-dashboard.tsx +++ b/app/routes/_layout+/admin-dashboard+/_layout.tsx @@ -1,6 +1,7 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; import { Link, Outlet } from "@remix-run/react"; import { ErrorBoundryComponent } from "~/components/errors"; +import HorizontalTabs from "~/components/layout/horizontal-tabs"; import { requireAuthSession } from "~/modules/auth"; @@ -14,8 +15,20 @@ export const handle = { breadcrumb: () => Admin dashboard, }; +const items = [ + { to: "users", content: "Users" }, + { to: "announcements", content: "Announcements" }, +]; + export default function Area51Page() { - return ; + return ( +
+ +
+ +
+
+ ); } export const ErrorBoundary = () => ; diff --git a/app/routes/_layout+/admin-dashboard+/announcements.new.tsx b/app/routes/_layout+/admin-dashboard+/announcements.new.tsx new file mode 100644 index 000000000..25ac79810 --- /dev/null +++ b/app/routes/_layout+/admin-dashboard+/announcements.new.tsx @@ -0,0 +1,84 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form } from "@remix-run/react"; +import Input from "~/components/forms/input"; +import { Switch } from "~/components/forms/switch"; +import { MarkdownEditor } from "~/components/markdown"; +import { Button } from "~/components/shared"; +import { db } from "~/database"; +import { requireAuthSession } from "~/modules/auth"; +import { requireAdmin } from "~/utils/roles.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + await requireAuthSession(request); + await requireAdmin(request); + + return json({}); +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + await requireAuthSession(request); + await requireAdmin(request); + + const formData = await request.formData(); + const name = formData.get("name") as string; + const content = formData.get("content") as string; + const link = formData.get("link") as string; + const linkText = formData.get("linkText") as string; + const published = formData.get("published") === "on"; + + await db.announcement.create({ + data: { + name, + content, + link, + linkText, + published, + }, + }); + + return redirect("/admin-dashboard/announcements"); +}; + +export default function NewAnnouncement() { + return ( +
+
+ +
+ + +
+ + +
+ +
+ +
+
+
+ + +
+
+
+ ); +} diff --git a/app/routes/_layout+/admin-dashboard+/announcements.tsx b/app/routes/_layout+/admin-dashboard+/announcements.tsx new file mode 100644 index 000000000..e6f54d442 --- /dev/null +++ b/app/routes/_layout+/admin-dashboard+/announcements.tsx @@ -0,0 +1,103 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { Outlet, useFetcher, useLoaderData } from "@remix-run/react"; +import { Switch } from "~/components/forms/switch"; +import { MarkdownViewer } from "~/components/markdown"; +import { Button } from "~/components/shared"; +import { Table, Td, Th, Tr } from "~/components/table"; +import { db } from "~/database"; +import { requireAuthSession } from "~/modules/auth"; +import { requireAdmin } from "~/utils/roles.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + await requireAuthSession(request); + await requireAdmin(request); + + const announcements = await db.announcement.findMany({ + orderBy: { + createdAt: "desc", + }, + }); + + return json({ announcements }); +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + await requireAuthSession(request); + await requireAdmin(request); + const formData = await request.formData(); + const published = formData.get("published") === "on"; + const announcementId = formData.get("id") as string; + + await db.announcement.update({ + where: { + id: announcementId, + }, + data: { + published, + }, + }); + + return null; +}; + +export default function Announcements() { + const { announcements } = useLoaderData(); + const fetcher = useFetcher(); + return ( +
+
+

Announcements

+ +
+

+ The latest announcement will be visible on the user's dashboard. +

+ + +
+ + + + + + + + + + + + {announcements.map((a) => ( + + + + + + + + ))} + +
NameContentLinkLink TextPublished
{a.name} + + {a.link}{a.linkText} + { + e.preventDefault(); + fetcher.submit(e.currentTarget); + }} + > + + + +
+
+
+ ); +} diff --git a/app/routes/_layout+/admin-dashboard.org.$organizationId.tsx b/app/routes/_layout+/admin-dashboard+/org.$organizationId.tsx similarity index 100% rename from app/routes/_layout+/admin-dashboard.org.$organizationId.tsx rename to app/routes/_layout+/admin-dashboard+/org.$organizationId.tsx diff --git a/app/routes/_layout+/admin-dashboard._index.tsx b/app/routes/_layout+/admin-dashboard+/users.tsx similarity index 96% rename from app/routes/_layout+/admin-dashboard._index.tsx rename to app/routes/_layout+/admin-dashboard+/users.tsx index 2da080b55..76260878b 100644 --- a/app/routes/_layout+/admin-dashboard._index.tsx +++ b/app/routes/_layout+/admin-dashboard+/users.tsx @@ -54,7 +54,7 @@ export default function Area51() { navigate(itemId)} + navigate={(itemId) => navigate(`../${itemId}`)} /> diff --git a/app/routes/_layout+/categories.tsx b/app/routes/_layout+/categories.tsx index 59526f9d8..f7b7f5539 100644 --- a/app/routes/_layout+/categories.tsx +++ b/app/routes/_layout+/categories.tsx @@ -52,6 +52,7 @@ export async function loader({ request }: LoaderFunctionArgs) { singular: "category", plural: "categories", }; + return json( { header, @@ -123,6 +124,7 @@ export default function CategoriesPage() { headerChildren={ <> Description + Assets Actions } @@ -135,7 +137,11 @@ export default function CategoriesPage() { const CategoryItem = ({ item, }: { - item: Pick; + item: Pick & { + _count: { + assets: number; + }; + }; }) => ( <> @@ -146,6 +152,7 @@ const CategoryItem = ({ {item.description} + {item._count.assets}