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:
parent
a8333c010f
commit
06deb99bcd
12 changed files with 543 additions and 17 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue