Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat[frontend]: new follow up mode #18

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions frontend/src/components/meeting/followup/MeetingFollowUpOverview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import {
Accordion,
AccordionItem,
BreadcrumbItem,
Breadcrumbs,
Card,
Divider,
ScrollShadow,
} from "@nextui-org/react"
import clsx from "clsx"
import Head from "next/head"
import { Fragment, ReactNode } from "react"
import { BsChevronRight, BsHouse } from "react-icons/bs"
import { BarLoader } from "react-spinners"

import { extractErrorMessage } from "@/api/util"
import MeetingSelectBreadcrumbs from "@/components/meeting/breadcrumbs/MeetingSelectBreadcrumbs"
import { MeetingTagChips } from "@/components/meeting/MeetingTagChips"
import ProjectSelectBreadcrumbs from "@/components/project/breadcrumbs/ProjectSelectBreadcrumbs"
import GlowingCard from "@/components/ui/card/glow/GlowingCardItem"
import GlowingCards from "@/components/ui/card/glow/GlowingCardsContainer"
import Flex from "@/components/ui/layout/Flex"
import RenderMarkdown from "@/components/ui/text/RenderMarkdown"
import { useAuth } from "@/contexts/AuthContext"
import { getMeetingURL } from "@/util/url"

export default function MeetingFollowUpOverview({
projectID,
meetingID,
}: {
projectID: number
meetingID: number
}) {
const { meetings, topics, actions } = useAuth()

const meetingInfoQuery = meetings!.useFind(projectID, meetingID)
const topicListQuery = topics!.useList(projectID, meetingID)
const actionListQuery = actions!.useListForMeeting(projectID, meetingID)

if (meetingInfoQuery.isLoading) {
return <BarLoader color="white" />
}
if (meetingInfoQuery.isError) {
return <>Error: {extractErrorMessage(meetingInfoQuery.error)}</>
}

const meeting = meetingInfoQuery.data.data

const actionList = actionListQuery.data?.data ?? []
const openActions = actionList.filter((action) => !action.closed_at.Valid)
const closedActions = actionList.filter((action) => action.closed_at.Valid)

const topicList = topicListQuery.data?.data ?? []
const openTopics = topicList.filter((topic) => !topic.closed_at.Valid)
const closedTopics = topicList.filter((topic) => topic.closed_at.Valid)

return (
<div className="flex h-full w-full flex-col">
<Head>
<title>Perplex - F/U M# {meeting.name ?? "Unknown Project"}</title>
</Head>

<div className="mb-4">
<Breadcrumbs>
<BreadcrumbItem href="/" startContent={<BsHouse />}>
Home
</BreadcrumbItem>
<BreadcrumbItem href={`/project/${projectID}`}>
<ProjectSelectBreadcrumbs projectID={projectID} />
</BreadcrumbItem>
<BreadcrumbItem href={getMeetingURL(projectID, meetingID)}>
<MeetingSelectBreadcrumbs
meetingID={meeting.ID}
meetingName={meeting.name}
projectID={projectID}
/>
</BreadcrumbItem>
<BreadcrumbItem>Follow Up</BreadcrumbItem>
</Breadcrumbs>
</div>

<div className="flex w-full items-center justify-center">
<main className="flex w-full max-w-5xl flex-col items-center space-y-3 px-10 lg:px-0">
<section className="flex w-full flex-col items-center justify-center space-y-2">
<h2 className="w-fit rounded-full bg-neutral-900 px-3 py-1 text-tiny font-medium uppercase text-neutral-400">
Follow Up
</h2>
<h1 className="text-4xl font-semibold">{meeting.name}</h1>
<ScrollShadow
orientation="horizontal"
className="max-w-xs md:max-w-md lg:max-w-lg xl:max-w-xl"
hideScrollBar
>
<MeetingTagChips tags={meeting.tags} />
</ScrollShadow>
</section>

{meeting.description && (
<Accordion isCompact>
<AccordionItem
title="Description"
className="rounded-md bg-neutral-900 px-4 py-2"
>
<RenderMarkdown markdown={meeting.description} />
</AccordionItem>
</Accordion>
)}

<Divider />

<GlowingCards className="grid w-full gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<StatisticCard
title="Topics"
stats={[
{
title: "Open",
value: openTopics.length,
isDanger: !!openTopics.length,
},
{ title: "Closed", value: closedTopics.length },
]}
/>
<StatisticCard
title="Actions"
stats={[
{
title: "Open",
value: openActions.length,
isDanger: !!openActions.length,
},
{ title: "Closed", value: closedActions.length },
]}
/>
<StatisticCard
title="Comments"
stats={[
{ title: "During", value: 11 },
{ title: "After", value: 4 },
]}
/>
</GlowingCards>
</main>
</div>
</div>
)
}

type Statistic = {
title: string
value: any
isDanger?: boolean
}

function StatisticCard({
title,
stats,
}: {
title: string | ReactNode
stats: Statistic[]
}) {
return (
<GlowingCard
as={Card}
isPressable
classNames={{
content: "bg-neutral-900 flex flex-col items-center overflow-hidden",
}}
>
<span className="w-full bg-neutral-950 p-2 text-center text-sm font-semibold uppercase text-neutral-200">
{title}
</span>
<div className="flex w-full items-center justify-between space-x-4 px-6 py-4">
{stats.map((stat, index) => (
<Fragment key={index}>
{index > 0 && <BsChevronRight className="text-neutral-400" />}
<Flex col>
<h2 className="text-tiny uppercase text-neutral-400">
{stat.title}
</h2>
<h1
className={clsx("text-4xl font-bold", {
"text-orange-500": stat.isDanger,
})}
>
{stat.value}
</h1>
</Flex>
</Fragment>
))}
</div>
</GlowingCard>
)
}
203 changes: 203 additions & 0 deletions frontend/src/components/meeting/followup/MeetingFollowUpTabActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import {
Accordion,
AccordionItem,
Avatar,
AvatarGroup,
Chip,
Link,
ScrollShadow,
} from "@nextui-org/react"
import clsx from "clsx"
import { BsCheck, BsCheckAll, BsRecord2, BsTriangleFill } from "react-icons/bs"

import { Action, Meeting } from "@/api/types"
import Admonition from "@/components/ui/Admonition"
import Flex from "@/components/ui/layout/Flex"
import { TruncateTitle } from "@/components/ui/text/TruncateText"
import { useAuth } from "@/contexts/AuthContext"
import { getUserAvatarURL } from "@/util/avatar"
import { getActionURL } from "@/util/url"

export function MeetingFollowUpTabActions({
openActions,
closedActions,
}: {
openActions: Action[]
closedActions: Action[]
}) {
return (
<>
{/* Action Follow Up Banner */}
{openActions.length === 0 ? (
<Admonition style="success" fullWidth>
<BsCheckAll />
<span>
All actions that were assigned to a meeting topic have been closed.
</span>
</Admonition>
) : (
<Admonition style="danger" fullWidth>
<BsTriangleFill />
<span>
There are still open actions that were assigned to a meeting topic.
</span>
</Admonition>
)}

<div className="mt-2 flex items-start space-x-2">
{/* Actions closed by last {date-selector} */}
<Accordion defaultExpandedKeys={["closed"]}>
<AccordionItem
key="closed"
title={
<Flex gap={1} className="text-md">
<BsCheckAll />
<span className="font-semibold text-neutral-300">Finished</span>
<Chip variant="faded" size="sm">
{closedActions.length}
</Chip>
</Flex>
}
isCompact
>
<MeetingFollowUpActionList actions={closedActions} />
</AccordionItem>
</Accordion>

{/* Actions still open */}
<Accordion defaultExpandedKeys={["open"]}>
<AccordionItem
key="open"
title={
<Flex gap={1} className="text-md">
<BsRecord2 />
<span className="font-semibold text-neutral-300">
Still Open
</span>
<Chip variant="faded" size="sm">
{openActions.length}
</Chip>
</Flex>
}
isCompact
>
<MeetingFollowUpActionList actions={openActions} />
</AccordionItem>
</Accordion>
</div>
</>
)
}

export function MeetingFollowUpTabActionsWrapper({
meeting,
}: {
meeting: Meeting
}) {
const { actions } = useAuth()

const actionListQuery = actions!.useListForMeeting(
meeting.project_id,
meeting.ID,
)

if (actionListQuery.isLoading) {
return <span>Loading Actions...</span>
}

const actionsList: Action[] = actionListQuery.data?.data || []
if (actionsList.length === 0) {
return (
<Flex col>
<h2 className="text-lg font-semibold">No Actions</h2>
<span>No actions found that were assigned to a meeting topic</span>
</Flex>
)
}

const actionListOpen = actionsList
.filter((action) => !action.closed_at.Valid)
.sort((a, b) => a.ID - b.ID)

const actionListClosed = actionsList
.filter((action) => action.closed_at.Valid)
.sort((a, b) => a.ID - b.ID)

return (
<>
<MeetingFollowUpTabActions
openActions={actionListOpen}
closedActions={actionListClosed}
/>
</>
)
}

function MeetingFollowUpActionList({ actions }: { actions: Action[] }) {
return (
<ScrollShadow className="max-h-80">
{actions.map((action) => (
<Link
className={clsx(
"flex items-center justify-between rounded-md border border-transparent p-2 transition duration-150 ease-in-out",
"hover:cursor-pointer hover:border-neutral-800 hover:bg-neutral-900",
)}
key={action.ID}
href={getActionURL(action.project_id, action.ID)}
>
<div className="flex flex-col">
<Flex gap={2}>
<span
className={clsx("text-2xl", {
"text-neutral-500": action.closed_at.Valid,
"text-red-500": !action.closed_at.Valid,
})}
>
{action.closed_at.Valid ? <BsCheck /> : <BsRecord2 />}
</span>
<TruncateTitle truncate={20} className="truncate">
{action.title}
</TruncateTitle>
<span className="text-neutral-500">#{action.ID}</span>
</Flex>

{/* Action Actions */}
<Flex gap={2} className="mt-4">
<AvatarGroup max={3} size="sm">
{action.assigned_users.map((user) => (
<Avatar key={user.id} src={getUserAvatarURL(user.id)} />
))}
</AvatarGroup>
<ScrollShadow
orientation="horizontal"
hideScrollBar
className="max-w-xs"
>
<Flex gap={1}>
{action.tags?.length > 0 ? (
action.tags.map((tag) => (
<Chip
key={tag.ID}
className="whitespace-nowrap"
variant="bordered"
style={{
borderColor: tag.color,
}}
>
{tag.title}
</Chip>
))
) : (
<span className="text-sm italic text-default-400">
No Tags
</span>
)}
</Flex>
</ScrollShadow>
</Flex>
</div>
</Link>
))}
</ScrollShadow>
)
}
Loading