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:
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user