1338 lines
45 KiB
TypeScript
1338 lines
45 KiB
TypeScript
"use client";
|
||||
|
|
|
|||
|
|
import {
|
|||
|
|
Archive,
|
|||
|
|
ArrowLeft,
|
|||
|
|
Bug,
|
|||
|
|
ChevronDown,
|
|||
|
|
Clock,
|
|||
|
|
CommandIcon,
|
|||
|
|
File,
|
|||
|
|
Inbox,
|
|||
|
|
Lightbulb,
|
|||
|
|
MailOpen,
|
|||
|
|
MessageSquare,
|
|||
|
|
MoreHorizontal,
|
|||
|
|
PanelRight,
|
|||
|
|
Plus,
|
|||
|
|
Search,
|
|||
|
|
Send,
|
|||
|
|
Sparkles,
|
|||
|
|
Trash2,
|
|||
|
|
User,
|
|||
|
|
UserCheck,
|
|||
|
|
Users,
|
|||
|
|
} from "lucide-react";
|
|||
|
|
import * as React from "react";
|
|||
|
|
|
|||
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|||
|
|
import { Badge } from "@/components/ui/badge";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import {
|
|||
|
|
CommandDialog,
|
|||
|
|
CommandEmpty,
|
|||
|
|
CommandGroup,
|
|||
|
|
CommandInput,
|
|||
|
|
CommandItem,
|
|||
|
|
CommandList,
|
|||
|
|
CommandSeparator,
|
|||
|
|
} from "@/components/ui/command";
|
|||
|
|
import {
|
|||
|
|
Drawer,
|
|||
|
|
DrawerContent,
|
|||
|
|
DrawerHeader,
|
|||
|
|
DrawerTitle,
|
|||
|
|
} from "@/components/ui/drawer";
|
|||
|
|
import {
|
|||
|
|
DropdownMenu,
|
|||
|
|
DropdownMenuContent,
|
|||
|
|
DropdownMenuGroup,
|
|||
|
|
DropdownMenuItem,
|
|||
|
|
DropdownMenuLabel,
|
|||
|
|
DropdownMenuSeparator,
|
|||
|
|
DropdownMenuTrigger,
|
|||
|
|
} from "@/components/ui/dropdown-menu";
|
|||
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|||
|
|
import {
|
|||
|
|
Sidebar,
|
|||
|
|
SidebarContent,
|
|||
|
|
SidebarGroup,
|
|||
|
|
SidebarGroupContent,
|
|||
|
|
SidebarGroupLabel,
|
|||
|
|
SidebarHeader,
|
|||
|
|
SidebarInset,
|
|||
|
|
SidebarMenu,
|
|||
|
|
SidebarMenuBadge,
|
|||
|
|
SidebarMenuButton,
|
|||
|
|
SidebarMenuItem,
|
|||
|
|
SidebarProvider,
|
|||
|
|
useSidebar,
|
|||
|
|
} from "@/components/ui/sidebar";
|
|||
|
|
import { Textarea } from "@/components/ui/textarea";
|
|||
|
|
import { cn } from "@/lib/utils";
|
|||
|
|
|
|||
|
|
type NavItemId =
|
|||
|
|
| "inbox"
|
|||
|
|
| "unassigned"
|
|||
|
|
| "assigned"
|
|||
|
|
| "drafts"
|
|||
|
|
| "archived"
|
|||
|
|
| "spam";
|
|||
|
|
|
|||
|
|
type BucketId = "support" | "bugs" | "features" | "internal";
|
|||
|
|
|
|||
|
|
type TicketStatus = "active" | "pending" | "closed";
|
|||
|
|
|
|||
|
|
type NavItem = {
|
|||
|
|
id: NavItemId;
|
|||
|
|
label: string;
|
|||
|
|
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
|||
|
|
count?: number;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type Bucket = {
|
|||
|
|
id: BucketId;
|
|||
|
|
label: string;
|
|||
|
|
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type User = {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
email: string;
|
|||
|
|
avatar: string;
|
|||
|
|
role?: string;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type Customer = {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
email: string;
|
|||
|
|
company: string;
|
|||
|
|
role: string;
|
|||
|
|
avatar: string;
|
|||
|
|
isHighValue: boolean;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type Message = {
|
|||
|
|
id: string;
|
|||
|
|
sender: User | Customer;
|
|||
|
|
content: string;
|
|||
|
|
timestamp: string;
|
|||
|
|
date: string;
|
|||
|
|
isStaff: boolean;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type Ticket = {
|
|||
|
|
id: string;
|
|||
|
|
subject: string;
|
|||
|
|
preview: string;
|
|||
|
|
timestamp: string;
|
|||
|
|
read: boolean;
|
|||
|
|
customer: Customer;
|
|||
|
|
assignee?: User;
|
|||
|
|
status: TicketStatus;
|
|||
|
|
messages: Message[];
|
|||
|
|
respondingUser?: User;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type PreviousConversation = {
|
|||
|
|
id: string;
|
|||
|
|
subject: string;
|
|||
|
|
timestamp: string;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const navItems: NavItem[] = [
|
|||
|
|
{ id: "inbox", label: "صندوق ورودی", icon: Inbox, count: 6 },
|
|||
|
|
{ id: "unassigned", label: "بدون مسئول", icon: MailOpen, count: 10 },
|
|||
|
|
{ id: "assigned", label: "محولشده", icon: UserCheck, count: 3 },
|
|||
|
|
{ id: "drafts", label: "پیشنویسها", icon: File, count: 1 },
|
|||
|
|
{ id: "archived", label: "بایگانی", icon: Archive },
|
|||
|
|
{ id: "spam", label: "هرزنامه", icon: Trash2 },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const buckets: Bucket[] = [
|
|||
|
|
{ id: "support", label: "درخواستهای پشتیبانی", icon: MessageSquare },
|
|||
|
|
{ id: "bugs", label: "گزارش باگ", icon: Bug },
|
|||
|
|
{ id: "features", label: "درخواست ویژگی", icon: Lightbulb },
|
|||
|
|
{ id: "internal", label: "داخلی", icon: Users },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const staffUsers: User[] = [
|
|||
|
|
{
|
|||
|
|
id: "peter",
|
|||
|
|
name: "رضا کریمی",
|
|||
|
|
email: "peter.lann@cloudstar.com",
|
|||
|
|
avatar:
|
|||
|
|
"/block-media/shadcnblocks/block/avatar/avatar3.webp",
|
|||
|
|
role: "کارشناس پشتیبانی",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "alex",
|
|||
|
|
name: "امیر چناری",
|
|||
|
|
email: "alex.chen@cloudstar.com",
|
|||
|
|
avatar:
|
|||
|
|
"/block-media/shadcnblocks/block/avatar/avatar3.webp",
|
|||
|
|
role: "کارشناس پشتیبانی",
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const mockCustomers: Customer[] = [
|
|||
|
|
{
|
|||
|
|
id: "sarah",
|
|||
|
|
name: "مریم Tran",
|
|||
|
|
email: "sarah.tran@example.com",
|
|||
|
|
company: "BrightWave Marketing",
|
|||
|
|
role: "مدیر عملیات",
|
|||
|
|
avatar:
|
|||
|
|
"/block-media/shadcnblocks/block/avatar/avatar2.webp",
|
|||
|
|
isHighValue: true,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "mike",
|
|||
|
|
name: "محمد جعفری",
|
|||
|
|
email: "mike.j@techcorp.io",
|
|||
|
|
company: "TechCorp",
|
|||
|
|
role: "توسعهدهنده",
|
|||
|
|
avatar:
|
|||
|
|
"/block-media/shadcnblocks/block/avatar/avatar5.webp",
|
|||
|
|
isHighValue: false,
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const mockTickets: Ticket[] = [
|
|||
|
|
{
|
|||
|
|
id: "1",
|
|||
|
|
subject: "مشکل اتصال یکپارچهسازی Slack",
|
|||
|
|
preview:
|
|||
|
|
"تیم ما میخواهد Slack را به CloudStar وصل کند، اما فرایند احراز هویت با خطا مواجه میشود...",
|
|||
|
|
timestamp: "۶ ساعت پیش",
|
|||
|
|
read: false,
|
|||
|
|
customer: mockCustomers[0],
|
|||
|
|
assignee: staffUsers[0],
|
|||
|
|
status: "active",
|
|||
|
|
respondingUser: staffUsers[0],
|
|||
|
|
messages: [
|
|||
|
|
{
|
|||
|
|
id: "m1",
|
|||
|
|
sender: mockCustomers[0],
|
|||
|
|
content: `سلام پشتیبانی CloudStar،
|
|||
|
|
|
|||
|
|
تیم ما میخواهد پیامرسان تیمی را به CloudStar وصل کند، اما فرایند احراز هویت با این خطا متوقف میشود: «احراز هویت token invalid».
|
|||
|
|
|
|||
|
|
چند بار دوباره وصل شدیم و حتی workspace را ریاستارت کردیم، اما نتیجهای نگرفتیم.
|
|||
|
|
|
|||
|
|
میتوانید کمک کنید این یکپارچهسازی کار کند؟
|
|||
|
|
|
|||
|
|
ممنون،
|
|||
|
|
مریم Tran
|
|||
|
|
مدیر عملیات، BrightWave Marketing`,
|
|||
|
|
timestamp: "۸:۰۳",
|
|||
|
|
date: "۲۹ مرداد",
|
|||
|
|
isStaff: false,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "m2",
|
|||
|
|
sender: staffUsers[0],
|
|||
|
|
content: `سلام مریم،
|
|||
|
|
|
|||
|
|
ممنون از پیامتان خوشحال میشوم کمک کنم! این خطا معمولاً وقتی رخ میدهد که پیامرسان تیمی هنگام اتصال، مجوزهای لازم را به CloudStar نمیدهد. این موارد را امتحان کنید:
|
|||
|
|
|
|||
|
|
۱ قبل از شروع اتصال، در workspace درست پیامرسان تیمی وارد شده باشید.
|
|||
|
|
۲ هنگام درخواست، همه مجوزها را به CloudStar بدهید (گاهی «Deny» اشتباهی زده میشود).
|
|||
|
|
۳. یک پنجره ناشناس/خصوصی امتحان کنید تا credentialهای قدیمی مزاحم نباشند.
|
|||
|
|
|
|||
|
|
اگر جواب داد یا هنوز خطا میبینید، خبر دهید!`,
|
|||
|
|
timestamp: "۱۲:۵۶",
|
|||
|
|
date: "۲۹ مرداد",
|
|||
|
|
isStaff: true,
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "2",
|
|||
|
|
subject: "فایلهای گمشده در فضای کار مشترک",
|
|||
|
|
preview:
|
|||
|
|
"دیروز مجموعهای از فایلهای پروژه را در فضای کار مشترک بارگذاری کردم. امروز دو فایل دیگر نیست...",
|
|||
|
|
timestamp: "۶ ساعت پیش",
|
|||
|
|
read: false,
|
|||
|
|
customer: mockCustomers[1],
|
|||
|
|
status: "active",
|
|||
|
|
respondingUser: staffUsers[1],
|
|||
|
|
messages: [],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "3",
|
|||
|
|
subject: "مغایرت در صورتحساب آخر",
|
|||
|
|
preview:
|
|||
|
|
"فاکتور این ماه ۱۰ لایسنس Pro نشان میدهد، در حالی که فقط ۸ کاربر فعال داریم. لطفاً بررسی کنید...",
|
|||
|
|
timestamp: "۸ ساعت پیش",
|
|||
|
|
read: true,
|
|||
|
|
customer: mockCustomers[0],
|
|||
|
|
status: "pending",
|
|||
|
|
messages: [],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "4",
|
|||
|
|
subject: "امکان بازنشانی رمز عبور نیست",
|
|||
|
|
preview:
|
|||
|
|
'با لینک «فراموشی رمز عبور» سعی کردم رمز CloudStar را بازنشانی کنم، اما ایمیل بازنشانی هرگز نرسید...',
|
|||
|
|
timestamp: "۱۲ ساعت پیش",
|
|||
|
|
read: true,
|
|||
|
|
customer: mockCustomers[1],
|
|||
|
|
status: "active",
|
|||
|
|
messages: [],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "5",
|
|||
|
|
subject: "آمار داشبورد بهروز نمیشود",
|
|||
|
|
preview:
|
|||
|
|
"داشبورد آمار از دیروز حدود ساعت ۱۵ بهروزرسانی نمیشود. همه نمودارها روی همان مقادیر ماندهاند...",
|
|||
|
|
timestamp: "۱ روز پیش",
|
|||
|
|
read: true,
|
|||
|
|
customer: mockCustomers[0],
|
|||
|
|
status: "closed",
|
|||
|
|
messages: [],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "6",
|
|||
|
|
subject: "درخواست مستندات انطباق HIPAA",
|
|||
|
|
preview:
|
|||
|
|
"قبل از ادامه فرایند onboarding، تیم حقوقی ما به مستندات CloudStar درباره HIPAA نیاز دارد...",
|
|||
|
|
timestamp: "۲ هفته پیش",
|
|||
|
|
read: true,
|
|||
|
|
customer: mockCustomers[1],
|
|||
|
|
status: "pending",
|
|||
|
|
messages: [],
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const previousConversations: PreviousConversation[] = [
|
|||
|
|
{
|
|||
|
|
id: "prev1",
|
|||
|
|
subject: "مشکل اتصال یکپارچهسازی Slack",
|
|||
|
|
timestamp: "۶ ساعت پیش",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "prev2",
|
|||
|
|
subject: "انقضای توکن API",
|
|||
|
|
timestamp: "۲ هفته پیش",
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
function getInitials(name: string) {
|
|||
|
|
return (
|
|||
|
|
name
|
|||
|
|
.split(" ")
|
|||
|
|
.filter(Boolean)
|
|||
|
|
.slice(0, 2)
|
|||
|
|
.map((part) => part[0]?.toUpperCase())
|
|||
|
|
.join("") || "U"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getStatusColor(status: TicketStatus) {
|
|||
|
|
switch (status) {
|
|||
|
|
case "active":
|
|||
|
|
return "bg-emerald-500";
|
|||
|
|
case "pending":
|
|||
|
|
return "bg-amber-500";
|
|||
|
|
case "closed":
|
|||
|
|
return "bg-muted-foreground";
|
|||
|
|
default:
|
|||
|
|
return "bg-muted-foreground";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getStatusLabel(status: TicketStatus) {
|
|||
|
|
switch (status) {
|
|||
|
|
case "active":
|
|||
|
|
return "فعال";
|
|||
|
|
case "pending":
|
|||
|
|
return "در انتظار";
|
|||
|
|
case "closed":
|
|||
|
|
return "بسته";
|
|||
|
|
default:
|
|||
|
|
return status;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
|||
|
|
activeNavItem: NavItemId;
|
|||
|
|
activeBucket: BucketId | null;
|
|||
|
|
onNavItemChange: (id: NavItemId) => void;
|
|||
|
|
onBucketChange: (id: BucketId | null) => void;
|
|||
|
|
onSearchClick: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function AppSidebar({
|
|||
|
|
activeNavItem,
|
|||
|
|
activeBucket,
|
|||
|
|
onNavItemChange,
|
|||
|
|
onBucketChange,
|
|||
|
|
onSearchClick,
|
|||
|
|
className,
|
|||
|
|
...props
|
|||
|
|
}: AppSidebarProps) {
|
|||
|
|
const { state } = useSidebar();
|
|||
|
|
const isCollapsed = state === "collapsed";
|
|||
|
|
return (
|
|||
|
|
<Sidebar
|
|||
|
|
side="right"
|
|||
|
|
collapsible="offcanvas"
|
|||
|
|
variant="inset"
|
|||
|
|
className={cn(className)}
|
|||
|
|
{...props}
|
|||
|
|
>
|
|||
|
|
<SidebarHeader
|
|||
|
|
className={cn("flex h-14 flex-row items-center justify-between")}
|
|||
|
|
>
|
|||
|
|
<SidebarMenu>
|
|||
|
|
<SidebarMenuItem>
|
|||
|
|
<DropdownMenu>
|
|||
|
|
<div className="flex w-full items-center group-data-[collapsible=icon]:justify-center">
|
|||
|
|
<DropdownMenuTrigger asChild>
|
|||
|
|
<Button
|
|||
|
|
variant="ghost"
|
|||
|
|
className="flex h-auto items-center gap-3 p-0! hover:bg-transparent"
|
|||
|
|
>
|
|||
|
|
<div className="flex size-8 items-center justify-center rounded-sm bg-primary">
|
|||
|
|
<img
|
|||
|
|
src="/block-media/shadcnblocks/block/logos/ShadcnBlocks-logo.svg"
|
|||
|
|
alt="شادسیان بلاکس"
|
|||
|
|
className="size-5 invert dark:invert-0"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
{!isCollapsed && (
|
|||
|
|
<>
|
|||
|
|
<span className="font-semibold">ری یو آی</span>
|
|||
|
|
<ChevronDown className="size-3 text-muted-foreground" />
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</Button>
|
|||
|
|
</DropdownMenuTrigger>
|
|||
|
|
</div>
|
|||
|
|
<DropdownMenuContent className="w-56" align="start">
|
|||
|
|
<DropdownMenuLabel>فضای کار</DropdownMenuLabel>
|
|||
|
|
<DropdownMenuSeparator />
|
|||
|
|
<DropdownMenuGroup>
|
|||
|
|
<DropdownMenuItem>تنظیمات فضای کار</DropdownMenuItem>
|
|||
|
|
<DropdownMenuItem>دعوت همتیمیها</DropdownMenuItem>
|
|||
|
|
<DropdownMenuItem>صورتحساب</DropdownMenuItem>
|
|||
|
|
</DropdownMenuGroup>
|
|||
|
|
<DropdownMenuSeparator />
|
|||
|
|
<DropdownMenuItem>خروج</DropdownMenuItem>
|
|||
|
|
</DropdownMenuContent>
|
|||
|
|
</DropdownMenu>
|
|||
|
|
</SidebarMenuItem>
|
|||
|
|
</SidebarMenu>
|
|||
|
|
<Button variant="ghost" size="icon" className="size-7">
|
|||
|
|
<MoreHorizontal className="size-4" />
|
|||
|
|
</Button>
|
|||
|
|
</SidebarHeader>
|
|||
|
|
|
|||
|
|
<SidebarContent>
|
|||
|
|
<SidebarGroup className="py-2">
|
|||
|
|
<SidebarGroupContent>
|
|||
|
|
<SidebarMenu>
|
|||
|
|
<SidebarMenuItem>
|
|||
|
|
<SidebarMenuButton onClick={onSearchClick} className="px-2">
|
|||
|
|
<Search className="size-4" />
|
|||
|
|
<span>جستجو</span>
|
|||
|
|
<kbd className="ms-auto hidden items-center justify-center rounded-md font-mono font-medium text-muted-foreground/70 sm:flex">
|
|||
|
|
<CommandIcon
|
|||
|
|
className="size-3 font-medium"
|
|||
|
|
strokeWidth={1.5}
|
|||
|
|
/>
|
|||
|
|
<span>K</span>
|
|||
|
|
</kbd>
|
|||
|
|
</SidebarMenuButton>
|
|||
|
|
</SidebarMenuItem>
|
|||
|
|
</SidebarMenu>
|
|||
|
|
</SidebarGroupContent>
|
|||
|
|
</SidebarGroup>
|
|||
|
|
|
|||
|
|
<SidebarGroup className="py-0">
|
|||
|
|
<SidebarGroupContent>
|
|||
|
|
<SidebarMenu>
|
|||
|
|
{navItems.map((item) => {
|
|||
|
|
const Icon = item.icon;
|
|||
|
|
const isActive =
|
|||
|
|
activeNavItem === item.id && activeBucket === null;
|
|||
|
|
return (
|
|||
|
|
<SidebarMenuItem key={item.id}>
|
|||
|
|
<SidebarMenuButton
|
|||
|
|
onClick={() => {
|
|||
|
|
onNavItemChange(item.id);
|
|||
|
|
onBucketChange(null);
|
|||
|
|
}}
|
|||
|
|
isActive={isActive}
|
|||
|
|
className="px-2"
|
|||
|
|
>
|
|||
|
|
<Icon className="size-4" />
|
|||
|
|
<span>{item.label}</span>
|
|||
|
|
{item.count !== undefined && (
|
|||
|
|
<SidebarMenuBadge>{item.count}</SidebarMenuBadge>
|
|||
|
|
)}
|
|||
|
|
</SidebarMenuButton>
|
|||
|
|
</SidebarMenuItem>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</SidebarMenu>
|
|||
|
|
</SidebarGroupContent>
|
|||
|
|
</SidebarGroup>
|
|||
|
|
|
|||
|
|
<SidebarGroup>
|
|||
|
|
<SidebarGroupLabel>دستهها</SidebarGroupLabel>
|
|||
|
|
<SidebarGroupContent>
|
|||
|
|
<SidebarMenu>
|
|||
|
|
{buckets.map((bucket) => {
|
|||
|
|
const Icon = bucket.icon;
|
|||
|
|
return (
|
|||
|
|
<SidebarMenuItem key={bucket.id}>
|
|||
|
|
<SidebarMenuButton
|
|||
|
|
onClick={() => onBucketChange(bucket.id)}
|
|||
|
|
isActive={activeBucket === bucket.id}
|
|||
|
|
className="px-2"
|
|||
|
|
>
|
|||
|
|
<Icon className="size-4" />
|
|||
|
|
<span>{bucket.label}</span>
|
|||
|
|
</SidebarMenuButton>
|
|||
|
|
</SidebarMenuItem>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</SidebarMenu>
|
|||
|
|
</SidebarGroupContent>
|
|||
|
|
</SidebarGroup>
|
|||
|
|
</SidebarContent>
|
|||
|
|
</Sidebar>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface TicketListPanelProps {
|
|||
|
|
tickets: Ticket[];
|
|||
|
|
selectedTicketId: string | null;
|
|||
|
|
onTicketSelect: (ticketId: string) => void;
|
|||
|
|
activeNavItem: NavItemId;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function TicketListPanel({
|
|||
|
|
tickets,
|
|||
|
|
selectedTicketId,
|
|||
|
|
onTicketSelect,
|
|||
|
|
activeNavItem,
|
|||
|
|
}: TicketListPanelProps) {
|
|||
|
|
const navItem = navItems.find((item) => item.id === activeNavItem);
|
|||
|
|
const title = navItem?.label ?? "صندوق ورودی";
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex h-full w-1/4 max-w-[320px] min-w-[240px] shrink-0 flex-col overflow-hidden border-e bg-background">
|
|||
|
|
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
|
|||
|
|
<div className="truncate text-start text-base font-medium text-foreground">
|
|||
|
|
{title} شما
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<ScrollArea className="min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]>div]:!block">
|
|||
|
|
{tickets.map((ticket) => {
|
|||
|
|
const isSelected = selectedTicketId === ticket.id;
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
key={ticket.id}
|
|||
|
|
onClick={() => onTicketSelect(ticket.id)}
|
|||
|
|
className={cn(
|
|||
|
|
"w-full border-b p-4 text-start text-sm leading-tight last:border-b-0 hover:bg-muted/50",
|
|||
|
|
!ticket.read && "bg-muted/30",
|
|||
|
|
isSelected && "bg-muted",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<div className="min-w-0">
|
|||
|
|
<div className="flex items-center justify-between gap-2">
|
|||
|
|
<span
|
|||
|
|
className={cn(
|
|||
|
|
"truncate text-sm",
|
|||
|
|
!ticket.read && "font-semibold",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{ticket.subject}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
|||
|
|
{ticket.preview}
|
|||
|
|
</p>
|
|||
|
|
{ticket.respondingUser && (
|
|||
|
|
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|||
|
|
<Avatar className="size-4">
|
|||
|
|
<AvatarImage
|
|||
|
|
src={ticket.respondingUser.avatar}
|
|||
|
|
alt={ticket.respondingUser.name}
|
|||
|
|
/>
|
|||
|
|
<AvatarFallback className="text-[8px]">
|
|||
|
|
{getInitials(ticket.respondingUser.name)}
|
|||
|
|
</AvatarFallback>
|
|||
|
|
</Avatar>
|
|||
|
|
<span>
|
|||
|
|
{ticket.respondingUser.name.split(" ")[0]} در حال
|
|||
|
|
پاسخ است...
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</ScrollArea>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface MessageBubbleProps {
|
|||
|
|
message: Message;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function MessageBubble({ message }: MessageBubbleProps) {
|
|||
|
|
const sender = message.sender;
|
|||
|
|
const isCustomer = "company" in sender;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<div className="flex items-start gap-3">
|
|||
|
|
<Avatar className="mt-0.5 size-10 shrink-0">
|
|||
|
|
<AvatarImage src={sender.avatar} alt={sender.name} />
|
|||
|
|
<AvatarFallback className="bg-primary text-xs font-medium text-primary-foreground">
|
|||
|
|
{getInitials(sender.name)}
|
|||
|
|
</AvatarFallback>
|
|||
|
|
</Avatar>
|
|||
|
|
<div className="min-w-0 flex-1">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<span className="font-medium">{sender.name}</span>
|
|||
|
|
{!isCustomer && (
|
|||
|
|
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
|
|||
|
|
پشتیبانی
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|||
|
|
<span>از</span>
|
|||
|
|
<span>{sender.email}</span>
|
|||
|
|
</div>
|
|||
|
|
<span className="text-xs text-muted-foreground">
|
|||
|
|
{message.date}, {message.timestamp}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="ps-13 text-sm leading-relaxed whitespace-pre-wrap">
|
|||
|
|
{message.content}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ReplyComposerProps {
|
|||
|
|
recipientEmail: string;
|
|||
|
|
respondingUser?: User;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ReplyComposer({ recipientEmail, respondingUser }: ReplyComposerProps) {
|
|||
|
|
return (
|
|||
|
|
<div className="shrink-0 border-t bg-background px-6 py-4 lg:px-10">
|
|||
|
|
<div className="mx-auto max-w-3xl rounded-lg border bg-muted/30">
|
|||
|
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
|||
|
|
<ArrowLeft className="size-4 shrink-0 rotate-[135deg] text-muted-foreground" />
|
|||
|
|
<span className="truncate text-sm text-muted-foreground">
|
|||
|
|
{recipientEmail}
|
|||
|
|
</span>
|
|||
|
|
<Button
|
|||
|
|
variant="ghost"
|
|||
|
|
size="sm"
|
|||
|
|
className="ms-auto h-6 shrink-0 px-2 text-xs"
|
|||
|
|
>
|
|||
|
|
رونوشت
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
<Textarea
|
|||
|
|
placeholder="پاسخ خود را بنویسید..."
|
|||
|
|
className="min-h-[80px] resize-none border-0 bg-transparent focus-visible:ring-0 md:min-h-[100px]"
|
|||
|
|
/>
|
|||
|
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-3 py-2">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<Button variant="ghost" size="icon" className="size-8 shrink-0">
|
|||
|
|
<Plus className="size-4" />
|
|||
|
|
</Button>
|
|||
|
|
<span className="hidden text-xs text-muted-foreground sm:inline">
|
|||
|
|
از <kbd className="rounded bg-muted px-1">/</kbd> برای میانبرها
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
{respondingUser && (
|
|||
|
|
<div className="hidden items-center gap-1.5 text-xs text-muted-foreground sm:flex">
|
|||
|
|
<Avatar className="size-5">
|
|||
|
|
<AvatarImage
|
|||
|
|
src={respondingUser.avatar}
|
|||
|
|
alt={respondingUser.name}
|
|||
|
|
/>
|
|||
|
|
<AvatarFallback className="text-[8px]">
|
|||
|
|
{getInitials(respondingUser.name)}
|
|||
|
|
</AvatarFallback>
|
|||
|
|
</Avatar>
|
|||
|
|
<span>
|
|||
|
|
{respondingUser.name.split(" ")[0]} در حال پاسخ است...
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<DropdownMenu>
|
|||
|
|
<DropdownMenuTrigger asChild>
|
|||
|
|
<Button variant="outline" size="sm" className="gap-1">
|
|||
|
|
بستن
|
|||
|
|
<ChevronDown className="size-3" />
|
|||
|
|
</Button>
|
|||
|
|
</DropdownMenuTrigger>
|
|||
|
|
<DropdownMenuContent align="start">
|
|||
|
|
<DropdownMenuItem>بستن بهعنوان حلشده</DropdownMenuItem>
|
|||
|
|
<DropdownMenuItem>بستن بهعنوان هرزنامه</DropdownMenuItem>
|
|||
|
|
<DropdownMenuItem>بستن بدون پاسخ</DropdownMenuItem>
|
|||
|
|
</DropdownMenuContent>
|
|||
|
|
</DropdownMenu>
|
|||
|
|
<Button size="sm" className="gap-1">
|
|||
|
|
<Send className="size-3" />
|
|||
|
|
ارسال
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface InboxAgentPanelProps {
|
|||
|
|
ticket: Ticket;
|
|||
|
|
previousConversations: PreviousConversation[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function InboxAgentPanel({
|
|||
|
|
ticket,
|
|||
|
|
previousConversations,
|
|||
|
|
}: InboxAgentPanelProps) {
|
|||
|
|
const customer = ticket.customer;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex h-full w-1/4 max-w-[360px] min-w-[280px] shrink-0 flex-col overflow-hidden border-s bg-background">
|
|||
|
|
<div className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
|
|||
|
|
<Sparkles className="size-4" />
|
|||
|
|
<span className="font-semibold">دستیار صندوق ورودی</span>
|
|||
|
|
</div>
|
|||
|
|
<ScrollArea className="min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]>div]:!block">
|
|||
|
|
<div className="space-y-6 p-4 text-start">
|
|||
|
|
<section className="space-y-3">
|
|||
|
|
<div className="flex items-start gap-3">
|
|||
|
|
<Avatar className="size-10 shrink-0">
|
|||
|
|
<AvatarImage src={customer.avatar} alt={customer.name} />
|
|||
|
|
<AvatarFallback className="bg-primary font-medium text-primary-foreground">
|
|||
|
|
{getInitials(customer.name)}
|
|||
|
|
</AvatarFallback>
|
|||
|
|
</Avatar>
|
|||
|
|
<div className="min-w-0 flex-1">
|
|||
|
|
<div className="font-medium">{customer.name}</div>
|
|||
|
|
<div className="text-sm text-muted-foreground">
|
|||
|
|
{customer.role}, {customer.company}
|
|||
|
|
</div>
|
|||
|
|
{customer.isHighValue && (
|
|||
|
|
<Badge
|
|||
|
|
variant="outline"
|
|||
|
|
className="mt-1.5 border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-400"
|
|||
|
|
>
|
|||
|
|
مشتری ارزشمند
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section className="space-y-3">
|
|||
|
|
<div className="rounded-lg bg-muted/50 p-3 text-sm">
|
|||
|
|
<p className="text-muted-foreground">
|
|||
|
|
مشتری آرام به نظر میرسد، اما ممکن است ناراضی شود. احتمالاً
|
|||
|
|
فنی نیست چون راهنمای یکپارچهسازی پیامرسان تیمی را نخوانده است.
|
|||
|
|
</p>
|
|||
|
|
<p className="mt-3 text-muted-foreground">
|
|||
|
|
رضا با جزئیات لازم پاسخ داده، اما هنوز تأیید نکرده که مشکل
|
|||
|
|
حل شده است یا نه.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section className="space-y-3">
|
|||
|
|
<p className="text-sm text-muted-foreground">
|
|||
|
|
اگر مشتری در ۱۲ ساعت آینده پاسخ ندهد، پیگیری ارسال شود:
|
|||
|
|
</p>
|
|||
|
|
<div className="rounded-lg border bg-muted/30 p-3 text-sm">
|
|||
|
|
<p>
|
|||
|
|
سلام مریم، پیگیری میکنم ببینم توانستید یکپارچهسازی را راه
|
|||
|
|
بیندازید؟
|
|||
|
|
</p>
|
|||
|
|
<p className="mt-2">
|
|||
|
|
اگر هنوز مشکل دارید، خوشحال میشوم یک تماس هماهنگ کنیم تا
|
|||
|
|
سریعتر راه بیفتید.
|
|||
|
|
</p>
|
|||
|
|
<p className="mt-2">با احترام، رضا کریمی</p>
|
|||
|
|
</div>
|
|||
|
|
<Button variant="outline" className="w-full gap-2">
|
|||
|
|
<Clock className="size-4" />
|
|||
|
|
زمانبندی پیگیری
|
|||
|
|
</Button>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section className="space-y-3">
|
|||
|
|
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
|||
|
|
گفتگوهای قبلی
|
|||
|
|
</p>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{previousConversations.map((conv) => (
|
|||
|
|
<button
|
|||
|
|
key={conv.id}
|
|||
|
|
type="button"
|
|||
|
|
className="flex w-full items-start justify-between gap-2 rounded-md p-2 text-start hover:bg-muted"
|
|||
|
|
>
|
|||
|
|
<span className="line-clamp-2 text-sm">{conv.subject}</span>
|
|||
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|||
|
|
{conv.timestamp}
|
|||
|
|
</span>
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
</div>
|
|||
|
|
</ScrollArea>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface TicketCommandPaletteProps {
|
|||
|
|
open: boolean;
|
|||
|
|
onOpenChange: (open: boolean) => void;
|
|||
|
|
tickets: Ticket[];
|
|||
|
|
onTicketSelect: (ticketId: string) => void;
|
|||
|
|
onNavItemSelect: (navItemId: NavItemId) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function TicketCommandPalette({
|
|||
|
|
open,
|
|||
|
|
onOpenChange,
|
|||
|
|
tickets,
|
|||
|
|
onTicketSelect,
|
|||
|
|
onNavItemSelect,
|
|||
|
|
}: TicketCommandPaletteProps) {
|
|||
|
|
return (
|
|||
|
|
<CommandDialog open={open} onOpenChange={onOpenChange}>
|
|||
|
|
<CommandInput placeholder="جستجوی تیکت، مشتری یا ناوبری..." />
|
|||
|
|
<CommandList>
|
|||
|
|
<CommandEmpty>نتیجهای یافت نشد.</CommandEmpty>
|
|||
|
|
<CommandGroup heading="تیکتها">
|
|||
|
|
{tickets.slice(0, 5).map((ticket) => (
|
|||
|
|
<CommandItem
|
|||
|
|
key={ticket.id}
|
|||
|
|
onSelect={() => {
|
|||
|
|
onTicketSelect(ticket.id);
|
|||
|
|
onOpenChange(false);
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<MessageSquare className="me-2 size-4" />
|
|||
|
|
<span className="truncate">{ticket.subject}</span>
|
|||
|
|
<span className="ms-auto text-xs text-muted-foreground">
|
|||
|
|
{ticket.timestamp}
|
|||
|
|
</span>
|
|||
|
|
</CommandItem>
|
|||
|
|
))}
|
|||
|
|
</CommandGroup>
|
|||
|
|
<CommandSeparator />
|
|||
|
|
<CommandGroup heading="مشتریان">
|
|||
|
|
{mockCustomers.map((customer) => (
|
|||
|
|
<CommandItem key={customer.id}>
|
|||
|
|
<User className="me-2 size-4" />
|
|||
|
|
<span>{customer.name}</span>
|
|||
|
|
<span className="ms-2 text-xs text-muted-foreground">
|
|||
|
|
{customer.company}
|
|||
|
|
</span>
|
|||
|
|
</CommandItem>
|
|||
|
|
))}
|
|||
|
|
</CommandGroup>
|
|||
|
|
<CommandSeparator />
|
|||
|
|
<CommandGroup heading="ناوبری">
|
|||
|
|
{navItems.map((item) => {
|
|||
|
|
const Icon = item.icon;
|
|||
|
|
return (
|
|||
|
|
<CommandItem
|
|||
|
|
key={item.id}
|
|||
|
|
onSelect={() => {
|
|||
|
|
onNavItemSelect(item.id);
|
|||
|
|
onOpenChange(false);
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<Icon className="me-2 size-4" />
|
|||
|
|
<span>{item.label}</span>
|
|||
|
|
{item.count !== undefined && (
|
|||
|
|
<Badge variant="secondary" className="ms-auto">
|
|||
|
|
{item.count}
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</CommandItem>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</CommandGroup>
|
|||
|
|
</CommandList>
|
|||
|
|
</CommandDialog>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function ApplicationShell10() {
|
|||
|
|
const [activeNavItem, setActiveNavItem] = React.useState<NavItemId>("inbox");
|
|||
|
|
const [activeBucket, setActiveBucket] = React.useState<BucketId | null>(null);
|
|||
|
|
const [selectedTicketId, setSelectedTicketId] = React.useState<string | null>(
|
|||
|
|
mockTickets[0]?.id ?? null,
|
|||
|
|
);
|
|||
|
|
const [tickets, setTickets] = React.useState<Ticket[]>(mockTickets);
|
|||
|
|
const [isAgentPanelOpen, setIsAgentPanelOpen] = React.useState(true);
|
|||
|
|
const [isCommandOpen, setIsCommandOpen] = React.useState(false);
|
|||
|
|
const [isMobileTicketListOpen, setIsMobileTicketListOpen] =
|
|||
|
|
React.useState(false);
|
|||
|
|
const [isMobileConversationOpen, setIsMobileConversationOpen] =
|
|||
|
|
React.useState(false);
|
|||
|
|
const [isMobileAgentOpen, setIsMobileAgentOpen] = React.useState(false);
|
|||
|
|
|
|||
|
|
const selectedTicket = tickets.find((t) => t.id === selectedTicketId) ?? null;
|
|||
|
|
|
|||
|
|
// Keyboard shortcut for command palette
|
|||
|
|
React.useEffect(() => {
|
|||
|
|
const down = (e: KeyboardEvent) => {
|
|||
|
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
setIsCommandOpen((open) => !open);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
document.addEventListener("keydown", down);
|
|||
|
|
return () => document.removeEventListener("keydown", down);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const handleTicketSelect = (ticketId: string) => {
|
|||
|
|
setSelectedTicketId(ticketId);
|
|||
|
|
setTickets((prev) =>
|
|||
|
|
prev.map((t) => (t.id === ticketId ? { ...t, read: true } : t)),
|
|||
|
|
);
|
|||
|
|
if (window.innerWidth < 768) {
|
|||
|
|
setIsMobileConversationOpen(true);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<SidebarProvider dir="rtl" lang="fa" className="h-svh overflow-hidden">
|
|||
|
|
<AppSidebar
|
|||
|
|
activeNavItem={activeNavItem}
|
|||
|
|
activeBucket={activeBucket}
|
|||
|
|
onNavItemChange={setActiveNavItem}
|
|||
|
|
onBucketChange={setActiveBucket}
|
|||
|
|
onSearchClick={() => setIsCommandOpen(true)}
|
|||
|
|
className="hidden md:flex"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<div className="flex h-full flex-col overflow-hidden pb-16 md:hidden">
|
|||
|
|
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<div className="flex size-8 items-center justify-center rounded-sm bg-primary">
|
|||
|
|
<img
|
|||
|
|
src="/block-media/shadcnblocks/block/logos/ShadcnBlocks-logo.svg"
|
|||
|
|
alt="شادسیان بلاکس"
|
|||
|
|
className="size-5 invert dark:invert-0"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<span className="font-semibold">
|
|||
|
|
{navItems.find((item) => item.id === activeNavItem)?.label ??
|
|||
|
|
"صندوق ورودی"}{" "}
|
|||
|
|
شما
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<ScrollArea className="min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]>div]:!block">
|
|||
|
|
{tickets.map((ticket) => {
|
|||
|
|
const isSelected = selectedTicketId === ticket.id;
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
key={ticket.id}
|
|||
|
|
onClick={() => handleTicketSelect(ticket.id)}
|
|||
|
|
className={cn(
|
|||
|
|
"w-full border-b p-4 text-start text-sm leading-tight last:border-b-0 hover:bg-muted/50",
|
|||
|
|
!ticket.read && "bg-muted/30",
|
|||
|
|
isSelected && "bg-muted",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<div className="min-w-0">
|
|||
|
|
<div className="flex items-center justify-between gap-2">
|
|||
|
|
<span
|
|||
|
|
className={cn(
|
|||
|
|
"truncate text-sm",
|
|||
|
|
!ticket.read && "font-semibold",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{ticket.subject}
|
|||
|
|
</span>
|
|||
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|||
|
|
{ticket.timestamp}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
|||
|
|
{ticket.preview}
|
|||
|
|
</p>
|
|||
|
|
{ticket.respondingUser && (
|
|||
|
|
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|||
|
|
<Avatar className="size-4">
|
|||
|
|
<AvatarImage
|
|||
|
|
src={ticket.respondingUser.avatar}
|
|||
|
|
alt={ticket.respondingUser.name}
|
|||
|
|
/>
|
|||
|
|
<AvatarFallback className="text-[8px]">
|
|||
|
|
{getInitials(ticket.respondingUser.name)}
|
|||
|
|
</AvatarFallback>
|
|||
|
|
</Avatar>
|
|||
|
|
<span>
|
|||
|
|
{ticket.respondingUser.name.split(" ")[0]} در حال
|
|||
|
|
پاسخ است...
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</ScrollArea>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<SidebarInset className="hidden min-h-0 overflow-hidden md:flex">
|
|||
|
|
<div className="flex h-full w-full">
|
|||
|
|
<TicketListPanel
|
|||
|
|
tickets={tickets}
|
|||
|
|
selectedTicketId={selectedTicketId}
|
|||
|
|
onTicketSelect={handleTicketSelect}
|
|||
|
|
activeNavItem={activeNavItem}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|||
|
|
{selectedTicket ? (
|
|||
|
|
<>
|
|||
|
|
<header className="flex h-14 shrink-0 items-center justify-between border-b bg-background px-4">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<MessageSquare className="size-5" />
|
|||
|
|
<span className="font-medium">
|
|||
|
|
پاسخ: {selectedTicket.subject}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<DropdownMenu>
|
|||
|
|
<DropdownMenuTrigger asChild>
|
|||
|
|
<Button variant="outline" size="sm" className="gap-1.5">
|
|||
|
|
<span
|
|||
|
|
className={cn(
|
|||
|
|
"size-2 rounded-full",
|
|||
|
|
getStatusColor(selectedTicket.status),
|
|||
|
|
)}
|
|||
|
|
/>
|
|||
|
|
<span>{getStatusLabel(selectedTicket.status)}</span>
|
|||
|
|
<ChevronDown className="size-3" />
|
|||
|
|
</Button>
|
|||
|
|
</DropdownMenuTrigger>
|
|||
|
|
<DropdownMenuContent align="start">
|
|||
|
|
<DropdownMenuItem>فعال</DropdownMenuItem>
|
|||
|
|
<DropdownMenuItem>در انتظار</DropdownMenuItem>
|
|||
|
|
<DropdownMenuItem>بسته</DropdownMenuItem>
|
|||
|
|
</DropdownMenuContent>
|
|||
|
|
</DropdownMenu>
|
|||
|
|
<Button
|
|||
|
|
variant="ghost"
|
|||
|
|
size="icon"
|
|||
|
|
className="size-8"
|
|||
|
|
onClick={() => setIsAgentPanelOpen(!isAgentPanelOpen)}
|
|||
|
|
>
|
|||
|
|
<PanelRight
|
|||
|
|
className={cn(
|
|||
|
|
"size-4",
|
|||
|
|
isAgentPanelOpen && "text-primary",
|
|||
|
|
)}
|
|||
|
|
/>
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<ScrollArea className="min-h-0 flex-1">
|
|||
|
|
<div className="px-6 py-6 lg:px-10">
|
|||
|
|
<div className="mx-auto max-w-3xl space-y-8">
|
|||
|
|
{selectedTicket.messages.map((message) => (
|
|||
|
|
<MessageBubble key={message.id} message={message} />
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</ScrollArea>
|
|||
|
|
|
|||
|
|
<ReplyComposer
|
|||
|
|
recipientEmail={selectedTicket.customer.email}
|
|||
|
|
respondingUser={selectedTicket.respondingUser}
|
|||
|
|
/>
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
<div className="flex flex-1 items-center justify-center text-muted-foreground">
|
|||
|
|
<div className="text-center">
|
|||
|
|
<MessageSquare className="mx-auto size-12 opacity-50" />
|
|||
|
|
<p className="mt-2 text-sm">یک تیکت را برای مشاهده انتخاب کنید</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{isAgentPanelOpen && selectedTicket && (
|
|||
|
|
<InboxAgentPanel
|
|||
|
|
ticket={selectedTicket}
|
|||
|
|
previousConversations={previousConversations}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</SidebarInset>
|
|||
|
|
|
|||
|
|
<TicketCommandPalette
|
|||
|
|
open={isCommandOpen}
|
|||
|
|
onOpenChange={setIsCommandOpen}
|
|||
|
|
tickets={tickets}
|
|||
|
|
onTicketSelect={handleTicketSelect}
|
|||
|
|
onNavItemSelect={(navItemId) => {
|
|||
|
|
setActiveNavItem(navItemId);
|
|||
|
|
setActiveBucket(null);
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<Drawer
|
|||
|
|
open={isMobileTicketListOpen}
|
|||
|
|
onOpenChange={setIsMobileTicketListOpen}
|
|||
|
|
dismissible
|
|||
|
|
>
|
|||
|
|
<DrawerContent dir="rtl" className="h-[85vh] text-start md:hidden">
|
|||
|
|
<DrawerHeader>
|
|||
|
|
<DrawerTitle className="text-start">تیکتها</DrawerTitle>
|
|||
|
|
</DrawerHeader>
|
|||
|
|
<ScrollArea className="min-h-0 flex-1 px-4">
|
|||
|
|
{tickets.map((ticket) => (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
key={ticket.id}
|
|||
|
|
onClick={() => {
|
|||
|
|
handleTicketSelect(ticket.id);
|
|||
|
|
setIsMobileTicketListOpen(false);
|
|||
|
|
}}
|
|||
|
|
className={cn(
|
|||
|
|
"w-full border-b p-4 text-start text-sm",
|
|||
|
|
!ticket.read && "bg-muted/30",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<div className="min-w-0">
|
|||
|
|
<div className="flex items-center justify-between gap-2">
|
|||
|
|
<span
|
|||
|
|
className={cn(
|
|||
|
|
"truncate",
|
|||
|
|
!ticket.read && "font-semibold",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{ticket.subject}
|
|||
|
|
</span>
|
|||
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|||
|
|
{ticket.timestamp}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
|||
|
|
{ticket.preview}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</ScrollArea>
|
|||
|
|
</DrawerContent>
|
|||
|
|
</Drawer>
|
|||
|
|
|
|||
|
|
<Drawer
|
|||
|
|
open={isMobileConversationOpen}
|
|||
|
|
onOpenChange={setIsMobileConversationOpen}
|
|||
|
|
dismissible
|
|||
|
|
>
|
|||
|
|
<DrawerContent
|
|||
|
|
dir="rtl"
|
|||
|
|
className="h-[90vh] overflow-hidden text-start md:hidden"
|
|||
|
|
>
|
|||
|
|
<DrawerHeader className="sr-only">
|
|||
|
|
<DrawerTitle>گفتگو</DrawerTitle>
|
|||
|
|
</DrawerHeader>
|
|||
|
|
{selectedTicket && (
|
|||
|
|
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
|||
|
|
<div className="flex shrink-0 items-center justify-between border-b px-4 py-3">
|
|||
|
|
<span className="line-clamp-1 font-medium">
|
|||
|
|
{selectedTicket.subject}
|
|||
|
|
</span>
|
|||
|
|
<Button
|
|||
|
|
variant="ghost"
|
|||
|
|
size="icon"
|
|||
|
|
className="size-8 shrink-0"
|
|||
|
|
onClick={() => setIsMobileAgentOpen(true)}
|
|||
|
|
>
|
|||
|
|
<Sparkles className="size-4" />
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
<ScrollArea className="min-h-0 flex-1">
|
|||
|
|
<div className="space-y-6 p-4">
|
|||
|
|
{selectedTicket.messages.map((message) => (
|
|||
|
|
<MessageBubble key={message.id} message={message} />
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</ScrollArea>
|
|||
|
|
<div className="shrink-0">
|
|||
|
|
<ReplyComposer
|
|||
|
|
recipientEmail={selectedTicket.customer.email}
|
|||
|
|
respondingUser={selectedTicket.respondingUser}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</DrawerContent>
|
|||
|
|
</Drawer>
|
|||
|
|
|
|||
|
|
<Drawer
|
|||
|
|
open={isMobileAgentOpen}
|
|||
|
|
onOpenChange={setIsMobileAgentOpen}
|
|||
|
|
dismissible
|
|||
|
|
>
|
|||
|
|
<DrawerContent dir="rtl" className="h-[85vh] text-start md:hidden">
|
|||
|
|
<DrawerHeader>
|
|||
|
|
<DrawerTitle className="flex items-center gap-2 text-start">
|
|||
|
|
<Sparkles className="size-4" />
|
|||
|
|
دستیار صندوق ورودی
|
|||
|
|
</DrawerTitle>
|
|||
|
|
</DrawerHeader>
|
|||
|
|
{selectedTicket && (
|
|||
|
|
<ScrollArea className="min-h-0 flex-1 px-4 pb-6">
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
<section className="space-y-3">
|
|||
|
|
<div className="flex items-start gap-3">
|
|||
|
|
<Avatar className="size-10 shrink-0">
|
|||
|
|
<AvatarImage
|
|||
|
|
src={selectedTicket.customer.avatar}
|
|||
|
|
alt={selectedTicket.customer.name}
|
|||
|
|
/>
|
|||
|
|
<AvatarFallback>
|
|||
|
|
{getInitials(selectedTicket.customer.name)}
|
|||
|
|
</AvatarFallback>
|
|||
|
|
</Avatar>
|
|||
|
|
<div className="min-w-0 flex-1">
|
|||
|
|
<div className="font-medium">
|
|||
|
|
{selectedTicket.customer.name}
|
|||
|
|
</div>
|
|||
|
|
<div className="text-sm text-muted-foreground">
|
|||
|
|
{selectedTicket.customer.role},{" "}
|
|||
|
|
{selectedTicket.customer.company}
|
|||
|
|
</div>
|
|||
|
|
{selectedTicket.customer.isHighValue && (
|
|||
|
|
<Badge
|
|||
|
|
variant="outline"
|
|||
|
|
className="mt-1.5 border-amber-500 bg-amber-50 text-amber-700"
|
|||
|
|
>
|
|||
|
|
مشتری ارزشمند
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section className="rounded-lg bg-muted/50 p-3 text-sm text-muted-foreground">
|
|||
|
|
<p>
|
|||
|
|
مشتری آرام به نظر میرسد، اما ممکن است ناراضی شود.
|
|||
|
|
</p>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section className="space-y-3">
|
|||
|
|
<div className="rounded-lg border bg-muted/30 p-3 text-sm">
|
|||
|
|
<p>سلام مریم، پیگیری میکنم...</p>
|
|||
|
|
</div>
|
|||
|
|
<Button variant="outline" className="w-full gap-2">
|
|||
|
|
<Clock className="size-4" />
|
|||
|
|
زمانبندی پیگیری
|
|||
|
|
</Button>
|
|||
|
|
</section>
|
|||
|
|
</div>
|
|||
|
|
</ScrollArea>
|
|||
|
|
)}
|
|||
|
|
</DrawerContent>
|
|||
|
|
</Drawer>
|
|||
|
|
|
|||
|
|
<nav
|
|||
|
|
aria-label="ناوبری پایین موبایل"
|
|||
|
|
className="fixed inset-x-0 bottom-0 z-40 border-t bg-background/95 backdrop-blur md:hidden"
|
|||
|
|
>
|
|||
|
|
<div className="grid grid-cols-5">
|
|||
|
|
{[
|
|||
|
|
{
|
|||
|
|
id: "inbox",
|
|||
|
|
label: "صندوق ورودی",
|
|||
|
|
icon: Inbox,
|
|||
|
|
onClick: () => {
|
|||
|
|
setActiveNavItem("inbox");
|
|||
|
|
setIsMobileTicketListOpen(true);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "unassigned",
|
|||
|
|
label: "بدون مسئول",
|
|||
|
|
icon: MailOpen,
|
|||
|
|
onClick: () => {
|
|||
|
|
setActiveNavItem("unassigned");
|
|||
|
|
setIsMobileTicketListOpen(true);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "assigned",
|
|||
|
|
label: "محولشده",
|
|||
|
|
icon: UserCheck,
|
|||
|
|
onClick: () => {
|
|||
|
|
setActiveNavItem("assigned");
|
|||
|
|
setIsMobileTicketListOpen(true);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "search",
|
|||
|
|
label: "جستجو",
|
|||
|
|
icon: Search,
|
|||
|
|
onClick: () => setIsCommandOpen(true),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "more",
|
|||
|
|
label: "بیشتر",
|
|||
|
|
icon: MoreHorizontal,
|
|||
|
|
onClick: () => {},
|
|||
|
|
},
|
|||
|
|
].map((item) => {
|
|||
|
|
const Icon = item.icon;
|
|||
|
|
const isActive = activeNavItem === item.id;
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
key={item.id}
|
|||
|
|
type="button"
|
|||
|
|
onClick={item.onClick}
|
|||
|
|
className={cn(
|
|||
|
|
"flex flex-col items-center gap-1 py-2 text-xs",
|
|||
|
|
isActive
|
|||
|
|
? "text-foreground"
|
|||
|
|
: "text-muted-foreground hover:text-foreground",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<Icon className="size-5" />
|
|||
|
|
<span>{item.label}</span>
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</nav>
|
|||
|
|
</SidebarProvider>
|
|||
|
|
);
|
|||
|
|
}
|