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.
This commit is contained in:
@@ -1,74 +1,54 @@
|
||||
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
Loader2Icon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import {
|
||||
notifySetupReadinessUpdated,
|
||||
SETUP_READINESS_UPDATED_EVENT,
|
||||
} from "@/components/operator-readiness-indicator"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
applySetupQuickFix,
|
||||
fetchSetupReadiness,
|
||||
type SetupReadinessResponse,
|
||||
} from "@/lib/api/setup-readiness"
|
||||
|
||||
export function SetupReadinessBanner({
|
||||
onUpdated,
|
||||
}: {
|
||||
export function SetupReadinessBanner({ onUpdated }: {
|
||||
onUpdated?: () => void
|
||||
}) {
|
||||
const t = useI18n()
|
||||
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
|
||||
const [fixing, setFixing] = useState(false)
|
||||
|
||||
const loadReadiness = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchSetupReadiness()
|
||||
setReadiness(data)
|
||||
} catch {
|
||||
// Ignore in banner if failed
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadReadiness()
|
||||
let active = true
|
||||
let latestRequest = 0
|
||||
const loadReadiness = () => {
|
||||
const request = ++latestRequest
|
||||
void fetchSetupReadiness().then((data) => {
|
||||
if (active && request === latestRequest) setReadiness(data)
|
||||
}).catch(() => {
|
||||
// Keep this optional background banner quiet on failure.
|
||||
})
|
||||
}
|
||||
loadReadiness()
|
||||
const handleUpdated = () => {
|
||||
void loadReadiness()
|
||||
loadReadiness()
|
||||
onUpdated?.()
|
||||
}
|
||||
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
return () => {
|
||||
active = false
|
||||
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
}
|
||||
}, [loadReadiness])
|
||||
|
||||
async function handleQuickSetupAll() {
|
||||
setFixing(true)
|
||||
try {
|
||||
const res = await applySetupQuickFix({ action: "quick_setup_all" })
|
||||
setReadiness(res.readiness)
|
||||
notifySetupReadinessUpdated()
|
||||
onUpdated?.()
|
||||
toast.success(t("setupWizard.quickFixSuccess"))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("common.operationFailed"),
|
||||
)
|
||||
} finally {
|
||||
setFixing(false)
|
||||
}
|
||||
}
|
||||
}, [onUpdated])
|
||||
|
||||
if (!readiness) {
|
||||
return null
|
||||
@@ -137,24 +117,12 @@ export function SetupReadinessBanner({
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{canQuickSetupAll ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5 rounded-md"
|
||||
disabled={fixing}
|
||||
onClick={() => void handleQuickSetupAll()}
|
||||
>
|
||||
{fixing ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-4" />
|
||||
)}
|
||||
<span>
|
||||
{fixing
|
||||
? t("setupWizard.quickSetupRunning")
|
||||
: t("setupWizard.quickSetupAll")}
|
||||
</span>
|
||||
<Button size="sm" className="gap-1.5 rounded-md" render={<Link href="/dashboard/setup" />}>
|
||||
<SparklesIcon className="size-4" />
|
||||
<span>{t("setupWizard.preview.review")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Modified in this independent fork for explicit dated/recurring calendar modes; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
@@ -286,6 +288,7 @@ export default function DashboardAgentTeamSchedulesPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-[calc(100vh-var(--header-height))] min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4 lg:p-6">
|
||||
<p className="shrink-0 rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground">{t("supportAvailability.datedShiftsNotice")} <Link href="/dashboard/support-availability" className="underline underline-offset-2">{t("supportAvailability.title")}</Link></p>
|
||||
<div className="shrink-0 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ButtonGroup>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
type CreateAdminAgentTeamPayload,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { availabilityStatusOptions } from "@/lib/support-availability-i18n";
|
||||
import { ServiceStatus } from "@/lib/generated/enums";
|
||||
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string;
|
||||
@@ -65,7 +67,7 @@ const emptyForm: EditForm = {
|
||||
agentCode: "",
|
||||
displayName: "",
|
||||
avatar: "",
|
||||
serviceStatus: String(ServiceStatus.Idle) as "0" | "1",
|
||||
serviceStatus: String(ServiceStatus.Idle) as "0" | "1" | "2" | "3" | "4",
|
||||
maxConcurrentCount: "0",
|
||||
priorityLevel: "0",
|
||||
autoAssignEnabled: true,
|
||||
@@ -79,7 +81,7 @@ type EditForm = {
|
||||
agentCode: string;
|
||||
displayName: string;
|
||||
avatar: string;
|
||||
serviceStatus: "0" | "1";
|
||||
serviceStatus: "0" | "1" | "2" | "3" | "4";
|
||||
maxConcurrentCount: string;
|
||||
priorityLevel: string;
|
||||
autoAssignEnabled: boolean;
|
||||
@@ -94,7 +96,7 @@ function createEditFormSchema(t: TFunction) {
|
||||
agentCode: z.string().trim().min(1, t("agentProfile.agentCodeRequired")),
|
||||
displayName: z.string().trim().min(1, t("agentProfile.displayNameRequired")),
|
||||
avatar: z.string().trim(),
|
||||
serviceStatus: z.enum(["0", "1"], {
|
||||
serviceStatus: z.enum(["0", "1", "2", "3", "4"], {
|
||||
message: t("agentProfile.statusRequired"),
|
||||
}),
|
||||
maxConcurrentCount: z
|
||||
@@ -112,10 +114,7 @@ function createEditFormSchema(t: TFunction) {
|
||||
}
|
||||
|
||||
function getServiceStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
|
||||
{ value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
|
||||
];
|
||||
return availabilityStatusOptions(t, false);
|
||||
}
|
||||
|
||||
function buildForm(item: AdminAgentProfile | null): EditForm {
|
||||
@@ -529,7 +528,7 @@ function AgentEditDialogBody({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.serviceStatus}>
|
||||
{!itemId ? <Field data-invalid={!!errors.serviceStatus}>
|
||||
<FieldLabel>{t("agentProfile.serviceStatus")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
@@ -548,7 +547,7 @@ function AgentEditDialogBody({
|
||||
/>
|
||||
<FieldError errors={[errors.serviceStatus]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</Field> : <p className="text-sm text-muted-foreground">{t("supportAvailability.statusSeparate")}</p>}
|
||||
|
||||
<Field className="min-h-32">
|
||||
<FieldLabel>{t("agentProfile.avatar")}</FieldLabel>
|
||||
@@ -625,10 +624,12 @@ function AgentEditDialogBody({
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
disabled
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("supportAvailability.offlineDeprecated")}</p>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -5,13 +6,19 @@ import {
|
||||
PanelLeftOpenIcon,
|
||||
UserCogIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
DashboardCrudPage,
|
||||
type DashboardCrudColumn,
|
||||
type DashboardCrudFilter,
|
||||
} from "@/components/dashboard/crud";
|
||||
import { isSameSession } from "@/lib/session-request-guard";
|
||||
import type { AuthSession } from "@/lib/auth";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { AVAILABILITY_UPDATED_EVENT, notifyAvailabilityUpdated, OperatorStatusDialog } from "@/components/support-availability/operator-status";
|
||||
import { fetchAvailabilityAgents, type OperatorAvailability } from "@/lib/api/support-availability";
|
||||
import { availabilityReasonLabel, availabilityStatusLabel, availabilityStatusOptions } from "@/lib/support-availability-i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -24,7 +31,6 @@ import {
|
||||
type CreateAdminAgentProfilePayload,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { ServiceStatus } from "@/lib/generated/enums";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { EditDialog } from "./_components/edit";
|
||||
import { AgentTeamSidebar } from "./_components/team-sidebar";
|
||||
@@ -34,20 +40,33 @@ type TFunction = (key: string, values?: Record<string, string | number>) => stri
|
||||
function getServiceStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "all", label: t("agentProfile.allStatuses") },
|
||||
{ value: String(ServiceStatus.Idle), label: t("agentProfile.statusIdle") },
|
||||
{ value: String(ServiceStatus.Busy), label: t("agentProfile.statusBusy") },
|
||||
...availabilityStatusOptions(t),
|
||||
];
|
||||
}
|
||||
|
||||
function getStatusLabel(value: number, t: TFunction) {
|
||||
return (
|
||||
getServiceStatusOptions(t).find((item) => item.value === String(value))
|
||||
?.label ?? String(value)
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardAgentsPage() {
|
||||
const t = useI18n();
|
||||
const { session } = useAuth();
|
||||
const accountId = session?.user.id;
|
||||
const canUpdateAvailability = Boolean(session?.permissions.includes("agent.update"));
|
||||
const [availability, setAvailability] = useState<{ accountId?: number; items: OperatorAvailability[] }>({ items: [] });
|
||||
const [statusDialog, setStatusDialog] = useState<{ session: AuthSession; value: OperatorAvailability } | null>(null);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let pending = false;
|
||||
async function refresh() {
|
||||
if (pending || !accountId) return;
|
||||
pending = true;
|
||||
try { const items = await fetchAvailabilityAgents(); if (active) setAvailability({ accountId, items }); }
|
||||
catch { if (active) setAvailability({ accountId, items: [] }); }
|
||||
finally { pending = false; }
|
||||
}
|
||||
void refresh();
|
||||
const onRefresh = () => { if (document.visibilityState === "visible") void refresh(); };
|
||||
window.addEventListener(AVAILABILITY_UPDATED_EVENT, onRefresh);
|
||||
const timer = window.setInterval(onRefresh, 30000);
|
||||
return () => { active = false; window.clearInterval(timer); window.removeEventListener(AVAILABILITY_UPDATED_EVENT, onRefresh); };
|
||||
}, [accountId]);
|
||||
const serviceStatusOptions = useMemo(() => getServiceStatusOptions(t), [t]);
|
||||
const [selectedTeam, setSelectedTeam] = useState<AdminAgentTeam | null>(null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
@@ -130,19 +149,21 @@ export default function DashboardAgentsPage() {
|
||||
{
|
||||
key: "rules",
|
||||
label: t("agentProfile.columnRules"),
|
||||
render: (item) => (
|
||||
<>
|
||||
<Badge variant="outline">
|
||||
{getStatusLabel(item.serviceStatus, t)}
|
||||
</Badge>
|
||||
render: (item) => {
|
||||
const current = availability.accountId === accountId ? availability.items.find(value => value.userId === item.userId) : undefined;
|
||||
return <>
|
||||
<div className="flex flex-wrap gap-1.5"><Badge variant="outline">{current ? availabilityStatusLabel(current.effectiveServiceStatus, t) : t("supportAvailability.status.unknown")}</Badge>
|
||||
{current ? <Badge variant="outline">{t(current.online ? "supportAvailability.online" : "supportAvailability.offline")}</Badge> : null}</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{current ? availabilityReasonLabel(current.reason, t) : t("supportAvailability.reason.unknown")}</p>
|
||||
{current && canUpdateAvailability ? <Button type="button" variant="link" size="sm" className="h-auto px-0" onClick={() => { if (session) setStatusDialog({ session, value: current }); }}>{t("supportAvailability.manageStatus")}</Button> : null}
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{t("agentProfile.capacityPriority", {
|
||||
capacity: item.maxConcurrentCount,
|
||||
priority: item.priorityLevel,
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
</>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "dispatch",
|
||||
@@ -154,13 +175,7 @@ export default function DashboardAgentsPage() {
|
||||
? t("agentProfile.autoAssign")
|
||||
: t("agentProfile.noAutoAssign")}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={item.receiveOfflineMessage ? "secondary" : "outline"}
|
||||
>
|
||||
{item.receiveOfflineMessage
|
||||
? t("agentProfile.offlineReceive")
|
||||
: t("agentProfile.noOfflineReceive")}
|
||||
</Badge>
|
||||
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -183,11 +198,12 @@ export default function DashboardAgentsPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
[t, availability, accountId, canUpdateAvailability, session],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
{statusDialog && isSameSession(statusDialog.session, session) ? <OperatorStatusDialog value={statusDialog.value} admin onClose={() => setStatusDialog(null)} onSaved={() => notifyAvailabilityUpdated()} /> : null}
|
||||
<div
|
||||
className={`shrink-0 overflow-hidden transition-[width] duration-200 ${
|
||||
sidebarCollapsed ? "w-0" : "w-80"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Controller, Resolver, useForm, useWatch } from "react-hook-form"
|
||||
import { z } from "zod/v4"
|
||||
@@ -1375,13 +1376,15 @@ function ChannelFormBody({
|
||||
)
|
||||
}
|
||||
|
||||
// The origin cannot change within a document. An explicit server snapshot keeps
|
||||
// the generated access links hydration-safe without a state-setting effect.
|
||||
const subscribeToOrigin = () => () => {}
|
||||
const readBrowserOrigin = () => window.location.origin
|
||||
const readServerOrigin = () => ""
|
||||
|
||||
function WebAccessGuide({ channelId }: { channelId: string }) {
|
||||
const t = useI18n()
|
||||
const [origin, setOrigin] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
setOrigin(window.location.origin)
|
||||
}, [])
|
||||
const origin = useSyncExternalStore(subscribeToOrigin, readBrowserOrigin, readServerOrigin)
|
||||
|
||||
const accessUrl = useMemo(() => {
|
||||
if (!origin || !channelId) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: retain operator drafts and forward pending-send state.
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
@@ -11,11 +13,14 @@ import {
|
||||
sendOperatorPresence,
|
||||
useAgentConversationsStore,
|
||||
} from "@/hooks/use-agent-conversation-realtime"
|
||||
import type { MessageEditorDraftStore } from "@/lib/message-editor-drafts"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
|
||||
import { suggestAgentReply } from "@/lib/api/agent"
|
||||
|
||||
type AgentMessageEditorProps = {
|
||||
draftStore: MessageEditorDraftStore
|
||||
sending?: boolean
|
||||
disabled?: boolean
|
||||
uploadingAsset?: boolean
|
||||
onSend: (html: string) => Promise<void>
|
||||
@@ -28,6 +33,8 @@ type AgentMessageEditorProps = {
|
||||
* Supports quick replies picker and slash command (/) autocomplete.
|
||||
*/
|
||||
export function AgentMessageEditor({
|
||||
draftStore,
|
||||
sending = false,
|
||||
disabled = false,
|
||||
uploadingAsset = false,
|
||||
onSend,
|
||||
@@ -79,6 +86,8 @@ export function AgentMessageEditor({
|
||||
<SharedMessageEditor
|
||||
variant="agent"
|
||||
conversationId={selectedConversationId}
|
||||
draftStore={draftStore}
|
||||
sending={sending}
|
||||
disabled={disabled}
|
||||
uploadingAsset={uploadingAsset}
|
||||
quickReplies={{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: capture operator recipients and preserve failed message drafts.
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -61,6 +63,7 @@ import {
|
||||
type AIWorkflowRun,
|
||||
} from "@/lib/api/admin";
|
||||
import { readSession } from "@/lib/auth";
|
||||
import { createMessageEditorDraftStore } from "@/lib/message-editor-drafts";
|
||||
import { renderIMMessageHTML } from "@/lib/im-message";
|
||||
import {
|
||||
agentConversationSelectors,
|
||||
@@ -177,6 +180,8 @@ export function ChatPanel() {
|
||||
null,
|
||||
);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const [draftStore] = useState(createMessageEditorDraftStore);
|
||||
useEffect(() => () => draftStore.clear(), [draftStore]);
|
||||
const [claiming, setClaiming] = useState(false);
|
||||
const [claimDialogOpen, setClaimDialogOpen] = useState(false);
|
||||
const [transferDialogOpen, setTransferDialogOpen] = useState(false);
|
||||
@@ -314,12 +319,19 @@ export function ChatPanel() {
|
||||
};
|
||||
|
||||
const handleSend = async (html: string) => {
|
||||
if (!conversation || sending || isClosedConversation) return;
|
||||
// Bind the displayed draft to its recipient before the store reads selection.
|
||||
const recipientId = conversation?.id;
|
||||
try {
|
||||
if (!recipientId || sending || isClosedConversation ||
|
||||
useAgentConversationsStore.getState().selectedConversationId !== recipientId) {
|
||||
throw new Error(t("conversation.sendMessageFailed"));
|
||||
}
|
||||
shouldStickToBottomRef.current = true;
|
||||
await sendMessage(html);
|
||||
const message = await sendMessage(html, recipientId);
|
||||
if (!message) throw new Error(t("conversation.sendMessageFailed"));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("conversation.sendMessageFailed"));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -369,7 +381,7 @@ export function ChatPanel() {
|
||||
setWorkflowRunLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!conversation) {
|
||||
@@ -495,20 +507,28 @@ export function ChatPanel() {
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1">
|
||||
<AgentMessageEditor
|
||||
disabled={!conversation || sending}
|
||||
draftStore={draftStore}
|
||||
disabled={!conversation}
|
||||
sending={sending}
|
||||
uploadingAsset={uploadingAsset}
|
||||
onSend={handleSend}
|
||||
onUploadImage={async (file) => {
|
||||
shouldStickToBottomRef.current = true;
|
||||
const uploaded = await uploadImage(file);
|
||||
return uploaded;
|
||||
try {
|
||||
return await uploadImage(file, conversation.id);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("upload.imageUploadFailed"));
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
onSendAttachment={async (file) => {
|
||||
shouldStickToBottomRef.current = true;
|
||||
try {
|
||||
await sendAttachment(file);
|
||||
const message = await sendAttachment(file, conversation.id);
|
||||
if (!message) throw new Error(t("conversation.sendAttachmentFailed"));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("conversation.sendAttachmentFailed"));
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
@@ -145,7 +146,7 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
|
||||
{getKnowledgeAnswerStatusLabel(answerResult.answerStatus, answerResult.answerStatusName, t)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{answerResult.latencyMs}ms · {answerResult.modelName || "fallback"}
|
||||
{answerResult.latencyMs}ms · {answerResult.modelName || t("knowledge.answerFallback")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background p-3 whitespace-pre-wrap">
|
||||
@@ -161,12 +162,12 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-xs font-medium">
|
||||
{getSearchResultLabel(citation)}
|
||||
{getSearchResultLabel(citation, t)}
|
||||
</div>
|
||||
<Badge variant="outline">{citation.score.toFixed(4)}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{citation.sectionPath || citation.title || `Chunk #${citation.chunkNo}`}
|
||||
{citation.sectionPath || citation.title || t("knowledge.chunkNumber", { number: citation.chunkNo })}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
|
||||
{citation.snippet}
|
||||
@@ -192,12 +193,12 @@ export function DebugPanel({ knowledgeBaseId }: DebugPanelProps) {
|
||||
<div key={`${item.chunkId}-${item.documentId}`} className="rounded-md border bg-background p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{getSearchResultLabel(item)}
|
||||
{getSearchResultLabel(item, t)}
|
||||
</div>
|
||||
<Badge variant="outline">{item.score.toFixed(4)}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.sectionPath || item.title || `Chunk #${item.chunkNo}`}
|
||||
{item.sectionPath || item.title || t("knowledge.chunkNumber", { number: item.chunkNo })}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-muted-foreground whitespace-pre-wrap">
|
||||
{item.content}
|
||||
@@ -218,7 +219,7 @@ function getSearchResultLabel(item: {
|
||||
faqId?: number
|
||||
documentTitle?: string
|
||||
documentId?: number
|
||||
}) {
|
||||
}, t: (key: string, values?: Record<string, string | number>) => string) {
|
||||
if (item.faqQuestion) {
|
||||
return item.faqQuestion
|
||||
}
|
||||
@@ -226,7 +227,7 @@ function getSearchResultLabel(item: {
|
||||
return item.documentTitle
|
||||
}
|
||||
if (item.faqId && item.faqId > 0) {
|
||||
return `FAQ ${item.faqId}`
|
||||
return t("knowledge.faqNumber", { number: item.faqId })
|
||||
}
|
||||
return `Document ${item.documentId ?? 0}`
|
||||
return t("knowledge.documentNumber", { number: item.documentId ?? 0 })
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
KnowledgeDocumentIndexStatus,
|
||||
Status,
|
||||
} from "@/lib/generated/enums";
|
||||
import { getKnowledgeIndexErrorLabel } from "@/lib/knowledge-i18n";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import { DocumentEditDialog } from "./document-edit";
|
||||
import { KnowledgeContentDetailDialog } from "./knowledge-content-detail";
|
||||
@@ -139,7 +141,7 @@ function renderIndexStatusBadge(item: KnowledgeDocumentListItem, t: TFunction) {
|
||||
<span className="inline-flex">{badge}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" className="max-w-sm whitespace-normal">
|
||||
{item.indexError}
|
||||
{getKnowledgeIndexErrorLabel(item.indexError, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -637,7 +639,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.indexStatus === KnowledgeDocumentIndexStatus.Indexed
|
||||
? t("knowledge.indexTime", { time: formatDateTime(item.indexedAt) })
|
||||
: item.indexError || getIndexStatusLabel(item.indexStatus, t)}
|
||||
: getKnowledgeIndexErrorLabel(item.indexError, t) || getIndexStatusLabel(item.indexStatus, t)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
} from "@/lib/api/admin";
|
||||
import { KnowledgeDocumentIndexStatus } from "@/lib/generated/enums";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { getKnowledgeIndexErrorLabel } from "@/lib/knowledge-i18n";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import { FAQEditDialog } from "./faq-edit";
|
||||
import { FAQImportDialog } from "./faq-import-dialog";
|
||||
@@ -127,7 +129,7 @@ function renderIndexStatusBadge(item: KnowledgeFAQ, t: TFunction) {
|
||||
<span className="inline-flex">{badge}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" className="max-w-sm whitespace-normal">
|
||||
{item.indexError}
|
||||
{getKnowledgeIndexErrorLabel(item.indexError, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -42,26 +43,41 @@ export function KnowledgeBulkMoveDialog({
|
||||
onSubmit,
|
||||
}: KnowledgeBulkMoveDialogProps) {
|
||||
const t = useI18n();
|
||||
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([]);
|
||||
const [directoryResult, setDirectoryResult] = useState<{
|
||||
knowledgeBaseId: number;
|
||||
directories: KnowledgeDirectory[];
|
||||
}>({ knowledgeBaseId, directories: [] });
|
||||
const [targetDirectoryId, setTargetDirectoryId] = useState("0");
|
||||
const [selectionScope, setSelectionScope] = useState({ knowledgeBaseId, open });
|
||||
|
||||
// A new base or dialog opening is a new selection session. Reset before the
|
||||
// children render, rather than exposing the previous target for one frame.
|
||||
if (selectionScope.knowledgeBaseId !== knowledgeBaseId || selectionScope.open !== open) {
|
||||
setSelectionScope({ knowledgeBaseId, open });
|
||||
setTargetDirectoryId("0");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setTargetDirectoryId("0");
|
||||
let active = true;
|
||||
fetchKnowledgeDirectories(knowledgeBaseId)
|
||||
.then(setDirectories)
|
||||
.then((result) => {
|
||||
if (active) setDirectoryResult({ knowledgeBaseId, directories: result });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) return;
|
||||
console.error(error);
|
||||
setDirectories([]);
|
||||
setDirectoryResult({ knowledgeBaseId, directories: [] });
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [knowledgeBaseId, open]);
|
||||
|
||||
const directoryOptions = useMemo(() => [
|
||||
{ value: "0", label: t("knowledge.rootContent") },
|
||||
...flattenDirectoryOptions(directories),
|
||||
], [directories, t]);
|
||||
...flattenDirectoryOptions(directoryResult.knowledgeBaseId === knowledgeBaseId ? directoryResult.directories : []),
|
||||
], [directoryResult, knowledgeBaseId, t]);
|
||||
|
||||
return (
|
||||
<ProjectDialog
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { markdownToHtml } from "@/components/content-editor/convert";
|
||||
import { KnowledgeDocumentContentType } from "@/lib/generated/enums";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { getKnowledgeStatusLabel, getKnowledgeIndexStatusLabel, getKnowledgeIndexErrorLabel } from "@/lib/knowledge-i18n";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
|
||||
type KnowledgeContentDetailDialogProps = {
|
||||
@@ -128,11 +130,11 @@ export function KnowledgeContentDetailDialog({
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.status")}</div>
|
||||
<Badge variant="outline">{item.statusName || item.status}</Badge>
|
||||
<Badge variant="outline">{getKnowledgeStatusLabel(item.status, t)}</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.indexStatus")}</div>
|
||||
<Badge variant="secondary">{item.indexStatusName || item.indexStatus}</Badge>
|
||||
<Badge variant="secondary">{getKnowledgeIndexStatusLabel(item.indexStatus, t)}</Badge>
|
||||
</div>
|
||||
<DetailField
|
||||
label={t("knowledge.createdAt")}
|
||||
@@ -158,7 +160,7 @@ export function KnowledgeContentDetailDialog({
|
||||
|
||||
{item.indexError ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
{item.indexError}
|
||||
{getKnowledgeIndexErrorLabel(item.indexError, t)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
@@ -57,7 +58,7 @@ function CitationList({ hits, t }: { hits: KnowledgeRetrieveHit[]; t: TFunction
|
||||
<div key={item.id} className="rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{getHitSourceLabel(item, t)}</span>
|
||||
<Badge variant="outline">Chunk #{item.chunkNo}</Badge>
|
||||
<Badge variant="outline">{t("knowledge.chunkNumber", { number: item.chunkNo })}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.sectionPath || item.title || t("knowledge.unrecordedSection")}
|
||||
@@ -124,6 +125,7 @@ export function RetrieveLogDetailDrawer({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 pb-6">
|
||||
<p className="text-xs text-muted-foreground">{t("knowledge.historyRecordedNotice")}</p>
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">{t("knowledge.requestInfo")}</h3>
|
||||
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-2">
|
||||
@@ -143,7 +145,7 @@ export function RetrieveLogDetailDrawer({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Request ID</div>
|
||||
<div className="text-xs text-muted-foreground">{t("knowledge.requestId")}</div>
|
||||
<div className="mt-1 break-all font-mono text-xs">{detail.log.requestId || "-"}</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
@@ -165,15 +167,15 @@ export function RetrieveLogDetailDrawer({
|
||||
<h3 className="text-sm font-semibold">{t("knowledge.retrieveStrategy")}</h3>
|
||||
<div className="grid gap-3 rounded-lg border p-4 md:grid-cols-3">
|
||||
<Metric
|
||||
label="Chunk Provider"
|
||||
label={t("knowledge.chunkProvider")}
|
||||
value={detail.log.chunkProvider ? getKnowledgeChunkProviderLabel(detail.log.chunkProvider, t) : "-"}
|
||||
mono
|
||||
/>
|
||||
<Metric label="Target Tokens" value={detail.log.chunkTargetTokens} />
|
||||
<Metric label="Max Tokens" value={detail.log.chunkMaxTokens} />
|
||||
<Metric label="Overlap Tokens" value={detail.log.chunkOverlapTokens} />
|
||||
<Metric label="Rerank" value={detail.log.rerankEnabled ? t("knowledge.rerankEnabled") : t("knowledge.rerankDisabled")} />
|
||||
<Metric label="Rerank Limit" value={detail.log.rerankLimit} />
|
||||
<Metric label={t("knowledge.targetToken")} value={detail.log.chunkTargetTokens} />
|
||||
<Metric label={t("knowledge.maxToken")} value={detail.log.chunkMaxTokens} />
|
||||
<Metric label={t("knowledge.overlapToken")} value={detail.log.chunkOverlapTokens} />
|
||||
<Metric label={t("knowledge.rerank")} value={detail.log.rerankEnabled ? t("knowledge.rerankEnabled") : t("knowledge.rerankDisabled")} />
|
||||
<Metric label={t("knowledge.rerankLimitLabel")} value={detail.log.rerankLimit} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -187,12 +189,12 @@ export function RetrieveLogDetailDrawer({
|
||||
<Metric label={t("knowledge.hitCount")} value={detail.log.hitCount} />
|
||||
<Metric label={t("knowledge.citations")} value={detail.log.citationCount} />
|
||||
<Metric label={t("knowledge.contextChunks")} value={detail.log.usedChunkCount} />
|
||||
<Metric label="Top Score" value={detail.log.topScore.toFixed(4)} mono />
|
||||
<Metric label={t("knowledge.topScore")} value={detail.log.topScore.toFixed(4)} mono />
|
||||
<Metric label={t("knowledge.retrieveMs")} value={`${detail.log.retrieveMs} ms`} />
|
||||
<Metric label={t("knowledge.generateMs")} value={`${detail.log.generateMs} ms`} />
|
||||
<Metric label={t("knowledge.totalMs")} value={`${detail.log.latencyMs} ms`} />
|
||||
<Metric label="Prompt Tokens" value={detail.log.promptTokens} />
|
||||
<Metric label="Completion Tokens" value={detail.log.completionTokens} />
|
||||
<Metric label={t("knowledge.promptTokens")} value={detail.log.promptTokens} />
|
||||
<Metric label={t("knowledge.completionTokens")} value={detail.log.completionTokens} />
|
||||
<Metric label={t("knowledge.model")} value={detail.log.modelName || "-"} mono />
|
||||
<Metric label={t("knowledge.sessionId")} value={detail.log.sessionId || "-"} mono />
|
||||
</div>
|
||||
@@ -221,10 +223,10 @@ export function RetrieveLogDetailDrawer({
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{t("knowledge.section", { section: item.sectionPath || item.title || "-" })}</span>
|
||||
<span>Chunk #{item.chunkNo}</span>
|
||||
<span>Provider:{item.provider || "-"}</span>
|
||||
<span>Score:{item.score.toFixed(4)}</span>
|
||||
<span>Rerank:{item.rerankScore ? item.rerankScore.toFixed(4) : "-"}</span>
|
||||
<span>{t("knowledge.chunkNumber", { number: item.chunkNo })}</span>
|
||||
<span>{t("knowledge.providerValue", { value: item.provider ? getKnowledgeChunkProviderLabel(item.provider, t) : "-" })}</span>
|
||||
<span>{t("knowledge.scoreValue", { value: item.score.toFixed(4) })}</span>
|
||||
<span>{t("knowledge.rerankValue", { value: item.rerankScore ? item.rerankScore.toFixed(4) : "-" })}</span>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="text-sm leading-6 whitespace-pre-wrap text-foreground/90">
|
||||
@@ -236,7 +238,7 @@ export function RetrieveLogDetailDrawer({
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">TraceData</h3>
|
||||
<h3 className="text-sm font-semibold">{t("knowledge.traceData")}</h3>
|
||||
<div className="rounded-lg border bg-muted/20 p-4">
|
||||
<pre className="overflow-x-auto text-xs leading-6 text-muted-foreground">
|
||||
{traceData ? JSON.stringify(traceData, null, 2) : detail.log.traceData || "-"}
|
||||
@@ -259,9 +261,9 @@ function getHitSourceLabel(item: KnowledgeRetrieveHit, t: TFunction) {
|
||||
return item.documentTitle
|
||||
}
|
||||
if (item.faqId > 0) {
|
||||
return `FAQ #${item.faqId}`
|
||||
return t("knowledge.faqNumber", { number: item.faqId })
|
||||
}
|
||||
return `${t("knowledge.document")} #${item.documentId}`
|
||||
return t("knowledge.documentNumber", { number: item.documentId })
|
||||
}
|
||||
|
||||
function Metric({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
@@ -190,13 +191,13 @@ export function RetrieveLogList({
|
||||
},
|
||||
{
|
||||
name: "rerankEnabled",
|
||||
label: "Rerank",
|
||||
label: t("knowledge.rerank"),
|
||||
type: "select",
|
||||
defaultValue: "all",
|
||||
allValue: "all",
|
||||
valueType: "number",
|
||||
options: rerankOptions,
|
||||
placeholder: "Rerank",
|
||||
placeholder: t("knowledge.rerank"),
|
||||
},
|
||||
]}
|
||||
fetchList={(query) => fetchKnowledgeRetrieveLogs({ knowledgeBaseId, ...query })}
|
||||
@@ -243,20 +244,20 @@ export function RetrieveLogList({
|
||||
},
|
||||
{
|
||||
key: "topScore",
|
||||
label: "TopScore",
|
||||
label: t("knowledge.topScore"),
|
||||
className: "w-24 text-right font-mono text-xs",
|
||||
render: (item) => item.topScore.toFixed(4),
|
||||
},
|
||||
{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
label: t("knowledge.chunkProvider"),
|
||||
className: "w-28",
|
||||
render: (item) =>
|
||||
item.chunkProvider ? providerLabel(item.chunkProvider, t) : "-",
|
||||
},
|
||||
{
|
||||
key: "rerank",
|
||||
label: "Rerank",
|
||||
label: t("knowledge.rerank"),
|
||||
className: "w-24",
|
||||
render: (item) =>
|
||||
item.rerankEnabled
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
"use client"
|
||||
|
||||
import { CircleHelpIcon, FileTextIcon, RefreshCwIcon } from "lucide-react"
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "@/lib/api/admin"
|
||||
import { KnowledgeBaseType, Status } from "@/lib/generated/enums"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { getKnowledgeTypeLabel, getKnowledgeStatusLabel } from "@/lib/knowledge-i18n"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
|
||||
export default function DashboardKnowledgePage() {
|
||||
@@ -83,7 +85,7 @@ export default function DashboardKnowledgePage() {
|
||||
{
|
||||
key: "type",
|
||||
label: t("knowledge.columnType"),
|
||||
render: (item) => <Badge variant="outline">{item.knowledgeTypeName}</Badge>,
|
||||
render: (item) => <Badge variant="outline">{getKnowledgeTypeLabel(item.knowledgeType, t)}</Badge>,
|
||||
},
|
||||
{
|
||||
key: "contentCount",
|
||||
@@ -101,7 +103,7 @@ export default function DashboardKnowledgePage() {
|
||||
label: t("knowledge.columnStatus"),
|
||||
render: (item) => (
|
||||
<Badge variant={item.status === Status.Ok ? "secondary" : "outline"}>
|
||||
{item.statusName}
|
||||
{getKnowledgeStatusLabel(item.status, t)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
@@ -131,7 +133,7 @@ export default function DashboardKnowledgePage() {
|
||||
rowActions={[
|
||||
{
|
||||
key: "rebuild-index",
|
||||
label: (item) => t("knowledge.rebuildIndex"),
|
||||
label: () => t("knowledge.rebuildIndex"),
|
||||
icon: <RefreshCwIcon />,
|
||||
run: async ({ item }) => {
|
||||
await rebuildKnowledgeBaseIndex(item.id)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
XCircleIcon,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { DashboardPage } from "@/components/dashboard-page"
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -33,12 +35,14 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { availabilityStatusLabel } from "@/lib/support-availability-i18n"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
applySetupQuickFix,
|
||||
fetchSetupReadiness,
|
||||
simulateSetupRoute,
|
||||
type SetupQuickFixAction,
|
||||
type SetupQuickFixResponse,
|
||||
type SetupReadinessResponse,
|
||||
type SetupSimulateRouteResponse,
|
||||
type SetupStepStatus,
|
||||
@@ -98,6 +102,15 @@ export function SetupWizardView() {
|
||||
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fixingAction, setFixingAction] = useState<string | null>(null)
|
||||
const fixingRef = useRef(false)
|
||||
const selectedChannelRef = useRef("")
|
||||
const simulationRequestRef = useRef(0)
|
||||
const readinessRequestRef = useRef(0)
|
||||
const [pendingFix, setPendingFix] = useState<{
|
||||
action: SetupQuickFixAction
|
||||
entityId?: number
|
||||
preview: SetupQuickFixResponse
|
||||
} | null>(null)
|
||||
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>("")
|
||||
const [simulation, setSimulation] =
|
||||
@@ -105,31 +118,38 @@ export function SetupWizardView() {
|
||||
const [simulating, setSimulating] = useState(false)
|
||||
|
||||
const runSimulation = useCallback(async (channelIdNum?: number) => {
|
||||
const requestId = ++simulationRequestRef.current
|
||||
setSimulating(true)
|
||||
setSimulation(null)
|
||||
try {
|
||||
const sim = await simulateSetupRoute({
|
||||
channel_id: channelIdNum && channelIdNum > 0 ? channelIdNum : undefined,
|
||||
bypass_ai: true,
|
||||
bypass_ai: false,
|
||||
})
|
||||
setSimulation(sim)
|
||||
if (requestId !== simulationRequestRef.current) return
|
||||
setSimulation({ ...sim, candidate_operators: sim.candidate_operators ?? [] })
|
||||
if (sim.channel_id > 0) {
|
||||
selectedChannelRef.current = String(sim.channel_id)
|
||||
setSelectedChannelId(String(sim.channel_id))
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== simulationRequestRef.current) return
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("common.operationFailed"),
|
||||
)
|
||||
} finally {
|
||||
setSimulating(false)
|
||||
if (requestId === simulationRequestRef.current) setSimulating(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
const requestId = ++readinessRequestRef.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchSetupReadiness()
|
||||
if (requestId !== readinessRequestRef.current) return
|
||||
setReadiness(data)
|
||||
const currentNum = Number(selectedChannelId)
|
||||
const currentNum = Number(selectedChannelRef.current)
|
||||
const targetChannelId =
|
||||
currentNum > 0
|
||||
? currentNum
|
||||
@@ -141,13 +161,19 @@ export function SetupWizardView() {
|
||||
}
|
||||
await runSimulation(targetChannelId)
|
||||
} catch (error) {
|
||||
if (requestId !== readinessRequestRef.current) return
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("common.loadFailed"),
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (requestId === readinessRequestRef.current) setLoading(false)
|
||||
}
|
||||
}, [runSimulation, selectedChannelId, t])
|
||||
}, [runSimulation, t])
|
||||
|
||||
const invalidateRequests = useCallback(() => {
|
||||
simulationRequestRef.current++
|
||||
readinessRequestRef.current++
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll()
|
||||
@@ -157,30 +183,51 @@ export function SetupWizardView() {
|
||||
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
return () => {
|
||||
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
invalidateRequests()
|
||||
}
|
||||
}, [loadAll])
|
||||
}, [invalidateRequests, loadAll])
|
||||
|
||||
async function handleQuickFix(
|
||||
action: SetupQuickFixAction,
|
||||
entityId?: number,
|
||||
) {
|
||||
const key = entityId ? `${action}:${entityId}` : action
|
||||
setFixingAction(key)
|
||||
async function handleQuickFix(action: SetupQuickFixAction, entityId?: number) {
|
||||
if (fixingRef.current) return
|
||||
fixingRef.current = true
|
||||
setFixingAction(entityId ? `${action}:${entityId}` : action)
|
||||
try {
|
||||
const res = await applySetupQuickFix({ action, entity_id: entityId })
|
||||
setReadiness(res.readiness)
|
||||
notifySetupReadinessUpdated()
|
||||
toast.success(t("setupWizard.quickFixSuccess"))
|
||||
const targetChannelId =
|
||||
Number(selectedChannelId) ||
|
||||
res.readiness.channels.find((ch) => ch.status === 0)?.id ||
|
||||
0
|
||||
await runSimulation(targetChannelId)
|
||||
const preview = await applySetupQuickFix({ action, entity_id: entityId })
|
||||
setPendingFix({ action, entityId, preview })
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("common.operationFailed"),
|
||||
)
|
||||
toast.error(error instanceof Error ? error.message : t("common.operationFailed"))
|
||||
} finally {
|
||||
fixingRef.current = false
|
||||
setFixingAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmQuickFix() {
|
||||
if (!pendingFix || fixingRef.current) return
|
||||
fixingRef.current = true
|
||||
setFixingAction(pendingFix.action)
|
||||
try {
|
||||
const result = await applySetupQuickFix({
|
||||
action: pendingFix.action,
|
||||
entity_id: pendingFix.entityId,
|
||||
apply: true,
|
||||
preview_token: pendingFix.preview.preview_token,
|
||||
})
|
||||
if (!result.applied) throw new Error(t("setupWizard.preview.notApplied"))
|
||||
if (result.readiness) setReadiness(result.readiness)
|
||||
setPendingFix(null)
|
||||
notifySetupReadinessUpdated()
|
||||
if (result.warning_codes.length) {
|
||||
toast.warning(result.warning_codes.map((code) => t(`setupWizard.repairWarnings.${code}`)).join(" "))
|
||||
} else {
|
||||
toast.success(t("setupWizard.quickFixSuccess"))
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("common.operationFailed"))
|
||||
// A failed/stale preview must be reviewed again, not silently reapplied.
|
||||
setPendingFix(null)
|
||||
} finally {
|
||||
fixingRef.current = false
|
||||
setFixingAction(null)
|
||||
}
|
||||
}
|
||||
@@ -418,7 +465,10 @@ export function SetupWizardView() {
|
||||
</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{readiness.steps.map((step) => {
|
||||
const primaryQuickFix = step.quick_fix_actions[0]
|
||||
const primaryQuickFix =
|
||||
(step.key === "ai_agents" || step.key === "channels") && step.total_count > 0
|
||||
? undefined
|
||||
: step.quick_fix_actions[0]
|
||||
return (
|
||||
<div
|
||||
key={step.key}
|
||||
@@ -469,6 +519,11 @@ export function SetupWizardView() {
|
||||
{t(`setupWizard.issues.${issue.code}`)}
|
||||
</span>
|
||||
</div>
|
||||
{issue.quick_fix_action && issue.entity_id > 0 && issue.entity_type !== "operator" ? (
|
||||
<Button size="sm" variant="outline" className="shrink-0 rounded-md" disabled={fixingAction !== null} onClick={() => void handleQuickFix(issue.quick_fix_action!, issue.entity_id)}>
|
||||
{t("setupWizard.preview.review")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -542,6 +597,7 @@ export function SetupWizardView() {
|
||||
options={channelOptions}
|
||||
value={selectedChannelId}
|
||||
onChange={(val) => {
|
||||
selectedChannelRef.current = val
|
||||
setSelectedChannelId(val)
|
||||
void runSimulation(Number(val))
|
||||
}}
|
||||
@@ -705,9 +761,7 @@ export function SetupWizardView() {
|
||||
variant="outline"
|
||||
className="rounded-md text-xs"
|
||||
>
|
||||
{cand.service_status === 0
|
||||
? t("setupWizard.simulator.statusIdle")
|
||||
: t("setupWizard.simulator.statusBusy")}
|
||||
{availabilityStatusLabel(cand.service_status, t)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -744,6 +798,39 @@ export function SetupWizardView() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Dialog open={pendingFix !== null} onOpenChange={(open) => { if (!open && !fixingRef.current) setPendingFix(null) }}>
|
||||
<DialogContent className="grid max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto] rounded-md sm:max-w-2xl" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("setupWizard.preview.title")}</DialogTitle>
|
||||
<DialogDescription>{t("setupWizard.preview.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 overflow-y-auto break-words">
|
||||
{pendingFix?.preview.changes.length === 0 ? <p>{t("setupWizard.preview.noChanges")}</p> : null}
|
||||
{pendingFix?.preview.changes.map((change, index) => (
|
||||
<div key={`${change.entity_type}-${change.entity_id}-${index}`} className="space-y-2 rounded-md border p-3">
|
||||
<p className="font-medium">{t(`setupWizard.preview.${change.operation}`)}: {change.entity_name}{change.entity_id > 0 ? ` (#${change.entity_id})` : ""}</p>
|
||||
<dl className="space-y-1 text-xs">
|
||||
{Object.entries(change.after).map(([field, value]) => (
|
||||
<div key={field} className="grid gap-1 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]">
|
||||
<dt className="text-muted-foreground">{t(`setupWizard.repairFields.${field}`)}</dt>
|
||||
<dd>{change.before && field in change.before ? `${String(change.before[field])} → ` : ""}{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
{pendingFix?.preview.warning_codes.map((code) => <p key={code} className="text-sm text-amber-700 dark:text-amber-300">{t(`setupWizard.repairWarnings.${code}`)}</p>)}
|
||||
<Link className="text-sm underline" href="/dashboard/agent-team-schedules">{t("setupWizard.preview.manageSchedules")}</Link>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={fixingAction !== null} onClick={() => setPendingFix(null)}>{t("common.cancel")}</Button>
|
||||
<Button disabled={fixingAction !== null || !pendingFix?.preview.changes.length} onClick={() => void confirmQuickFix()}>
|
||||
{fixingAction !== null ? <Loader2Icon className="size-4 animate-spin" /> : null}
|
||||
{t("setupWizard.preview.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client"
|
||||
|
||||
import { Loader2Icon, PlusIcon, Trash2Icon } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { isSameSession } from "@/lib/session-request-guard"
|
||||
import { readSession } from "@/lib/auth"
|
||||
import { useConfirm } from "@/components/confirm-provider"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { notifyAvailabilityUpdated } from "@/components/support-availability/operator-status"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||
import { previewSupportCalendar, updateSupportCalendar, type CalendarPreview, type SupportCalendar, type SupportPeriod } from "@/lib/api/support-availability"
|
||||
import { calendarValidationKey, formatAvailabilityTime, localDateTimeCandidates, selectedLocalDateTimeISO } from "@/lib/support-availability"
|
||||
|
||||
export function CalendarEditor({ initial, writable, onDirtyChange, onBusyChange, onSaved }: { initial: SupportCalendar; writable: boolean; onDirtyChange: (dirty: boolean) => void; onBusyChange?: (busy: boolean) => void; onSaved: (value: SupportCalendar) => void }) {
|
||||
const t = useI18n()
|
||||
const { locale } = useAppLocale()
|
||||
const confirm = useConfirm()
|
||||
const { session } = useAuth()
|
||||
const capturedSession = useRef(session)
|
||||
const leaving = useRef(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [preview, setPreview] = useState<CalendarPreview | null>(null)
|
||||
const [previewAt, setPreviewAt] = useState("")
|
||||
const [previewOccurrence, setPreviewOccurrence] = useState("")
|
||||
const [previewError, setPreviewError] = useState("")
|
||||
const mutation = useRef(false)
|
||||
const mounted = useRef(true)
|
||||
const previewRequest = useRef(0)
|
||||
const form = useForm<SupportCalendar>({ defaultValues: initial })
|
||||
const { control, register, watch, handleSubmit, reset, setError, clearErrors, formState: { isDirty, errors } } = form
|
||||
const weekly = useFieldArray({ control, name: "weekly" })
|
||||
const exceptions = useFieldArray({ control, name: "exceptions" })
|
||||
const draft = watch()
|
||||
const fingerprint = JSON.stringify(draft)
|
||||
const previewFingerprint = useRef("")
|
||||
const effectiveTimezone = draft.mode === "legacy" ? initial.effectiveTimezone : draft.timezone || initial.deploymentTimezone
|
||||
const locked = !editing || saving
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const weekdays = [1, 2, 3, 4, 5, 6, 7].map(day => ({ value: String(day), label: t(`supportAvailability.weekday.${day}`) }))
|
||||
useEffect(() => { onDirtyChange(isDirty) }, [isDirty, onDirtyChange])
|
||||
useEffect(() => { onBusyChange?.(saving); return () => onBusyChange?.(false) }, [saving, onBusyChange])
|
||||
useEffect(() => {
|
||||
const warn = (event: BeforeUnloadEvent) => { if (isDirty && !leaving.current) { event.preventDefault(); event.returnValue = "" } }
|
||||
const guardLink = (event: MouseEvent) => {
|
||||
if (!isDirty || event.defaultPrevented || !(event.target instanceof Element)) return
|
||||
const anchor = event.target.closest("a[href]") as HTMLAnchorElement | null
|
||||
if (!anchor || anchor.target === "_blank" || anchor.origin !== window.location.origin || anchor.href === window.location.href) return
|
||||
event.preventDefault()
|
||||
void confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }).then(approved => { if (approved && mounted.current) { leaving.current = true; reset(initial); window.location.assign(anchor.href) } })
|
||||
}
|
||||
const sourceUrl = window.location.href
|
||||
const sourceState = window.history.state
|
||||
const guardBack = (event: PopStateEvent) => {
|
||||
if (!isDirty || leaving.current || window.location.href === sourceUrl) return
|
||||
const destination = window.location.href
|
||||
// Keep the current form mounted while the user reviews browser Back/Forward.
|
||||
// Capture runs before App Router's normal popstate listener.
|
||||
event.stopImmediatePropagation()
|
||||
window.history.pushState(sourceState, "", sourceUrl)
|
||||
void confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }).then(approved => {
|
||||
if (approved && mounted.current) { leaving.current = true; window.location.assign(destination) }
|
||||
})
|
||||
}
|
||||
window.addEventListener("popstate", guardBack, true)
|
||||
window.addEventListener("beforeunload", warn)
|
||||
document.addEventListener("click", guardLink, true)
|
||||
return () => { window.removeEventListener("popstate", guardBack, true); window.removeEventListener("beforeunload", warn); document.removeEventListener("click", guardLink, true) }
|
||||
}, [isDirty, confirm, initial, reset, t])
|
||||
const invalidatePreview = useCallback(() => { previewRequest.current++ }, [])
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; invalidatePreview() } }, [invalidatePreview])
|
||||
async function cancel() {
|
||||
if (isDirty && !(await confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }))) return
|
||||
reset(initial); setEditing(false); setPreview(null); clearErrors()
|
||||
}
|
||||
async function save(calendar: SupportCalendar) {
|
||||
if (mutation.current || !writable || !editing || !isDirty || !mounted.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
const validation = calendarValidationKey(calendar)
|
||||
if (validation) { setError("root", { message: t(validation) }); return }
|
||||
if (!(await confirm({ title: t("supportAvailability.confirmCalendarTitle"), description: initial.mode === "legacy" && calendar.mode === "recurring" ? t("supportAvailability.confirmRecurring") : t("supportAvailability.confirmCalendar"), confirmText: t("supportAvailability.saveCalendar") }))) return
|
||||
if (!mounted.current || mutation.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
mutation.current = true; setSaving(true); clearErrors()
|
||||
try {
|
||||
const result = await updateSupportCalendar(calendar)
|
||||
if (!mounted.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
// Re-read on the page to obtain server-normalized effective timezone and arrays.
|
||||
reset({ ...result, weekly: result.weekly ?? [], exceptions: (result.exceptions ?? []).map(day => ({ ...day, periods: day.periods ?? [] })) })
|
||||
setEditing(false); setPreview(null); toast.success(t("supportAvailability.calendarSaved")); notifyAvailabilityUpdated(); onSaved(result)
|
||||
} catch (error) { if (mounted.current) setError("root", { message: error instanceof Error ? error.message : t("supportAvailability.saveFailed") }) }
|
||||
finally { mutation.current = false; if (mounted.current) setSaving(false) }
|
||||
}
|
||||
async function runPreview() {
|
||||
const validation = calendarValidationKey(draft)
|
||||
if (validation) { setPreviewError(t(validation)); return }
|
||||
const at = previewAt ? selectedLocalDateTimeISO(previewAt, previewOccurrence) : undefined
|
||||
if (previewAt && !at) { setPreviewError(t("supportAvailability.invalidPreviewTime")); return }
|
||||
const requestId = ++previewRequest.current
|
||||
setPreviewing(true); setPreviewError("")
|
||||
try {
|
||||
const result = await previewSupportCalendar(draft, at ?? undefined)
|
||||
if (mounted.current && previewRequest.current === requestId) { previewFingerprint.current = `${fingerprint}:${previewAt}:${previewOccurrence}`; setPreview(result) }
|
||||
} catch (error) { if (mounted.current && previewRequest.current === requestId) { setPreview(null); setPreviewError(error instanceof Error ? error.message : t("supportAvailability.previewFailed")) } }
|
||||
finally { if (mounted.current && previewRequest.current === requestId) setPreviewing(false) }
|
||||
}
|
||||
const previewCurrent = preview && previewFingerprint.current === `${fingerprint}:${previewAt}:${previewOccurrence}`
|
||||
return <form onSubmit={handleSubmit(save)} className="space-y-6" noValidate>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b pb-3">
|
||||
<h2 className="text-base font-semibold">{t("supportAvailability.calendar")}</h2>
|
||||
{writable ? editing ? <div className="flex gap-2"><Button variant="outline" type="button" disabled={saving} onClick={() => void cancel()}>{t("supportAvailability.cancel")}</Button><Button type="submit" disabled={saving || !isDirty}>{saving ? <Loader2Icon className="size-4 animate-spin" /> : null}{t(saving ? "supportAvailability.saving" : "supportAvailability.saveCalendar")}</Button></div> : <Button variant="outline" type="button" onClick={() => setEditing(true)}>{t("supportAvailability.editCalendar")}</Button> : <p className="text-sm text-muted-foreground">{t("supportAvailability.readOnly")}</p>}
|
||||
</div>
|
||||
{errors.root?.message ? <p role="alert" className="text-sm text-destructive">{errors.root.message}</p> : null}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field><FieldLabel htmlFor="availability-timezone">{t(initial.teamId === 0 ? "supportAvailability.deploymentTimezone" : "supportAvailability.teamTimezone")}</FieldLabel><Input id="availability-timezone" placeholder={initial.teamId ? initial.deploymentTimezone : "Europe/Moscow"} disabled={locked} {...register("timezone")} /><p className="text-xs text-muted-foreground">{t(initial.teamId ? "supportAvailability.inheritTimezoneHelp" : "supportAvailability.timezoneHelp")}</p><p className="text-sm">{t("supportAvailability.effectiveTimezone", { timezone: effectiveTimezone })}</p></Field>
|
||||
{initial.teamId !== 0 ? <Field><FieldLabel>{t("supportAvailability.scheduleMode")}</FieldLabel><Controller control={control} name="mode" render={({ field }) => <OptionCombobox value={field.value} options={[{ value: "legacy", label: t("supportAvailability.legacy") }, { value: "recurring", label: t("supportAvailability.recurring") }]} onChange={field.onChange} disabled={locked} ariaLabel={t("supportAvailability.scheduleMode")} placeholder={t("supportAvailability.scheduleMode")} />} /></Field> : null}
|
||||
</div>
|
||||
{initial.teamId !== 0 ? <><p className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">{t("supportAvailability.legacyHelp")} <Link href="/dashboard/agent-team-schedules" className="underline underline-offset-2">{t("supportAvailability.viewDatedShifts")}</Link></p>
|
||||
{draft.mode === "recurring" ? <>
|
||||
<section className="space-y-3" aria-labelledby="weekly-hours-title"><div className="flex flex-wrap items-center justify-between gap-2"><h3 id="weekly-hours-title" className="text-sm font-semibold">{t("supportAvailability.weeklyHours")}</h3>{editing ? <Button type="button" variant="outline" size="sm" disabled={saving} onClick={() => weekly.append({ weekday: 1, start: "09:00", end: "17:00" })}><PlusIcon className="size-4" />{t("supportAvailability.addPeriod")}</Button> : null}</div><p className="text-xs text-muted-foreground">{t("supportAvailability.hoursHelp")}</p>
|
||||
{weekly.fields.length === 0 ? <p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">{t("supportAvailability.noWeeklyHours")}</p> : weekly.fields.map((period, index) => <div key={period.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-2 rounded-md border p-3 sm:grid-cols-[minmax(8rem,1fr)_1fr_1fr_auto]">
|
||||
<Field className="col-span-2 sm:col-span-1"><FieldLabel>{t("supportAvailability.day")}</FieldLabel><Controller control={control} name={`weekly.${index}.weekday`} render={({ field }) => <OptionCombobox value={String(field.value)} onChange={value => field.onChange(Number(value))} options={weekdays} disabled={locked} ariaLabel={t("supportAvailability.day")} placeholder={t("supportAvailability.day")} />} /></Field>
|
||||
<Field><FieldLabel htmlFor={`weekly-start-${period.id}`}>{t("supportAvailability.start")}</FieldLabel><Input id={`weekly-start-${period.id}`} type="time" disabled={locked} {...register(`weekly.${index}.start`)} /></Field><Field><FieldLabel htmlFor={`weekly-end-${period.id}`}>{t("supportAvailability.end")}</FieldLabel><Input id={`weekly-end-${period.id}`} type="text" inputMode="text" placeholder="HH:mm" disabled={locked} {...register(`weekly.${index}.end`)} /></Field>
|
||||
{editing ? <Button className="justify-self-end" type="button" variant="ghost" size="icon" aria-label={t("supportAvailability.removePeriod")} disabled={saving} onClick={() => weekly.remove(index)}><Trash2Icon className="size-4" /></Button> : <span />}
|
||||
</div>)}
|
||||
</section>
|
||||
<section className="space-y-3" aria-labelledby="date-exceptions-title"><div className="flex flex-wrap items-center justify-between gap-2"><h3 id="date-exceptions-title" className="text-sm font-semibold">{t("supportAvailability.exceptions")}</h3>{editing ? <Button type="button" variant="outline" size="sm" disabled={saving} onClick={() => exceptions.append({ date: "", closed: true, periods: [] })}><PlusIcon className="size-4" />{t("supportAvailability.addException")}</Button> : null}</div><p className="text-xs text-muted-foreground">{t("supportAvailability.exceptionsHelp")}</p>
|
||||
{!exceptions.fields.length ? <p className="text-sm text-muted-foreground">{t("supportAvailability.noExceptions")}</p> : exceptions.fields.map((day, index) => <div key={day.id} className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-end gap-3"><Field className="w-auto min-w-44 flex-1"><FieldLabel htmlFor={`exception-date-${day.id}`}>{t("supportAvailability.date")}</FieldLabel><Input id={`exception-date-${day.id}`} type="date" disabled={locked} {...register(`exceptions.${index}.date`)} /></Field><Field className="w-auto pb-2"><FieldLabel htmlFor={`exception-closed-${day.id}`}>{t("supportAvailability.closedDay")}</FieldLabel><Controller control={control} name={`exceptions.${index}.closed`} render={({ field }) => <Switch id={`exception-closed-${day.id}`} checked={field.value} onCheckedChange={field.onChange} disabled={locked} />} /></Field>{editing ? <Button variant="ghost" type="button" size="icon" disabled={saving} aria-label={t("supportAvailability.removeException")} onClick={() => exceptions.remove(index)}><Trash2Icon className="size-4" /></Button> : null}</div>
|
||||
{!draft.exceptions[index]?.closed ? <Controller control={control} name={`exceptions.${index}.periods`} render={({ field }) => <ExceptionPeriods value={field.value ?? []} onChange={field.onChange} disabled={locked} id={day.id} />} /> : null}
|
||||
</div>)}
|
||||
</section>
|
||||
</> : <p className="text-sm text-muted-foreground">{t("supportAvailability.legacyActive")}</p>}
|
||||
<section className="space-y-3 rounded-md border p-4" aria-labelledby="calendar-preview-title"><h3 id="calendar-preview-title" className="text-sm font-semibold">{t("supportAvailability.preview")}</h3><p className="text-xs text-muted-foreground">{t("supportAvailability.previewHelp", { timezone: browserTimezone })}</p><div className="flex flex-wrap items-end gap-3"><Field className="min-w-48 flex-1"><FieldLabel htmlFor="preview-at">{t("supportAvailability.previewAt")}</FieldLabel><Input id="preview-at" type="datetime-local" value={previewAt} onChange={event => setPreviewAt(event.target.value)} disabled={previewing || saving} /></Field><Button type="button" variant="outline" disabled={previewing || saving} onClick={() => void runPreview()}>{previewing ? <Loader2Icon className="size-4 animate-spin" /> : null}{t(previewing ? "supportAvailability.previewing" : "supportAvailability.runPreview")}</Button></div>
|
||||
{localDateTimeCandidates(previewAt).length > 1 ? <Field><FieldLabel>{t("supportAvailability.dstOccurrence", { field: t("supportAvailability.previewAt") })}</FieldLabel><OptionCombobox value={selectedLocalDateTimeISO(previewAt, previewOccurrence) ?? ""} options={localDateTimeCandidates(previewAt).map(value => ({ value, label: value }))} onChange={setPreviewOccurrence} disabled={previewing} ariaLabel={t("supportAvailability.chooseOccurrence")} placeholder={t("supportAvailability.chooseOccurrence")} /></Field> : null}
|
||||
{previewError ? <p role="alert" className="text-sm text-destructive">{previewError}</p> : null}
|
||||
{previewCurrent ? <div className="space-y-1 text-sm" role="status"><p>{t(preview.open ? "supportAvailability.scheduleOpen" : "supportAvailability.scheduleClosed")}</p><p>{t("supportAvailability.effectiveTimezone", { timezone: preview.timezone })}</p>{preview.nextOpeningAt ? <p>{t("supportAvailability.nextOpening", { time: formatAvailabilityTime(preview.nextOpeningAt, preview.timezone, locale) })}</p> : !preview.open ? <p>{t("supportAvailability.noNextOpening")}</p> : null}<p className="text-xs text-muted-foreground">{t("supportAvailability.notPresencePromise")}</p></div> : preview ? <p className="text-xs text-muted-foreground">{t("supportAvailability.previewStale")}</p> : null}
|
||||
</section></> : null}
|
||||
</form>
|
||||
}
|
||||
|
||||
function ExceptionPeriods({ value, onChange, disabled, id }: { value: SupportPeriod[]; onChange: (value: SupportPeriod[]) => void; disabled: boolean; id: string }) {
|
||||
const t = useI18n()
|
||||
return <div className="space-y-2">{value.map((period, index) => <div key={index} className="flex items-end gap-2"><Field><FieldLabel htmlFor={`${id}-start-${index}`}>{t("supportAvailability.start")}</FieldLabel><Input id={`${id}-start-${index}`} type="time" value={period.start} disabled={disabled} onChange={event => onChange(value.map((p, i) => i === index ? { ...p, start: event.target.value } : p))} /></Field><Field><FieldLabel htmlFor={`${id}-end-${index}`}>{t("supportAvailability.end")}</FieldLabel><Input id={`${id}-end-${index}`} type="text" inputMode="text" placeholder="HH:mm" value={period.end} disabled={disabled} onChange={event => onChange(value.map((p, i) => i === index ? { ...p, end: event.target.value } : p))} /></Field>{!disabled ? <Button type="button" variant="ghost" size="icon" aria-label={t("supportAvailability.removePeriod")} onClick={() => onChange(value.filter((_, i) => i !== index))}><Trash2Icon className="size-4" /></Button> : null}</div>)}{!disabled ? <Button type="button" variant="outline" size="sm" onClick={() => onChange([...value, { start: "09:00", end: "17:00" }])}><PlusIcon className="size-4" />{t("supportAvailability.addPeriod")}</Button> : null}</div>
|
||||
}
|
||||
63
web/app/(dashboard)/dashboard/support-availability/page.tsx
Normal file
63
web/app/(dashboard)/dashboard/support-availability/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import { RefreshCwIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useConfirm } from "@/components/confirm-provider"
|
||||
import { DashboardPage, DashboardToolbar } from "@/components/dashboard-page"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { OperatorStatusControl } from "@/components/support-availability/operator-status"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { fetchAgentTeamsAll, type AdminAgentTeam } from "@/lib/api/admin"
|
||||
import { fetchSupportCalendar, type SupportCalendar } from "@/lib/api/support-availability"
|
||||
import { CalendarEditor } from "./_components/calendar-editor"
|
||||
|
||||
// A scoped configuration editor, not a paginated CRUD resource.
|
||||
export default function SupportAvailabilityPage() {
|
||||
const { session } = useAuth()
|
||||
return <SupportAvailabilityContent key={session?.user.id ?? "signed-out"} />
|
||||
}
|
||||
function SupportAvailabilityContent() {
|
||||
const t = useI18n()
|
||||
const { session } = useAuth()
|
||||
const confirm = useConfirm()
|
||||
const can = (permission: string) => Boolean(session?.permissions.includes(permission))
|
||||
const canDefault = can("supportConfig.view")
|
||||
const canTeams = can("agentTeam.view") && can("agentTeamSchedule.view")
|
||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
|
||||
const [teamId, setTeamId] = useState<number | null>(canDefault ? 0 : null)
|
||||
const [calendar, setCalendar] = useState<SupportCalendar | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
const [teamError, setTeamError] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [revision, setRevision] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!canTeams) return
|
||||
let active = true
|
||||
fetchAgentTeamsAll().then(data => { if (active) { setTeams(data); setTeamError(""); setTeamId(current => current ?? data[0]?.id ?? null); if (!data.length && !canDefault) setLoading(false) } }).catch(error => { if (active) { setTeamError(error instanceof Error ? error.message : t("supportAvailability.loadFailed")); if (!canDefault) setLoading(false) } })
|
||||
return () => { active = false }
|
||||
}, [canTeams, canDefault, revision, t])
|
||||
useEffect(() => {
|
||||
if (teamId === null) return
|
||||
let active = true
|
||||
fetchSupportCalendar(teamId).then(data => { if (active) { setCalendar(data); setError("") } }).catch(error => { if (active) { setCalendar(null); setError(error instanceof Error ? error.message : t("supportAvailability.loadFailed")) } }).finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [teamId, revision, t])
|
||||
async function changeScope(next: number) {
|
||||
if (busy) return
|
||||
if (dirty && !(await confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }))) return
|
||||
setDirty(false); setCalendar(null); setLoading(true); setTeamId(next)
|
||||
}
|
||||
const options = [...(canDefault ? [{ value: "0", label: t("supportAvailability.deploymentDefault") }] : []), ...teams.map(team => ({ value: String(team.id), label: team.name }))]
|
||||
return <DashboardPage>
|
||||
<DashboardToolbar actions={<OperatorStatusControl />}><div><h1 className="text-lg font-semibold">{t("supportAvailability.title")}</h1><p className="mt-1 max-w-3xl text-sm text-muted-foreground">{t("supportAvailability.description")}</p></div></DashboardToolbar>
|
||||
{!canDefault && !canTeams ? <p className="text-sm text-muted-foreground">{t("supportAvailability.noPermission")}</p> : <>
|
||||
<div className="flex max-w-xl items-center gap-2"><div className="min-w-0 flex-1"><OptionCombobox value={teamId === null ? "" : String(teamId)} disabled={busy} options={options} onChange={value => void changeScope(Number(value))} ariaLabel={t("supportAvailability.selectScope")} placeholder={t("supportAvailability.selectScope")} /></div><Button type="button" variant="outline" size="icon" aria-label={t("supportAvailability.refresh")} disabled={dirty || busy} onClick={() => { setLoading(true); setRevision(value => value + 1) }}><RefreshCwIcon className="size-4" /></Button></div>
|
||||
{teamError ? <p role="alert" className="text-sm text-destructive">{teamError}</p> : null}
|
||||
{error ? <div role="alert" className="space-y-2 rounded-md border p-4"><p className="text-sm text-destructive">{error}</p><Button variant="outline" onClick={() => { setLoading(true); setRevision(value => value + 1) }}>{t("supportAvailability.retry")}</Button></div> : calendar && calendar.teamId === teamId ? <CalendarEditor key={`${teamId}:${revision}`} initial={calendar} writable={can(teamId === 0 ? "supportConfig.update" : "agentTeamSchedule.update")} onDirtyChange={setDirty} onBusyChange={setBusy} onSaved={value => { setCalendar(value); setDirty(false); setRevision(revision => revision + 1) }} /> : <p className="p-4 text-sm text-muted-foreground" role="status">{t(teamId === null && !loading ? "supportAvailability.noTeams" : "supportAvailability.loading")}</p>}
|
||||
</>}
|
||||
</DashboardPage>
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import { CheckCircle2Icon } from "lucide-react"
|
||||
import { MessageSquareIcon } from "lucide-react"
|
||||
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { getStandaloneChatUrl } from "@/components/support-chat/close-navigation"
|
||||
|
||||
export default function Page() {
|
||||
const t = useI18n()
|
||||
@@ -10,15 +13,18 @@ export default function Page() {
|
||||
return (
|
||||
<main className="flex min-h-svh items-center justify-center bg-muted px-6 text-foreground">
|
||||
<section className="grid max-w-sm justify-items-center gap-4 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900">
|
||||
<CheckCircle2Icon className="size-7" />
|
||||
<div className="flex size-14 items-center justify-center rounded-md border bg-card text-muted-foreground">
|
||||
<MessageSquareIcon className="size-7" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<h1 className="text-lg font-semibold text-foreground">{t("supportChat.closedTitle")}</h1>
|
||||
<h1 className="text-lg font-semibold text-foreground">{t("supportChat.dismissedTitle")}</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{t("supportChat.closedDescription")}
|
||||
{t("supportChat.dismissedDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => window.location.replace(getStandaloneChatUrl(window.location.search))}>
|
||||
{t("supportChat.returnToChat")}
|
||||
</Button>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user