Skip to content

Commit

Permalink
Merge pull request #495 from COS301-SE-2024/feat/objection-ui
Browse files Browse the repository at this point in the history
Add UI for viewing objections
  • Loading branch information
vfeistel authored Sep 28, 2024
2 parents 2f52230 + 6102df9 commit 9b0bb03
Show file tree
Hide file tree
Showing 22 changed files with 446 additions and 151 deletions.
169 changes: 148 additions & 21 deletions admin-frontend/src/app/(pages)/disputes/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@
import { Button } from "@/components/ui/button";

import { DialogClose, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { X } from "lucide-react";
import { UserIcon, X } from "lucide-react";
import Sidebar from "@/components/admin/sidebar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { StatusBadge, StatusDropdown } from "@/components/admin/status-dropdown";
import { DisputeStatusBadge, ExpertStatusBadge } from "@/components/admin/status-badge";
import {
type UserDetails,
type DisputeDetails,
DisputeStatus,
DisputeDetailsResponse,
type DisputeStatus,
type DisputeDetailsResponse,
type ExpertSummary,
} from "@/lib/types/dispute";
import { changeDisputeStatus, getDisputeDetails } from "@/lib/api/dispute";
import { useToast } from "@/lib/hooks/use-toast";
Expand All @@ -28,22 +29,34 @@ import {
import { Download, EllipsisVertical, FileText, Trash } from "lucide-react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ObjectionStatusBadge } from "@/components/admin/status-badge";
import { DisputeStatusDropdown, ObjectionStatusDropdown } from "@/components/admin/status-dropdown";
import { changeObjectionStatus, getExpertObjections } from "@/lib/api/expert";
import { DISPUTE_DETAILS_KEY, DISPUTE_LIST_KEY } from "@/lib/constants";
import { ObjectionListResponse, ObjectionStatus } from "@/lib/types/experts";

export default function DisputeDetails({ id: disputeId }: { id: string }) {
export default function DisputeDetails({ id: disputeId }: { id: number }) {
const { toast } = useToast();
const client = useQueryClient();
const { data, error } = useQuery({
queryKey: ["dispute"],
const details = useQuery({
queryKey: [DISPUTE_DETAILS_KEY, disputeId],
queryFn: async () => getDisputeDetails(disputeId),
});
const objections = useQuery({
queryKey: [DISPUTE_DETAILS_KEY, disputeId, "objections"],
queryFn: async () => getExpertObjections(disputeId),
});

const status = useMutation({
mutationFn: (status: DisputeStatus) => changeDisputeStatus(disputeId, status),
onSuccess: (data, variables) => {
client.setQueryData(["dispute"], (old: DisputeDetailsResponse) => ({
client.setQueryData([DISPUTE_DETAILS_KEY, disputeId], (old: DisputeDetailsResponse) => ({
...old,
status: variables,
}));
client.invalidateQueries({
queryKey: [DISPUTE_LIST_KEY],
});
toast({
title: "Status updated successfully",
});
Expand All @@ -58,10 +71,10 @@ export default function DisputeDetails({ id: disputeId }: { id: string }) {
});

return (
data && (
details.data && (
<Sidebar open className="p-6 md:pl-8 rounded-l-2xl flex flex-col">
<DialogHeader className="grid grid-cols-[1fr_auto] gap-2 border-b pb-6 mb-6 border-primary-500/50 space-y-0 items-center">
<DialogTitle className="p-2">{data.title}</DialogTitle>
<DialogTitle className="p-2">{details.data.title}</DialogTitle>
<div className="flex justify-end items-start">
<DialogClose asChild>
<Button variant="ghost" className="rounded-full aspect-square p-2 m-0">
Expand All @@ -70,29 +83,31 @@ export default function DisputeDetails({ id: disputeId }: { id: string }) {
</DialogClose>
</div>
<div className="flex gap-2 items-center">
<StatusDropdown
initialValue={data.status}
<DisputeStatusDropdown
initialValue={details.data.status}
onSelect={(val) => status.mutate(val)}
disabled={status.isPending}
>
<StatusBadge dropdown value={data.status} />
</StatusDropdown>
<span>{data.date_filed}</span>
<DisputeStatusBadge dropdown variant={details.data.status}>
{details.data.status}
</DisputeStatusBadge>
</DisputeStatusDropdown>
<span>{details.data.date_filed}</span>
</div>

<p>Case Number: {data.id}</p>
<p>Case Number: {details.data.id}</p>
</DialogHeader>
<div className="overflow-y-auto grow space-y-6 pr-3">
<Card>
<CardHeader>
<CardTitle>Overview</CardTitle>
<CardDescription>{data.description}</CardDescription>
<CardDescription>{details.data.description}</CardDescription>
</CardHeader>
<CardContent>
<h4 className="mb-1">Evidence</h4>
<ul className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-2">
{data.evidence.length > 0 ? (
data.evidence.map((evi) => (
{details.data.evidence.length > 0 ? (
details.data.evidence.map((evi) => (
<Evidence
key={evi.id}
id={evi.id}
Expand All @@ -113,14 +128,42 @@ export default function DisputeDetails({ id: disputeId }: { id: string }) {
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-3">
<section className="space-y-3">
<CardTitle>Complainant</CardTitle>
<UserDetails {...data.complainant} />
<UserDetails {...details.data.complainant} />
</section>
<section className="space-y-3">
<CardTitle>Respondent</CardTitle>
<UserDetails {...data.respondent} />
<UserDetails {...details.data.respondent} />
</section>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Experts</CardTitle>
<CardDescription>See who is assigned to the case.</CardDescription>
</CardHeader>
<CardContent>
<ul className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-2">
{details.data?.experts.length == 0 && (
<li className="text-sm text-black/50 dark:text-white/50">
No experts are assigned to the case.
</li>
)}
{details.data?.experts.map((exp) => (
<ExpertAssignment key={exp.id} {...exp} />
))}
</ul>
{!objections.isPending && objections.data!.length > 0 && (
<>
<CardTitle className="mt-5 text-lg">Objections</CardTitle>
<ul className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-2">
{objections.data!.map((obj) => (
<Objection key={obj.id} disputeId={disputeId} {...obj} />
))}
</ul>
</>
)}
</CardContent>
</Card>
</div>
</Sidebar>
)
Expand Down Expand Up @@ -183,3 +226,87 @@ function Evidence({
</li>
);
}

function ExpertAssignment({ id, full_name, status }: ExpertSummary) {
return (
<li className="grid grid-cols-[auto_1fr_auto] gap-2 items-center px-3 py-2 border border-primary-500/30 rounded-md">
<UserIcon size="1.7rem" />
<span className="truncate">{full_name}</span>
<ExpertStatusBadge variant={status}>{status}</ExpertStatusBadge>
</li>
);
}

function Objection({
disputeId,
id,
ticket_id,
expert_name,
user_name,
date_submitted,
status,
}: {
disputeId: number;
id: number;
ticket_id: number;
expert_name: string;
user_name: string;
date_submitted: string;
status: ObjectionStatus;
}) {
const { toast } = useToast();
const client = useQueryClient();
const statusMut = useMutation({
mutationFn: (data: ObjectionStatus) => changeObjectionStatus(disputeId, id, data),

onSuccess: (data, variables) => {
client.setQueryData(
[DISPUTE_DETAILS_KEY, disputeId, "objections"],
(old: ObjectionListResponse) =>
old.map((obj) =>
obj.id !== id
? obj
: {
...obj,
status: variables,
}
)
);
client.invalidateQueries({
queryKey: [DISPUTE_LIST_KEY, disputeId],
});
toast({
title: "Objection status updated successfully",
});
},
onError: (error) => {
toast({
variant: "error",
title: "Something went wrong",
description: error?.message,
});
},
});

return (
<li className="grid grid-cols-[1fr_auto] gap-2 items-center px-3 py-2 border border-primary-500/30 rounded-md">
<div>
<Link
className="hover:underline truncate"
href={{ pathname: "/tickets", query: { id: ticket_id.toString() } }}
>
{expert_name}
</Link>
<br />
<span className="truncate opacity-50">
by {user_name}, {date_submitted}
</span>
</div>
<ObjectionStatusDropdown onSelect={(s) => statusMut.mutate(s)}>
<ObjectionStatusBadge dropdown variant={status}>
{status}
</ObjectionStatusBadge>
</ObjectionStatusDropdown>
</li>
);
}
10 changes: 7 additions & 3 deletions admin-frontend/src/app/(pages)/disputes/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,20 @@ import { useState } from "react";
import DisputeFilters from "./dispute-filter";

const searchSchema = z.object({
id: z.string().optional(),
id: z.coerce
.number({
message: "Invalid dispute ID",
})
.optional(),
});

export default function Disputes({ searchParams }: { searchParams: unknown }) {
const { data: params, error: searchError } = searchSchema.safeParse(searchParams);
if (!params) {
throw new Error(JSON.stringify(searchError));
throw new Error(searchError.issues[0].message);
}

const client = new QueryClient();
const [client] = useState(new QueryClient());

const [filter, setFilter] = useState<DisputeFilter[]>([]);
const [page, setPage] = useState<number>(0);
Expand Down
10 changes: 5 additions & 5 deletions admin-frontend/src/app/(pages)/disputes/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { getDisputeList } from "@/lib/api/dispute";
import { useQuery } from "@tanstack/react-query";
import { AdminDisputesResponse, DisputeFilter, Filter, type AdminDispute } from "@/lib/types";

import { StatusBadge } from "@/components/admin/status-dropdown";
import { DisputeStatusBadge } from "@/components/admin/status-badge";
import { LinkIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
Expand All @@ -24,11 +24,11 @@ import {
PaginationPrevious,
PaginationNext,
} from "@/components/ui/pagination";
import { PAGE_SIZE } from "@/lib/constants";
import { DISPUTE_LIST_KEY, PAGE_SIZE } from "@/lib/constants";

export function DisputeTable({ filters, page = 0 }: { filters?: DisputeFilter[]; page: number }) {
const { data, error, isPending } = useQuery({
queryKey: ["disputeTable", filters, page],
queryKey: [DISPUTE_LIST_KEY, filters, page],
queryFn: () =>
getDisputeList({
filter: filters,
Expand Down Expand Up @@ -79,7 +79,7 @@ function DisputeRow(props: AdminDispute) {
<Link href={{ pathname: "/disputes", query: { id: props.id } }}>{props.title}</Link>
</TableCell>
<TableCell>
<StatusBadge value={props.status} />
<DisputeStatusBadge variant={props.status}>{props.status}</DisputeStatusBadge>
</TableCell>
<TableCell>
<Link
Expand All @@ -106,7 +106,7 @@ export function DisputePager({
page?: number;
}) {
const query = useQuery<AdminDisputesResponse>({
queryKey: ["disputeTable", filters, page],
queryKey: [DISPUTE_LIST_KEY, filters, page],
});

const [current, setCurrent] = useState(page);
Expand Down
8 changes: 6 additions & 2 deletions admin-frontend/src/app/(pages)/tickets/details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { FormEvent } from "react";
import { TICKET_DETAILS_KEY, TICKET_LIST_KEY } from "@/lib/constants";
import Link from "next/link";

export default function TicketDetails({ ticketId }: { ticketId: number }) {
const { data, error } = useQuery({
Expand Down Expand Up @@ -81,13 +82,16 @@ export default function TicketDetails({ ticketId }: { ticketId: number }) {
<Sidebar open className="p-6 md:pl-8 rounded-l-2xl flex flex-col">
{data && (
<>
<SidebarHeader title={data.subject} className="flex gap-2 items-center">
<SidebarHeader title={data.subject} className="flex gap-2 items-center flex-wrap">
<TicketStatusDropdown onSelect={status.mutate}>
<TicketStatusBadge variant={data.status} dropdown>
{data.status}
</TicketStatusBadge>
</TicketStatusDropdown>
<span>{data.date_created}</span>
<span className="grow">{data.date_created}</span>
<Link href={{ pathname: "/disputes", query: { id: data.dispute_id } }}>
Go to dispute
</Link>
</SidebarHeader>
<div className="overflow-y-auto grow space-y-6 pr-3">
<Card>
Expand Down
8 changes: 6 additions & 2 deletions admin-frontend/src/app/(pages)/tickets/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ import { useState } from "react";
import TicketFilters from "./ticket-filters";

const searchSchema = z.object({
id: z.number().optional(),
id: z.coerce
.number({
message: "Invalid ticket ID",
})
.optional(),
});

export default function Tickets({ searchParams }: { searchParams: unknown }) {
const { data: params, error: searchError } = searchSchema.safeParse(searchParams);
if (!params) {
throw new Error(JSON.stringify(searchError));
throw new Error(searchError.issues[0].message);
}

const [client] = useState(new QueryClient());
Expand Down
Loading

0 comments on commit 9b0bb03

Please sign in to comment.