feat(export,tickets,forms,emails):\n- Corrige scroll de Dialogs e melhora UI de seleção de colunas (ícones e separador)\n- Ajusta rota/params da exportação em massa e adiciona modal de exportação individual\n- Renomeia 'Chamado padrão' para 'Chamado' e garante visibilidade total para admin/agente\n- Adiciona toggles por empresa/usuário para habilitar Admissão/Desligamento\n- Exibe badge do tipo de solicitação na listagem e no cabeçalho do ticket\n- Prepara notificações por e-mail (comentário público e encerramento) via SMTP\n

This commit is contained in:
codex-bot 2025-11-04 13:41:32 -03:00
parent a8333c010f
commit 06deb99bcd
12 changed files with 543 additions and 17 deletions

View file

@ -80,7 +80,9 @@ import { Textarea } from "@/components/ui/textarea"
import { TimePicker } from "@/components/ui/time-picker"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { useQuery } from "convex/react"
import { useQuery, useMutation } from "convex/react"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
import { MultiValueInput } from "@/components/ui/multi-value-input"
import { AdminDevicesOverview } from "@/components/admin/devices/admin-devices-overview"
@ -1685,6 +1687,13 @@ function CompanySheet({ tenantId, editor, onClose, onCreated, onUpdated }: Compa
</AccordionContent>
</AccordionItem>
<AccordionItem value="requestTypes" className="rounded-lg border border-border/60 bg-muted/20 px-4">
<AccordionTrigger className="py-3 font-semibold">Tipos de solicitação</AccordionTrigger>
<AccordionContent className="pb-5">
<CompanyRequestTypesControls tenantId={tenantId} companyId={editor?.mode === "edit" ? editor.company.id : null} />
</AccordionContent>
</AccordionItem>
{editor?.mode === "edit" ? (
<AccordionItem value="machines" className="rounded-lg border border-border/60 bg-muted/20 px-4">
<AccordionTrigger className="py-3 font-semibold">Dispositivos vinculadas</AccordionTrigger>
@ -2173,3 +2182,69 @@ function BusinessHoursEditor({ form }: BusinessHoursEditorProps) {
</div>
)
}
type CompanyRequestTypesControlsProps = { tenantId?: string | null; companyId: string | null }
function CompanyRequestTypesControls({ tenantId, companyId }: CompanyRequestTypesControlsProps) {
const { convexUserId } = useAuth()
const canLoad = Boolean(tenantId && convexUserId)
const settings = useQuery(
api.ticketFormSettings.list,
canLoad ? { tenantId: tenantId as string, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ template: string; scope: string; companyId?: string | null; enabled: boolean; updatedAt: number }> | undefined
const upsert = useMutation(api.ticketFormSettings.upsert)
const resolveEnabled = (template: "admissao" | "desligamento") => {
const scoped = (settings ?? []).filter((s) => s.template === template)
const base = true
if (!companyId) return base
const latest = scoped
.filter((s) => s.scope === "company" && String(s.companyId ?? "") === String(companyId))
.sort((a, b) => b.updatedAt - a.updatedAt)[0]
return typeof latest?.enabled === "boolean" ? latest.enabled : base
}
const admissaoEnabled = resolveEnabled("admissao")
const desligamentoEnabled = resolveEnabled("desligamento")
const handleToggle = async (template: "admissao" | "desligamento", enabled: boolean) => {
if (!tenantId || !convexUserId || !companyId) return
try {
await upsert({
tenantId,
actorId: convexUserId as Id<"users">,
template,
scope: "company",
companyId: companyId as unknown as Id<"companies">,
enabled,
})
toast.success("Configuração salva.")
} catch (error) {
console.error("Falha ao salvar configuração de formulário", error)
toast.error("Não foi possível salvar a configuração.")
}
}
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">Defina quais tipos de solicitação estão disponíveis para colaboradores/gestores desta empresa. Administradores e agentes sempre veem todas as opções.</p>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={admissaoEnabled}
onCheckedChange={(v) => handleToggle("admissao", Boolean(v))}
disabled={!companyId}
/>
<span>Admissão de colaborador</span>
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={desligamentoEnabled}
onCheckedChange={(v) => handleToggle("desligamento", Boolean(v))}
disabled={!companyId}
/>
<span>Desligamento de colaborador</span>
</label>
</div>
</div>
)
}

View file

@ -22,6 +22,8 @@ import {
Lock,
Cloud,
RefreshCcw,
CheckSquare,
RotateCcw,
AlertTriangle,
Key,
Globe,
@ -1694,13 +1696,13 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
if (companyFilterSlug !== "all") {
params.set("companyId", companyFilterSlug)
}
orderedSelection.forEach((id) => params.append("deviceId", id))
orderedSelection.forEach((id) => params.append("machineId", id))
params.set("columns", JSON.stringify(normalizedColumns))
if (selectedTemplateId) {
params.set("templateId", selectedTemplateId)
}
const qs = params.toString()
const url = `/api/reports/devices-inventory.xlsx${qs ? `?${qs}` : ""}`
const url = `/api/reports/machines-inventory.xlsx${qs ? `?${qs}` : ""}`
const response = await fetch(url)
if (!response.ok) {
@ -2013,10 +2015,13 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-slate-900">Colunas</p>
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" onClick={handleSelectAllColumns} disabled={isExporting}>
<Button type="button" variant="ghost" size="sm" onClick={handleSelectAllColumns} disabled={isExporting} className="gap-2">
<CheckSquare className="size-4" />
Selecionar todas
</Button>
<Button type="button" variant="ghost" size="sm" onClick={handleResetColumns} disabled={isExporting}>
<Separator orientation="vertical" className="h-4" />
<Button type="button" variant="ghost" size="sm" onClick={handleResetColumns} disabled={isExporting} className="gap-2">
<RotateCcw className="size-4" />
Restaurar padrão
</Button>
</div>
@ -3304,6 +3309,95 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
}
}, [])
// Exportação individual (colunas personalizadas)
const [isSingleExportOpen, setIsSingleExportOpen] = useState(false)
const [singleExporting, setSingleExporting] = useState(false)
const [singleExportError, setSingleExportError] = useState<string | null>(null)
const [singleColumns, setSingleColumns] = useState<DeviceInventoryColumnConfig[]>([...DEFAULT_DEVICE_COLUMN_CONFIG])
const { convexUserId } = useAuth()
const deviceFieldDefs = useQuery(
api.deviceFields.listForTenant,
convexUserId && device ? { tenantId: device.tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: string; key: string; label: string }> | undefined
const baseColumnOptionsSingle = useMemo(
() => DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => ({ key: meta.key, label: meta.label })),
[]
)
const customColumnOptionsSingle = useMemo(() => {
const map = new Map<string, { key: string; label: string }>()
;(deviceFieldDefs ?? []).forEach((field) => map.set(field.key, { key: field.key, label: field.label }))
;(device?.customFields ?? []).forEach((field) => {
if (!map.has(field.fieldKey)) map.set(field.fieldKey, { key: field.fieldKey, label: field.label })
})
return Array.from(map.values())
.map((f) => ({ key: `custom:${f.key}`, label: f.label }))
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
}, [deviceFieldDefs, device?.customFields])
const singleCustomOrder = useMemo(() => customColumnOptionsSingle.map((c) => c.key), [customColumnOptionsSingle])
useEffect(() => {
setSingleColumns((prev) => orderColumnConfig(prev, singleCustomOrder))
}, [singleCustomOrder])
const toggleSingleColumn = useCallback((key: string, checked: boolean, label?: string) => {
setSingleColumns((prev) => {
const filtered = prev.filter((col) => col.key !== key)
if (checked) return orderColumnConfig([...filtered, { key, label }], singleCustomOrder)
return filtered
})
}, [singleCustomOrder])
const resetSingleColumns = useCallback(() => {
setSingleColumns(orderColumnConfig([...DEFAULT_DEVICE_COLUMN_CONFIG], singleCustomOrder))
}, [singleCustomOrder])
const selectAllSingleColumns = useCallback(() => {
const all: DeviceInventoryColumnConfig[] = [
...baseColumnOptionsSingle.map((c) => ({ key: c.key })),
...customColumnOptionsSingle.map((c) => ({ key: c.key, label: c.label })),
]
setSingleColumns(orderColumnConfig(all, singleCustomOrder))
}, [baseColumnOptionsSingle, customColumnOptionsSingle, singleCustomOrder])
const handleExportSingle = useCallback(async () => {
if (!device) return
const normalized = orderColumnConfig(singleColumns, singleCustomOrder)
if (normalized.length === 0) {
toast.info("Selecione ao menos uma coluna para exportar.")
return
}
setSingleExporting(true)
setSingleExportError(null)
try {
const params = new URLSearchParams()
params.append("machineId", device.id)
params.set("columns", JSON.stringify(normalized))
const url = `/api/reports/machines-inventory.xlsx?${params.toString()}`
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const disposition = response.headers.get("Content-Disposition")
const filenameMatch = disposition?.match(/filename="?([^";]+)"?/i)
const filename = filenameMatch?.[1] ?? `machine-inventory-${device.hostname}.xlsx`
const blob = await response.blob()
const downloadUrl = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = downloadUrl
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(downloadUrl)
setIsSingleExportOpen(false)
} catch (err) {
console.error("Falha na exportação individual", err)
setSingleExportError("Não foi possível gerar a planilha. Tente novamente.")
} finally {
setSingleExporting(false)
}
}, [device, singleColumns, singleCustomOrder])
return (
<Card className="border-slate-200">
<CardHeader className="gap-1">
@ -4969,13 +5063,11 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
<div className="flex flex-wrap items-center justify-end gap-2">
{device ? (
<Button size="sm" variant="outline" asChild className="inline-flex items-center gap-2">
<a href={`/api/admin/devices/${device.id}/inventory.xlsx`} download>
<Download className="size-4" /> Exportar planilha
</a>
</Button>
) : null}
{device ? (
<Button size="sm" variant="outline" className="inline-flex items-center gap-2" onClick={() => setIsSingleExportOpen(true)}>
<Download className="size-4" /> Exportar planilha
</Button>
) : null}
<DialogTrigger asChild>
<Button size="sm" variant="outline" onClick={() => setOpenDialog(true)}>Inventário completo</Button>
</DialogTrigger>
@ -5021,6 +5113,83 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</DialogContent>
</Dialog>
{/* Exportação individual: seleção de colunas */}
<Dialog open={isSingleExportOpen} onOpenChange={(open) => (!singleExporting ? setIsSingleExportOpen(open) : null)}>
<DialogContent className="max-w-2xl space-y-4">
<DialogHeader>
<DialogTitle>Exportar planilha {device.displayName ?? device.hostname ?? "Dispositivo"}</DialogTitle>
<DialogDescription>Escolha as colunas a incluir na exportação.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900">Colunas</p>
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" onClick={selectAllSingleColumns} disabled={singleExporting} className="gap-2">
<CheckSquare className="size-4" /> Selecionar todas
</Button>
<Separator orientation="vertical" className="h-4" />
<Button type="button" variant="ghost" size="sm" onClick={resetSingleColumns} disabled={singleExporting} className="gap-2">
<RotateCcw className="size-4" /> Restaurar padrão
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{baseColumnOptionsSingle.map((col) => {
const checked = singleColumns.some((c) => c.key === col.key)
return (
<label key={col.key} className="flex items-center gap-2 text-sm text-slate-700">
<Checkbox
checked={checked}
onCheckedChange={(value) => toggleSingleColumn(col.key, value === true || value === "indeterminate")}
disabled={singleExporting}
/>
<span>{col.label}</span>
</label>
)
})}
</div>
{customColumnOptionsSingle.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-slate-500">Campos personalizados</p>
<div className="grid gap-3 sm:grid-cols-2">
{customColumnOptionsSingle.map((col) => {
const checked = singleColumns.some((c) => c.key === col.key)
return (
<label key={col.key} className="flex items-center gap-2 text-sm text-slate-700">
<Checkbox
checked={checked}
onCheckedChange={(value) => toggleSingleColumn(col.key, value === true || value === "indeterminate", col.label)}
disabled={singleExporting}
/>
<span>{col.label}</span>
</label>
)
})}
</div>
</div>
) : null}
<p className="text-xs text-slate-500">{singleColumns.length} coluna{singleColumns.length === 1 ? "" : "s"} selecionada{singleColumns.length === 1 ? "" : "s"}.</p>
</div>
{singleExportError ? <p className="text-sm text-destructive">{singleExportError}</p> : null}
<DialogFooter className="gap-2 sm:gap-2">
<Button type="button" variant="outline" onClick={() => setIsSingleExportOpen(false)} disabled={singleExporting}>
Cancelar
</Button>
<Button type="button" onClick={handleExportSingle} disabled={singleExporting} className="gap-2">
{singleExporting ? (
<>
<Spinner className="mr-2 size-4" /> Exportando...
</>
) : (
<>
<Download className="size-4" /> Exportar
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteDialog} onOpenChange={(open) => { if (!open) setDeleting(false); setDeleteDialog(open) }}>
<DialogContent>
<DialogHeader>

View file

@ -22,6 +22,10 @@ import {
IconUserOff,
} from "@tabler/icons-react"
import { toast } from "sonner"
import { useQuery, useMutation } from "convex/react"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
import {
COMPANY_CONTACT_ROLES,
@ -255,6 +259,41 @@ function AccountsTable({
[accounts, editAccountId]
)
// Tipos de solicitação por usuário
const { convexUserId } = useAuth()
const formSettings = useQuery(
api.ticketFormSettings.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ template: string; scope: string; userId?: string | null; enabled: boolean; updatedAt: number }> | undefined
const upsertFormSetting = useMutation(api.ticketFormSettings.upsert)
const resolveUserFormEnabled = useCallback((template: "admissao" | "desligamento") => {
if (!editAccount) return true
const scoped = (formSettings ?? []).filter((s) => s.template === template)
const latest = scoped
.filter((s) => s.scope === "user" && String(s.userId ?? "") === String(editAccount.id))
.sort((a, b) => b.updatedAt - a.updatedAt)[0]
return typeof latest?.enabled === "boolean" ? latest.enabled : true
}, [formSettings, editAccount])
const handleToggleUserForm = useCallback(async (template: "admissao" | "desligamento", enabled: boolean) => {
if (!convexUserId || !editAccount) return
try {
await upsertFormSetting({
tenantId,
actorId: convexUserId as Id<"users">,
template,
scope: "user",
userId: editAccount.id as unknown as Id<"users">,
enabled,
})
toast.success("Configuração salva.")
} catch (error) {
console.error("Falha ao salvar configuração de formulário por usuário", error)
toast.error("Não foi possível salvar a configuração.")
}
}, [tenantId, convexUserId, editAccount, upsertFormSetting])
const roleSelectOptions = useMemo<SearchableComboboxOption[]>(
() => ROLE_OPTIONS_DISPLAY.map((option) => ({ value: option.value, label: option.label })),
[],
@ -976,6 +1015,29 @@ function AccountsTable({
disabled={isSavingAccount}
/>
</div>
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
<p className="text-sm font-semibold text-foreground">Tipos de solicitação</p>
<p className="mb-2 text-xs text-muted-foreground">Disponíveis para este colaborador/gestor no portal. Administradores e agentes sempre veem todas as opções.</p>
<div className="grid gap-2 sm:grid-cols-2">
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={resolveUserFormEnabled("admissao")}
onCheckedChange={(v) => handleToggleUserForm("admissao", Boolean(v))}
disabled={!editAccount || isSavingAccount}
/>
<span>Admissão de colaborador</span>
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={resolveUserFormEnabled("desligamento")}
onCheckedChange={(v) => handleToggleUserForm("desligamento", Boolean(v))}
disabled={!editAccount || isSavingAccount}
/>
<span>Desligamento de colaborador</span>
</label>
</div>
</div>
</div>
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">

View file

@ -56,7 +56,7 @@ export function PortalTicketForm() {
const forms = useMemo<TicketFormDefinition[]>(() => {
const base: TicketFormDefinition = {
key: "default",
label: "Chamado padrão",
label: "Chamado",
description: "Formulário básico para solicitações gerais.",
fields: [],
}

View file

@ -188,7 +188,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const forms = useMemo<TicketFormDefinition[]>(() => {
const base: TicketFormDefinition = {
key: "default",
label: "Chamado padrão",
label: "Chamado",
description: "Formulário básico para abertura de chamados gerais.",
fields: [],
}

View file

@ -1245,7 +1245,18 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</div>
) : (
<div className="space-y-1">
<h1 className="break-words text-2xl font-semibold text-neutral-900">{subject}</h1>
<div className="flex flex-wrap items-center gap-2">
<h1 className="break-words text-2xl font-semibold text-neutral-900">{subject}</h1>
{ticket.formTemplate ? (
<span className="inline-flex items-center rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-xs font-semibold text-sky-700">
{ticket.formTemplate === "admissao"
? "Admissão"
: ticket.formTemplate === "desligamento"
? "Desligamento"
: "Chamado"}
</span>
) : null}
</div>
{summary ? <p className="max-w-2xl text-sm text-neutral-600">{summary}</p> : null}
</div>
)}

View file

@ -221,6 +221,17 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
<span className="text-sm text-neutral-600 line-clamp-1 break-words">
{ticket.summary ?? "Sem resumo"}
</span>
{ticket.formTemplate ? (
<div className="flex items-center gap-2">
<Badge className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700">
{ticket.formTemplate === "admissao"
? "Admissão"
: ticket.formTemplate === "desligamento"
? "Desligamento"
: "Chamado"}
</Badge>
</div>
) : null}
<div className="flex flex-col gap-1 text-xs text-neutral-500">
{ticket.category ? (
<Badge className={categoryChipClass}>

View file

@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[95vw] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border bg-popover p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed left-[50%] top-[50%] z-50 grid w-[95vw] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border bg-popover p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 max-h-[85vh] overflow-y-auto",
className
)}
{...props}