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/user ticket UI #536

Merged
merged 17 commits into from
Sep 30, 2024
Merged
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
2 changes: 1 addition & 1 deletion api/handlers/dispute/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ func (m *disputeModelReal) GetDisputeExperts(disputeId int64) (experts []models.
Select("users.id, users.first_name || ' ' || users.surname AS full_name, email, users.phone_number AS phone, role").
Joins("JOIN users ON dispute_experts_view.expert = users.id").
Where("dispute = ?", disputeId).
Where("dispute_experts_view.status = 'Approved'").
Where("dispute_experts_view.status IN ('Approved', 'Review')").
Where("role = 'Mediator' OR role = 'Arbitrator' OR role = 'Conciliator' OR role = 'expert'").
Find(&experts).Error

Expand Down
19 changes: 10 additions & 9 deletions api/handlers/ticket/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ func (t *TicketModelReal) CreateTicket(userID int64, dispute int64, subject stri
err := t.db.Where("id = ?", dispute).
Where("complainant = ? OR respondant = ?", userID, userID).
First(&disputeModel).Error
if err != nil {
logger.Warn("User might be an expert")
err = t.db.Where("id = ?", dispute).
Where("expert = ?", userID).First(&disputeModel).Error
}
if err != nil {
logger.WithError(err).Error("Error creating ticket")
return models.Ticket{}, err
Expand Down Expand Up @@ -265,20 +270,16 @@ func (t *TicketModelReal) GetTicketsByUserID(uid int64, searchTerm *string, limi
queryParams = append(queryParams, uid)
countParams = append(countParams, uid)
if searchTerm != nil {
queryString.WriteString(" AND WHERE t.subject LIKE ?")
countString.WriteString(" AND WHERE t.subject LIKE ?")
queryString.WriteString(" AND t.subject LIKE ?")
countString.WriteString(" AND t.subject LIKE ?")
queryParams = append(queryParams, "%"+*searchTerm+"%")
countParams = append(countParams, "%"+*searchTerm+"%")
}

if filters != nil && len(*filters) > 0 {
if searchTerm != nil {
queryString.WriteString(" AND ")
countString.WriteString(" AND ")
} else {
queryString.WriteString(" AND WHERE ")
countString.WriteString(" AND WHERE ")
}
queryString.WriteString(" AND ")
countString.WriteString(" AND ")

for i, filter := range *filters {
queryString.WriteString("t." + filter.Attr + " = ?")
countString.WriteString("t." + filter.Attr + " = ?")
Expand Down
2 changes: 1 addition & 1 deletion api/models/requestModels.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ type Filter struct {
Attr string `json:"attr"`

// The value to search for
Value string `json:"value"`
Value interface{} `json:"value"`
}

type DateFilter struct {
Expand Down
6 changes: 6 additions & 0 deletions api/models/utilityModels.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ type AdminDisputeExperts struct {
Status string `gorm:"column:status" json:"status"`
}

type DisputeExpertsView struct {
Dispute int64 `gorm:"column:dispute" json:"dispute"`
Expert int64 `gorm:"column:expert" json:"expert"`
Status ExpertStatus `gorm:"column:status" json:"status"`
}

// type TicketMessage struct {
// ID
// }
54 changes: 20 additions & 34 deletions frontend/src/app/disputes/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { getDisputeDetails } from "@/lib/api/dispute";
import { getDisputeDetails, getDisputeWorkflow } from "@/lib/api/dispute";
import { Metadata } from "next";

import DisputeClientPage from "./client-page";
Expand All @@ -14,6 +14,9 @@ import DisputeDecisionForm from "@/components/dispute/decision-form";
import CreateTicketDialog from "@/components/dispute/ticket-form";
import { Button } from "@/components/ui/button";
import WorkflowSelect from "@/components/form/workflow-select";
import DisputeHeader from "@/components/dispute/dispute-header";
import Link from "next/link";
import { State } from "@/lib/interfaces/workflow";

type Props = {
params: { id: string };
Expand All @@ -28,17 +31,19 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {

export default async function DisputePage({ params }: Props) {
const { data, error } = await getDisputeDetails(params.id);
const workflow = await getDisputeWorkflow(params.id);
if (error || !data) {
return <h1>{error}</h1>;
}

return (
<div className="grow overflow-y-auto flex flex-col">
<DisputeHeader
<DisputeHeader2
id={data.id}
label={data.title}
startDate={data.case_date.substring(0, 10)}
status={data.status}
state={workflow.definition.states[workflow.current_state]}
/>
<Separator />
<ScrollArea className="grow overflow-y-auto p-4">
Expand All @@ -49,49 +54,30 @@ export default async function DisputePage({ params }: Props) {
);
}

function DisputeHeader({
id,
label,
startDate,
status: initialStatus,
}: {
function DisputeHeader2(props: {
id: string;
label: string;
startDate: string;
status: string;
state: State;
}) {
// TODO: Add contracts for this
const user = (jwtDecode(getAuthToken()) as any).user.id;
const role = (jwtDecode(getAuthToken()) as any).user.role;

return (
<header className="p-4 py-6 flex items-start">
<div className="grow">
<h1 className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-2xl">{label}</h1>
<p className="mb-4">Started: {startDate}</p>
{/*TODO: Figure out the conditions for displaying expert rejection */}
{role == "expert" && <ExpertRejectForm expertId={user} disputeId={id} />}
<DisputeHeader {...props}>
{role == "expert" && <ExpertRejectForm expertId={user} disputeId={props.id} />}

{role == "expert" && (
<DisputeDecisionForm disputeId={id} asChild>
<Button>Render decision</Button>
</DisputeDecisionForm>
)}
{role == "expert" && (
<DisputeDecisionForm disputeId={props.id} asChild>
<Button>Render decision</Button>
</DisputeDecisionForm>
)}

<CreateTicketDialog asChild dispute={id}>
<Button>Create ticket</Button>
</CreateTicketDialog>

</div>

<dl className="grid grid-cols-2 gap-2">
<dt className="text-right font-bold">Dispute ID:</dt>
<dd>{id}</dd>
<dt className="text-right font-bold">Status:</dt>
<dd>
<StatusDropdown disputeId={id} status={initialStatus} />
</dd>
</dl>
</header>
<Button variant="outline" asChild>
<Link href={`/disputes/${props.id}/tickets`}>Go to tickets</Link>
</Button>
</DisputeHeader>
);
}
49 changes: 49 additions & 0 deletions frontend/src/app/disputes/[id]/tickets/[tid]/message-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";

import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { addTicketMessage } from "@/lib/api/tickets";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";

const messageSchema = z.object({
message: z.string().trim().min(1),
});
type MessageData = z.infer<typeof messageSchema>;

export default function MessageForm({ ticket }: { ticket: number }) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<MessageData>({
resolver: zodResolver(messageSchema),
});

async function onSubmit(data: MessageData) {
await addTicketMessage(ticket, data.message);
}

return (
<Card asChild>
<form onSubmit={handleSubmit(onSubmit)}>
<CardHeader>
<CardTitle>Send a message</CardTitle>
</CardHeader>
<CardContent>
<Textarea {...register("message")} />
</CardContent>
<CardFooter className="justify-end">
{errors.message && (
<p role="alert" className="text-red-500 grow">
{errors.message.message}
</p>
)}
<Button>Send</Button>
</CardFooter>
</form>
</Card>
);
}
59 changes: 59 additions & 0 deletions frontend/src/app/disputes/[id]/tickets/[tid]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { getTicketDetails } from "@/lib/api/tickets";
import MessageForm from "./message-form";
import Link from "next/link";
import { ChevronLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";

type Props = {
params: { tid: string; id: string };
};

export default async function TicketDetails({ params: { tid, id } }: Props) {
const details = await getTicketDetails(parseInt(tid));

return (
<div className="grid grid-rows-[auto_1fr] w-full">
<header className="p-4 py-6 border-b border-dre-200/30 grid grid-cols-[auto_1fr] gap-2">
<div>
<Button
asChild
className="rounded-full aspect-square p-1 justify-center"
variant="ghost"
title="Back to tickets"
>
<Link href={`/disputes/${id}/tickets`}>
<ChevronLeftIcon />
</Link>
</Button>
</div>
<div>
<h1 className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-2xl">
{details.subject}
</h1>
<p>Status: {details.status}</p>
</div>
</header>
<main className="p-4 space-y-4 overflow-y-auto">
<Card>
<CardHeader>
<CardTitle>Description</CardTitle>
<CardDescription>{details.body}</CardDescription>
</CardHeader>
</Card>
{details.messages.map((ticket) => (
<Card key={ticket.id}>
<CardHeader>
<CardTitle>{ticket.user.full_name}</CardTitle>
<CardDescription>Sent on {ticket.date_sent}</CardDescription>
</CardHeader>
<CardContent asChild>
<p>{ticket.message}</p>
</CardContent>
</Card>
))}
<MessageForm ticket={parseInt(tid)} />
</main>
</div>
);
}
65 changes: 65 additions & 0 deletions frontend/src/app/disputes/[id]/tickets/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import DisputeHeader from "@/components/dispute/dispute-header";
import CreateTicketDialog from "@/components/dispute/ticket-form";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { getTicketSummaries } from "@/lib/api/tickets";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import Link from "next/link";

type Props = {
params: { id: string };
};

export default async function TicketsPage({ params: { id } }: Props) {
const data = await getTicketSummaries(parseInt(id));
return (
<div className="grid grid-rows-[auto_1fr] w-full">
<header className="p-4 py-6 border-b border-dre-200/30 grid grid-cols-[auto_1fr_auto] gap-2">
<div>
<Button
asChild
className="rounded-full aspect-square p-1 justify-center"
variant="ghost"
title="Back to dispute"
>
<Link href={`/disputes/${id}`}>
<ChevronLeftIcon />
</Link>
</Button>
</div>
<div>
<h1 className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-2xl">
Dispute tickets
</h1>
<p>Dispute ID: {id}</p>
</div>
<CreateTicketDialog asChild dispute={id}>
<Button className="mt-3">Create ticket</Button>
</CreateTicketDialog>
</header>
<main className="p-4 py-6">
<ul className="space-y-6">
{data.tickets.map((ticket) => (
<Card key={ticket.id} className="p-4 grid grid-cols-[1fr_auto_auto] items-center gap-3">
<div className="space-y-2">
<CardTitle>{ticket.subject}</CardTitle>
<CardDescription>Opened on {ticket.date_created}</CardDescription>
</div>
<p>{ticket.status}</p>
<Button asChild variant="outline">
<Link href={`./tickets/${ticket.id}`}>Read more...</Link>
</Button>
</Card>
))}
</ul>
</main>
</div>
);
}
2 changes: 1 addition & 1 deletion frontend/src/app/disputes/clientSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default function ClientSearch() {
{filteredData.length > 0 ? (
filteredData.map((d) => (
<li key={d.id}>
<DisputeLink href={`/disputes/${d.id}`} role={d.role} title={d.title} />
<DisputeLink dispute={d.id} role={d.role} title={d.title} />
</li>
))
) : (
Expand Down
18 changes: 13 additions & 5 deletions frontend/src/app/disputes/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,21 @@ import { Role } from "@/lib/interfaces/dispute";
import { cn } from "@/lib/utils";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useParams, usePathname } from "next/navigation";

export function DisputeLink({ href, role, title }: { title: string; role: Role; href: string }) {
const pathname = usePathname();
const c = cn(buttonVariants({ variant: pathname == href ? "default" : "ghost" }), "flex");
export function DisputeLink({
dispute,
role,
title,
}: {
title: string;
role: Role;
dispute: string;
}) {
const { id } = useParams();
const c = cn(buttonVariants({ variant: dispute == id ? "default" : "ghost" }), "flex");
return (
<Link href={href} className={c}>
<Link href={`/disputes/${dispute}`} className={c}>
<span className="grow truncate" title={title}>
{title}
</span>
Expand Down
Loading
Loading