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

Add UI for viewing objections #495

Merged
merged 13 commits into from
Sep 28, 2024
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
Loading