Files
agent-desk-ng/web/components/conversation-actions/transfer-dialog.tsx
mono 2f7a491666 feat: harden support, localize knowledge and add operator availability [skip ci]
Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.

Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.

Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.

Expected source tree: 0a4a7528e9.
2026-10-16 15:21:42 +00:00

264 lines
7.8 KiB
TypeScript

// Modified in this independent fork for support availability; see docs/hardening/README.md.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { ArrowRightLeftIcon } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { Controller, Resolver, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod/v4"
import { OptionCombobox } from "@/components/option-combobox"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog"
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
import {
assignConversation,
transferConversation,
} from "@/lib/api/admin"
import { fetchAvailabilityAgents, type OperatorAvailability } from "@/lib/api/support-availability"
import { useI18n } from "@/i18n/provider"
type ConversationTransferDialogProps = {
open: boolean
mode: "assign" | "transfer"
conversationId: number | null
onOpenChange: (open: boolean) => void
onSuccess?: () => Promise<void> | void
}
type TransferForm = {
toUserId: string
reason: string
}
const emptyForm: TransferForm = {
toUserId: "",
reason: "",
}
export function ConversationTransferDialog({
open,
mode,
conversationId,
onOpenChange,
onSuccess,
}: ConversationTransferDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open ? (
<ConversationTransferDialogBody
key={conversationId ? `transfer-${conversationId}` : "transfer"}
mode={mode}
conversationId={conversationId}
onOpenChange={onOpenChange}
onSuccess={onSuccess}
/>
) : null}
</Dialog>
)
}
type ConversationTransferDialogBodyProps = {
mode: "assign" | "transfer"
conversationId: number | null
onOpenChange: (open: boolean) => void
onSuccess?: () => Promise<void> | void
}
function ConversationTransferDialogBody({
mode,
conversationId,
onOpenChange,
onSuccess,
}: ConversationTransferDialogBodyProps) {
const t = useI18n()
const [saving, setSaving] = useState(false)
const [loadingAgents, setLoadingAgents] = useState(false)
const [agents, setAgents] = useState<OperatorAvailability[]>([])
const userOptions = agents.map((agent) => ({
value: String(agent.userId),
label: agent.displayName || t("conversationAction.agentFallback", { id: agent.userId }),
}))
const transferSchema = useMemo(
() =>
z.object({
toUserId: z.string().trim().min(1, t("conversationAction.targetAgentRequired")),
reason: z.string().trim(),
}),
[t]
)
const transferResolver = useMemo(
() => zodResolver(transferSchema as never) as Resolver<TransferForm>,
[transferSchema]
)
const form = useForm<TransferForm>({
resolver: transferResolver,
defaultValues: emptyForm,
})
const {
control,
handleSubmit,
reset,
register,
formState: { errors },
} = form
useEffect(() => {
reset(emptyForm)
}, [conversationId, reset])
useEffect(() => {
if (!conversationId) {
setAgents([])
setLoadingAgents(false)
return
}
setLoadingAgents(true)
let active = true
fetchAvailabilityAgents(conversationId)
.then((data) => {
if (active) setAgents(data.filter((item) => item.eligible))
})
.catch((error) => {
if (active) toast.error(error instanceof Error ? error.message : t("conversationAction.loadAgentsFailed"))
})
.finally(() => {
if (active) setLoadingAgents(false)
})
return () => { active = false }
}, [t, conversationId])
async function onFormSubmit(values: TransferForm) {
if (!conversationId) {
toast.error(t("conversationAction.conversationMissing"))
return
}
const toUserId = Number(values.toUserId)
const reason = values.reason.trim()
setSaving(true)
try {
if (mode === "assign") {
await assignConversation(conversationId, toUserId, reason)
toast.success(t("conversationAction.assigned", { id: conversationId }))
} else {
await transferConversation(conversationId, toUserId, reason)
toast.success(t("conversationAction.transferred", { id: conversationId }))
}
reset(emptyForm)
onOpenChange(false)
await onSuccess?.()
} catch (error) {
toast.error(
error instanceof Error
? error.message
: mode === "assign"
? t("conversationAction.assignFailed")
: t("conversationAction.transferFailed")
)
} finally {
setSaving(false)
}
}
const isAssign = mode === "assign"
return (
<DialogContent className="max-w-lg gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-6 pt-6">
<DialogTitle>
{isAssign ? t("conversationAction.assignTitle") : t("conversationAction.transferTitle")}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 p-6">
<Field data-invalid={!!errors.toUserId}>
<FieldLabel htmlFor="conversation-transfer-user">
{t("conversationAction.targetAgent")}
</FieldLabel>
<FieldContent>
<Controller
control={control}
name="toUserId"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={userOptions}
placeholder={
!conversationId
? t("conversationAction.conversationMissing")
: loadingAgents
? t("conversationAction.loading")
: t("conversationAction.selectTargetAgent")
}
searchPlaceholder={t("conversationAction.searchAgent")}
emptyText={t("conversationAction.emptyAgents")}
disabled={saving || loadingAgents || !conversationId}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.toUserId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.reason}>
<FieldLabel htmlFor="conversation-transfer-reason">
{isAssign ? t("conversationAction.assignNote") : t("conversationAction.transferReason")}
</FieldLabel>
<FieldContent>
<Textarea
id="conversation-transfer-reason"
rows={4}
placeholder={
isAssign
? t("conversationAction.assignPlaceholder")
: t("conversationAction.transferPlaceholder")
}
aria-invalid={!!errors.reason}
{...register("reason")}
/>
<FieldError errors={[errors.reason]} />
</FieldContent>
</Field>
</div>
<DialogFooter className="mx-0 mb-0 px-6 py-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("conversationAction.cancel")}
</Button>
<Button type="submit" disabled={saving || loadingAgents || !conversationId || agents.length === 0}>
<ArrowRightLeftIcon />
{saving
? isAssign
? t("conversationAction.assigning")
: t("conversationAction.transferring")
: isAssign
? t("conversationAction.confirmAssign")
: t("conversationAction.confirmTransfer")}
</Button>
</DialogFooter>
</form>
</DialogContent>
)
}