chore: prep platform improvements

This commit is contained in:
Esdras Renan 2025-11-09 21:09:38 -03:00
parent a62f3d5283
commit c5ddd54a3e
24 changed files with 777 additions and 649 deletions

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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">

View 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>
)
}

View 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)}
/>
</>
)
}

View file

@ -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>

View file

@ -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

View file

@ -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",

View file

@ -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>

View file

@ -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)

View file

@ -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")

View file

@ -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" />

View file

@ -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

View file

@ -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" />