feat : update

This commit is contained in:
2026-10-02 17:33:14 +07:00 Unverified
parent 63898fb90d
commit a966d996a9
34 changed files with 4678 additions and 147 deletions
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { setCommissionAvailability } from "@/app/admin/commission/actions";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
@@ -31,9 +30,8 @@ export function AdminCommissionAvailabilityControl({ isOpen, closureMessage }: {
});
}
return <Card><CardHeader><div className="flex items-center gap-3">
<CardTitle>การรับ Commission</CardTitle><Badge variant={isOpen ? "default" : "secondary"}>
{isOpen ? "เปิดรับ" : "ปิดรับ"}</Badge></div>
return <Card><CardHeader>
<CardTitle>การรับ Commission</CardTitle>
<CardDescription>การปิดรับจะหยุดคำขอใหม่ ลูกค้าที่มีคำขออยู่แล้วชำระเงินต่อได้</CardDescription>
</CardHeader><CardContent className="flex flex-col gap-3">
<label htmlFor="commission-closure-message" className="text-sm font-medium">ข้อความเมื่อปิดรับ</label>
-18
View File
@@ -1,18 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { LogOutIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth/client";
import { unsubscribeCommissionPush } from "./push-client";
import { CommissionPushEntryPrompt } from "./push-entry-prompt";
export function CommissionSignOut() {
const router = useRouter();
return <><CommissionPushEntryPrompt scope="customer" /><Button type="button" variant="ghost" size="icon-sm" aria-label="ออกจากระบบ" onClick={async () => {
await unsubscribeCommissionPush().catch(() => undefined);
await authClient.signOut();
router.push("/commission");
router.refresh();
}}><LogOutIcon /></Button></>;
}
+130 -41
View File
@@ -2,8 +2,9 @@
import { useEffect, useRef, useState, type ClipboardEvent, type FormEvent, type KeyboardEvent } from "react";
import { useRouter } from "next/navigation";
import { ImagePlus, MessageCircle, SendHorizontal, SmilePlus, Trash2, X } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import dynamic from "next/dynamic";
import { ImagePlus, MessageCircle, ReplyIcon, SendHorizontal, SmilePlus, Trash2, X } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
@@ -23,13 +24,31 @@ import { parseCommissionMessageEvent } from "@/lib/commission/message-event";
import { CommissionPushEntryPrompt } from "./push-entry-prompt";
type ChatMessage = { id: string; authorId: string; authorName: string; text: string | null;
authorImage: string | null; replyToId: string | null;
imageUrl: string | null; createdAt: Date; reactions: { userId: string; emoji: string }[] };
type OptimisticMessage = { localId: string; serverId: string | null; text: string; imageUrl: string | null;
status: "sending" | "sent" };
replyToId: string | null; status: "sending" | "sent" };
type OptimisticReaction = { active: boolean; status: "sending" | "sent" };
const emojiChoices = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
const EmojiPicker = dynamic(() => import("emoji-picker-react"), { ssr: false });
const reactionKey = (messageId: string, emoji: string) => `${messageId}:${emoji}`;
function ReactionEmoji({ emoji }: { emoji: string }) {
const [failed, setFailed] = useState(false);
const unified = Array.from(emoji, (character) => character.codePointAt(0)!.toString(16)).join("-");
if (failed) return <span aria-hidden="true">{emoji}</span>;
// CDN images are already 64 px assets displayed at 16 px.
// eslint-disable-next-line @next/next/no-img-element
return <img src={`https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/${unified}.png`}
alt="" width={16} height={16} loading="lazy" decoding="async" onError={() => setFailed(true)} />;
}
function ReplyPreview({ message }: { message: ChatMessage }) {
return <div className="max-w-full rounded-md bg-muted/50 px-2 py-1 text-xs">
<span className="font-medium">{message.authorName}</span>
<p className="truncate text-muted-foreground">{message.text || (message.imageUrl ? "รูปภาพ" : "ข้อความ")}</p>
</div>;
}
function CommissionMessageImage({ src }: { src: string }) {
const imageRef = useRef<HTMLImageElement>(null);
const [state, setState] = useState<"loading" | "loaded" | "error">("loading");
@@ -74,6 +93,10 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
const [optimisticMessages, setOptimisticMessages] = useState<OptimisticMessage[]>([]);
const nextOptimisticId = useRef(0);
const [reactionFor, setReactionFor] = useState<string | null>(null);
const [replyToId, setReplyToId] = useState<string | null>(null);
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
const [closedAlertOpen, setClosedAlertOpen] = useState(false);
const [statusBusy, setStatusBusy] = useState(false);
const [optimisticReactions, setOptimisticReactions] = useState<Record<string, OptimisticReaction>>({});
const pendingReactions = useRef(new Set<string>());
const [deleteFor, setDeleteFor] = useState<string | null>(null);
@@ -87,6 +110,8 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
const visibleOptimisticMessages = optimisticMessages.filter((message) => !message.serverId ||
!messages.some((saved) => saved.id === message.serverId));
const visibleMessages = messages.filter((message) => !deletedMessageIds.has(message.id));
const visibleMessageById = new Map(visibleMessages.map((message) => [message.id, message]));
const replyTarget = replyToId ? visibleMessageById.get(replyToId) ?? null : null;
useEffect(() => () => {
if (imagePreviewRef.current) URL.revokeObjectURL(imagePreviewRef.current);
@@ -125,6 +150,7 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
const receive = (event: Event) => {
const value = (event as MessageEvent<string>).data;
if (!value.startsWith("typing:")) {
if (value === "status:closed" && !admin) setClosedAlertOpen(true);
const message = parseCommissionMessageEvent(value);
if (message && message.authorId !== userId && (document.hidden || !document.hasFocus())) {
unread += 1;
@@ -154,15 +180,26 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
window.removeEventListener("focus", markRead);
if (document.title === managedTitle) document.title = originalTitle;
};
}, [ticketId, ticketTitle, userId, router]);
}, [ticketId, ticketTitle, userId, admin, router]);
useEffect(() => {
const viewport = scrollRef.current;
if (viewport && stayAtBottom.current) viewport.scrollTop = viewport.scrollHeight;
}, [messages, optimisticMessages]);
useEffect(() => {
const viewport = scrollRef.current;
if (!viewport) return;
const observer = new ResizeObserver(() => {
if (stayAtBottom.current) viewport.scrollTop = viewport.scrollHeight;
});
observer.observe(viewport);
return () => observer.disconnect();
}, []);
function sendTyping(active: boolean) {
if (status !== "open") return;
// eslint-disable-next-line react-hooks/purity -- This function runs only from input event handlers.
const now = Date.now();
if (active && typingActive.current && now - lastTypingSent.current < 4000) return;
if (!active && !typingActive.current) return;
@@ -213,15 +250,17 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
if (imageUrl) optimisticImageUrls.current.add(imageUrl);
setOptimisticMessages((current) => [...current.filter((message) => !message.serverId ||
!messages.some((saved) => saved.id === message.serverId)), {
localId, serverId: null, text: text.trim(), imageUrl, status: "sending",
localId, serverId: null, text: text.trim(), imageUrl, replyToId: replyTarget?.id ?? null, status: "sending",
}]);
stayAtBottom.current = true;
try {
const data = new FormData(form);
data.set("text", text.trim());
if (replyTarget) data.set("replyToId", replyTarget.id);
else data.delete("replyToId");
if (image) data.set("image", image);
else data.delete("image");
form.reset(); setText(""); setAttachment(null); sendTyping(false);
form.reset(); setText(""); setAttachment(null); setReplyToId(null); sendTyping(false);
const response = await fetch(`/api/commission/tickets/${ticketId}/messages`, { method: "POST", body: data });
if (!response.ok) throw new Error("ส่งข้อความไม่สำเร็จ กรุณาลองอีกครั้ง");
const result = await response.json() as { messageId: string };
@@ -232,7 +271,7 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
} catch (cause) {
if (imageUrl) { URL.revokeObjectURL(imageUrl); optimisticImageUrls.current.delete(imageUrl); }
setOptimisticMessages((current) => current.filter((message) => message.localId !== localId));
setText(originalText); setAttachment(originalImage);
setText(originalText); setAttachment(originalImage); setReplyToId(replyTarget?.id ?? null);
setError(cause instanceof Error ? cause.message : "ส่งข้อความไม่สำเร็จ");
sendingRef.current = false;
if (deferredRefresh.current) { deferredRefresh.current = false; router.refresh(); }
@@ -277,6 +316,7 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
const response = await fetch(`/api/commission/tickets/${ticketId}/messages/${messageId}`, { method: "DELETE" });
if (!response.ok) throw new Error();
setDeletedMessageIds((current) => new Set(current).add(messageId));
if (replyToId === messageId) setReplyToId(null);
setReactionFor(null);
router.refresh();
} catch { setError("ลบข้อความไม่สำเร็จ กรุณาลองอีกครั้ง"); }
@@ -284,12 +324,16 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
}
async function toggleStatus() {
if (statusBusy) return;
setStatusBusy(true);
try {
const response = await fetch(`/api/commission/tickets/${ticketId}/status`, { method: "PATCH",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: status === "open" ? "closed" : "open" }) });
if (!response.ok) throw new Error();
setCloseConfirmOpen(false);
router.refresh();
} catch { setError("เปลี่ยนสถานะ Ticket ไม่สำเร็จ กรุณาลองอีกครั้ง"); }
finally { setStatusBusy(false); }
}
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
@@ -299,17 +343,38 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
}
}
return <>{!admin && <CommissionPushEntryPrompt scope="customer" />}<Card className="h-svh min-w-0 gap-0 py-0 lg:h-[min(85svh,900px)]">
<CardHeader className="border-b py-3">
return <>{!admin && <CommissionPushEntryPrompt scope="customer" />}
<AlertDialog open={closedAlertOpen} onOpenChange={setClosedAlertOpen}>
<AlertDialogContent><AlertDialogHeader>
<AlertDialogTitle>Ticket นี้ปิดแล้ว</AlertDialogTitle>
<AlertDialogDescription>ทีมงานปิด Ticket นี้แล้ว คุณยังอ่านบทสนทนาเดิมได้</AlertDialogDescription>
</AlertDialogHeader><AlertDialogFooter>
<AlertDialogAction onClick={() => setClosedAlertOpen(false)}>รับทราบ</AlertDialogAction>
</AlertDialogFooter></AlertDialogContent>
</AlertDialog>
<Card className="h-full min-h-0 min-w-0 gap-0 py-0">
<CardHeader className="shrink-0 border-b py-3">
<CardTitle>แชต{admin ? "กับลูกค้า" : "กับทีมงาน"}</CardTitle>
<CardDescription>พูดคุยและส่งรูปภาพเกี่ยวกับคำขอนี้</CardDescription>
<CardAction className="flex flex-wrap items-center gap-2">
<Badge variant={status === "open" ? "secondary" : "outline"}>{status === "open" ? "เปิดอยู่" : "ปิดแล้ว"}</Badge>
{admin && <Button variant="outline" size="sm" onClick={toggleStatus}>
{status === "open" ? "ปิด Ticket" : "เปิดอีกครั้ง"}
</Button>}
{admin && (status === "open" ? <AlertDialog open={closeConfirmOpen} onOpenChange={setCloseConfirmOpen}>
<AlertDialogTrigger render={<Button variant="outline" size="sm" disabled={statusBusy} />}>ปิด Ticket</AlertDialogTrigger>
<AlertDialogContent><AlertDialogHeader>
<AlertDialogTitle>ปิด Ticket นี้?</AlertDialogTitle>
<AlertDialogDescription>ลูกค้าจะได้รับแจ้งว่า Ticket ปิดแล้ว และจะส่งข้อความเพิ่มไม่ได้จนกว่าจะเปิดอีกครั้ง</AlertDialogDescription>
</AlertDialogHeader><AlertDialogFooter>
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction onClick={() => void toggleStatus()}>ยืนยันการปิด</AlertDialogAction>
</AlertDialogFooter></AlertDialogContent>
</AlertDialog> : <Button variant="outline" size="sm" disabled={statusBusy} onClick={() => void toggleStatus()}>
เปิดอีกครั้ง
</Button>)}
</CardAction>
</CardHeader>
{!admin && status === "closed" && <Alert className="mx-3 mt-3 w-auto sm:mx-5">
<AlertDescription>Ticket นี้ปิดแล้ว คุณยังอ่านบทสนทนาเดิมได้</AlertDescription>
</Alert>}
<CardContent ref={scrollRef} role="log" aria-label="ข้อความใน Ticket" aria-live="polite"
onScroll={(event) => { const viewport = event.currentTarget;
stayAtBottom.current = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80; }}
@@ -319,11 +384,17 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
<EmptyTitle>เริ่มบทสนทนา</EmptyTitle>
<EmptyDescription>ส่งข้อความหรือรูปภาพเพื่อพูดคุยเกี่ยวกับคำขอนี้</EmptyDescription>
</EmptyHeader>
</Empty> : <MessageGroup className="gap-5">
</Empty> : <MessageGroup className="gap-3">
{visibleMessages.map((message) => {
const mine = message.authorId === userId;
const sentAt = new Date(message.createdAt);
const counts = emojiChoices.map((emoji) => {
const parent = message.replyToId ? visibleMessageById.get(message.replyToId) : null;
const emojiValues = new Set([
...message.reactions.map((reaction) => reaction.emoji),
...Object.keys(optimisticReactions).filter((key) => key.startsWith(`${message.id}:`))
.map((key) => key.slice(message.id.length + 1)),
]);
const counts = [...emojiValues].map((emoji) => {
const savedCount = message.reactions.filter((reaction) => reaction.emoji === emoji).length;
const savedActive = message.reactions.some((reaction) => reaction.emoji === emoji && reaction.userId === userId);
const update = optimisticReactions[reactionKey(message.id, emoji)];
@@ -333,42 +404,35 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
return { emoji, count, active, pending: update?.status === "sending" };
}).filter(({ count }) => count > 0);
return <Message key={message.id} align={mine ? "end" : "start"}>
{!mine && <MessageAvatar><Avatar size="sm"><AvatarFallback>
<MessageAvatar><Avatar size="sm">
{message.authorImage && <AvatarImage src={message.authorImage} alt="" />}
<AvatarFallback>
{message.authorName.trim().charAt(0).toUpperCase() || "?"}
</AvatarFallback></Avatar></MessageAvatar>}
<MessageContent className={cn("w-auto max-w-[min(82%,36rem)] gap-1", mine ? "items-end" : "items-start")}>
</AvatarFallback></Avatar></MessageAvatar>
<MessageContent className={cn("w-full gap-0.5", mine ? "items-end" : "items-start")}>
<MessageHeader className="px-1">{mine ? "คุณ" : message.authorName}</MessageHeader>
<Bubble variant={message.imageUrl ? "ghost" : mine ? "muted" : "secondary"} className="max-w-full">
<div className={cn("flex max-w-[min(82%,36rem)] items-center gap-1", mine && "flex-row-reverse")}>
<Bubble variant={message.imageUrl ? "ghost" : mine ? "muted" : "secondary"} className="min-w-0 max-w-full">
<BubbleContent className={cn("max-w-full", message.imageUrl ? "border-0 bg-transparent p-0 shadow-none" : "px-4 py-2.5 shadow-sm", mine ? "rounded-br-md" : "rounded-bl-md")}>
{parent && <ReplyPreview message={parent} />}
{message.text && <p className={cn("whitespace-pre-wrap", mine && "text-foreground", message.imageUrl && "pb-2")}>{message.text}</p>}
{message.imageUrl && <CommissionMessageImage src={message.imageUrl} />}
</BubbleContent>
</Bubble>
{counts.length > 0 && <div className={cn("flex flex-wrap gap-1", mine && "justify-end")}>
{counts.map(({ emoji, count, active, pending }) => <Button key={emoji} type="button" size="xs"
variant={active ? "secondary" : "outline"} disabled={status !== "open" || pending}
aria-label={`${emoji} ${count} คน`} aria-pressed={active}
onClick={() => react(message.id, emoji)}><span className={pending ? "text-muted-foreground" : "text-foreground"}>
{emoji} {count}
</span></Button>)}
</div>}
<MessageFooter className="gap-1 px-1">
<time dateTime={sentAt.toISOString()} title={sentAt.toLocaleString("th-TH")}>
{sentAt.toLocaleTimeString("th-TH", { hour: "2-digit", minute: "2-digit" })}
</time>
<div className="flex shrink-0 items-center gap-0.5 rounded-md bg-card/90 opacity-0 transition-opacity pointer-events-none group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-focus-within/message:pointer-events-auto group-focus-within/message:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
{status === "open" && <Popover open={reactionFor === message.id}
onOpenChange={(open) => setReactionFor(open ? message.id : null)}>
<PopoverTrigger render={<Button type="button" variant="ghost" size="icon-xs" aria-label="เพิ่มรีแอ็กชัน" />}>
<SmilePlus />
</PopoverTrigger>
<PopoverContent align={mine ? "end" : "start"} className="w-auto">
<div className="flex gap-1" aria-label="เลือกรีแอ็กชัน">
{emojiChoices.map((emoji) => <Button key={emoji} type="button" variant="ghost" size="icon-sm"
aria-label={`ตอบสนอง ${emoji}`} disabled={optimisticReactions[reactionKey(message.id, emoji)]?.status === "sending"}
onClick={() => react(message.id, emoji)}>{emoji}</Button>)}
</div>
<PopoverContent align={mine ? "end" : "start"} className="w-auto p-0">
<EmojiPicker lazyLoadEmojis onEmojiClick={(value) => void react(message.id, value.emoji)} />
</PopoverContent>
</Popover>}
{status === "open" && <Button type="button" variant="ghost" size="icon-xs" aria-label="ตอบกลับข้อความ"
onClick={() => { setReplyToId(message.id); document.getElementById("commission-message")?.focus(); }}>
<ReplyIcon />
</Button>}
{mine && <AlertDialog open={deleteFor === message.id}
onOpenChange={(open) => setDeleteFor(open ? message.id : null)}>
<AlertDialogTrigger render={<Button type="button" variant="ghost" size="icon-xs"
@@ -384,29 +448,54 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>}
</div>
</div>
{counts.length > 0 && <div className={cn("flex flex-wrap gap-1", mine && "justify-end")}>
{counts.map(({ emoji, count, active, pending }) => <Button key={emoji} type="button" size="xs"
variant={active ? "secondary" : "outline"} disabled={status !== "open" || pending}
aria-label={`${emoji} ${count} คน`} aria-pressed={active}
onClick={() => react(message.id, emoji)}><span className={cn("inline-flex items-center gap-1",
pending ? "text-muted-foreground" : "text-foreground")}>
<ReactionEmoji emoji={emoji} /> {count}
</span></Button>)}
</div>}
<MessageFooter className={cn("w-full justify-center gap-1 px-1 group-data-[align=end]/message:justify-center",
mine ? "translate-x-5" : "-translate-x-5")}>
<time dateTime={sentAt.toISOString()} title={sentAt.toLocaleString("th-TH")}>
{sentAt.toLocaleTimeString("th-TH", { hour: "2-digit", minute: "2-digit" })}
</time>
</MessageFooter>
</MessageContent>
</Message>;
})}
{visibleOptimisticMessages.map((message) => <Message key={message.localId} align="end">
<MessageContent className="w-auto max-w-[min(82%,36rem)] items-end gap-1">
<MessageContent className="w-full items-end gap-1">
<MessageHeader className="px-1">คุณ</MessageHeader>
<Bubble variant={message.imageUrl ? "ghost" : "muted"} className="max-w-full">
<Bubble variant={message.imageUrl ? "ghost" : "muted"} className="max-w-[min(82%,36rem)]">
<BubbleContent className={cn("max-w-full", message.imageUrl ? "border-0 bg-transparent p-0 shadow-none" : "px-4 py-2.5 shadow-sm")}>
{message.replyToId && visibleMessageById.get(message.replyToId) &&
<ReplyPreview message={visibleMessageById.get(message.replyToId)!} />}
{message.text && <p className={cn("whitespace-pre-wrap", message.status === "sending" ? "text-muted-foreground" : "text-foreground", message.imageUrl && "pb-2")}>{message.text}</p>}
{message.imageUrl && <div className={cn(message.status === "sending" && "opacity-50")}>
<CommissionMessageImage src={message.imageUrl} />
</div>}
</BubbleContent>
</Bubble>
<MessageFooter className="gap-1 px-1">{message.status === "sending" ? "กำลังส่ง..." : "ส่งแล้ว"}</MessageFooter>
<MessageFooter className="w-full justify-center gap-1 px-1 group-data-[align=end]/message:justify-center">
{message.status === "sending" ? "กำลังส่ง..." : "ส่งแล้ว"}
</MessageFooter>
</MessageContent>
</Message>)}
</MessageGroup>}
</CardContent>
<CardFooter className="block bg-card p-3 sm:p-4">
<CardFooter className="block max-h-[50%] shrink-0 overflow-y-auto bg-card p-3 sm:p-4">
{typing && status === "open" && <p role="status" className="mb-2 text-xs text-muted-foreground">{typing.name} typing...</p>}
{status === "open" ? <form onSubmit={send}><FieldGroup className="gap-2">
{replyTarget && <div className="flex items-start gap-2 rounded-lg border bg-muted/30 p-2">
<div className="min-w-0 flex-1"><ReplyPreview message={replyTarget} /></div>
<Button type="button" variant="ghost" size="icon-xs" aria-label="ยกเลิกการตอบกลับ"
onClick={() => setReplyToId(null)}><X /></Button>
</div>}
{image && <div className="relative w-fit rounded-lg border bg-muted/50 p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
{imagePreview && <img src={imagePreview} alt="ตัวอย่างรูปภาพที่จะแนบ" className="size-28 rounded-md bg-muted object-contain" />}
+8 -16
View File
@@ -5,8 +5,8 @@ import { publicMediaUrl } from "@/lib/media/storage";
import { HttpError } from "@/lib/security/http";
import { CommissionRequestSummary } from "./request-summary";
import { CommissionTicketChat } from "./ticket-chat";
import { CommissionTicketRename } from "./ticket-rename";
import { CommissionSlipDialog } from "./slip-dialog";
import { CommissionTicketPanels } from "./ticket-panels";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export async function CommissionTicketDetail({ id, admin }: { id: string; admin: boolean }) {
@@ -19,25 +19,17 @@ export async function CommissionTicketDetail({ id, admin }: { id: string; admin:
throw cause;
}
const catalog = await getCommissionLabels(data.checkout.request);
return <div className="flex flex-col gap-6">
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0"><h1 className="wrap-anywhere font-heading text-3xl font-semibold">
{data.ticket.title || `Ticket #${data.ticket.id.slice(0, 8)}`}
</h1>
<p className="text-muted-foreground">{data.ticket.title ? `#${data.ticket.id.slice(0, 8)} - ` : ""}{data.customerName} - {data.ticket.status === "open" ? "เปิด" : "ปิด"}</p>
</div>
{admin && <CommissionTicketRename ticketId={id} title={data.ticket.title} />}
</header>
<div className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,300px)_minmax(0,1fr)]">
<Card className="h-fit lg:max-h-[78svh] lg:overflow-y-auto"><CardHeader><CardTitle>คำขอที่ชำระเงินแล้ว - ฿{data.checkout.amountBaht}</CardTitle></CardHeader>
<CardContent className="flex flex-col gap-3">{catalog && <CommissionRequestSummary request={data.checkout.request} catalog={catalog} amountBaht={data.checkout.amountBaht} />}
return <div className="flex min-h-0 flex-1 flex-col gap-3">
<CommissionTicketPanels summary={
<Card className="h-full min-h-0"><CardHeader className="shrink-0"><CardTitle>คำขอที่ชำระเงินแล้ว - ฿{data.checkout.amountBaht}</CardTitle></CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">{catalog && <CommissionRequestSummary request={data.checkout.request} catalog={catalog} amountBaht={data.checkout.amountBaht} />}
<p className="text-sm text-muted-foreground">การชำระเงิน: {data.payment.transRef} - {data.payment.transferredAt.toLocaleString("th-TH")}</p>
<CommissionSlipDialog src={publicMediaUrl(data.payment.slipObjectKey)} />
</CardContent></Card>
<CommissionTicketChat ticketId={id} ticketTitle={data.ticket.title || `Ticket #${id.slice(0, 8)}`}
} chat={<CommissionTicketChat ticketId={id} ticketTitle={data.ticket.title || `Ticket #${id.slice(0, 8)}`}
userId={data.currentUserId} status={data.ticket.status}
messages={data.messages.map(({ imageObjectKey, ...message }) => ({ ...message,
imageUrl: imageObjectKey ? publicMediaUrl(imageObjectKey) : null }))} admin={admin} />
</div>
imageUrl: imageObjectKey ? publicMediaUrl(imageObjectKey) : null }))} admin={admin} />}
/>
</div>;
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { useState, type ReactNode } from "react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/lib/utils";
export function CommissionTicketPanels({ summary, chat }: { summary: ReactNode; chat: ReactNode }) {
const [active, setActive] = useState<"chat" | "summary">("chat");
return <div className="flex min-h-0 flex-1 flex-col gap-2 lg:grid lg:grid-cols-[minmax(0,300px)_minmax(0,1fr)] lg:grid-rows-[minmax(0,1fr)] lg:gap-4">
<ToggleGroup aria-label="ส่วนของ Ticket" variant="outline" size="sm" value={[active]}
onValueChange={(values) => { if (values[0] === "chat" || values[0] === "summary") setActive(values[0]); }}
className="shrink-0 lg:hidden">
<ToggleGroupItem value="chat">แชต</ToggleGroupItem>
<ToggleGroupItem value="summary">รายละเอียดคำขอ</ToggleGroupItem>
</ToggleGroup>
<div className={cn("min-h-0 min-w-0 flex-1", active !== "summary" && "hidden lg:block")}>{summary}</div>
<div className={cn("min-h-0 min-w-0 flex-1", active !== "chat" && "hidden lg:block")}>{chat}</div>
</div>;
}
+20 -12
View File
@@ -1,29 +1,38 @@
"use client";
import { useState, type FormEvent } from "react";
import { useState, type FormEvent, type MouseEvent, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function CommissionTicketRename({ ticketId, title }: { ticketId: string; title: string | null }) {
export function CommissionTicketRename({ children }: { children: ReactNode }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [value, setValue] = useState(title ?? "");
const [ticketId, setTicketId] = useState<string | null>(null);
const [value, setValue] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
function changeOpen(next: boolean) {
if (busy) return;
setOpen(next);
if (next) { setValue(title ?? ""); setError(""); }
}
function openFromClick(event: MouseEvent<HTMLDivElement>) {
if (!(event.target instanceof Element)) return;
const trigger = event.target.closest<HTMLButtonElement>("[data-ticket-rename-id]");
if (!trigger) return;
setTicketId(trigger.dataset.ticketRenameId ?? null);
setValue(trigger.dataset.ticketRenameTitle ?? "");
setError("");
setOpen(true);
}
async function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy) return;
if (busy || !ticketId) return;
setBusy(true); setError("");
try {
const response = await fetch(`/api/commission/tickets/${ticketId}/title`, {
@@ -36,10 +45,8 @@ export function CommissionTicketRename({ ticketId, title }: { ticketId: string;
finally { setBusy(false); }
}
return <Dialog open={open} onOpenChange={changeOpen}>
<DialogTrigger render={<Button type="button" variant="outline" size="sm" aria-label="เปลี่ยนชื่อ Ticket" />}>
<Pencil data-icon="inline-start" /> เปลี่ยนชื่อ
</DialogTrigger>
return <div className="flex flex-col gap-5" onClick={openFromClick}>{children}
<Dialog open={open} onOpenChange={changeOpen}>
<DialogContent>
<form onSubmit={save} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
@@ -58,5 +65,6 @@ export function CommissionTicketRename({ ticketId, title }: { ticketId: string;
</div>
</form>
</DialogContent>
</Dialog>;
</Dialog>
</div>;
}