405 lines
16 KiB
TypeScript

'use client';
import { Empty } from '@/components/empty';
import { ProList, ProListActions } from '@/components/pro-list';
import {
createUserTicket,
createUserTicketFollow,
getUserTicketDetails,
getUserTicketList,
updateUserTicketStatus,
} from '@/services/user/ticket';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@workspace/ui/components/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@workspace/ui/components/dialog';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@workspace/ui/components/drawer';
import { Input } from '@workspace/ui/components/input';
import { Label } from '@workspace/ui/components/label';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import { Textarea } from '@workspace/ui/components/textarea';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { Icon } from '@workspace/ui/custom-components/icon';
import { cn } from '@workspace/ui/lib/utils';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import NextImage from 'next/legacy/image';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
export default function Page() {
const t = useTranslations('ticket');
const [ticketId, setTicketId] = useState<any>(null);
const [message, setMessage] = useState('');
const { data: ticket, refetch: refetchTicket } = useQuery({
queryKey: ['getUserTicketDetails', ticketId],
queryFn: async () => {
const { data } = await getUserTicketDetails({ id: ticketId });
return data.data as API.Ticket;
},
enabled: !!ticketId,
refetchInterval: 5000,
});
const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setTimeout(() => {
if (scrollRef.current) {
scrollRef.current.children[1]?.scrollTo({
top: scrollRef.current.children[1].scrollHeight,
behavior: 'smooth',
});
}
}, 66);
}, [ticket?.follow?.length]);
const ref = useRef<ProListActions>(null);
const [create, setCreate] = useState<Partial<API.CreateUserTicketRequest & { open: boolean }>>();
return (
<>
<ProList<API.Ticket, { status: number }>
action={ref}
header={{
title: t('ticketList'),
toolbar: (
<Dialog open={create?.open} onOpenChange={(open) => setCreate({ open })}>
<DialogTrigger asChild>
<Button
className={
'rounded-full border-[#0F2C53] bg-[#0F2C53] px-[35px] py-[9px] text-center font-bold hover:bg-[#225BA9] hover:text-white sm:min-w-[150px] sm:text-xl'
}
>
{t('createTicket')}
</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>{t('createTicket')}</DialogTitle>
<DialogDescription>{t('createTicketDescription')}</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<Label htmlFor='title'>{t('title')}</Label>
<Input
id='title'
defaultValue={create?.title}
onChange={(e) => setCreate({ ...create, title: e.target.value! })}
/>
<Label htmlFor='content'>{t('description')}</Label>
<Textarea
id='content'
defaultValue={create?.description}
onChange={(e) => setCreate({ ...create, description: e.target.value! })}
/>
</div>
<DialogFooter>
<Button
disabled={!create?.title || !create?.description}
onClick={async () => {
await createUserTicket({
title: create!.title!,
description: create!.description!,
});
ref.current?.refresh();
toast.success(t('createSuccess'));
setCreate({ open: false });
}}
>
{t('submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
),
}}
params={[
{
key: 'search',
},
{
key: 'status',
placeholder: t('status.0'),
options: [
{
label: t('close'),
value: '4',
},
],
},
]}
request={async (pagination, filters) => {
const { data } = await getUserTicketList({
...pagination,
...filters,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
renderItem={(item) => {
return (
<Card className='overflow-hidden sm:pl-16'>
<CardHeader className='flex flex-row items-center justify-between gap-2 space-y-0 bg-transparent p-3 pb-0 sm:pb-3'>
<CardTitle>
<span
className={cn(
'flex items-center gap-2 before:block before:size-1.5 before:animate-pulse before:rounded-full before:ring-2 before:ring-opacity-50',
{
'before:bg-yellow-500 before:ring-yellow-500': item.status === 1,
'before:bg-rose-500 before:ring-rose-500': item.status === 2,
'before:bg-green-500 before:ring-green-500': item.status === 3,
'before:bg-zinc-500 before:ring-zinc-500': item.status === 4,
},
)}
>
{t(`status.${item.status}`)}
</span>
</CardTitle>
<CardDescription className='flex gap-2'>
{item.status !== 4 ? (
<>
<Button
key='reply'
variant='destructive'
className={
'hidden min-w-[150px] rounded-full border-[#A8D4ED] bg-[#A8D4ED] text-center text-xl font-bold hover:border-[#225BA9] hover:bg-[#225BA9] hover:text-white sm:flex'
}
onClick={() => setTicketId(item.id)}
>
{t('reply')}
</Button>
<ConfirmButton
key='close'
trigger={
<Button
variant='destructive'
className={
'rounded-full border-white bg-transparent text-center font-bold text-[#FF4248] shadow-none hover:bg-transparent sm:min-w-[150px] sm:border-[#F8BFD2] sm:bg-[#F8BFD2] sm:text-xl sm:shadow sm:hover:border-[#F8BFD2] sm:hover:bg-[#FF4248] sm:hover:text-white'
}
>
{t('close')}
</Button>
}
title={t('confirmClose')}
description={t('closeWarning')}
onConfirm={async () => {
await updateUserTicketStatus({ id: item.id, status: 4 });
toast.success(t('closeSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>
</>
) : (
<Button key='check' size='sm' onClick={() => setTicketId(item.id)}>
{t('check')}
</Button>
)}
</CardDescription>
</CardHeader>
<CardContent className='p-3 text-[10px] sm:text-sm'>
<ul className='grid grid-cols-2 gap-3 *:flex *:flex-col lg:grid-cols-3'>
<li>
<span className='font-normal text-[#225BA9]'>{t('title')}</span>
<span className={'font-bold'}> {item.title}</span>
</li>
<li className='order-2 sm:order-3'>
<span className='font-normal text-[#225BA9]'>{t('description')}</span>
<time className={'font-bold'}>{item.description}</time>
</li>
<li className=''>
<span className='font-normal text-[#225BA9]'>{t('updatedAt')}</span>
<time className={'font-bold'}>{formatDate(item.updated_at)}</time>
</li>
</ul>
</CardContent>
<CardFooter className={'flex justify-center sm:hidden'}>
<Button
key='reply'
className={
'ml-3 min-w-[150px] rounded-full border-[#A8D4ED] bg-[#A8D4ED] px-[35px] py-[9px] text-center font-bold hover:border-[#225BA9] hover:bg-[#225BA9] hover:text-white sm:text-xl'
}
onClick={() => setTicketId(item.id)}
>
{t('reply')}
</Button>
</CardFooter>
</Card>
);
}}
empty={<Empty />}
/>
<Drawer
open={!!ticketId}
onOpenChange={(open) => {
if (!open) setTicketId(null);
}}
>
<DrawerContent className='container mx-auto h-screen'>
<DrawerHeader className='border-b text-left'>
<DrawerTitle>{ticket?.title}</DrawerTitle>
<DrawerDescription className='line-clamp-3'>{ticket?.description}</DrawerDescription>
</DrawerHeader>
<ScrollArea className='h-full overflow-hidden' ref={scrollRef}>
<div className='flex flex-col gap-4 p-4'>
{ticket?.follow?.map((item) => (
<div
key={item.id}
className={cn('flex items-center gap-4', {
'flex-row-reverse': item.from !== 'System',
})}
>
<div
className={cn('flex flex-col gap-1', {
'items-end': item.from !== 'System',
})}
>
<p className='text-muted-foreground text-sm'>{formatDate(item.created_at)}</p>
<p
className={cn('bg-accent w-fit rounded-lg p-2 font-medium', {
'bg-primary text-primary-foreground': item.from !== 'System',
})}
>
{item.type === 1 && item.content}
{item.type === 2 && (
<NextImage
src={item.content!}
width={300}
height={300}
className='!size-auto object-cover'
alt='image'
/>
)}
</p>
</div>
</div>
))}
</div>
</ScrollArea>
{ticket?.status !== 4 && (
<DrawerFooter>
<form
className='flex w-full flex-row items-center gap-2'
onSubmit={async (event) => {
event.preventDefault();
if (message) {
await createUserTicketFollow({
ticket_id: ticketId,
from: 'User',
type: 1,
content: message,
});
refetchTicket();
setMessage('');
}
}}
>
<Button type='button' variant='outline' className='p-0'>
<Label htmlFor='picture' className='p-2'>
<Icon icon='uil:image-upload' className='text-2xl' />
</Label>
<Input
id='picture'
type='file'
className='hidden'
accept='image/*'
onChange={(event) => {
const file = event.target.files?.[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
const img = new Image();
img.src = e.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const maxWidth = 300;
const maxHeight = 300;
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxWidth) {
height = Math.round((maxWidth / width) * height);
width = maxWidth;
}
} else {
if (height > maxHeight) {
width = Math.round((maxHeight / height) * width);
height = maxHeight;
}
}
canvas.width = width;
canvas.height = height;
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
const reader = new FileReader();
reader.readAsDataURL(blob!);
reader.onloadend = async () => {
await createUserTicketFollow({
ticket_id: ticketId,
from: 'User',
type: 2,
content: reader.result as string,
});
refetchTicket();
};
},
'image/webp',
0.8,
);
};
};
}
}}
/>
</Button>
<Input
placeholder={t('inputPlaceholder')}
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<Button type='submit' disabled={!message}>
<Icon icon='uil:navigator' />
</Button>
</form>
</DrawerFooter>
)}
</DrawerContent>
</Drawer>
</>
);
}