chore: prep platform improvements
This commit is contained in:
parent
a62f3d5283
commit
c5ddd54a3e
24 changed files with 777 additions and 649 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
|
@ -22,8 +22,6 @@ import {
|
|||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type DeleteState<T extends "category" | "subcategory"> =
|
||||
| { type: T; targetId: string; reason: string }
|
||||
|
|
@ -38,7 +36,6 @@ export function CategoriesManager() {
|
|||
const [subcategoryDraft, setSubcategoryDraft] = useState("")
|
||||
const [subcategoryList, setSubcategoryList] = useState<string[]>([])
|
||||
const [deleteState, setDeleteState] = useState<DeleteState<"category" | "subcategory">>(null)
|
||||
const [slaCategory, setSlaCategory] = useState<TicketCategory | null>(null)
|
||||
const createCategory = useMutation(api.categories.createCategory)
|
||||
const deleteCategory = useMutation(api.categories.deleteCategory)
|
||||
const updateCategory = useMutation(api.categories.updateCategory)
|
||||
|
|
@ -315,7 +312,6 @@ export function CategoriesManager() {
|
|||
onDeleteSubcategory={(subcategoryId) =>
|
||||
setDeleteState({ type: "subcategory", targetId: subcategoryId, reason: "" })
|
||||
}
|
||||
onConfigureSla={() => setSlaCategory(category)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
))
|
||||
|
|
@ -378,12 +374,6 @@ export function CategoriesManager() {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CategorySlaDrawer
|
||||
category={slaCategory}
|
||||
tenantId={tenantId}
|
||||
viewerId={viewerId}
|
||||
onClose={() => setSlaCategory(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -396,7 +386,6 @@ interface CategoryItemProps {
|
|||
onCreateSubcategory: (categoryId: string, payload: { name: string }) => Promise<void>
|
||||
onUpdateSubcategory: (subcategory: TicketSubcategory, name: string) => Promise<void>
|
||||
onDeleteSubcategory: (subcategoryId: string) => void
|
||||
onConfigureSla: () => void
|
||||
}
|
||||
|
||||
function CategoryItem({
|
||||
|
|
@ -407,7 +396,6 @@ function CategoryItem({
|
|||
onCreateSubcategory,
|
||||
onUpdateSubcategory,
|
||||
onDeleteSubcategory,
|
||||
onConfigureSla,
|
||||
}: CategoryItemProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [name, setName] = useState(category.name)
|
||||
|
|
@ -461,9 +449,6 @@ function CategoryItem({
|
|||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={onConfigureSla} disabled={disabled}>
|
||||
Configurar SLA
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}>
|
||||
Editar
|
||||
</Button>
|
||||
|
|
@ -579,349 +564,3 @@ type RuleFormState = {
|
|||
alertThreshold: number
|
||||
pauseStatuses: string[]
|
||||
}
|
||||
|
||||
const PRIORITY_ROWS = [
|
||||
{ value: "URGENT", label: "Crítico" },
|
||||
{ value: "HIGH", label: "Alta" },
|
||||
{ value: "MEDIUM", label: "Média" },
|
||||
{ value: "LOW", label: "Baixa" },
|
||||
{ value: "DEFAULT", label: "Sem prioridade" },
|
||||
] as const
|
||||
|
||||
const TIME_UNITS: Array<{ value: RuleFormState["responseUnit"]; label: string; factor: number }> = [
|
||||
{ value: "minutes", label: "Minutos", factor: 1 },
|
||||
{ value: "hours", label: "Horas", factor: 60 },
|
||||
{ value: "days", label: "Dias", factor: 1440 },
|
||||
]
|
||||
|
||||
const MODE_OPTIONS: Array<{ value: RuleFormState["responseMode"]; label: string }> = [
|
||||
{ value: "calendar", label: "Horas corridas" },
|
||||
{ value: "business", label: "Horas úteis" },
|
||||
]
|
||||
|
||||
const PAUSE_STATUS_OPTIONS = [
|
||||
{ value: "PENDING", label: "Pendente" },
|
||||
{ value: "AWAITING_ATTENDANCE", label: "Em atendimento" },
|
||||
{ value: "PAUSED", label: "Pausado" },
|
||||
] as const
|
||||
|
||||
const DEFAULT_RULE_STATE: RuleFormState = {
|
||||
responseValue: "",
|
||||
responseUnit: "hours",
|
||||
responseMode: "calendar",
|
||||
solutionValue: "",
|
||||
solutionUnit: "hours",
|
||||
solutionMode: "calendar",
|
||||
alertThreshold: 80,
|
||||
pauseStatuses: ["PAUSED"],
|
||||
}
|
||||
|
||||
type CategorySlaDrawerProps = {
|
||||
category: TicketCategory | null
|
||||
tenantId: string
|
||||
viewerId: Id<"users"> | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function CategorySlaDrawer({ category, tenantId, viewerId, onClose }: CategorySlaDrawerProps) {
|
||||
const [rules, setRules] = useState<Record<string, RuleFormState>>(() => buildDefaultRuleState())
|
||||
const [saving, setSaving] = useState(false)
|
||||
const drawerOpen = Boolean(category)
|
||||
|
||||
const canLoad = Boolean(category && viewerId)
|
||||
const existing = useQuery(
|
||||
api.categorySlas.get,
|
||||
canLoad
|
||||
? {
|
||||
tenantId,
|
||||
viewerId: viewerId as Id<"users">,
|
||||
categoryId: category!.id as Id<"ticketCategories">,
|
||||
}
|
||||
: "skip"
|
||||
) as { rules: Array<{ priority: string; responseTargetMinutes: number | null; responseMode?: string | null; solutionTargetMinutes: number | null; solutionMode?: string | null; alertThreshold?: number | null; pauseStatuses?: string[] | null }> } | undefined
|
||||
|
||||
const saveSla = useMutation(api.categorySlas.save)
|
||||
|
||||
useEffect(() => {
|
||||
if (!existing?.rules) {
|
||||
setRules(buildDefaultRuleState())
|
||||
return
|
||||
}
|
||||
const next = buildDefaultRuleState()
|
||||
for (const rule of existing.rules) {
|
||||
const priority = rule.priority?.toUpperCase() ?? "DEFAULT"
|
||||
next[priority] = convertRuleToForm(rule)
|
||||
}
|
||||
setRules(next)
|
||||
}, [existing, category?.id])
|
||||
|
||||
const handleChange = (priority: string, patch: Partial<RuleFormState>) => {
|
||||
setRules((current) => ({
|
||||
...current,
|
||||
[priority]: {
|
||||
...current[priority],
|
||||
...patch,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const togglePause = (priority: string, status: string) => {
|
||||
setRules((current) => {
|
||||
const selected = new Set(current[priority].pauseStatuses)
|
||||
if (selected.has(status)) {
|
||||
selected.delete(status)
|
||||
} else {
|
||||
selected.add(status)
|
||||
}
|
||||
if (selected.size === 0) {
|
||||
selected.add("PAUSED")
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[priority]: {
|
||||
...current[priority],
|
||||
pauseStatuses: Array.from(selected),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!category || !viewerId) return
|
||||
setSaving(true)
|
||||
toast.loading("Salvando SLA...", { id: "category-sla" })
|
||||
try {
|
||||
const payload = PRIORITY_ROWS.map((row) => {
|
||||
const form = rules[row.value]
|
||||
return {
|
||||
priority: row.value,
|
||||
responseTargetMinutes: convertToMinutes(form.responseValue, form.responseUnit),
|
||||
responseMode: form.responseMode,
|
||||
solutionTargetMinutes: convertToMinutes(form.solutionValue, form.solutionUnit),
|
||||
solutionMode: form.solutionMode,
|
||||
alertThreshold: Math.min(Math.max(form.alertThreshold, 5), 95) / 100,
|
||||
pauseStatuses: form.pauseStatuses,
|
||||
}
|
||||
})
|
||||
await saveSla({
|
||||
tenantId,
|
||||
actorId: viewerId,
|
||||
categoryId: category.id as Id<"ticketCategories">,
|
||||
rules: payload,
|
||||
})
|
||||
toast.success("SLA atualizado", { id: "category-sla" })
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível salvar as regras de SLA.", { id: "category-sla" })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={drawerOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configurar SLA — {category?.name ?? ""}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Defina metas de resposta e resolução para cada prioridade. Os prazos em horas úteis consideram apenas
|
||||
segunda a sexta, das 8h às 18h.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{PRIORITY_ROWS.map((row) => {
|
||||
const form = rules[row.value]
|
||||
return (
|
||||
<div key={row.value} className="space-y-3 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-neutral-900">{row.label}</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{row.value === "DEFAULT" ? "Aplicado quando o ticket não tem prioridade definida." : "Aplica-se aos tickets desta prioridade."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SlaInputGroup
|
||||
title="Tempo de resposta"
|
||||
amount={form.responseValue}
|
||||
unit={form.responseUnit}
|
||||
mode={form.responseMode}
|
||||
onAmountChange={(value) => handleChange(row.value, { responseValue: value })}
|
||||
onUnitChange={(value) => handleChange(row.value, { responseUnit: value as RuleFormState["responseUnit"] })}
|
||||
onModeChange={(value) => handleChange(row.value, { responseMode: value as RuleFormState["responseMode"] })}
|
||||
/>
|
||||
<SlaInputGroup
|
||||
title="Tempo de solução"
|
||||
amount={form.solutionValue}
|
||||
unit={form.solutionUnit}
|
||||
mode={form.solutionMode}
|
||||
onAmountChange={(value) => handleChange(row.value, { solutionValue: value })}
|
||||
onUnitChange={(value) => handleChange(row.value, { solutionUnit: value as RuleFormState["solutionUnit"] })}
|
||||
onModeChange={(value) => handleChange(row.value, { solutionMode: value as RuleFormState["solutionMode"] })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Alertar quando</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={95}
|
||||
step={5}
|
||||
value={form.alertThreshold}
|
||||
onChange={(event) => handleChange(row.value, { alertThreshold: Number(event.target.value) || 0 })}
|
||||
/>
|
||||
<span className="text-xs text-neutral-500">% do tempo for consumido.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Estados que pausam</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{PAUSE_STATUS_OPTIONS.map((option) => {
|
||||
const selected = form.pauseStatuses.includes(option.value)
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => togglePause(row.value, option.value)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-xs font-semibold transition",
|
||||
selected ? "border-primary bg-primary text-primary-foreground" : "border-slate-200 bg-white text-neutral-600"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !viewerId}>
|
||||
{saving ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function buildDefaultRuleState() {
|
||||
return PRIORITY_ROWS.reduce<Record<string, RuleFormState>>((acc, row) => {
|
||||
acc[row.value] = { ...DEFAULT_RULE_STATE }
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function convertRuleToForm(rule: {
|
||||
priority: string
|
||||
responseTargetMinutes: number | null
|
||||
responseMode?: string | null
|
||||
solutionTargetMinutes: number | null
|
||||
solutionMode?: string | null
|
||||
alertThreshold?: number | null
|
||||
pauseStatuses?: string[] | null
|
||||
}): RuleFormState {
|
||||
const response = minutesToForm(rule.responseTargetMinutes)
|
||||
const solution = minutesToForm(rule.solutionTargetMinutes)
|
||||
return {
|
||||
responseValue: response.amount,
|
||||
responseUnit: response.unit,
|
||||
responseMode: (rule.responseMode ?? "calendar") as RuleFormState["responseMode"],
|
||||
solutionValue: solution.amount,
|
||||
solutionUnit: solution.unit,
|
||||
solutionMode: (rule.solutionMode ?? "calendar") as RuleFormState["solutionMode"],
|
||||
alertThreshold: Math.round(((rule.alertThreshold ?? 0.8) * 100)),
|
||||
pauseStatuses: rule.pauseStatuses && rule.pauseStatuses.length > 0 ? rule.pauseStatuses : ["PAUSED"],
|
||||
}
|
||||
}
|
||||
|
||||
function minutesToForm(input?: number | null) {
|
||||
if (!input || input <= 0) {
|
||||
return { amount: "", unit: "hours" as RuleFormState["responseUnit"] }
|
||||
}
|
||||
for (const option of [...TIME_UNITS].reverse()) {
|
||||
if (input % option.factor === 0) {
|
||||
return { amount: String(Math.round(input / option.factor)), unit: option.value }
|
||||
}
|
||||
}
|
||||
return { amount: String(input), unit: "minutes" as RuleFormState["responseUnit"] }
|
||||
}
|
||||
|
||||
function convertToMinutes(value: string, unit: RuleFormState["responseUnit"]) {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return undefined
|
||||
}
|
||||
const factor = TIME_UNITS.find((item) => item.value === unit)?.factor ?? 1
|
||||
return Math.round(numeric * factor)
|
||||
}
|
||||
|
||||
type SlaInputGroupProps = {
|
||||
title: string
|
||||
amount: string
|
||||
unit: RuleFormState["responseUnit"]
|
||||
mode: RuleFormState["responseMode"]
|
||||
onAmountChange: (value: string) => void
|
||||
onUnitChange: (value: string) => void
|
||||
onModeChange: (value: string) => void
|
||||
}
|
||||
|
||||
function SlaInputGroup({ title, amount, unit, mode, onAmountChange, onUnitChange, onModeChange }: SlaInputGroupProps) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{title}</p>
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={amount}
|
||||
onChange={(event) => onAmountChange(event.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<Select value={unit} onValueChange={onUnitChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Unidade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_UNITS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={mode} onValueChange={onModeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Tipo de contagem" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ import {
|
|||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
|
|
@ -468,67 +468,6 @@ export function normalizeDeviceRemoteAccessList(raw: unknown): DeviceRemoteAcces
|
|||
return entries
|
||||
}
|
||||
|
||||
const REMOTE_ACCESS_METADATA_IGNORED_KEYS = new Set([
|
||||
"provider",
|
||||
"tool",
|
||||
"vendor",
|
||||
"name",
|
||||
"identifier",
|
||||
"code",
|
||||
"id",
|
||||
"accessId",
|
||||
"username",
|
||||
"user",
|
||||
"login",
|
||||
"email",
|
||||
"account",
|
||||
"password",
|
||||
"pass",
|
||||
"secret",
|
||||
"pin",
|
||||
"url",
|
||||
"link",
|
||||
"remoteUrl",
|
||||
"console",
|
||||
"viewer",
|
||||
"notes",
|
||||
"note",
|
||||
"description",
|
||||
"obs",
|
||||
"lastVerifiedAt",
|
||||
"verifiedAt",
|
||||
"checkedAt",
|
||||
"updatedAt",
|
||||
])
|
||||
|
||||
function extractRemoteAccessMetadataEntries(metadata: Record<string, unknown> | null | undefined) {
|
||||
if (!metadata) return [] as Array<[string, unknown]>
|
||||
return Object.entries(metadata).filter(([key, value]) => {
|
||||
if (REMOTE_ACCESS_METADATA_IGNORED_KEYS.has(key)) return false
|
||||
if (value === null || value === undefined) return false
|
||||
if (typeof value === "string" && value.trim().length === 0) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function formatRemoteAccessMetadataKey(key: string) {
|
||||
return key
|
||||
.replace(/[_.-]+/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
function formatRemoteAccessMetadataValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return ""
|
||||
if (typeof value === "string") return value
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (value instanceof Date) return formatAbsoluteDateTime(value)
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function readText(record: Record<string, unknown>, ...keys: string[]): string | undefined {
|
||||
const stringValue = readString(record, ...keys)
|
||||
if (stringValue) return stringValue
|
||||
|
|
@ -3029,7 +2968,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
const [deleteDialog, setDeleteDialog] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [accessDialog, setAccessDialog] = useState(false)
|
||||
const [accessEmail, setAccessEmail] = useState<string>(primaryLinkedUser?.email ?? "")
|
||||
const [accessEmail, setAccessEmail] = useState<string>("")
|
||||
const [accessName, setAccessName] = useState<string>(primaryLinkedUser?.name ?? "")
|
||||
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">(personaRole === "manager" ? "manager" : "collaborator")
|
||||
const [savingAccess, setSavingAccess] = useState(false)
|
||||
|
|
@ -3091,10 +3030,13 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
|
||||
// removed copy/export inventory JSON buttons as requested
|
||||
useEffect(() => {
|
||||
setAccessEmail(primaryLinkedUser?.email ?? "")
|
||||
setAccessEmail("")
|
||||
}, [device?.id])
|
||||
|
||||
useEffect(() => {
|
||||
setAccessName(primaryLinkedUser?.name ?? "")
|
||||
setAccessRole(personaRole === "manager" ? "manager" : "collaborator")
|
||||
}, [device?.id, primaryLinkedUser?.email, primaryLinkedUser?.name, personaRole])
|
||||
}, [device?.id, primaryLinkedUser?.name, personaRole])
|
||||
|
||||
useEffect(() => {
|
||||
setIsActiveLocal(device?.isActive ?? true)
|
||||
|
|
@ -3711,10 +3653,55 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
<InfoChip key={chip.key} label={chip.label} value={chip.value} icon={chip.icon} tone={chip.tone} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/80 px-4 py-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Controles do dispositivo</p>
|
||||
{device.registeredBy ? (
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
Registrada via <span className="text-slate-800">{device.registeredBy}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
|
||||
<ShieldCheck className="size-4" />
|
||||
Ajustar acesso
|
||||
</Button>
|
||||
{!isManualMobile ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2 border-dashed border-amber-300 text-amber-700 hover:border-amber-400 hover:text-amber-800"
|
||||
onClick={handleResetAgent}
|
||||
disabled={isResettingAgent}
|
||||
>
|
||||
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
|
||||
{isResettingAgent ? "Resetando agente..." : "Resetar agente"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isActiveLocal ? "outline" : "default"}
|
||||
className={cn(
|
||||
"gap-2 border-dashed",
|
||||
!isActiveLocal && "bg-emerald-600 text-white hover:bg-emerald-600/90"
|
||||
)}
|
||||
onClick={handleToggleActive}
|
||||
disabled={togglingActive}
|
||||
>
|
||||
{isActiveLocal ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
|
||||
{isActiveLocal ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campos personalizados (posicionado logo após métricas) */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 border-t border-slate-100 pt-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-neutral-900">Campos personalizados</h4>
|
||||
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-semibold">
|
||||
|
|
@ -3816,50 +3803,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
|
||||
<ShieldCheck className="size-4" />
|
||||
Ajustar acesso
|
||||
</Button>
|
||||
{!isManualMobile ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2 border-dashed border-amber-300 text-amber-700 hover:border-amber-400 hover:text-amber-800"
|
||||
onClick={handleResetAgent}
|
||||
disabled={isResettingAgent}
|
||||
>
|
||||
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
|
||||
{isResettingAgent ? "Resetando agente..." : "Resetar agente"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isActiveLocal ? "outline" : "default"}
|
||||
className={cn(
|
||||
"gap-2 border-dashed",
|
||||
!isActiveLocal && "bg-emerald-600 text-white hover:bg-emerald-600/90"
|
||||
)}
|
||||
onClick={handleToggleActive}
|
||||
disabled={togglingActive}
|
||||
>
|
||||
{isActiveLocal ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
|
||||
{isActiveLocal ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{device.registeredBy ? (
|
||||
<span
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"gap-2 border-dashed border-slate-200 bg-background cursor-default select-text text-neutral-700 hover:bg-background hover:text-neutral-700 focus-visible:outline-none"
|
||||
)}
|
||||
>
|
||||
Registrada via {device.registeredBy}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3 border-t border-slate-100 pt-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h4 className="text-sm font-semibold">Acesso remoto</h4>
|
||||
|
|
@ -3889,7 +3833,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
{hasRemoteAccess ? (
|
||||
<div className="space-y-3">
|
||||
{remoteAccessEntries.map((entry) => {
|
||||
const metadataEntries = extractRemoteAccessMetadataEntries(entry.metadata)
|
||||
const lastVerifiedDate =
|
||||
entry.lastVerifiedAt && Number.isFinite(entry.lastVerifiedAt)
|
||||
? new Date(entry.lastVerifiedAt)
|
||||
|
|
@ -3977,12 +3920,12 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
) : null}
|
||||
{isRustDesk && (entry.identifier || entry.password) ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-1 inline-flex items-center gap-2 bg-white/80 text-slate-800 hover:bg-white"
|
||||
className="mt-1 inline-flex items-center gap-2 border-[#00d6eb]/60 bg-white text-slate-800 shadow-sm transition-colors hover:border-[#00d6eb] hover:bg-[#00e8ff]/10 hover:text-slate-900 focus-visible:border-[#00d6eb] focus-visible:ring-[#00e8ff]/30"
|
||||
onClick={() => handleRustDeskConnect(entry)}
|
||||
>
|
||||
<MonitorSmartphone className="size-4" /> Conectar via RustDesk
|
||||
<MonitorSmartphone className="size-4 text-[#009bb1]" /> Conectar via RustDesk
|
||||
</Button>
|
||||
) : null}
|
||||
{entry.notes ? (
|
||||
|
|
@ -4020,21 +3963,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{metadataEntries.length ? (
|
||||
<details className="mt-3 rounded-lg border border-slate-200 bg-white/70 px-3 py-2 text-[11px] text-slate-600">
|
||||
<summary className="cursor-pointer font-semibold text-slate-700 outline-none transition-colors hover:text-slate-900">
|
||||
Metadados adicionais
|
||||
</summary>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
{metadataEntries.map(([key, value]) => (
|
||||
<div key={`${entry.clientId}-${key}`} className="flex items-center justify-between gap-3 rounded-md border border-slate-200 bg-white px-2 py-1 shadow-sm">
|
||||
<span className="font-semibold text-slate-700">{formatRemoteAccessMetadataKey(key)}</span>
|
||||
<span className="truncate text-right text-slate-600">{formatRemoteAccessMetadataValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
|
@ -4047,7 +3975,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<section className="space-y-3 border-t border-slate-100 pt-6">
|
||||
<h4 className="text-sm font-semibold">Usuários vinculados</h4>
|
||||
<div className="space-y-2">
|
||||
{primaryLinkedUser?.email ? (
|
||||
|
|
@ -4339,7 +4267,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
</Dialog>
|
||||
|
||||
{!isManualMobile ? (
|
||||
<section className="space-y-2">
|
||||
<section className="space-y-3 border-t border-slate-100 pt-6">
|
||||
<h4 className="text-sm font-semibold">Sincronização</h4>
|
||||
<div className="grid gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex justify-between gap-4">
|
||||
|
|
@ -4377,7 +4305,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
) : null}
|
||||
|
||||
{!isManualMobile ? (
|
||||
<section className="space-y-2">
|
||||
<section className="space-y-3 border-t border-slate-100 pt-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h4 className="text-sm font-semibold">Métricas recentes</h4>
|
||||
{lastUpdateRelative ? (
|
||||
|
|
@ -4391,7 +4319,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
) : null}
|
||||
|
||||
{!isManualMobile && (hardware || network || (labels && labels.length > 0)) ? (
|
||||
<section className="space-y-3">
|
||||
<section className="space-y-4 border-t border-slate-100 pt-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">Inventário</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
@ -4500,7 +4428,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
|
||||
{/* Discos (agente) */}
|
||||
{disks.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<section className="space-y-3 border-t border-slate-100 pt-6">
|
||||
<h4 className="text-sm font-semibold">Discos e partições</h4>
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/60">
|
||||
<Table>
|
||||
|
|
@ -4531,7 +4459,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
|
||||
{/* Inventário estendido por SO */}
|
||||
{extended ? (
|
||||
<section className="space-y-3">
|
||||
<section className="space-y-4 border-t border-slate-100 pt-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">Inventário estendido</h4>
|
||||
<p className="text-xs text-muted-foreground">Dados ricos coletados pelo agente, variam por sistema operacional.</p>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { Checkbox } from "@/components/ui/checkbox"
|
|||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
|
||||
type FieldOption = { value: string; label: string }
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ type Field = {
|
|||
options: FieldOption[]
|
||||
order: number
|
||||
scope: string
|
||||
companyId: string | null
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<Field["type"], string> = {
|
||||
|
|
@ -54,6 +56,11 @@ export function FieldsManager() {
|
|||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
) as Array<{ id: string; key: string; label: string }> | undefined
|
||||
|
||||
const companies = useQuery(
|
||||
api.companies.list,
|
||||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
) as Array<{ id: string; name: string; slug?: string }> | undefined
|
||||
|
||||
const scopeOptions = useMemo(
|
||||
() => [
|
||||
{ value: "all", label: "Todos os formulários" },
|
||||
|
|
@ -62,6 +69,28 @@ export function FieldsManager() {
|
|||
[templates]
|
||||
)
|
||||
|
||||
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||
if (!companies) return []
|
||||
return companies
|
||||
.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
description: company.slug ?? undefined,
|
||||
keywords: company.slug ? [company.slug] : [],
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
|
||||
}, [companies])
|
||||
|
||||
const companyLabelById = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
companyOptions.forEach((option) => map.set(option.value, option.label))
|
||||
return map
|
||||
}, [companyOptions])
|
||||
|
||||
const companyComboboxOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||
return [{ value: "all", label: "Todas as empresas" }, ...companyOptions]
|
||||
}, [companyOptions])
|
||||
|
||||
const templateLabelByKey = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
templates?.forEach((tpl) => map.set(tpl.key, tpl.label))
|
||||
|
|
@ -79,9 +108,11 @@ export function FieldsManager() {
|
|||
const [required, setRequired] = useState(false)
|
||||
const [options, setOptions] = useState<FieldOption[]>([])
|
||||
const [scopeSelection, setScopeSelection] = useState<string>("all")
|
||||
const [companySelection, setCompanySelection] = useState<string>("all")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editingField, setEditingField] = useState<Field | null>(null)
|
||||
const [editingScope, setEditingScope] = useState<string>("all")
|
||||
const [editingCompanySelection, setEditingCompanySelection] = useState<string>("all")
|
||||
|
||||
const totals = useMemo(() => {
|
||||
if (!fields) return { total: 0, required: 0, select: 0 }
|
||||
|
|
@ -99,6 +130,8 @@ export function FieldsManager() {
|
|||
setRequired(false)
|
||||
setOptions([])
|
||||
setScopeSelection("all")
|
||||
setCompanySelection("all")
|
||||
setEditingCompanySelection("all")
|
||||
}
|
||||
|
||||
const normalizeOptions = (source: FieldOption[]) =>
|
||||
|
|
@ -121,6 +154,7 @@ export function FieldsManager() {
|
|||
}
|
||||
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
|
||||
const scopeValue = scopeSelection === "all" ? undefined : scopeSelection
|
||||
const companyIdValue = companySelection === "all" ? undefined : (companySelection as Id<"companies">)
|
||||
setSaving(true)
|
||||
toast.loading("Criando campo...", { id: "field" })
|
||||
try {
|
||||
|
|
@ -133,6 +167,7 @@ export function FieldsManager() {
|
|||
required,
|
||||
options: preparedOptions,
|
||||
scope: scopeValue,
|
||||
companyId: companyIdValue,
|
||||
})
|
||||
toast.success("Campo criado", { id: "field" })
|
||||
resetForm()
|
||||
|
|
@ -173,6 +208,7 @@ export function FieldsManager() {
|
|||
setRequired(field.required)
|
||||
setOptions(field.options)
|
||||
setEditingScope(field.scope ?? "all")
|
||||
setEditingCompanySelection(field.companyId ?? "all")
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
|
|
@ -187,6 +223,7 @@ export function FieldsManager() {
|
|||
}
|
||||
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
|
||||
const scopeValue = editingScope === "all" ? undefined : editingScope
|
||||
const companyIdValue = editingCompanySelection === "all" ? undefined : (editingCompanySelection as Id<"companies">)
|
||||
setSaving(true)
|
||||
toast.loading("Atualizando campo...", { id: "field-edit" })
|
||||
try {
|
||||
|
|
@ -200,6 +237,7 @@ export function FieldsManager() {
|
|||
required,
|
||||
options: preparedOptions,
|
||||
scope: scopeValue,
|
||||
companyId: companyIdValue,
|
||||
})
|
||||
toast.success("Campo atualizado", { id: "field-edit" })
|
||||
setEditingField(null)
|
||||
|
|
@ -347,6 +385,25 @@ export function FieldsManager() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Empresa (opcional)</Label>
|
||||
<SearchableCombobox
|
||||
value={companySelection}
|
||||
onValueChange={(value) => setCompanySelection(value ?? "all")}
|
||||
options={companyComboboxOptions}
|
||||
placeholder="Todas as empresas"
|
||||
renderValue={(option) =>
|
||||
option ? (
|
||||
<span className="truncate">{option.label}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Todas as empresas</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Selecione uma empresa para tornar este campo exclusivo dela. Sem seleção, o campo aparecerá em todos os tickets.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -443,9 +500,14 @@ export function FieldsManager() {
|
|||
) : null}
|
||||
</div>
|
||||
<CardDescription className="text-neutral-600">Identificador: {field.key}</CardDescription>
|
||||
<Badge variant="secondary" className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-neutral-700">
|
||||
{scopeLabel}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-neutral-700">
|
||||
{scopeLabel}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-neutral-700">
|
||||
{field.companyId ? `Empresa: ${companyLabelById.get(field.companyId) ?? "Específica"}` : "Todas as empresas"}
|
||||
</Badge>
|
||||
</div>
|
||||
{field.description ? (
|
||||
<p className="text-sm text-neutral-600">{field.description}</p>
|
||||
) : null}
|
||||
|
|
@ -554,6 +616,25 @@ export function FieldsManager() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Empresa (opcional)</Label>
|
||||
<SearchableCombobox
|
||||
value={editingCompanySelection}
|
||||
onValueChange={(value) => setEditingCompanySelection(value ?? "all")}
|
||||
options={companyComboboxOptions}
|
||||
placeholder="Todas as empresas"
|
||||
renderValue={(option) =>
|
||||
option ? (
|
||||
<span className="truncate">{option.label}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Todas as empresas</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Defina uma empresa para restringir este campo apenas aos tickets dela.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
400
src/components/admin/slas/category-sla-drawer.tsx
Normal file
400
src/components/admin/slas/category-sla-drawer.tsx
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketCategory } from "@/lib/schemas/category"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
const PRIORITY_ROWS = [
|
||||
{ value: "URGENT", label: "Crítico" },
|
||||
{ value: "HIGH", label: "Alta" },
|
||||
{ value: "MEDIUM", label: "Média" },
|
||||
{ value: "LOW", label: "Baixa" },
|
||||
{ value: "DEFAULT", label: "Sem prioridade" },
|
||||
] as const
|
||||
|
||||
const TIME_UNITS: Array<{ value: RuleFormState["responseUnit"]; label: string; factor: number }> = [
|
||||
{ value: "minutes", label: "Minutos", factor: 1 },
|
||||
{ value: "hours", label: "Horas", factor: 60 },
|
||||
{ value: "days", label: "Dias", factor: 1440 },
|
||||
]
|
||||
|
||||
const MODE_OPTIONS: Array<{ value: RuleFormState["responseMode"]; label: string }> = [
|
||||
{ value: "calendar", label: "Horas corridas" },
|
||||
{ value: "business", label: "Horas úteis" },
|
||||
]
|
||||
|
||||
const PAUSE_STATUS_OPTIONS = [
|
||||
{ value: "PENDING", label: "Pendente" },
|
||||
{ value: "AWAITING_ATTENDANCE", label: "Em atendimento" },
|
||||
{ value: "PAUSED", label: "Pausado" },
|
||||
] as const
|
||||
|
||||
const DEFAULT_RULE_STATE: RuleFormState = {
|
||||
responseValue: "",
|
||||
responseUnit: "hours",
|
||||
responseMode: "calendar",
|
||||
solutionValue: "",
|
||||
solutionUnit: "hours",
|
||||
solutionMode: "calendar",
|
||||
alertThreshold: 80,
|
||||
pauseStatuses: ["PAUSED"],
|
||||
}
|
||||
|
||||
type RuleFormState = {
|
||||
responseValue: string
|
||||
responseUnit: "minutes" | "hours" | "days"
|
||||
responseMode: "calendar" | "business"
|
||||
solutionValue: string
|
||||
solutionUnit: "minutes" | "hours" | "days"
|
||||
solutionMode: "calendar" | "business"
|
||||
alertThreshold: number
|
||||
pauseStatuses: string[]
|
||||
}
|
||||
|
||||
export type CategorySlaDrawerProps = {
|
||||
category: TicketCategory | null
|
||||
tenantId: string
|
||||
viewerId: Id<"users"> | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CategorySlaDrawer({ category, tenantId, viewerId, onClose }: CategorySlaDrawerProps) {
|
||||
const [rules, setRules] = useState<Record<string, RuleFormState>>(() => buildDefaultRuleState())
|
||||
const [saving, setSaving] = useState(false)
|
||||
const drawerOpen = Boolean(category)
|
||||
|
||||
const canLoad = Boolean(category && viewerId)
|
||||
const existing = useQuery(
|
||||
api.categorySlas.get,
|
||||
canLoad
|
||||
? {
|
||||
tenantId,
|
||||
viewerId: viewerId as Id<"users">,
|
||||
categoryId: category!.id as Id<"ticketCategories">,
|
||||
}
|
||||
: "skip"
|
||||
) as {
|
||||
rules: Array<{
|
||||
priority: string
|
||||
responseTargetMinutes: number | null
|
||||
responseMode?: string | null
|
||||
solutionTargetMinutes: number | null
|
||||
solutionMode?: string | null
|
||||
alertThreshold?: number | null
|
||||
pauseStatuses?: string[] | null
|
||||
}>
|
||||
} | undefined
|
||||
|
||||
const saveSla = useMutation(api.categorySlas.save)
|
||||
|
||||
useEffect(() => {
|
||||
if (!existing?.rules) {
|
||||
setRules(buildDefaultRuleState())
|
||||
return
|
||||
}
|
||||
const next = buildDefaultRuleState()
|
||||
for (const rule of existing.rules) {
|
||||
const priority = rule.priority?.toUpperCase() ?? "DEFAULT"
|
||||
next[priority] = convertRuleToForm(rule)
|
||||
}
|
||||
setRules(next)
|
||||
}, [existing, category?.id])
|
||||
|
||||
const handleChange = (priority: string, patch: Partial<RuleFormState>) => {
|
||||
setRules((current) => ({
|
||||
...current,
|
||||
[priority]: {
|
||||
...current[priority],
|
||||
...patch,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const togglePause = (priority: string, status: string) => {
|
||||
setRules((current) => {
|
||||
const selected = new Set(current[priority].pauseStatuses)
|
||||
if (selected.has(status)) {
|
||||
selected.delete(status)
|
||||
} else {
|
||||
selected.add(status)
|
||||
}
|
||||
if (selected.size === 0) {
|
||||
selected.add("PAUSED")
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[priority]: {
|
||||
...current[priority],
|
||||
pauseStatuses: Array.from(selected),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!category || !viewerId) return
|
||||
setSaving(true)
|
||||
toast.loading("Salvando SLA...", { id: "category-sla" })
|
||||
try {
|
||||
const payload = PRIORITY_ROWS.map((row) => {
|
||||
const form = rules[row.value]
|
||||
return {
|
||||
priority: row.value,
|
||||
responseTargetMinutes: convertToMinutes(form.responseValue, form.responseUnit),
|
||||
responseMode: form.responseMode,
|
||||
solutionTargetMinutes: convertToMinutes(form.solutionValue, form.solutionUnit),
|
||||
solutionMode: form.solutionMode,
|
||||
alertThreshold: Math.min(Math.max(form.alertThreshold, 5), 95) / 100,
|
||||
pauseStatuses: form.pauseStatuses,
|
||||
}
|
||||
})
|
||||
await saveSla({
|
||||
tenantId,
|
||||
actorId: viewerId,
|
||||
categoryId: category.id as Id<"ticketCategories">,
|
||||
rules: payload,
|
||||
})
|
||||
toast.success("SLA atualizado", { id: "category-sla" })
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível salvar as regras de SLA.", { id: "category-sla" })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={drawerOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configurar SLA — {category?.name ?? ""}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Defina metas de resposta e resolução para cada prioridade. Os prazos em horas úteis consideram apenas
|
||||
segunda a sexta, das 8h às 18h.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{PRIORITY_ROWS.map((row) => {
|
||||
const form = rules[row.value]
|
||||
return (
|
||||
<div key={row.value} className="space-y-3 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-neutral-900">{row.label}</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{row.value === "DEFAULT"
|
||||
? "Aplicado quando o ticket não tem prioridade definida."
|
||||
: "Aplica-se aos tickets desta prioridade."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SlaInputGroup
|
||||
title="Tempo de resposta"
|
||||
amount={form.responseValue}
|
||||
unit={form.responseUnit}
|
||||
mode={form.responseMode}
|
||||
onAmountChange={(value) => handleChange(row.value, { responseValue: value })}
|
||||
onUnitChange={(value) =>
|
||||
handleChange(row.value, { responseUnit: value as RuleFormState["responseUnit"] })
|
||||
}
|
||||
onModeChange={(value) =>
|
||||
handleChange(row.value, { responseMode: value as RuleFormState["responseMode"] })
|
||||
}
|
||||
/>
|
||||
<SlaInputGroup
|
||||
title="Tempo de solução"
|
||||
amount={form.solutionValue}
|
||||
unit={form.solutionUnit}
|
||||
mode={form.solutionMode}
|
||||
onAmountChange={(value) => handleChange(row.value, { solutionValue: value })}
|
||||
onUnitChange={(value) =>
|
||||
handleChange(row.value, { solutionUnit: value as RuleFormState["solutionUnit"] })
|
||||
}
|
||||
onModeChange={(value) =>
|
||||
handleChange(row.value, { solutionMode: value as RuleFormState["solutionMode"] })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Alertar quando</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={95}
|
||||
step={5}
|
||||
value={form.alertThreshold}
|
||||
onChange={(event) => handleChange(row.value, { alertThreshold: Number(event.target.value) || 0 })}
|
||||
/>
|
||||
<span className="text-xs text-neutral-500">% do tempo for consumido.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Estados que pausam</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{PAUSE_STATUS_OPTIONS.map((option) => {
|
||||
const selected = form.pauseStatuses.includes(option.value)
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => togglePause(row.value, option.value)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-xs font-semibold transition",
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-slate-200 bg-white text-neutral-600"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !viewerId}>
|
||||
{saving ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function buildDefaultRuleState() {
|
||||
return PRIORITY_ROWS.reduce<Record<string, RuleFormState>>((acc, row) => {
|
||||
acc[row.value] = { ...DEFAULT_RULE_STATE }
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function convertRuleToForm(rule: {
|
||||
priority: string
|
||||
responseTargetMinutes: number | null
|
||||
responseMode?: string | null
|
||||
solutionTargetMinutes: number | null
|
||||
solutionMode?: string | null
|
||||
alertThreshold?: number | null
|
||||
pauseStatuses?: string[] | null
|
||||
}): RuleFormState {
|
||||
const response = minutesToForm(rule.responseTargetMinutes)
|
||||
const solution = minutesToForm(rule.solutionTargetMinutes)
|
||||
return {
|
||||
responseValue: response.amount,
|
||||
responseUnit: response.unit,
|
||||
responseMode: (rule.responseMode ?? "calendar") as RuleFormState["responseMode"],
|
||||
solutionValue: solution.amount,
|
||||
solutionUnit: solution.unit,
|
||||
solutionMode: (rule.solutionMode ?? "calendar") as RuleFormState["solutionMode"],
|
||||
alertThreshold: Math.round((rule.alertThreshold ?? 0.8) * 100),
|
||||
pauseStatuses: rule.pauseStatuses && rule.pauseStatuses.length > 0 ? rule.pauseStatuses : ["PAUSED"],
|
||||
}
|
||||
}
|
||||
|
||||
function minutesToForm(input?: number | null) {
|
||||
if (!input || input <= 0) {
|
||||
return { amount: "", unit: "hours" as RuleFormState["responseUnit"] }
|
||||
}
|
||||
for (const option of [...TIME_UNITS].reverse()) {
|
||||
if (input % option.factor === 0) {
|
||||
return { amount: String(Math.round(input / option.factor)), unit: option.value }
|
||||
}
|
||||
}
|
||||
return { amount: String(input), unit: "minutes" as RuleFormState["responseUnit"] }
|
||||
}
|
||||
|
||||
function convertToMinutes(value: string, unit: RuleFormState["responseUnit"]) {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return undefined
|
||||
}
|
||||
const factor = TIME_UNITS.find((item) => item.value === unit)?.factor ?? 1
|
||||
return Math.round(numeric * factor)
|
||||
}
|
||||
|
||||
type SlaInputGroupProps = {
|
||||
title: string
|
||||
amount: string
|
||||
unit: RuleFormState["responseUnit"]
|
||||
mode: RuleFormState["responseMode"]
|
||||
onAmountChange: (value: string) => void
|
||||
onUnitChange: (value: string) => void
|
||||
onModeChange: (value: string) => void
|
||||
}
|
||||
|
||||
function SlaInputGroup({ title, amount, unit, mode, onAmountChange, onUnitChange, onModeChange }: SlaInputGroupProps) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{title}</p>
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={amount}
|
||||
onChange={(event) => onAmountChange(event.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<Select value={unit} onValueChange={onUnitChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Unidade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_UNITS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={mode} onValueChange={onModeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Tipo de contagem" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
src/components/admin/slas/category-sla-manager.tsx
Normal file
94
src/components/admin/slas/category-sla-manager.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { TicketCategory } from "@/lib/schemas/category"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
import { CategorySlaDrawer } from "./category-sla-drawer"
|
||||
|
||||
export function CategorySlaManager() {
|
||||
const { session, convexUserId } = useAuth()
|
||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
const viewerId = convexUserId ? (convexUserId as Id<"users">) : null
|
||||
|
||||
const categories = useQuery(api.categories.list, { tenantId }) as TicketCategory[] | undefined
|
||||
const [selectedCategory, setSelectedCategory] = useState<TicketCategory | null>(null)
|
||||
|
||||
const sortedCategories = useMemo(() => {
|
||||
if (!categories) return []
|
||||
return [...categories].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||
}, [categories])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">SLA por categoria</CardTitle>
|
||||
<CardDescription>
|
||||
Ajuste metas específicas por prioridade para cada categoria. Útil quando determinados temas exigem prazos
|
||||
diferentes das políticas gerais.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{categories === undefined ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Skeleton key={`category-sla-skeleton-${index}`} className="h-16 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : sortedCategories.length === 0 ? (
|
||||
<p className="text-sm text-neutral-600">
|
||||
Cadastre categorias em <strong>Admin ▸ Campos personalizados</strong> para liberar esta configuração.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sortedCategories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-neutral-900">{category.name}</p>
|
||||
{category.description ? (
|
||||
<p className="text-xs text-neutral-500">{category.description}</p>
|
||||
) : null}
|
||||
{category.secondary.length ? (
|
||||
<Badge variant="outline" className="rounded-full border-slate-200 px-3 py-1 text-xs font-semibold">
|
||||
{category.secondary.length} subcategorias
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className="shrink-0"
|
||||
disabled={!viewerId}
|
||||
>
|
||||
Configurar SLA
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CategorySlaDrawer
|
||||
category={selectedCategory}
|
||||
tenantId={tenantId}
|
||||
viewerId={viewerId}
|
||||
onClose={() => setSelectedCategory(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ import { Label } from "@/components/ui/label"
|
|||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
|
||||
import { CategorySlaManager } from "./category-sla-manager"
|
||||
|
||||
type SlaPolicy = {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -327,6 +329,8 @@ export function SlasManager() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<CategorySlaManager />
|
||||
|
||||
<Dialog open={Boolean(editingSla)} onOpenChange={(value) => (!value ? setEditingSla(null) : null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
|
|
|
|||
|
|
@ -1043,7 +1043,7 @@ function AccountsTable({
|
|||
) : templates.length === 0 ? (
|
||||
<p className="text-xs text-neutral-500">Nenhum formulário configurado.</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
{templates.map((template) => (
|
||||
<label key={template.key} className="flex items-center gap-2 text-sm text-foreground">
|
||||
<Checkbox
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ const navigation: NavigationGroup[] = [
|
|||
{ title: "SLA & Produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" },
|
||||
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
|
||||
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },
|
||||
{ title: "Empresas", url: "/reports/company", icon: Building2, requiredRole: "staff" },
|
||||
{ title: "Clientes atendidos", url: "/reports/company", icon: Building2, requiredRole: "staff" },
|
||||
{ title: "Categorias", url: "/reports/categories", icon: Layers3, requiredRole: "staff" },
|
||||
{ title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" },
|
||||
],
|
||||
|
|
@ -111,7 +111,7 @@ const navigation: NavigationGroup[] = [
|
|||
{ title: "Filas", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
|
||||
{ title: "Times & papéis", url: "/admin/teams", icon: UserCog, requiredRole: "admin", hidden: true },
|
||||
{
|
||||
title: "Empresas",
|
||||
title: "Empresas & clientes",
|
||||
url: "/admin/companies",
|
||||
icon: Building,
|
||||
requiredRole: "admin",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle }
|
|||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
|
||||
|
|
@ -43,7 +42,6 @@ const topClientsChartConfig = {
|
|||
|
||||
export function HoursReport() {
|
||||
const [timeRange, setTimeRange] = useState("90d")
|
||||
const [query, setQuery] = useState("")
|
||||
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||
const { session, convexUserId, isStaff } = useAuth()
|
||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
|
@ -74,12 +72,11 @@ export function HoursReport() {
|
|||
}, [companies])
|
||||
const filtered = useMemo(() => {
|
||||
const items = data?.items ?? []
|
||||
const q = query.trim().toLowerCase()
|
||||
let list = items
|
||||
if (companyId !== "all") list = list.filter((it) => String(it.companyId) === companyId)
|
||||
if (q) list = list.filter((it) => it.name.toLowerCase().includes(q))
|
||||
return list
|
||||
}, [data?.items, query, companyId])
|
||||
if (companyId !== "all") {
|
||||
return items.filter((it) => String(it.companyId) === companyId)
|
||||
}
|
||||
return items
|
||||
}, [data?.items, companyId])
|
||||
|
||||
const totals = useMemo(() => {
|
||||
return filtered.reduce(
|
||||
|
|
@ -185,33 +182,37 @@ export function HoursReport() {
|
|||
<CardTitle>Horas</CardTitle>
|
||||
<CardDescription>Visualize o esforço interno e externo por empresa e acompanhe o consumo contratado.</CardDescription>
|
||||
<CardAction>
|
||||
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
<Input
|
||||
placeholder="Pesquisar empresa..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="h-9 w-full min-w-56 sm:w-72"
|
||||
/>
|
||||
<SearchableCombobox
|
||||
value={companyId}
|
||||
onValueChange={(next) => setCompanyId(next ?? "all")}
|
||||
options={companyOptions}
|
||||
placeholder="Todas as empresas"
|
||||
className="w-full min-w-56 sm:w-64"
|
||||
/>
|
||||
<ToggleGroup type="single" value={timeRange} onValueChange={setTimeRange} variant="outline" className="hidden md:flex">
|
||||
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
|
||||
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a
|
||||
href={`/api/reports/hours-by-client.xlsx?range=${timeRange}${query ? `&q=${encodeURIComponent(query)}` : ""}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
|
||||
download
|
||||
>
|
||||
Exportar XLSX
|
||||
</a>
|
||||
</Button>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<SearchableCombobox
|
||||
value={companyId}
|
||||
onValueChange={(next) => setCompanyId(next ?? "all")}
|
||||
options={companyOptions}
|
||||
placeholder="Todas as empresas"
|
||||
className="w-full min-w-56 lg:w-72"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{["90d", "30d", "7d"].map((range) => (
|
||||
<Button
|
||||
key={range}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={timeRange === range ? "default" : "outline"}
|
||||
onClick={() => setTimeRange(range)}
|
||||
>
|
||||
{range === "90d" ? "90 dias" : range === "30d" ? "30 dias" : "7 dias"}
|
||||
</Button>
|
||||
))}
|
||||
<Button asChild size="sm" variant="outline" className="gap-2">
|
||||
<a
|
||||
href={`/api/reports/hours-by-client.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
|
||||
download
|
||||
>
|
||||
Exportar XLSX
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
|
|
|||
|
|
@ -249,16 +249,6 @@ export function CloseTicketDialog({
|
|||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (templates.length > 0 && !selectedTemplateId && !message) {
|
||||
const first = templates[0]
|
||||
const hydrated = hydrateTemplateBody(first.body)
|
||||
setSelectedTemplateId(first.id)
|
||||
setMessage(hydrated)
|
||||
}
|
||||
}, [open, templates, selectedTemplateId, message, hydrateTemplateBody])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !enableAdjustment || !shouldAdjustTime) return
|
||||
const internal = splitDuration(workSummary?.internalWorkedMs ?? 0)
|
||||
|
|
|
|||
|
|
@ -184,9 +184,17 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
})
|
||||
}, [convexUserId, ensureTicketFormDefaultsMutation])
|
||||
|
||||
const companyValue = form.watch("companyId") ?? NO_COMPANY_VALUE
|
||||
|
||||
const formsRemote = useQuery(
|
||||
api.tickets.listTicketForms,
|
||||
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
convexUserId
|
||||
? {
|
||||
tenantId: DEFAULT_TENANT_ID,
|
||||
viewerId: convexUserId as Id<"users">,
|
||||
companyId: companyValue !== NO_COMPANY_VALUE ? (companyValue as Id<"companies">) : undefined,
|
||||
}
|
||||
: "skip"
|
||||
) as TicketFormDefinition[] | undefined
|
||||
|
||||
const forms = useMemo<TicketFormDefinition[]>(() => {
|
||||
|
|
@ -256,7 +264,6 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
const queueValue = form.watch("queueName") ?? "NONE"
|
||||
const assigneeValue = form.watch("assigneeId") ?? null
|
||||
const assigneeSelectValue = assigneeValue ?? "NONE"
|
||||
const companyValue = form.watch("companyId") ?? NO_COMPANY_VALUE
|
||||
const requesterValue = form.watch("requesterId") ?? ""
|
||||
const categoryIdValue = form.watch("categoryId")
|
||||
const subcategoryIdValue = form.watch("subcategoryId")
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export function RecentTicketsPanel() {
|
|||
const assigned = all
|
||||
.filter((t) => !!t.assignee)
|
||||
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
||||
return [...unassigned, ...assigned].slice(0, 6)
|
||||
return [...unassigned, ...assigned].slice(0, 3)
|
||||
}, [ticketsResult])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -131,7 +131,7 @@ export function RecentTicketsPanel() {
|
|||
<CardTitle className="text-lg font-semibold text-neutral-900">Últimos chamados</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="rounded-xl border border-slate-100 bg-slate-50/60 p-4">
|
||||
<Skeleton className="mb-2 h-4 w-48" />
|
||||
<Skeleton className="h-3 w-64" />
|
||||
|
|
|
|||
|
|
@ -221,6 +221,7 @@ export function TicketCustomFieldsSection({ ticket, variant = "card", className
|
|||
|
||||
const viewerId = convexUserId as Id<"users"> | null
|
||||
const tenantId = ticket.tenantId
|
||||
const ticketCompanyId = ticket.company?.id ?? null
|
||||
|
||||
const ensureTicketFormDefaults = useMutation(api.tickets.ensureTicketFormDefaults)
|
||||
|
||||
|
|
@ -247,7 +248,7 @@ export function TicketCustomFieldsSection({ ticket, variant = "card", className
|
|||
const formsRemote = useQuery(
|
||||
api.tickets.listTicketForms,
|
||||
canEdit && viewerId
|
||||
? { tenantId, viewerId }
|
||||
? { tenantId, viewerId, companyId: ticketCompanyId ? (ticketCompanyId as Id<"companies">) : undefined }
|
||||
: "skip"
|
||||
) as TicketFormDefinition[] | undefined
|
||||
|
||||
|
|
|
|||
|
|
@ -32,16 +32,16 @@ function SelectTrigger({
|
|||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"data-[placeholder]:text-neutral-400 [&_svg:not([class*='text-'])]:text-neutral-500 aria-invalid:border-red-500/80 aria-invalid:ring-red-500/20 flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-neutral-800 shadow-sm outline-none transition-all disabled:cursor-not-allowed disabled:opacity-60 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus-visible:ring-[3px] focus-visible:ring-[#00e8ff]/20 focus-visible:border-[#00d6eb] data-[state=open]:border-[#00d6eb] data-[state=open]:shadow-[0_0_0_3px_rgba(0,232,255,0.12)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"data-[placeholder]:text-neutral-400 [&_svg:not([class*='text-'])]:text-neutral-500 aria-invalid:border-red-500/80 aria-invalid:ring-red-500/20 flex w-full items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-neutral-800 shadow-sm outline-none transition-all disabled:cursor-not-allowed disabled:opacity-60 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus-visible:ring-[3px] focus-visible:ring-[#00e8ff]/20 focus-visible:border-[#00d6eb] data-[state=open]:border-[#00d6eb] data-[state=open]:shadow-[0_0_0_3px_rgba(0,232,255,0.12)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue