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
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
|
|
@ -31,6 +31,7 @@ import type * as seed from "../seed.js";
|
|||
import type * as slas from "../slas.js";
|
||||
import type * as teams from "../teams.js";
|
||||
import type * as ticketFormSettings from "../ticketFormSettings.js";
|
||||
import type * as ticketNotifications from "../ticketNotifications.js";
|
||||
import type * as tickets from "../tickets.js";
|
||||
import type * as users from "../users.js";
|
||||
|
||||
|
|
@ -72,6 +73,7 @@ declare const fullApi: ApiFromModules<{
|
|||
slas: typeof slas;
|
||||
teams: typeof teams;
|
||||
ticketFormSettings: typeof ticketFormSettings;
|
||||
ticketNotifications: typeof ticketNotifications;
|
||||
tickets: typeof tickets;
|
||||
users: typeof users;
|
||||
}>;
|
||||
|
|
|
|||
159
convex/ticketNotifications.ts
Normal file
159
convex/ticketNotifications.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"use node"
|
||||
|
||||
import tls from "tls"
|
||||
import { action } from "./_generated/server"
|
||||
import { v } from "convex/values"
|
||||
|
||||
function b64(input: string) {
|
||||
return Buffer.from(input, "utf8").toString("base64")
|
||||
}
|
||||
|
||||
async function sendSmtpMail(cfg: { host: string; port: number; username: string; password: string; from: string }, to: string, subject: string, html: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: false }, () => {
|
||||
let buffer = ""
|
||||
const send = (line: string) => socket.write(line + "\r\n")
|
||||
const wait = (expected: string | RegExp) =>
|
||||
new Promise<void>((res) => {
|
||||
const onData = (data: Buffer) => {
|
||||
buffer += data.toString()
|
||||
const lines = buffer.split(/\r?\n/)
|
||||
const last = lines.filter(Boolean).slice(-1)[0] ?? ""
|
||||
if (typeof expected === "string" ? last.startsWith(expected) : expected.test(last)) {
|
||||
socket.removeListener("data", onData)
|
||||
res()
|
||||
}
|
||||
}
|
||||
socket.on("data", onData)
|
||||
socket.on("error", reject)
|
||||
})
|
||||
|
||||
;(async () => {
|
||||
await wait(/^220 /)
|
||||
send(`EHLO ${cfg.host}`)
|
||||
await wait(/^250-/)
|
||||
await wait(/^250 /)
|
||||
send("AUTH LOGIN")
|
||||
await wait(/^334 /)
|
||||
send(b64(cfg.username))
|
||||
await wait(/^334 /)
|
||||
send(b64(cfg.password))
|
||||
await wait(/^235 /)
|
||||
send(`MAIL FROM:<${cfg.from.match(/<(.+)>/)?.[1] ?? cfg.from}>`)
|
||||
await wait(/^250 /)
|
||||
send(`RCPT TO:<${to}>`)
|
||||
await wait(/^250 /)
|
||||
send("DATA")
|
||||
await wait(/^354 /)
|
||||
const headers = [
|
||||
`From: ${cfg.from}`,
|
||||
`To: ${to}`,
|
||||
`Subject: ${subject}`,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
].join("\r\n")
|
||||
send(headers + "\r\n\r\n" + html + "\r\n.")
|
||||
await wait(/^250 /)
|
||||
send("QUIT")
|
||||
socket.end()
|
||||
resolve()
|
||||
})().catch(reject)
|
||||
})
|
||||
socket.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function buildBaseUrl() {
|
||||
return process.env.NEXT_PUBLIC_APP_URL || process.env.APP_BASE_URL || "http://localhost:3000"
|
||||
}
|
||||
|
||||
function emailTemplate({ title, message, ctaLabel, ctaUrl }: { title: string; message: string; ctaLabel: string; ctaUrl: string }) {
|
||||
return `
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f8fafc;padding:24px 0;font-family:Arial,Helvetica,sans-serif;color:#0f172a;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;">
|
||||
<tr>
|
||||
<td style="text-align:left;">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<img src="${buildBaseUrl()}/logo-raven.png" alt="Raven" style="width:36px;height:36px;border-radius:8px;"/>
|
||||
<span style="font-weight:700;font-size:18px;">Raven</span>
|
||||
</div>
|
||||
<h1 style="font-size:20px;line-height:1.3;margin:16px 0 8px 0;">${title}</h1>
|
||||
<p style="font-size:14px;line-height:1.6;margin:0 0 16px 0;color:#334155;">${message}</p>
|
||||
<a href="${ctaUrl}" style="display:inline-block;background:#111827;color:#fff;text-decoration:none;border-radius:10px;padding:10px 16px;font-weight:600;">${ctaLabel}</a>
|
||||
<p style="font-size:12px;color:#64748b;margin-top:20px;">Se o botão não funcionar, copie e cole esta URL no navegador:<br/><a href="${ctaUrl}" style="color:#0ea5e9;text-decoration:none;">${ctaUrl}</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="font-size:12px;color:#94a3b8;margin-top:12px;">© ${new Date().getFullYear()} Raven — Rever Tecnologia</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`
|
||||
}
|
||||
|
||||
export const sendPublicCommentEmail = action({
|
||||
args: {
|
||||
to: v.string(),
|
||||
ticketId: v.string(),
|
||||
reference: v.number(),
|
||||
subject: v.string(),
|
||||
},
|
||||
handler: async (_ctx, { to, ticketId, reference, subject }) => {
|
||||
const smtp = {
|
||||
host: process.env.SMTP_ADDRESS!,
|
||||
port: Number(process.env.SMTP_PORT ?? 465),
|
||||
username: process.env.SMTP_USERNAME!,
|
||||
password: process.env.SMTP_PASSWORD!,
|
||||
from: process.env.MAILER_SENDER_EMAIL || "Raven <no-reply@example.com>",
|
||||
}
|
||||
if (!smtp.host || !smtp.username || !smtp.password) {
|
||||
console.warn("SMTP not configured; skipping ticket comment email")
|
||||
return { skipped: true }
|
||||
}
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
|
||||
const html = emailTemplate({
|
||||
title: `Nova atualização no seu chamado #${reference}`,
|
||||
message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`,
|
||||
ctaLabel: "Abrir e responder",
|
||||
ctaUrl: url,
|
||||
})
|
||||
await sendSmtpMail(smtp, to, mailSubject, html)
|
||||
return { ok: true }
|
||||
},
|
||||
})
|
||||
|
||||
export const sendResolvedEmail = action({
|
||||
args: {
|
||||
to: v.string(),
|
||||
ticketId: v.string(),
|
||||
reference: v.number(),
|
||||
subject: v.string(),
|
||||
},
|
||||
handler: async (_ctx, { to, ticketId, reference, subject }) => {
|
||||
const smtp = {
|
||||
host: process.env.SMTP_ADDRESS!,
|
||||
port: Number(process.env.SMTP_PORT ?? 465),
|
||||
username: process.env.SMTP_USERNAME!,
|
||||
password: process.env.SMTP_PASSWORD!,
|
||||
from: process.env.MAILER_SENDER_EMAIL || "Raven <no-reply@example.com>",
|
||||
}
|
||||
if (!smtp.host || !smtp.username || !smtp.password) {
|
||||
console.warn("SMTP not configured; skipping ticket resolution email")
|
||||
return { skipped: true }
|
||||
}
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Seu chamado #${reference} foi encerrado`
|
||||
const html = emailTemplate({
|
||||
title: `Chamado #${reference} encerrado`,
|
||||
message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
|
||||
ctaLabel: "Ver detalhes",
|
||||
ctaUrl: url,
|
||||
})
|
||||
await sendSmtpMail(smtp, to, mailSubject, html)
|
||||
return { ok: true }
|
||||
},
|
||||
})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
// CI touch: enable server-side assignee filtering and trigger redeploy
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { api } from "./_generated/api";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Id, type Doc, type DataModel } from "./_generated/dataModel";
|
||||
|
|
@ -1235,6 +1236,7 @@ export const list = query({
|
|||
priority: t.priority,
|
||||
channel: t.channel,
|
||||
queue: queueName,
|
||||
formTemplate: t.formTemplate ?? null,
|
||||
company: company
|
||||
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
|
||||
: t.companyId || t.companySnapshot
|
||||
|
|
@ -1887,6 +1889,20 @@ export const addComment = mutation({
|
|||
});
|
||||
// bump ticket updatedAt
|
||||
await ctx.db.patch(args.ticketId, { updatedAt: now });
|
||||
// Notificação por e-mail: comentário público para o solicitante
|
||||
try {
|
||||
const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email
|
||||
if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
|
||||
await ctx.scheduler.runAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
|
||||
to: snapshotEmail,
|
||||
ticketId: String(ticketDoc._id),
|
||||
reference: ticketDoc.reference ?? 0,
|
||||
subject: ticketDoc.subject ?? "",
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[tickets] Falha ao agendar e-mail de comentário", e)
|
||||
}
|
||||
return id;
|
||||
},
|
||||
});
|
||||
|
|
@ -2122,6 +2138,22 @@ export async function resolveTicketHandler(
|
|||
createdAt: now,
|
||||
})
|
||||
|
||||
// Notificação por e-mail: encerramento do chamado
|
||||
try {
|
||||
const requesterDoc = await ctx.db.get(ticketDoc.requesterId)
|
||||
const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null
|
||||
if (email) {
|
||||
await ctx.scheduler.runAfter(0, api.ticketNotifications.sendResolvedEmail, {
|
||||
to: email,
|
||||
ticketId: String(ticketId),
|
||||
reference: ticketDoc.reference ?? 0,
|
||||
subject: ticketDoc.subject ?? "",
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[tickets] Falha ao agendar e-mail de encerramento", e)
|
||||
}
|
||||
|
||||
for (const rel of linkedTickets) {
|
||||
const existing = new Set<string>((rel.relatedTicketIds ?? []).map((value) => String(value)))
|
||||
existing.add(String(ticketId))
|
||||
|
|
@ -2433,10 +2465,14 @@ export const listTicketForms = query({
|
|||
}>
|
||||
|
||||
for (const template of TICKET_FORM_CONFIG) {
|
||||
const enabled = resolveFormEnabled(template.key, template.defaultEnabled, settings as Doc<"ticketFormSettings">[], {
|
||||
let enabled = resolveFormEnabled(template.key, template.defaultEnabled, settings as Doc<"ticketFormSettings">[], {
|
||||
companyId: viewerCompanyId,
|
||||
userId: viewer.user._id,
|
||||
})
|
||||
const viewerRole = (viewer.role ?? "").toUpperCase()
|
||||
if (viewerRole === "ADMIN" || viewerRole === "AGENT") {
|
||||
enabled = true
|
||||
}
|
||||
if (!enabled) {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const serverTicketSchema = z.object({
|
|||
priority: z.string(),
|
||||
channel: z.string(),
|
||||
queue: z.string().nullable(),
|
||||
formTemplate: z.string().nullable().optional(),
|
||||
requester: serverUserSchema,
|
||||
assignee: serverUserSchema.nullable(),
|
||||
company: z
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue