Add requester filter and improve error messages
- Add requester filter to device tickets history page - Create listMachineRequesters query to list unique requesters - Add friendly API error formatting in desktop agent - Translate validation errors to user-friendly Portuguese messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
bb82efa9d3
commit
cf31e78edb
3 changed files with 140 additions and 5 deletions
|
|
@ -134,6 +134,54 @@ function isTokenRevokedMessage(input: string) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatApiError(responseText: string, statusCode: number): string {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(responseText)
|
||||||
|
if (json.error === "Payload invalido" || json.error === "Payload inválido") {
|
||||||
|
const details = typeof json.details === "string" ? JSON.parse(json.details) : json.details
|
||||||
|
if (Array.isArray(details) && details.length > 0) {
|
||||||
|
const fieldLabels: Record<string, string> = {
|
||||||
|
"collaborator.email": "E-mail",
|
||||||
|
"collaborator.name": "Nome",
|
||||||
|
email: "E-mail",
|
||||||
|
name: "Nome",
|
||||||
|
provisioningCode: "Código de ativação",
|
||||||
|
hostname: "Nome do computador",
|
||||||
|
}
|
||||||
|
const messages: string[] = []
|
||||||
|
for (const err of details) {
|
||||||
|
const path = Array.isArray(err.path) ? err.path.join(".") : String(err.path ?? "")
|
||||||
|
const fieldLabel = fieldLabels[path] || path || "Campo"
|
||||||
|
if (err.code === "invalid_format" && err.format === "email") {
|
||||||
|
messages.push(`${fieldLabel}: formato de e-mail inválido`)
|
||||||
|
} else if (err.code === "invalid_format") {
|
||||||
|
messages.push(`${fieldLabel}: formato inválido`)
|
||||||
|
} else if (err.code === "too_small" || err.code === "too_short") {
|
||||||
|
messages.push(`${fieldLabel}: muito curto`)
|
||||||
|
} else if (err.code === "too_big" || err.code === "too_long") {
|
||||||
|
messages.push(`${fieldLabel}: muito longo`)
|
||||||
|
} else if (err.code === "invalid_type") {
|
||||||
|
messages.push(`${fieldLabel}: valor inválido`)
|
||||||
|
} else if (err.message) {
|
||||||
|
messages.push(`${fieldLabel}: ${err.message}`)
|
||||||
|
} else {
|
||||||
|
messages.push(`${fieldLabel}: erro de validação`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (messages.length > 0) {
|
||||||
|
return messages.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (json.error) {
|
||||||
|
return json.error
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Não é JSON, retorna o texto original
|
||||||
|
}
|
||||||
|
return `Erro no servidor (${statusCode})`
|
||||||
|
}
|
||||||
|
|
||||||
function buildRemoteAccessPayload(info: RustdeskInfo | null) {
|
function buildRemoteAccessPayload(info: RustdeskInfo | null) {
|
||||||
if (!info) return null
|
if (!info) return null
|
||||||
const payload: Record<string, string | undefined> = {
|
const payload: Record<string, string | undefined> = {
|
||||||
|
|
@ -1038,7 +1086,7 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
throw new Error(`Falha no registro (${res.status}): ${text.slice(0, 300)}`)
|
throw new Error(formatApiError(text, res.status))
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await res.json()) as MachineRegisterResponse
|
const data = (await res.json()) as MachineRegisterResponse
|
||||||
|
|
@ -1242,7 +1290,7 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
throw new Error(`Falha ao enviar inventário (${res.status}): ${text.slice(0, 200)}`)
|
throw new Error(formatApiError(text, res.status))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
|
|
||||||
|
|
@ -1282,6 +1282,7 @@ export const listOpenTickets = query({
|
||||||
type MachineTicketsHistoryFilter = {
|
type MachineTicketsHistoryFilter = {
|
||||||
statusFilter: "all" | "open" | "resolved"
|
statusFilter: "all" | "open" | "resolved"
|
||||||
priorityFilter: string | null
|
priorityFilter: string | null
|
||||||
|
requesterEmail: string | null
|
||||||
from: number | null
|
from: number | null
|
||||||
to: number | null
|
to: number | null
|
||||||
}
|
}
|
||||||
|
|
@ -1290,6 +1291,7 @@ type ListTicketsHistoryArgs = {
|
||||||
machineId: Id<"machines">
|
machineId: Id<"machines">
|
||||||
status?: "all" | "open" | "resolved"
|
status?: "all" | "open" | "resolved"
|
||||||
priority?: string
|
priority?: string
|
||||||
|
requesterEmail?: string
|
||||||
search?: string
|
search?: string
|
||||||
from?: number
|
from?: number
|
||||||
to?: number
|
to?: number
|
||||||
|
|
@ -1300,6 +1302,7 @@ type GetTicketsHistoryStatsArgs = {
|
||||||
machineId: Id<"machines">
|
machineId: Id<"machines">
|
||||||
status?: "all" | "open" | "resolved"
|
status?: "all" | "open" | "resolved"
|
||||||
priority?: string
|
priority?: string
|
||||||
|
requesterEmail?: string
|
||||||
search?: string
|
search?: string
|
||||||
from?: number
|
from?: number
|
||||||
to?: number
|
to?: number
|
||||||
|
|
@ -1343,6 +1346,13 @@ function createMachineTicketsQuery(
|
||||||
return working
|
return working
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesRequesterEmail(ticket: Doc<"tickets">, requesterEmail: string | null): boolean {
|
||||||
|
if (!requesterEmail) return true
|
||||||
|
const requesterSnapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined
|
||||||
|
if (!requesterSnapshot?.email) return false
|
||||||
|
return requesterSnapshot.email.toLowerCase() === requesterEmail.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
function matchesTicketSearch(ticket: Doc<"tickets">, searchTerm: string): boolean {
|
function matchesTicketSearch(ticket: Doc<"tickets">, searchTerm: string): boolean {
|
||||||
const normalized = searchTerm.trim().toLowerCase()
|
const normalized = searchTerm.trim().toLowerCase()
|
||||||
if (!normalized) return true
|
if (!normalized) return true
|
||||||
|
|
@ -1383,19 +1393,27 @@ export async function listTicketsHistoryHandler(ctx: QueryCtx, args: ListTickets
|
||||||
|
|
||||||
const normalizedStatusFilter = args.status ?? "all"
|
const normalizedStatusFilter = args.status ?? "all"
|
||||||
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null
|
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null
|
||||||
|
const normalizedRequesterEmail = args.requesterEmail?.trim().toLowerCase() ?? null
|
||||||
const searchTerm = args.search?.trim().toLowerCase() ?? null
|
const searchTerm = args.search?.trim().toLowerCase() ?? null
|
||||||
const from = typeof args.from === "number" ? args.from : null
|
const from = typeof args.from === "number" ? args.from : null
|
||||||
const to = typeof args.to === "number" ? args.to : null
|
const to = typeof args.to === "number" ? args.to : null
|
||||||
const filters: MachineTicketsHistoryFilter = {
|
const filters: MachineTicketsHistoryFilter = {
|
||||||
statusFilter: normalizedStatusFilter,
|
statusFilter: normalizedStatusFilter,
|
||||||
priorityFilter: normalizedPriorityFilter,
|
priorityFilter: normalizedPriorityFilter,
|
||||||
|
requesterEmail: normalizedRequesterEmail,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate(args.paginationOpts)
|
const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate(args.paginationOpts)
|
||||||
|
|
||||||
const page = searchTerm ? pageResult.page.filter((ticket) => matchesTicketSearch(ticket, searchTerm)) : pageResult.page
|
let page = pageResult.page
|
||||||
|
if (normalizedRequesterEmail) {
|
||||||
|
page = page.filter((ticket) => matchesRequesterEmail(ticket, normalizedRequesterEmail))
|
||||||
|
}
|
||||||
|
if (searchTerm) {
|
||||||
|
page = page.filter((ticket) => matchesTicketSearch(ticket, searchTerm))
|
||||||
|
}
|
||||||
const queueCache = new Map<string, Doc<"queues"> | null>()
|
const queueCache = new Map<string, Doc<"queues"> | null>()
|
||||||
const items = await Promise.all(
|
const items = await Promise.all(
|
||||||
page.map(async (ticket) => {
|
page.map(async (ticket) => {
|
||||||
|
|
@ -1448,6 +1466,7 @@ export const listTicketsHistory = query({
|
||||||
machineId: v.id("machines"),
|
machineId: v.id("machines"),
|
||||||
status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))),
|
status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))),
|
||||||
priority: v.optional(v.string()),
|
priority: v.optional(v.string()),
|
||||||
|
requesterEmail: v.optional(v.string()),
|
||||||
search: v.optional(v.string()),
|
search: v.optional(v.string()),
|
||||||
from: v.optional(v.number()),
|
from: v.optional(v.number()),
|
||||||
to: v.optional(v.number()),
|
to: v.optional(v.number()),
|
||||||
|
|
@ -1467,12 +1486,14 @@ export async function getTicketsHistoryStatsHandler(
|
||||||
|
|
||||||
const normalizedStatusFilter = args.status ?? "all"
|
const normalizedStatusFilter = args.status ?? "all"
|
||||||
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null
|
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null
|
||||||
|
const normalizedRequesterEmail = args.requesterEmail?.trim().toLowerCase() ?? null
|
||||||
const searchTerm = args.search?.trim().toLowerCase() ?? ""
|
const searchTerm = args.search?.trim().toLowerCase() ?? ""
|
||||||
const from = typeof args.from === "number" ? args.from : null
|
const from = typeof args.from === "number" ? args.from : null
|
||||||
const to = typeof args.to === "number" ? args.to : null
|
const to = typeof args.to === "number" ? args.to : null
|
||||||
const filters: MachineTicketsHistoryFilter = {
|
const filters: MachineTicketsHistoryFilter = {
|
||||||
statusFilter: normalizedStatusFilter,
|
statusFilter: normalizedStatusFilter,
|
||||||
priorityFilter: normalizedPriorityFilter,
|
priorityFilter: normalizedPriorityFilter,
|
||||||
|
requesterEmail: normalizedRequesterEmail,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
}
|
}
|
||||||
|
|
@ -1487,7 +1508,13 @@ export async function getTicketsHistoryStatsHandler(
|
||||||
numItems: MACHINE_TICKETS_STATS_PAGE_SIZE,
|
numItems: MACHINE_TICKETS_STATS_PAGE_SIZE,
|
||||||
cursor,
|
cursor,
|
||||||
})
|
})
|
||||||
const page = searchTerm ? pageResult.page.filter((ticket) => matchesTicketSearch(ticket, searchTerm)) : pageResult.page
|
let page = pageResult.page
|
||||||
|
if (normalizedRequesterEmail) {
|
||||||
|
page = page.filter((ticket) => matchesRequesterEmail(ticket, normalizedRequesterEmail))
|
||||||
|
}
|
||||||
|
if (searchTerm) {
|
||||||
|
page = page.filter((ticket) => matchesTicketSearch(ticket, searchTerm))
|
||||||
|
}
|
||||||
total += page.length
|
total += page.length
|
||||||
for (const ticket of page) {
|
for (const ticket of page) {
|
||||||
if (OPEN_TICKET_STATUSES.has(normalizeStatus(ticket.status))) {
|
if (OPEN_TICKET_STATUSES.has(normalizeStatus(ticket.status))) {
|
||||||
|
|
@ -1513,6 +1540,7 @@ export const getTicketsHistoryStats = query({
|
||||||
machineId: v.id("machines"),
|
machineId: v.id("machines"),
|
||||||
status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))),
|
status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))),
|
||||||
priority: v.optional(v.string()),
|
priority: v.optional(v.string()),
|
||||||
|
requesterEmail: v.optional(v.string()),
|
||||||
search: v.optional(v.string()),
|
search: v.optional(v.string()),
|
||||||
from: v.optional(v.number()),
|
from: v.optional(v.number()),
|
||||||
to: v.optional(v.number()),
|
to: v.optional(v.number()),
|
||||||
|
|
@ -1520,6 +1548,44 @@ export const getTicketsHistoryStats = query({
|
||||||
handler: getTicketsHistoryStatsHandler,
|
handler: getTicketsHistoryStatsHandler,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Lista os solicitantes unicos que abriram tickets nesta maquina
|
||||||
|
export const listMachineRequesters = query({
|
||||||
|
args: {
|
||||||
|
machineId: v.id("machines"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const machine = await ctx.db.get(args.machineId)
|
||||||
|
if (!machine) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickets = await ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_tenant_machine", (q) => q.eq("tenantId", machine.tenantId).eq("machineId", args.machineId))
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
const requestersMap = new Map<string, { email: string; name: string | null }>()
|
||||||
|
for (const ticket of tickets) {
|
||||||
|
const snapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined
|
||||||
|
if (snapshot?.email) {
|
||||||
|
const emailLower = snapshot.email.toLowerCase()
|
||||||
|
if (!requestersMap.has(emailLower)) {
|
||||||
|
requestersMap.set(emailLower, {
|
||||||
|
email: snapshot.email,
|
||||||
|
name: snapshot.name ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(requestersMap.values()).sort((a, b) => {
|
||||||
|
const nameA = a.name ?? a.email
|
||||||
|
const nameB = b.name ?? b.email
|
||||||
|
return nameA.localeCompare(nameB)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export async function updatePersonaHandler(
|
export async function updatePersonaHandler(
|
||||||
ctx: MutationCtx,
|
ctx: MutationCtx,
|
||||||
args: {
|
args: {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ type DeviceTicketsHistoryArgs = {
|
||||||
machineId: Id<"machines">
|
machineId: Id<"machines">
|
||||||
status?: "open" | "resolved"
|
status?: "open" | "resolved"
|
||||||
priority?: string
|
priority?: string
|
||||||
|
requesterEmail?: string
|
||||||
search?: string
|
search?: string
|
||||||
from?: number
|
from?: number
|
||||||
to?: number
|
to?: number
|
||||||
|
|
@ -146,12 +147,15 @@ function getPriorityMeta(priority: TicketPriority | string | null | undefined) {
|
||||||
export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: { tenantId: string; deviceId: string }) {
|
export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: { tenantId: string; deviceId: string }) {
|
||||||
const [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all")
|
const [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all")
|
||||||
const [priorityFilter, setPriorityFilter] = useState<string>("ALL")
|
const [priorityFilter, setPriorityFilter] = useState<string>("ALL")
|
||||||
|
const [requesterFilter, setRequesterFilter] = useState<string>("ALL")
|
||||||
const [periodPreset, setPeriodPreset] = useState<PeriodPreset>("90d")
|
const [periodPreset, setPeriodPreset] = useState<PeriodPreset>("90d")
|
||||||
const [customFrom, setCustomFrom] = useState<string>("")
|
const [customFrom, setCustomFrom] = useState<string>("")
|
||||||
const [customTo, setCustomTo] = useState<string>("")
|
const [customTo, setCustomTo] = useState<string>("")
|
||||||
const [searchValue, setSearchValue] = useState<string>("")
|
const [searchValue, setSearchValue] = useState<string>("")
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState<string>("")
|
const [debouncedSearch, setDebouncedSearch] = useState<string>("")
|
||||||
|
|
||||||
|
const requesters = useQuery(api.devices.listMachineRequesters, { machineId: deviceId as Id<"machines"> })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDebouncedSearch(searchValue.trim())
|
setDebouncedSearch(searchValue.trim())
|
||||||
|
|
@ -178,6 +182,9 @@ export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: {
|
||||||
if (priorityFilter !== "ALL") {
|
if (priorityFilter !== "ALL") {
|
||||||
args.priority = priorityFilter
|
args.priority = priorityFilter
|
||||||
}
|
}
|
||||||
|
if (requesterFilter !== "ALL") {
|
||||||
|
args.requesterEmail = requesterFilter
|
||||||
|
}
|
||||||
if (debouncedSearch) {
|
if (debouncedSearch) {
|
||||||
args.search = debouncedSearch
|
args.search = debouncedSearch
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +195,7 @@ export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: {
|
||||||
args.to = range.to
|
args.to = range.to
|
||||||
}
|
}
|
||||||
return args
|
return args
|
||||||
}, [debouncedSearch, deviceId, priorityFilter, range.from, range.to, statusFilter])
|
}, [debouncedSearch, deviceId, priorityFilter, requesterFilter, range.from, range.to, statusFilter])
|
||||||
|
|
||||||
const { results: tickets, status: paginationStatus, loadMore } = usePaginatedQuery(
|
const { results: tickets, status: paginationStatus, loadMore } = usePaginatedQuery(
|
||||||
api.devices.listTicketsHistory,
|
api.devices.listTicketsHistory,
|
||||||
|
|
@ -208,6 +215,7 @@ export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: {
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setStatusFilter("all")
|
setStatusFilter("all")
|
||||||
setPriorityFilter("ALL")
|
setPriorityFilter("ALL")
|
||||||
|
setRequesterFilter("ALL")
|
||||||
setPeriodPreset("90d")
|
setPeriodPreset("90d")
|
||||||
setCustomFrom("")
|
setCustomFrom("")
|
||||||
setCustomTo("")
|
setCustomTo("")
|
||||||
|
|
@ -271,6 +279,19 @@ export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: {
|
||||||
<SelectItem value="LOW">Baixa</SelectItem>
|
<SelectItem value="LOW">Baixa</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Select value={requesterFilter} onValueChange={(value) => setRequesterFilter(value)}>
|
||||||
|
<SelectTrigger className="sm:w-[200px]">
|
||||||
|
<SelectValue placeholder="Solicitante" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ALL">Todos os solicitantes</SelectItem>
|
||||||
|
{requesters?.map((requester: { email: string; name: string | null }) => (
|
||||||
|
<SelectItem key={requester.email} value={requester.email}>
|
||||||
|
{requester.name ?? requester.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<Select value={periodPreset} onValueChange={(value) => setPeriodPreset(value as PeriodPreset)}>
|
<Select value={periodPreset} onValueChange={(value) => setPeriodPreset(value as PeriodPreset)}>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue