diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index d860e1e..5d74ff6 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -1,3 +1,19 @@ fn main() { - tauri_build::build() + let windows = tauri_build::WindowsAttributes::new().app_manifest( + r#" + + + + + + + + + +"#, + ); + + let attrs = tauri_build::Attributes::new().windows_attributes(windows); + + tauri_build::try_build(attrs).expect("failed to run Tauri build script"); } diff --git a/convex/reports.ts b/convex/reports.ts index 76e0aaf..47e9381 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -1317,6 +1317,150 @@ export const ticketsByChannel = query({ handler: ticketsByChannelHandler, }); +type MachineCategoryDailyEntry = { + date: string + machineId: Id<"machines"> | null + machineHostname: string | null + companyId: Id<"companies"> | null + companyName: string | null + categoryId: Id<"ticketCategories"> | null + categoryName: string + total: number +} + +export async function ticketsByMachineAndCategoryHandler( + ctx: QueryCtx, + { + tenantId, + viewerId, + range, + companyId, + }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } +) { + const viewer = await requireStaff(ctx, viewerId, tenantId) + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 + + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - days * ONE_DAY_MS + + const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) + const categoriesMap = await fetchCategoryMap(ctx, tenantId) + + const companyIds = new Set>() + for (const ticket of tickets) { + if (ticket.companyId) { + companyIds.add(ticket.companyId) + } + } + + const companiesById = new Map | null>() + await Promise.all( + Array.from(companyIds).map(async (id) => { + const doc = await ctx.db.get(id) + companiesById.set(String(id), doc ?? null) + }) + ) + + const aggregated = new Map() + + for (const ticket of tickets) { + const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null + if (createdAt === null || createdAt < startMs || createdAt >= endMs) continue + + const hasMachine = Boolean(ticket.machineId) || Boolean(ticket.machineSnapshot) + if (!hasMachine) continue + + const date = formatDateKey(createdAt) + const machineId = (ticket.machineId ?? null) as Id<"machines"> | null + const machineSnapshot = (ticket.machineSnapshot ?? null) as + | { + hostname?: string | null + } + | null + const rawHostname = + typeof machineSnapshot?.hostname === "string" && machineSnapshot.hostname.trim().length > 0 + ? machineSnapshot.hostname.trim() + : null + const machineHostname = rawHostname ?? null + + const snapshot = (ticket.slaSnapshot ?? null) as { categoryId?: Id<"ticketCategories">; categoryName?: string } | null + const rawCategoryId = + ticket.categoryId && typeof ticket.categoryId === "string" + ? String(ticket.categoryId) + : snapshot?.categoryId + ? String(snapshot.categoryId) + : null + const categoryName = resolveCategoryName(rawCategoryId, snapshot, categoriesMap) + + const companyIdValue = (ticket.companyId ?? null) as Id<"companies"> | null + let companyName: string | null = null + if (companyIdValue) { + const company = companiesById.get(String(companyIdValue)) + if (company?.name && company.name.trim().length > 0) { + companyName = company.name.trim() + } + } + if (!companyName) { + const companySnapshot = (ticket.companySnapshot ?? null) as { name?: string | null } | null + if (companySnapshot?.name && companySnapshot.name.trim().length > 0) { + companyName = companySnapshot.name.trim() + } + } + if (!companyName) { + companyName = "Sem empresa" + } + + const key = [ + date, + machineId ? String(machineId) : "null", + machineHostname ?? "", + rawCategoryId ?? "uncategorized", + companyIdValue ? String(companyIdValue) : "null", + ].join("|") + + const existing = aggregated.get(key) + if (existing) { + existing.total += 1 + } else { + aggregated.set(key, { + date, + machineId, + machineHostname, + companyId: companyIdValue, + companyName, + categoryId: (rawCategoryId as Id<"ticketCategories"> | null) ?? null, + categoryName, + total: 1, + }) + } + } + + const items = Array.from(aggregated.values()).sort((a, b) => { + if (a.date !== b.date) return a.date.localeCompare(b.date) + const machineA = (a.machineHostname ?? "").toLowerCase() + const machineB = (b.machineHostname ?? "").toLowerCase() + if (machineA !== machineB) return machineA.localeCompare(machineB) + return a.categoryName.localeCompare(b.categoryName, "pt-BR") + }) + + return { + rangeDays: days, + items, + } +} + +export const ticketsByMachineAndCategory = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + range: v.optional(v.string()), + companyId: v.optional(v.id("companies")), + }, + handler: ticketsByMachineAndCategoryHandler, +}) + export async function hoursByClientHandler( ctx: QueryCtx, { tenantId, viewerId, range }: { tenantId: string; viewerId: Id<"users">; range?: string } diff --git a/convex/tickets.ts b/convex/tickets.ts index 580ac89..5c87caa 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -75,6 +75,7 @@ const MAX_SUMMARY_CHARS = 600; const MAX_COMMENT_CHARS = 20000; const DEFAULT_REOPEN_DAYS = 7; const MAX_REOPEN_DAYS = 14; +const VISIT_QUEUE_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"]; type AnyCtx = QueryCtx | MutationCtx; @@ -2013,6 +2014,7 @@ export const create = mutation({ ), formTemplate: v.optional(v.string()), chatEnabled: v.optional(v.boolean()), + visitDate: v.optional(v.number()), }, handler: async (ctx, args) => { const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId) @@ -2151,6 +2153,23 @@ export const create = mutation({ } const slaFields = applySlaSnapshot(slaSnapshot, now) + + let resolvedQueueDoc: Doc<"queues"> | null = null + if (resolvedQueueId) { + const queueDoc = await ctx.db.get(resolvedQueueId) + if (queueDoc && queueDoc.tenantId === args.tenantId) { + resolvedQueueDoc = queueDoc as Doc<"queues"> + } + } + + const queueLabel = (resolvedQueueDoc?.slug ?? resolvedQueueDoc?.name ?? "").toLowerCase() + const isVisitQueue = VISIT_QUEUE_KEYWORDS.some((keyword) => queueLabel.includes(keyword)) + const visitDueAt = + typeof args.visitDate === "number" && Number.isFinite(args.visitDate) ? args.visitDate : null + + if (isVisitQueue && !visitDueAt) { + throw new ConvexError("Informe a data da visita para tickets da fila de visitas") + } const id = await ctx.db.insert("tickets", { tenantId: args.tenantId, reference: nextRef, @@ -2191,7 +2210,7 @@ export const create = mutation({ closedAt: undefined, tags: [], slaPolicyId: undefined, - dueAt: undefined, + dueAt: visitDueAt && isVisitQueue ? visitDueAt : undefined, customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined, ...slaFields, }); diff --git a/src/app/admin/report-templates/page.tsx b/src/app/admin/report-templates/page.tsx new file mode 100644 index 0000000..5a1c6a6 --- /dev/null +++ b/src/app/admin/report-templates/page.tsx @@ -0,0 +1,26 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { ReportTemplatesManager } from "@/components/admin/reports/report-templates-manager" +import { requireAdminSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" + +export default async function AdminReportTemplatesPage() { + const session = await requireAdminSession() + + return ( + + } + > +
+ +
+
+ ) +} + diff --git a/src/app/reports/machines/page.tsx b/src/app/reports/machines/page.tsx new file mode 100644 index 0000000..2661f51 --- /dev/null +++ b/src/app/reports/machines/page.tsx @@ -0,0 +1,26 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { MachineCategoryReport } from "@/components/reports/machine-category-report" +import { requireAuthenticatedSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" + +export default async function ReportsMachinesPage() { + await requireAuthenticatedSession() + + return ( + + } + > +
+ +
+
+ ) +} + diff --git a/src/app/settings/templates/page.tsx b/src/app/settings/templates/page.tsx index 5d76883..12329ef 100644 --- a/src/app/settings/templates/page.tsx +++ b/src/app/settings/templates/page.tsx @@ -18,7 +18,9 @@ export default async function CommentTemplatesPage() { /> } > - +
+ +
) } diff --git a/src/components/admin/devices/admin-devices-overview.tsx b/src/components/admin/devices/admin-devices-overview.tsx index 60ea135..aac4093 100644 --- a/src/components/admin/devices/admin-devices-overview.tsx +++ b/src/components/admin/devices/admin-devices-overview.tsx @@ -323,7 +323,7 @@ type DeviceInventory = { collaborator?: { email?: string; name?: string; role?: string } } -type DeviceRemoteAccessEntry = { +export type DeviceRemoteAccessEntry = { id: string | null clientId: string provider: string | null @@ -478,7 +478,7 @@ function readText(record: Record, ...keys: string[]): string | return undefined } -function isRustDeskAccess(entry: DeviceRemoteAccessEntry | null | undefined) { +export function isRustDeskAccess(entry: DeviceRemoteAccessEntry | null | undefined) { if (!entry) return false const provider = (entry.provider ?? entry.metadata?.provider ?? "").toString().toLowerCase() if (provider.includes("rustdesk")) return true @@ -486,7 +486,7 @@ function isRustDeskAccess(entry: DeviceRemoteAccessEntry | null | undefined) { return url.includes("rustdesk") } -function buildRustDeskUri(entry: DeviceRemoteAccessEntry) { +export function buildRustDeskUri(entry: DeviceRemoteAccessEntry) { const identifier = (entry.identifier ?? "").replace(/\s+/g, "") if (!identifier) return null const params = new URLSearchParams() diff --git a/src/components/admin/reports/report-templates-manager.tsx b/src/components/admin/reports/report-templates-manager.tsx new file mode 100644 index 0000000..8e6ccfb --- /dev/null +++ b/src/components/admin/reports/report-templates-manager.tsx @@ -0,0 +1,493 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useMutation, useQuery } from "convex/react" +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" + +import { useAuth } from "@/lib/auth-client" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { DEVICE_INVENTORY_COLUMN_METADATA, type DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" +import { Skeleton } from "@/components/ui/skeleton" +import { cn } from "@/lib/utils" +import { toast } from "sonner" + +type DeviceExportTemplateListItem = { + id: string + slug: string + name: string + description?: string + columns: DeviceInventoryColumnConfig[] + filters?: unknown | null + companyId: string | null + isDefault: boolean + isActive: boolean + createdAt: number + updatedAt: number + createdBy?: string | null + updatedBy?: string | null +} + +type DeviceFieldDef = { + id: string + key: string + label: string +} + +type ReportTemplatesManagerProps = { + tenantId?: string | null +} + +export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplatesManagerProps) { + const { convexUserId, session, isAdmin } = useAuth() + const tenantId = tenantIdProp ?? session?.user.tenantId ?? DEFAULT_TENANT_ID + + const [filterCompanyId, setFilterCompanyId] = useState("all") + const [selectedTemplateId, setSelectedTemplateId] = useState("new") + + const canLoad = Boolean(convexUserId && isAdmin) + + const templates = useQuery( + api.deviceExportTemplates.list, + canLoad + ? ({ + tenantId, + viewerId: convexUserId as Id<"users">, + companyId: filterCompanyId !== "all" ? (filterCompanyId as Id<"companies">) : undefined, + includeInactive: true, + } as const) + : "skip" + ) as DeviceExportTemplateListItem[] | undefined + + const deviceFields = useQuery( + api.deviceFields.listForTenant, + canLoad + ? ({ + tenantId, + viewerId: convexUserId as Id<"users">, + } as const) + : "skip" + ) as DeviceFieldDef[] | undefined + + const companies = useQuery( + api.companies.list, + canLoad + ? ({ + tenantId, + viewerId: convexUserId as Id<"users">, + } as const) + : "skip" + ) as Array<{ id: Id<"companies">; name: string }> | undefined + + const createTemplate = useMutation(api.deviceExportTemplates.create) + const updateTemplate = useMutation(api.deviceExportTemplates.update) + const removeTemplate = useMutation(api.deviceExportTemplates.remove) + + const companyOptions = useMemo(() => { + const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }] + if (!companies || companies.length === 0) return base + const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR")) + return [ + base[0], + ...sorted.map((company) => ({ + value: company.id, + label: company.name, + })), + ] + }, [companies]) + + const availableColumns = useMemo( + () => { + const base = DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => ({ + key: meta.key, + label: meta.label, + group: "base" as const, + })) + const custom: { key: string; label: string; group: "custom" }[] = + (deviceFields ?? []).map((field) => ({ + key: `custom:${field.key}`, + label: field.label, + group: "custom" as const, + })) + return [...base, ...custom].sort((a, b) => a.label.localeCompare(b.label, "pt-BR")) + }, + [deviceFields] + ) + + const templateOptions = useMemo( + () => (templates ?? []).slice().sort((a, b) => a.name.localeCompare(b.name, "pt-BR")), + [templates] + ) + + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [targetCompanyId, setTargetCompanyId] = useState("all") + const [isDefault, setIsDefault] = useState(false) + const [isActive, setIsActive] = useState(true) + const [selectedColumns, setSelectedColumns] = useState([]) + const [isSaving, setIsSaving] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + + const selectedTemplate = useMemo( + () => templateOptions.find((tpl) => tpl.id === selectedTemplateId) ?? null, + [templateOptions, selectedTemplateId] + ) + + useEffect(() => { + if (!templates) return + if (selectedTemplateId === "new") { + setName("") + setDescription("") + setTargetCompanyId("all") + setIsDefault(false) + setIsActive(true) + setSelectedColumns(DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map((meta) => meta.key)) + return + } + const tpl = templateOptions.find((t) => t.id === selectedTemplateId) + if (!tpl) { + setSelectedTemplateId("new") + return + } + setName(tpl.name) + setDescription(tpl.description ?? "") + setTargetCompanyId(tpl.companyId ?? "all") + setIsDefault(Boolean(tpl.isDefault)) + setIsActive(tpl.isActive !== false) + const columnKeys = (tpl.columns ?? []).map((col) => col.key) + setSelectedColumns(columnKeys.length > 0 ? columnKeys : []) + }, [templates, templateOptions, selectedTemplateId]) + + const handleToggleColumn = (key: string, checked: boolean) => { + setSelectedColumns((prev) => { + const set = new Set(prev) + if (checked) { + set.add(key) + } else { + set.delete(key) + } + return Array.from(set) + }) + } + + const handleSelectAll = () => { + setSelectedColumns(availableColumns.map((col) => col.key)) + } + + const handleClearColumns = () => { + setSelectedColumns([]) + } + + const handleSave = async () => { + if (!convexUserId) { + toast.error("Sincronize a sessão antes de salvar templates.") + return + } + const trimmedName = name.trim() + if (trimmedName.length < 3) { + toast.error("Informe um nome para o template (mínimo 3 caracteres).") + return + } + if (selectedColumns.length === 0) { + toast.error("Selecione ao menos uma coluna para o template.") + return + } + const columnsPayload: DeviceInventoryColumnConfig[] = selectedColumns.map((key) => { + const meta = availableColumns.find((col) => col.key === key) + return { key, label: meta?.label } + }) + const companyIdValue = + targetCompanyId !== "all" ? (targetCompanyId as unknown as Id<"companies">) : undefined + + try { + setIsSaving(true) + if (!selectedTemplate || selectedTemplateId === "new") { + await createTemplate({ + tenantId, + actorId: convexUserId as Id<"users">, + name: trimmedName, + description: description.trim() || undefined, + columns: columnsPayload, + filters: undefined, + companyId: companyIdValue, + isDefault, + isActive, + }) + } else { + await updateTemplate({ + tenantId, + actorId: convexUserId as Id<"users">, + templateId: selectedTemplate.id as Id<"deviceExportTemplates">, + name: trimmedName, + description: description.trim() || undefined, + columns: columnsPayload, + filters: selectedTemplate.filters ?? undefined, + companyId: companyIdValue, + isDefault, + isActive, + }) + } + toast.success("Template salvo com sucesso.") + if (selectedTemplateId === "new") { + setSelectedTemplateId("new") + } + } catch (error) { + console.error("[report-templates] Failed to save template", error) + toast.error("Não foi possível salvar o template.") + } finally { + setIsSaving(false) + } + } + + const handleDelete = async () => { + if (!convexUserId || !selectedTemplate) return + try { + setIsDeleting(true) + await removeTemplate({ + tenantId, + actorId: convexUserId as Id<"users">, + templateId: selectedTemplate.id as Id<"deviceExportTemplates">, + }) + toast.success("Template removido com sucesso.") + setSelectedTemplateId("new") + } catch (error) { + console.error("[report-templates] Failed to delete template", error) + toast.error("Não foi possível remover o template.") + } finally { + setIsDeleting(false) + } + } + + if (!canLoad) { + return ( + + + Templates de relatórios + + Apenas administradores podem gerenciar templates de exportação. + + + + ) + } + + if (!templates || !companies) { + return ( +
+ + +
+ ) + } + + return ( +
+ + + Templates existentes + + Configure templates globais ou por empresa para exportar inventário de dispositivos. + + + +
+ setFilterCompanyId(value ?? "all")} + options={companyOptions} + placeholder="Todas as empresas" + className="w-full min-w-56 md:w-64" + /> + +
+ {templateOptions.length === 0 ? ( +

+ Nenhum template cadastrado ainda. Crie um template para padronizar as colunas das exportações de inventário. +

+ ) : ( +
+ {templateOptions.map((tpl) => ( + + ))} +
+ )} +
+
+ + + + + {selectedTemplateId === "new" ? "Novo template" : "Editar template"} + + + Defina o conjunto de colunas e, opcionalmente, vincule o template a uma empresa específica. + + + +
+ + setName(event.target.value)} + placeholder="Ex.: Inventário padrão, Inventário reduzido..." + className="h-9 rounded-lg border-slate-300 text-sm" + /> +
+
+ +