"use client" import { useCallback, useEffect, useRef, useState } from "react" import { CheckIcon, ChevronDownIcon, MessageSquareTextIcon, PencilIcon, PlusIcon, RefreshCcwIcon, SendIcon, UserRoundIcon, } from "lucide-react" import { toast } from "sonner" import { type CustomerFormSavePayload } from "@/components/customer-form" import { CustomerFormDialog } from "@/components/customer-form-dialog" import { CustomerLinkOrCreateDialog } from "@/components/customer-link-or-create-dialog" import { useConfirm } from "@/components/confirm-provider" import { ContentEditor } from "@/components/content-editor" import { ProjectDialog } from "@/components/project-dialog" import { isRichTextEmpty, SafeRichHTML } from "@/components/safe-rich-html" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Separator } from "@/components/ui/separator" import { saveCustomerProfile } from "@/lib/api/customer" import { changeTicketStatus, createTicketProgress, fetchTicketDetail, type CreateTicketPayload, type TicketDetail, type TicketStatus, type UpdateTicketPayload, updateTicket, } from "@/lib/api/ticket" import { useI18n } from "@/i18n/provider" import { cn, formatDateTime } from "@/lib/utils" import { EditDialog } from "./edit" import { TicketAssignDialog } from "./ticket-assign-dialog" import { getTicketStatusMeta, TicketStatusBadge } from "./ticket-status-badge" type TicketDetailDialogProps = { ticketId: number | null open: boolean onOpenChange: (open: boolean) => void onChanged: () => void } type TFunction = (key: string, values?: Record) => string function getStatusOptions(t: TFunction): Array<{ value: TicketStatus; label: string }> { return [ { value: "pending", label: t("ticket.statusPending") }, { value: "in_progress", label: t("ticket.statusInProgress") }, { value: "done", label: t("ticket.statusDone") }, ] } function getStatusLabel(status: TicketStatus, t: TFunction) { return getStatusOptions(t).find((option) => option.value === status)?.label ?? status } function sourceLabel(source: string, t: TFunction) { switch (source) { case "manual": return t("ticket.manualCreated") case "conversation": return t("ticket.conversationGenerated") default: return source || "-" } } function metadataValue(value?: string | number | null) { if (value === undefined || value === null || value === "") { return "-" } return String(value) } function getTicketCustomerId(ticket?: TicketDetail["ticket"] | null) { return Number(ticket?.customer?.id || ticket?.customerId || 0) } export function TicketDetailDialog({ ticketId, open, onOpenChange, onChanged, }: TicketDetailDialogProps) { const t = useI18n() const confirm = useConfirm() const [detail, setDetail] = useState(null) const [loading, setLoading] = useState(false) const [statusSaving, setStatusSaving] = useState(null) const [progressSaving, setProgressSaving] = useState(false) const [progressOpen, setProgressOpen] = useState(false) const [progressContent, setProgressContent] = useState("") const [assignOpen, setAssignOpen] = useState(false) const [editOpen, setEditOpen] = useState(false) const [editSaving, setEditSaving] = useState(false) const [customerEditOpen, setCustomerEditOpen] = useState(false) const [customerEditSaving, setCustomerEditSaving] = useState(false) const [customerLinkOpen, setCustomerLinkOpen] = useState(false) const loadSeqRef = useRef(0) const dialogSeqRef = useRef(0) const currentTicketIdRef = useRef(null) currentTicketIdRef.current = open ? ticketId : null function isCurrentOperation(targetTicketId: number, dialogSeq: number) { return currentTicketIdRef.current === targetTicketId && dialogSeqRef.current === dialogSeq } const loadDetail = useCallback(async (targetTicketId = ticketId, dialogSeq = dialogSeqRef.current) => { if (!targetTicketId || !isCurrentOperation(targetTicketId, dialogSeq)) { return } const seq = loadSeqRef.current + 1 loadSeqRef.current = seq if (!open || !targetTicketId) { setDetail(null) setLoading(false) return } setLoading(true) try { const data = await fetchTicketDetail(targetTicketId) if (loadSeqRef.current !== seq || !isCurrentOperation(targetTicketId, dialogSeq)) { return } setDetail(data) } catch (error) { if (loadSeqRef.current !== seq || !isCurrentOperation(targetTicketId, dialogSeq)) { return } toast.error(error instanceof Error ? error.message : t("ticket.loadDetailFailed")) } finally { if (loadSeqRef.current === seq) { setLoading(false) } } }, [open, t, ticketId]) useEffect(() => { dialogSeqRef.current += 1 setDetail(null) setLoading(false) setStatusSaving(null) setProgressSaving(false) setProgressOpen(false) setEditSaving(false) setCustomerEditSaving(false) setAssignOpen(false) setEditOpen(false) setCustomerEditOpen(false) setCustomerLinkOpen(false) setProgressContent("") }, [open, ticketId]) useEffect(() => { void loadDetail(ticketId, dialogSeqRef.current) }, [loadDetail, ticketId]) async function handleStatusChange(status: TicketStatus) { if (!detail || detail.ticket.status === status) { return } const confirmed = await confirm({ title: t("ticket.statusChangeConfirmTitle"), description: t("ticket.statusChangeConfirmDescription", { from: getStatusLabel(detail.ticket.status, t), to: getStatusLabel(status, t), }), confirmText: t("ticket.statusChangeConfirm"), }) if (!confirmed) { return } const activeTicketId = detail.ticket.id const activeDialogSeq = dialogSeqRef.current setStatusSaving(status) try { await changeTicketStatus({ ticketId: activeTicketId, status }) if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { return } toast.success(t("ticket.statusUpdated")) await loadDetail(activeTicketId, activeDialogSeq) if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { return } onChanged() } catch (error) { if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { return } toast.error(error instanceof Error ? error.message : t("ticket.statusUpdateFailed")) } finally { if (isCurrentOperation(activeTicketId, activeDialogSeq)) { setStatusSaving(null) } } } async function handleCreateProgress() { if (!detail) { return } const activeTicketId = detail.ticket.id const activeDialogSeq = dialogSeqRef.current const content = progressContent.trim() if (isRichTextEmpty(content)) { toast.error(t("ticket.progressRequired")) return } setProgressSaving(true) try { await createTicketProgress({ ticketId: activeTicketId, content, }) if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { return } toast.success(t("ticket.progressRecorded")) setProgressContent("") setProgressOpen(false) await loadDetail(activeTicketId, activeDialogSeq) if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { return } onChanged() } catch (error) { if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { return } toast.error(error instanceof Error ? error.message : t("ticket.progressCreateFailed")) } finally { if (isCurrentOperation(activeTicketId, activeDialogSeq)) { setProgressSaving(false) } } } async function handleAssigned() { const activeTicketId = ticket?.id const activeDialogSeq = dialogSeqRef.current if (!activeTicketId || !isCurrentOperation(activeTicketId, activeDialogSeq)) { return } await loadDetail(activeTicketId, activeDialogSeq) if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { return } onChanged() } async function handleUpdateTicket(payload: CreateTicketPayload | UpdateTicketPayload) { if (!("ticketId" in payload) || payload.ticketId <= 0) { toast.error(t("ticket.selectTicket")) return } const activeDialogSeq = dialogSeqRef.current setEditSaving(true) try { await updateTicket(payload) if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) { return } toast.success(t("ticket.updated")) setEditOpen(false) await loadDetail(payload.ticketId, activeDialogSeq) if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) { return } onChanged() } catch (error) { if (!isCurrentOperation(payload.ticketId, activeDialogSeq)) { return } toast.error(error instanceof Error ? error.message : t("ticket.updateFailed")) } finally { if (isCurrentOperation(payload.ticketId, activeDialogSeq)) { setEditSaving(false) } } } async function handleUpdateCustomer(payload: CustomerFormSavePayload) { const activeCustomerId = getTicketCustomerId(ticket) if (!ticket?.id || activeCustomerId <= 0) { toast.error(t("ticket.noLinkedCustomer")) return } if (customerEditSaving) { return } const activeTicketId = ticket.id const activeDialogSeq = dialogSeqRef.current setCustomerEditSaving(true) try { await saveCustomerProfile({ ...payload, id: activeCustomerId }) if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { return } toast.success(t("ticket.saved")) setCustomerEditOpen(false) await loadDetail(activeTicketId, activeDialogSeq) if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { return } onChanged() } catch (error) { if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { return } toast.error(error instanceof Error ? error.message : t("ticket.saveFailed")) } finally { if (isCurrentOperation(activeTicketId, activeDialogSeq)) { setCustomerEditSaving(false) } } } async function handleCustomerLinked() { const activeTicketId = ticket?.id const activeDialogSeq = dialogSeqRef.current if (!activeTicketId || !isCurrentOperation(activeTicketId, activeDialogSeq)) { return } await loadDetail(activeTicketId, activeDialogSeq) if (!isCurrentOperation(activeTicketId, activeDialogSeq)) { return } onChanged() } const ticket = detail?.ticket const customerId = getTicketCustomerId(ticket) const statusOptions = getStatusOptions(t) return ( <> {ticket?.title ?? t("ticket.detailTitle")} {ticket ? : null} } description={ ticket ? ( {ticket.ticketNo} {sourceLabel(ticket.source, t)} {t("ticket.creator", { name: metadataValue(ticket.createdByName || ticket.createdBy) })} ) : undefined } size="xxl" defaultFullscreen bodyScrollable={false} bodyClassName="flex w-full" > {loading && !ticket ? (
{t("ticket.loading")}
) : ticket ? (
{t("ticket.description")}
{t("ticket.currentStatus")}
} > {statusSaving ? t("ticket.updating") : getStatusLabel(ticket.status, t)} {statusOptions.map((option) => { const selected = ticket.status === option.value return ( void handleStatusChange(option.value)} className="justify-between" > {option.label} {selected ? : null} ) })}
{t("ticket.columnAssignee")}
{ticket.currentAssigneeName || t("ticket.unassigned")}
{t("ticket.tags")}
{ticket.tags && ticket.tags.length > 0 ? (
{ticket.tags.map((tag) => ( {tag.name} ))}
) : (
{t("ticket.emptyTags")}
)}
{t("ticket.customerInfo")}
{t("ticket.ticketInfo")}
) : (
{t("ticket.chooseTicket")}
)}
0 ? customerId : null} onSave={handleUpdateCustomer} /> { if (progressSaving) { return } setProgressOpen(nextOpen) if (!nextOpen) { setProgressContent("") } }} > {t("ticket.addProgress")}
setProgressContent(next.raw)} placeholder={t("ticket.progressPlaceholder")} disabled={progressSaving} allowedModes={["html"]} height={260} />
) } function MetadataItem({ label, value }: { label: string; value?: string | number | null }) { return (
{label}
{metadataValue(value)}
) }