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,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"
|
||||
|
||||
Reference in New Issue
Block a user