dashboard: substituir 'Abrir ticket' por botão 'Novo ticket' com modal (mesmo layout e funcionalidade da tela de tickets)
This commit is contained in:
parent
384d4411b6
commit
d2c1913221
3 changed files with 110 additions and 13 deletions
|
|
@ -42,6 +42,7 @@
|
||||||
- Relatórios, dashboards e páginas administrativas utilizam `AppShell`, garantindo header/sidebar consistentes.
|
- Relatórios, dashboards e páginas administrativas utilizam `AppShell`, garantindo header/sidebar consistentes.
|
||||||
- Use `SiteHeader` no `header` do `AppShell` para título/lead e ações.
|
- Use `SiteHeader` no `header` do `AppShell` para título/lead e ações.
|
||||||
- O conteúdo deve ficar dentro de `<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">`.
|
- O conteúdo deve ficar dentro de `<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">`.
|
||||||
|
- Persistir filtro global de empresa com `usePersistentCompanyFilter` (localStorage) para manter consistência entre relatórios.
|
||||||
|
|
||||||
## Entregas recentes
|
## Entregas recentes
|
||||||
- Exportações CSV (Backlog, Canais, CSAT, SLA e Horas por cliente) com parâmetros de período.
|
- Exportações CSV (Backlog, Canais, CSAT, SLA e Horas por cliente) com parâmetros de período.
|
||||||
|
|
|
||||||
|
|
@ -738,6 +738,93 @@ export const addComment = mutation({
|
||||||
});
|
});
|
||||||
// bump ticket updatedAt
|
// bump ticket updatedAt
|
||||||
await ctx.db.patch(args.ticketId, { updatedAt: now });
|
await ctx.db.patch(args.ticketId, { updatedAt: now });
|
||||||
|
|
||||||
|
// If public comment, attempt to notify requester by e-mail (best-effort)
|
||||||
|
if (args.visibility === "PUBLIC") {
|
||||||
|
try {
|
||||||
|
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 || "no-reply@example.com",
|
||||||
|
}
|
||||||
|
if (smtp.host && smtp.username && smtp.password) {
|
||||||
|
const requester = await ctx.db.get(ticketDoc.requesterId)
|
||||||
|
const to = (requester as any)?.email as string | undefined
|
||||||
|
if (to) {
|
||||||
|
// Minimal SMTP sender (AUTH LOGIN over implicit TLS)
|
||||||
|
const tls = await import("tls")
|
||||||
|
const b64 = (input: string) => Buffer.from(input, "utf8").toString("base64")
|
||||||
|
const sendSmtpMail = async (cfg: { host: string; port: number; username: string; password: string; from: string }, toAddr: 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 /)
|
||||||
|
const fromAddr = (cfg.from.match(/<\s*([^>\s]+)\s*>/)?.[1] ?? cfg.from)
|
||||||
|
send(`MAIL FROM:<${fromAddr}>`)
|
||||||
|
await wait(/^250 /)
|
||||||
|
send(`RCPT TO:<${toAddr}>`)
|
||||||
|
await wait(/^250 /)
|
||||||
|
send("DATA")
|
||||||
|
await wait(/^354 /)
|
||||||
|
const headers = [
|
||||||
|
`From: ${cfg.from}`,
|
||||||
|
`To: ${toAddr}`,
|
||||||
|
`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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const ticketRef = (ticketDoc as any).reference ? `#${(ticketDoc as any).reference}` : "Chamado"
|
||||||
|
const subject = `${ticketRef} atualizado: novo comentário público`
|
||||||
|
const body = `
|
||||||
|
<p>Olá,</p>
|
||||||
|
<p>Seu chamado <strong>${ticketRef}</strong> recebeu um novo comentário:</p>
|
||||||
|
<blockquote style="border-left: 3px solid #e5e7eb; margin: 8px 0; padding: 8px 12px; color: #374151;">${args.body}</blockquote>
|
||||||
|
<p>Atenciosamente,<br/>Equipe de suporte</p>
|
||||||
|
`
|
||||||
|
await sendSmtpMail(smtp, to, subject, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send public comment notification", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import dynamic from "next/dynamic"
|
||||||
import { AppShell } from "@/components/app-shell"
|
import { AppShell } from "@/components/app-shell"
|
||||||
import { SectionCards } from "@/components/section-cards"
|
import { SectionCards } from "@/components/section-cards"
|
||||||
import { SiteHeader } from "@/components/site-header"
|
import { SiteHeader } from "@/components/site-header"
|
||||||
|
|
@ -5,6 +6,14 @@ import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel"
|
||||||
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
|
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
|
||||||
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
|
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
|
||||||
|
|
||||||
|
const NewTicketDialog = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tickets/new-ticket-dialog").then((module) => ({
|
||||||
|
default: module.NewTicketDialog,
|
||||||
|
})),
|
||||||
|
{ ssr: false }
|
||||||
|
)
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
|
|
@ -12,8 +21,8 @@ export default function Dashboard() {
|
||||||
<SiteHeader
|
<SiteHeader
|
||||||
title="Central de operações"
|
title="Central de operações"
|
||||||
lead="Monitoramento em tempo real"
|
lead="Monitoramento em tempo real"
|
||||||
secondaryAction={<SiteHeader.SecondaryButton>Abrir ticket</SiteHeader.SecondaryButton>}
|
secondaryAction={<SiteHeader.SecondaryButton>Modo play</SiteHeader.SecondaryButton>}
|
||||||
primaryAction={<SiteHeader.PrimaryButton>Modo play</SiteHeader.PrimaryButton>}
|
primaryAction={<NewTicketDialog />}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue