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:
132
web/components/support-availability/operator-status.tsx
Normal file
132
web/components/support-availability/operator-status.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client"
|
||||
|
||||
import { Clock3Icon, Loader2Icon, RefreshCwIcon } from "lucide-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useConfirm } from "@/components/confirm-provider"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||
import { fetchAvailabilityAgents, fetchMyAvailability, updateAgentAvailability, updateMyAvailability, type OperatorAvailability } from "@/lib/api/support-availability"
|
||||
import { isSameSession } from "@/lib/session-request-guard"
|
||||
import { readSession } from "@/lib/auth"
|
||||
import { ServiceStatus } from "@/lib/generated/enums"
|
||||
import { availabilityReasonLabel, availabilityStatusLabel, availabilityStatusOptions } from "@/lib/support-availability-i18n"
|
||||
import { formatAvailabilityTime, localDateTimeCandidates, selectedLocalDateTimeISO, toLocalDateTime } from "@/lib/support-availability"
|
||||
|
||||
export const AVAILABILITY_UPDATED_EVENT = "support-availability-updated"
|
||||
export function notifyAvailabilityUpdated() { window.dispatchEvent(new Event(AVAILABILITY_UPDATED_EVENT)) }
|
||||
|
||||
export function OperatorStatusControl(props: { userId?: number; compact?: boolean }) {
|
||||
const { session } = useAuth()
|
||||
return <OperatorStatusControlContent key={session?.user.id ?? "signed-out"} {...props} />
|
||||
}
|
||||
function OperatorStatusControlContent({ userId, compact = false }: { userId?: number; compact?: boolean }) {
|
||||
const t = useI18n()
|
||||
const { session } = useAuth()
|
||||
const [value, setValue] = useState<OperatorAvailability | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const accountId = session?.user.id
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let inFlight = false
|
||||
let controller: AbortController | null = null
|
||||
async function load() {
|
||||
if (inFlight || !accountId) return
|
||||
inFlight = true
|
||||
controller = new AbortController()
|
||||
try {
|
||||
const data = userId ? (await fetchAvailabilityAgents(undefined, controller.signal)).find(item => item.userId === userId) ?? null : await fetchMyAvailability(controller.signal)
|
||||
if (active) { setValue(data); setError(false) }
|
||||
} catch { if (active) { setError(true); setValue(null) } }
|
||||
finally { inFlight = false; if (active) setLoading(false) }
|
||||
}
|
||||
void load()
|
||||
const refresh = () => { if (document.visibilityState === "visible") void load() }
|
||||
window.addEventListener(AVAILABILITY_UPDATED_EVENT, refresh)
|
||||
window.addEventListener("focus", refresh)
|
||||
const timer = window.setInterval(refresh, 30000)
|
||||
return () => { active = false; controller?.abort(); window.clearInterval(timer); window.removeEventListener("focus", refresh); window.removeEventListener(AVAILABILITY_UPDATED_EVENT, refresh) }
|
||||
}, [accountId, userId, refreshKey])
|
||||
// Responses are scoped to the mounted authenticated header/dialog; never show a previous account.
|
||||
if (!accountId || (value && !userId && value.userId !== accountId)) return null
|
||||
if (!value && !error) return loading ? <span className="inline-flex h-8 w-8 items-center justify-center" role="status" aria-label={t("supportAvailability.loading")}><Loader2Icon className="size-4 animate-spin" /></span> : null
|
||||
return <>
|
||||
<Button variant="outline" size="sm" className="h-8 max-w-56 gap-1.5 rounded-md px-2" title={t("supportAvailability.myStatus")} aria-label={t("supportAvailability.myStatus")} onClick={() => error ? setRefreshKey(key => key + 1) : setOpen(true)}>
|
||||
{error ? <RefreshCwIcon className="size-4 shrink-0" /> : <Clock3Icon className="size-4 shrink-0" />}
|
||||
<span className={compact ? "hidden truncate md:inline" : "truncate"}>{error ? t("supportAvailability.retryStatus") : value ? availabilityStatusLabel(value.effectiveServiceStatus, t) : t("supportAvailability.myStatus")}</span>
|
||||
</Button>
|
||||
{open && value ? <OperatorStatusDialog key={`${accountId}:${userId ?? "me"}`} value={value} admin={Boolean(userId)} onClose={() => setOpen(false)} onSaved={next => { setValue(next); notifyAvailabilityUpdated() }} /> : null}
|
||||
</>
|
||||
}
|
||||
|
||||
type StatusForm = { serviceStatus: string; start: string; end: string; startOccurrence: string; endOccurrence: string }
|
||||
export function OperatorStatusDialog({ value, admin = false, onClose, onSaved }: { value: OperatorAvailability; admin?: boolean; onClose: () => void; onSaved: (value: OperatorAvailability) => void }) {
|
||||
const t = useI18n()
|
||||
const { locale } = useAppLocale()
|
||||
const confirm = useConfirm()
|
||||
const { session } = useAuth()
|
||||
const capturedSession = useRef(session)
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [failure, setFailure] = useState("")
|
||||
const savingRef = useRef(false)
|
||||
const form = useForm<StatusForm>({ defaultValues: { serviceStatus: String(value.serviceStatus), start: toLocalDateTime(value.absenceStartAt), end: toLocalDateTime(value.absenceEndAt), startOccurrence: value.absenceStartAt ? new Date(value.absenceStartAt).toISOString() : "", endOccurrence: value.absenceEndAt ? new Date(value.absenceEndAt).toISOString() : "" } })
|
||||
const { register, control, handleSubmit, watch, setError, formState: { errors, isDirty } } = form
|
||||
const vacation = Number(watch("serviceStatus")) === ServiceStatus.Vacation
|
||||
const startISO = selectedLocalDateTimeISO(watch("start"), watch("startOccurrence")), endISO = selectedLocalDateTimeISO(watch("end"), watch("endOccurrence"))
|
||||
async function close() {
|
||||
if (savingRef.current) return
|
||||
if (isDirty && !(await confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }))) return
|
||||
onClose()
|
||||
}
|
||||
async function save(fields: StatusForm) {
|
||||
if (savingRef.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
const serviceStatus = Number(fields.serviceStatus) as ServiceStatus
|
||||
if (serviceStatus === ServiceStatus.Vacation) {
|
||||
const start = selectedLocalDateTimeISO(fields.start, fields.startOccurrence), end = selectedLocalDateTimeISO(fields.end, fields.endOccurrence)
|
||||
if (!start || !end || Date.parse(start) >= Date.parse(end)) { setError("end", { message: t("supportAvailability.invalidVacation") }); return }
|
||||
}
|
||||
savingRef.current = true; setSaving(true); setFailure("")
|
||||
try {
|
||||
const payload = { serviceStatus, ...(serviceStatus === ServiceStatus.Vacation ? { absenceStartAt: selectedLocalDateTimeISO(fields.start, fields.startOccurrence)!, absenceEndAt: selectedLocalDateTimeISO(fields.end, fields.endOccurrence)! } : {}) }
|
||||
const result = admin ? await updateAgentAvailability(value.userId, payload) : await updateMyAvailability(payload)
|
||||
if (!mounted.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
onSaved(result); toast.success(t("supportAvailability.statusSaved")); onClose()
|
||||
} catch (error) { if (mounted.current) setFailure(error instanceof Error ? error.message : t("supportAvailability.saveFailed")) }
|
||||
finally { savingRef.current = false; if (mounted.current) setSaving(false) }
|
||||
}
|
||||
return <ProjectDialog open onOpenChange={next => { if (!next) void close() }} title={admin ? t("supportAvailability.operatorStatus", { name: value.displayName || String(value.userId) }) : t("supportAvailability.myStatus")} description={t("supportAvailability.presenceHelp")} size="md" footer={<><Button variant="outline" disabled={saving} onClick={() => void close()}>{t("supportAvailability.cancel")}</Button><Button type="submit" form="operator-availability-form" disabled={saving || !isDirty}>{saving ? <Loader2Icon className="size-4 animate-spin" /> : null}{t(saving ? "supportAvailability.saving" : "supportAvailability.saveStatus")}</Button></>}>
|
||||
<div className="mb-4 space-y-2 rounded-md border p-3 text-sm">
|
||||
<div className="flex flex-wrap gap-2"><Badge variant="outline">{t(value.online ? "supportAvailability.online" : "supportAvailability.offline")}</Badge><Badge variant="outline">{availabilityStatusLabel(value.effectiveServiceStatus, t)}</Badge></div>
|
||||
<p>{availabilityReasonLabel(value.reason, t)}</p>
|
||||
{value.nextOpeningAt ? <p className="text-muted-foreground">{t("supportAvailability.nextOpening", { time: formatAvailabilityTime(value.nextOpeningAt, value.calendar.effectiveTimezone, locale) })}</p> : null}
|
||||
</div>
|
||||
<form id="operator-availability-form" onSubmit={handleSubmit(save)} className="space-y-4">
|
||||
<Field><FieldLabel>{t("supportAvailability.workingStatus")}</FieldLabel><Controller control={control} name="serviceStatus" render={({ field }) => <OptionCombobox options={availabilityStatusOptions(t)} value={field.value} onChange={field.onChange} ariaLabel={t("supportAvailability.workingStatus")} placeholder={t("supportAvailability.workingStatus")} disabled={saving} />} /></Field>
|
||||
<p className="text-xs text-muted-foreground">{t("supportAvailability.statusHelp")}</p>
|
||||
{vacation ? <>
|
||||
<p className="text-sm text-muted-foreground">{t("supportAvailability.vacationTimezone", { timezone: browserTimezone })}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field><FieldLabel htmlFor="absence-start">{t("supportAvailability.vacationStart")}</FieldLabel><Input id="absence-start" type="datetime-local" disabled={saving} {...register("start")} /></Field>
|
||||
<Field data-invalid={Boolean(errors.end)}><FieldLabel htmlFor="absence-end">{t("supportAvailability.vacationEnd")}</FieldLabel><Input id="absence-end" type="datetime-local" disabled={saving} {...register("end")} aria-invalid={Boolean(errors.end)} /><FieldError errors={[errors.end]} /></Field>
|
||||
</div>
|
||||
{(["start", "end"] as const).map(name => localDateTimeCandidates(watch(name)).length > 1 ? <Field key={name}><FieldLabel>{t("supportAvailability.dstOccurrence", { field: t(name === "start" ? "supportAvailability.vacationStart" : "supportAvailability.vacationEnd") })}</FieldLabel><Controller control={control} name={name === "start" ? "startOccurrence" : "endOccurrence"} render={({ field }) => <OptionCombobox value={selectedLocalDateTimeISO(watch(name), field.value) ?? ""} options={localDateTimeCandidates(watch(name)).map(value => ({ value, label: value }))} onChange={field.onChange} disabled={saving} ariaLabel={t("supportAvailability.chooseOccurrence")} placeholder={t("supportAvailability.chooseOccurrence")} />} /></Field> : null)}
|
||||
{startISO && endISO ? <p className="break-words text-xs text-muted-foreground">{t("supportAvailability.vacationExact", { start: startISO, end: endISO })}</p> : null}
|
||||
<p className="text-xs text-muted-foreground">{t("supportAvailability.vacationHelp", { status: availabilityStatusLabel(value.serviceStatus === ServiceStatus.Vacation ? value.returnServiceStatus ?? value.effectiveServiceStatus : value.effectiveServiceStatus, t) })}</p>
|
||||
</> : value.serviceStatus === ServiceStatus.Vacation ? <p className="text-sm text-muted-foreground">{t("supportAvailability.cancelVacationHelp")}</p> : null}
|
||||
{failure ? <p role="alert" className="text-sm text-destructive">{failure}</p> : null}
|
||||
</form>
|
||||
</ProjectDialog>
|
||||
}
|
||||
Reference in New Issue
Block a user