feat: improve ticket export and navigation
This commit is contained in:
parent
0731c5d1ea
commit
7d6f3bea01
28 changed files with 1612 additions and 609 deletions
|
|
@ -221,11 +221,17 @@ function App() {
|
|||
async function register() {
|
||||
if (!profile) return
|
||||
if (!provisioningSecret.trim()) { setError("Informe o código de provisionamento."); return }
|
||||
const normalizedEmail = collabEmail.trim().toLowerCase()
|
||||
if (!normalizedEmail) {
|
||||
setError("Informe o e-mail do colaborador ou gestor para vincular esta máquina.")
|
||||
return
|
||||
}
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const collaboratorPayload = collabEmail.trim()
|
||||
? { email: collabEmail.trim(), name: collabName.trim() || undefined }
|
||||
: undefined
|
||||
const collaboratorPayload = {
|
||||
email: normalizedEmail,
|
||||
name: collabName.trim() || undefined,
|
||||
}
|
||||
const collaboratorMetadata = collaboratorPayload
|
||||
? { ...collaboratorPayload, role: accessRole }
|
||||
: undefined
|
||||
|
|
@ -437,7 +443,9 @@ function App() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Colaborador (e-mail)</label>
|
||||
<label className="text-sm font-medium">
|
||||
Colaborador (e-mail) <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="colaborador@empresa.com" value={collabEmail} onChange={(e)=>setCollabEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
|
|
@ -524,7 +532,9 @@ function App() {
|
|||
</TabsContent>
|
||||
<TabsContent value="config" className="mt-4 space-y-3">
|
||||
<div className="grid gap-2">
|
||||
<label className="label">E-mail do colaborador (opcional)</label>
|
||||
<label className="label">
|
||||
E-mail do colaborador <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<input className="input" placeholder="colaborador@empresa.com" value={collabEmail} onChange={(e)=>setCollabEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
|
|
|
|||
|
|
@ -50,20 +50,24 @@ export const list = query({
|
|||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
kind: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
handler: async (ctx, { tenantId, viewerId, kind }) => {
|
||||
await requireStaff(ctx, viewerId, tenantId)
|
||||
const normalizedKind = (kind ?? "comment").toLowerCase()
|
||||
const templates = await ctx.db
|
||||
.query("commentTemplates")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
|
||||
return templates
|
||||
.filter((template) => (template.kind ?? "comment") === normalizedKind)
|
||||
.sort((a, b) => a.title.localeCompare(b.title, "pt-BR", { sensitivity: "base" }))
|
||||
.map((template) => ({
|
||||
id: template._id,
|
||||
title: template.title,
|
||||
body: template.body,
|
||||
kind: template.kind ?? "comment",
|
||||
createdAt: template.createdAt,
|
||||
updatedAt: template.updatedAt,
|
||||
createdBy: template.createdBy,
|
||||
|
|
@ -78,8 +82,9 @@ export const create = mutation({
|
|||
actorId: v.id("users"),
|
||||
title: v.string(),
|
||||
body: v.string(),
|
||||
kind: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, title, body }) => {
|
||||
handler: async (ctx, { tenantId, actorId, title, body, kind }) => {
|
||||
await requireStaff(ctx, actorId, tenantId)
|
||||
const normalizedTitle = normalizeTitle(title)
|
||||
if (!normalizedTitle || normalizedTitle.length < 3) {
|
||||
|
|
@ -89,19 +94,21 @@ export const create = mutation({
|
|||
if (!sanitizedBody) {
|
||||
throw new ConvexError("Informe o conteúdo do template")
|
||||
}
|
||||
const normalizedKind = (kind ?? "comment").toLowerCase()
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("commentTemplates")
|
||||
.withIndex("by_tenant_title", (q) => q.eq("tenantId", tenantId).eq("title", normalizedTitle))
|
||||
.first()
|
||||
|
||||
if (existing) {
|
||||
if (existing && (existing.kind ?? "comment") === normalizedKind) {
|
||||
throw new ConvexError("Já existe um template com este título")
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const id = await ctx.db.insert("commentTemplates", {
|
||||
tenantId,
|
||||
kind: normalizedKind,
|
||||
title: normalizedTitle,
|
||||
body: sanitizedBody,
|
||||
createdBy: actorId,
|
||||
|
|
@ -121,8 +128,9 @@ export const update = mutation({
|
|||
actorId: v.id("users"),
|
||||
title: v.string(),
|
||||
body: v.string(),
|
||||
kind: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { templateId, tenantId, actorId, title, body }) => {
|
||||
handler: async (ctx, { templateId, tenantId, actorId, title, body, kind }) => {
|
||||
await requireStaff(ctx, actorId, tenantId)
|
||||
const template = await ctx.db.get(templateId)
|
||||
if (!template || template.tenantId !== tenantId) {
|
||||
|
|
@ -137,17 +145,19 @@ export const update = mutation({
|
|||
if (!sanitizedBody) {
|
||||
throw new ConvexError("Informe o conteúdo do template")
|
||||
}
|
||||
const normalizedKind = (kind ?? "comment").toLowerCase()
|
||||
|
||||
const duplicate = await ctx.db
|
||||
.query("commentTemplates")
|
||||
.withIndex("by_tenant_title", (q) => q.eq("tenantId", tenantId).eq("title", normalizedTitle))
|
||||
.first()
|
||||
if (duplicate && duplicate._id !== templateId) {
|
||||
if (duplicate && duplicate._id !== templateId && (duplicate.kind ?? "comment") === normalizedKind) {
|
||||
throw new ConvexError("Já existe um template com este título")
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
await ctx.db.patch(templateId, {
|
||||
kind: normalizedKind,
|
||||
title: normalizedTitle,
|
||||
body: sanitizedBody,
|
||||
updatedBy: actorId,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,24 @@ function computeFingerprint(tenantId: string, companySlug: string | undefined, h
|
|||
return toHex(sha256(payload))
|
||||
}
|
||||
|
||||
function extractCollaboratorEmail(metadata: unknown): string | null {
|
||||
if (!metadata || typeof metadata !== "object") return null
|
||||
const record = metadata as Record<string, unknown>
|
||||
const collaborator = record["collaborator"]
|
||||
if (!collaborator || typeof collaborator !== "object") return null
|
||||
const email = (collaborator as { email?: unknown }).email
|
||||
if (typeof email !== "string") return null
|
||||
const trimmed = email.trim().toLowerCase()
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
function matchesExistingHardware(existing: Doc<"machines">, identifiers: NormalizedIdentifiers, hostname: string): boolean {
|
||||
const intersectsMac = existing.macAddresses.some((mac) => identifiers.macs.includes(mac))
|
||||
const intersectsSerial = existing.serialNumbers.some((serial) => identifiers.serials.includes(serial))
|
||||
const sameHostname = existing.hostname.trim().toLowerCase() === hostname.trim().toLowerCase()
|
||||
return intersectsMac || intersectsSerial || sameHostname
|
||||
}
|
||||
|
||||
function hashToken(token: string) {
|
||||
return toHex(sha256(token))
|
||||
}
|
||||
|
|
@ -305,11 +323,24 @@ export const register = mutation({
|
|||
const now = Date.now()
|
||||
const metadataPatch = args.metadata && typeof args.metadata === "object" ? (args.metadata as Record<string, unknown>) : undefined
|
||||
|
||||
const existing = await ctx.db
|
||||
let existing = await ctx.db
|
||||
.query("machines")
|
||||
.withIndex("by_tenant_fingerprint", (q) => q.eq("tenantId", tenantId).eq("fingerprint", fingerprint))
|
||||
.first()
|
||||
|
||||
if (!existing) {
|
||||
const collaboratorEmail = extractCollaboratorEmail(metadataPatch ?? args.metadata)
|
||||
if (collaboratorEmail) {
|
||||
const candidate = await ctx.db
|
||||
.query("machines")
|
||||
.withIndex("by_tenant_assigned_email", (q) => q.eq("tenantId", tenantId).eq("assignedUserEmail", collaboratorEmail))
|
||||
.first()
|
||||
if (candidate && matchesExistingHardware(candidate, identifiers, args.hostname)) {
|
||||
existing = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let machineId: Id<"machines">
|
||||
|
||||
if (existing) {
|
||||
|
|
@ -323,6 +354,7 @@ export const register = mutation({
|
|||
architecture: args.os.architecture,
|
||||
macAddresses: identifiers.macs,
|
||||
serialNumbers: identifiers.serials,
|
||||
fingerprint,
|
||||
metadata: metadataPatch ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata,
|
||||
lastHeartbeatAt: now,
|
||||
updatedAt: now,
|
||||
|
|
@ -834,6 +866,28 @@ export const getContext = query({
|
|||
},
|
||||
})
|
||||
|
||||
export const findByAuthEmail = query({
|
||||
args: {
|
||||
authEmail: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const normalizedEmail = args.authEmail.trim().toLowerCase()
|
||||
|
||||
const machine = await ctx.db
|
||||
.query("machines")
|
||||
.withIndex("by_auth_email", (q) => q.eq("authEmail", normalizedEmail))
|
||||
.first()
|
||||
|
||||
if (!machine) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: machine._id,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const linkAuthAccount = mutation({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ export default defineSchema({
|
|||
|
||||
commentTemplates: defineTable({
|
||||
tenantId: v.string(),
|
||||
kind: v.optional(v.string()),
|
||||
title: v.string(),
|
||||
body: v.string(),
|
||||
createdBy: v.id("users"),
|
||||
|
|
@ -154,7 +155,8 @@ export default defineSchema({
|
|||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_tenant", ["tenantId"])
|
||||
.index("by_tenant_title", ["tenantId", "title"]),
|
||||
.index("by_tenant_title", ["tenantId", "title"])
|
||||
.index("by_tenant_kind", ["tenantId", "kind"]),
|
||||
|
||||
ticketWorkSessions: defineTable({
|
||||
ticketId: v.id("tickets"),
|
||||
|
|
@ -267,6 +269,7 @@ export default defineSchema({
|
|||
.index("by_tenant", ["tenantId"])
|
||||
.index("by_tenant_company", ["tenantId", "companyId"])
|
||||
.index("by_tenant_fingerprint", ["tenantId", "fingerprint"])
|
||||
.index("by_tenant_assigned_email", ["tenantId", "assignedUserEmail"])
|
||||
.index("by_auth_email", ["authEmail"]),
|
||||
|
||||
machineTokens: defineTable({
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server";
|
|||
import { ConvexError, v } from "convex/values";
|
||||
import { Id, type Doc } from "./_generated/dataModel";
|
||||
|
||||
import { requireStaff, requireUser } from "./rbac";
|
||||
import { requireAdmin, requireStaff, requireUser } from "./rbac";
|
||||
|
||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]);
|
||||
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
|
||||
|
|
@ -1343,8 +1343,13 @@ export const playNext = mutation({
|
|||
});
|
||||
|
||||
export const remove = mutation({
|
||||
args: { ticketId: v.id("tickets") },
|
||||
handler: async (ctx, { ticketId }) => {
|
||||
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
|
||||
handler: async (ctx, { ticketId, actorId }) => {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
await requireAdmin(ctx, actorId, ticket.tenantId)
|
||||
// delete comments (and attachments)
|
||||
const comments = await ctx.db
|
||||
.query("ticketComments")
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
"next": "15.5.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdfkit": "^0.17.2",
|
||||
"@react-pdf/renderer": "^4.1.5",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
|
|
|
|||
313
pnpm-lock.yaml
generated
313
pnpm-lock.yaml
generated
|
|
@ -71,6 +71,9 @@ importers:
|
|||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@react-pdf/renderer':
|
||||
specifier: ^4.1.5
|
||||
version: 4.3.1(react@19.2.0)
|
||||
'@react-three/fiber':
|
||||
specifier: ^9.3.0
|
||||
version: 9.3.0(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(three@0.180.0)
|
||||
|
|
@ -1519,6 +1522,49 @@ packages:
|
|||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@react-pdf/fns@3.1.2':
|
||||
resolution: {integrity: sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==}
|
||||
|
||||
'@react-pdf/font@4.0.3':
|
||||
resolution: {integrity: sha512-N1qQDZr6phXYQOp033Hvm2nkUkx2LkszjGPbmRavs9VOYzi4sp31MaccMKptL24ii6UhBh/z9yPUhnuNe/qHwA==}
|
||||
|
||||
'@react-pdf/image@3.0.3':
|
||||
resolution: {integrity: sha512-lvP5ryzYM3wpbO9bvqLZYwEr5XBDX9jcaRICvtnoRqdJOo7PRrMnmB4MMScyb+Xw10mGeIubZAAomNAG5ONQZQ==}
|
||||
|
||||
'@react-pdf/layout@4.4.1':
|
||||
resolution: {integrity: sha512-GVzdlWoZWldRDzlWj3SttRXmVDxg7YfraAohwy+o9gb9hrbDJaaAV6jV3pc630Evd3K46OAzk8EFu8EgPDuVuA==}
|
||||
|
||||
'@react-pdf/pdfkit@4.0.4':
|
||||
resolution: {integrity: sha512-/nITLggsPlB66bVLnm0X7MNdKQxXelLGZG6zB5acF5cCgkFwmXHnLNyxYOUD4GMOMg1HOPShXDKWrwk2ZeHsvw==}
|
||||
|
||||
'@react-pdf/png-js@3.0.0':
|
||||
resolution: {integrity: sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==}
|
||||
|
||||
'@react-pdf/primitives@4.1.1':
|
||||
resolution: {integrity: sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==}
|
||||
|
||||
'@react-pdf/reconciler@1.1.4':
|
||||
resolution: {integrity: sha512-oTQDiR/t4Z/Guxac88IavpU2UgN7eR0RMI9DRKvKnvPz2DUasGjXfChAdMqDNmJJxxV26mMy9xQOUV2UU5/okg==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@react-pdf/render@4.3.1':
|
||||
resolution: {integrity: sha512-v1WAaAhQShQZGcBxfjkEThGCHVH9CSuitrZ1bIOLvB5iBKM14abYK5D6djKhWCwF6FTzYeT2WRjRMVgze/ND2A==}
|
||||
|
||||
'@react-pdf/renderer@4.3.1':
|
||||
resolution: {integrity: sha512-dPKHiwGTaOsKqNWCHPYYrx8CDfAGsUnV4tvRsEu0VPGxuot1AOq/M+YgfN/Pb+MeXCTe2/lv6NvA8haUtj3tsA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@react-pdf/stylesheet@6.1.1':
|
||||
resolution: {integrity: sha512-Iyw0A3wRIeQLN4EkaKf8yF9MvdMxiZ8JjoyzLzDHSxnKYoOA4UGu84veCb8dT9N8MxY5x7a0BUv/avTe586Plg==}
|
||||
|
||||
'@react-pdf/textkit@6.0.0':
|
||||
resolution: {integrity: sha512-fDt19KWaJRK/n2AaFoVm31hgGmpygmTV7LsHGJNGZkgzXcFyLsx+XUl63DTDPH3iqxj3xUX128t104GtOz8tTw==}
|
||||
|
||||
'@react-pdf/types@2.9.1':
|
||||
resolution: {integrity: sha512-5GoCgG0G5NMgpPuHbKG2xcVRQt7+E5pg3IyzVIIozKG3nLcnsXW4zy25vG1ZBQA0jmo39q34au/sOnL/0d1A4w==}
|
||||
|
||||
'@react-three/fiber@9.3.0':
|
||||
resolution: {integrity: sha512-myPe3YL/C8+Eq939/4qIVEPBW/uxV0iiUbmjfwrs9sGKYDG8ib8Dz3Okq7BQt8P+0k4igedONbjXMQy84aDFmQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -2320,6 +2366,9 @@ packages:
|
|||
'@webgpu/types@0.1.65':
|
||||
resolution: {integrity: sha512-cYrHab4d6wuVvDW5tdsfI6/o6vcLMDe6w2Citd1oS51Xxu2ycLCnVo4fqwujfKWijrZMInTJIKcXxteoy21nVA==}
|
||||
|
||||
abs-svg-path@0.1.1:
|
||||
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -2453,6 +2502,9 @@ packages:
|
|||
better-call@1.0.19:
|
||||
resolution: {integrity: sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw==}
|
||||
|
||||
bidi-js@1.0.3:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
|
||||
|
|
@ -2466,6 +2518,9 @@ packages:
|
|||
brotli@1.3.3:
|
||||
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
|
||||
|
||||
browserify-zlib@0.2.0:
|
||||
resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==}
|
||||
|
||||
browserslist@4.26.3:
|
||||
resolution: {integrity: sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
|
|
@ -2549,6 +2604,9 @@ packages:
|
|||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
color-string@1.9.1:
|
||||
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
|
|
@ -2746,6 +2804,9 @@ packages:
|
|||
electron-to-chromium@1.5.234:
|
||||
resolution: {integrity: sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==}
|
||||
|
||||
emoji-regex-xs@1.0.0:
|
||||
resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
|
||||
|
||||
emoji-regex@9.2.2:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
|
||||
|
|
@ -2936,6 +2997,10 @@ packages:
|
|||
eventemitter3@4.0.7:
|
||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
expect-type@1.2.2:
|
||||
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
|
@ -3108,9 +3173,18 @@ packages:
|
|||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hsl-to-hex@1.0.0:
|
||||
resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==}
|
||||
|
||||
hsl-to-rgb-for-reals@1.1.1:
|
||||
resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==}
|
||||
|
||||
htmlparser2@8.0.2:
|
||||
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||
|
||||
hyphen@1.10.6:
|
||||
resolution: {integrity: sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw==}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
|
|
@ -3130,6 +3204,9 @@ packages:
|
|||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -3142,6 +3219,9 @@ packages:
|
|||
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-arrayish@0.3.4:
|
||||
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
|
||||
|
||||
is-async-function@2.1.1:
|
||||
resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -3233,6 +3313,9 @@ packages:
|
|||
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-url@1.2.4:
|
||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||
|
||||
is-weakmap@2.0.2:
|
||||
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -3260,6 +3343,9 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^19.0.0
|
||||
|
||||
jay-peg@1.1.1:
|
||||
resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==}
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
|
@ -3438,6 +3524,9 @@ packages:
|
|||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
media-engine@1.0.3:
|
||||
resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==}
|
||||
|
||||
merge2@1.4.1:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -3520,6 +3609,9 @@ packages:
|
|||
node-releases@2.0.23:
|
||||
resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==}
|
||||
|
||||
normalize-svg-path@1.1.0:
|
||||
resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==}
|
||||
|
||||
nypm@0.6.2:
|
||||
resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==}
|
||||
engines: {node: ^14.16.0 || >=16.10.0}
|
||||
|
|
@ -3582,6 +3674,9 @@ packages:
|
|||
pako@0.2.9:
|
||||
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -3589,6 +3684,9 @@ packages:
|
|||
parse-srcset@1.0.2:
|
||||
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||
|
||||
parse-svg-path@0.1.2:
|
||||
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
|
||||
|
||||
path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -3637,6 +3735,9 @@ packages:
|
|||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||
|
||||
postcss@8.4.31:
|
||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
|
@ -3746,6 +3847,9 @@ packages:
|
|||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
queue@6.0.2:
|
||||
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
|
||||
|
||||
rc9@2.1.2:
|
||||
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
||||
|
||||
|
|
@ -3856,6 +3960,10 @@ packages:
|
|||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -3897,6 +4005,9 @@ packages:
|
|||
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
safe-push-apply@1.0.0:
|
||||
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -3911,6 +4022,9 @@ packages:
|
|||
scheduler@0.25.0:
|
||||
resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==}
|
||||
|
||||
scheduler@0.25.0-rc-603e6108-20241029:
|
||||
resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==}
|
||||
|
||||
scheduler@0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
|
|
@ -3969,6 +4083,9 @@ packages:
|
|||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
simple-swizzle@0.2.4:
|
||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||
|
||||
sonner@2.0.7:
|
||||
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||
peerDependencies:
|
||||
|
|
@ -4015,6 +4132,9 @@ packages:
|
|||
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
strip-bom@3.0.0:
|
||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -4049,6 +4169,9 @@ packages:
|
|||
peerDependencies:
|
||||
react: '>=17.0'
|
||||
|
||||
svg-arc-to-cubic-bezier@3.2.0:
|
||||
resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==}
|
||||
|
||||
tailwind-merge@3.3.1:
|
||||
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
||||
|
||||
|
|
@ -4220,6 +4343,9 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
vaul@1.1.2:
|
||||
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
|
||||
peerDependencies:
|
||||
|
|
@ -4229,6 +4355,10 @@ packages:
|
|||
victory-vendor@36.9.2:
|
||||
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
||||
|
||||
vite-compatible-readable-stream@3.6.1:
|
||||
resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
vite-node@2.1.9:
|
||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
|
|
@ -4374,6 +4504,9 @@ packages:
|
|||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yoga-layout@3.2.1:
|
||||
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
|
||||
|
||||
zod@4.1.11:
|
||||
resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==}
|
||||
|
||||
|
|
@ -5554,6 +5687,107 @@ snapshots:
|
|||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@react-pdf/fns@3.1.2': {}
|
||||
|
||||
'@react-pdf/font@4.0.3':
|
||||
dependencies:
|
||||
'@react-pdf/pdfkit': 4.0.4
|
||||
'@react-pdf/types': 2.9.1
|
||||
fontkit: 2.0.4
|
||||
is-url: 1.2.4
|
||||
|
||||
'@react-pdf/image@3.0.3':
|
||||
dependencies:
|
||||
'@react-pdf/png-js': 3.0.0
|
||||
jay-peg: 1.1.1
|
||||
|
||||
'@react-pdf/layout@4.4.1':
|
||||
dependencies:
|
||||
'@react-pdf/fns': 3.1.2
|
||||
'@react-pdf/image': 3.0.3
|
||||
'@react-pdf/primitives': 4.1.1
|
||||
'@react-pdf/stylesheet': 6.1.1
|
||||
'@react-pdf/textkit': 6.0.0
|
||||
'@react-pdf/types': 2.9.1
|
||||
emoji-regex-xs: 1.0.0
|
||||
queue: 6.0.2
|
||||
yoga-layout: 3.2.1
|
||||
|
||||
'@react-pdf/pdfkit@4.0.4':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@react-pdf/png-js': 3.0.0
|
||||
browserify-zlib: 0.2.0
|
||||
crypto-js: 4.2.0
|
||||
fontkit: 2.0.4
|
||||
jay-peg: 1.1.1
|
||||
linebreak: 1.1.0
|
||||
vite-compatible-readable-stream: 3.6.1
|
||||
|
||||
'@react-pdf/png-js@3.0.0':
|
||||
dependencies:
|
||||
browserify-zlib: 0.2.0
|
||||
|
||||
'@react-pdf/primitives@4.1.1': {}
|
||||
|
||||
'@react-pdf/reconciler@1.1.4(react@19.2.0)':
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
react: 19.2.0
|
||||
scheduler: 0.25.0-rc-603e6108-20241029
|
||||
|
||||
'@react-pdf/render@4.3.1':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@react-pdf/fns': 3.1.2
|
||||
'@react-pdf/primitives': 4.1.1
|
||||
'@react-pdf/textkit': 6.0.0
|
||||
'@react-pdf/types': 2.9.1
|
||||
abs-svg-path: 0.1.1
|
||||
color-string: 1.9.1
|
||||
normalize-svg-path: 1.1.0
|
||||
parse-svg-path: 0.1.2
|
||||
svg-arc-to-cubic-bezier: 3.2.0
|
||||
|
||||
'@react-pdf/renderer@4.3.1(react@19.2.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@react-pdf/fns': 3.1.2
|
||||
'@react-pdf/font': 4.0.3
|
||||
'@react-pdf/layout': 4.4.1
|
||||
'@react-pdf/pdfkit': 4.0.4
|
||||
'@react-pdf/primitives': 4.1.1
|
||||
'@react-pdf/reconciler': 1.1.4(react@19.2.0)
|
||||
'@react-pdf/render': 4.3.1
|
||||
'@react-pdf/types': 2.9.1
|
||||
events: 3.3.0
|
||||
object-assign: 4.1.1
|
||||
prop-types: 15.8.1
|
||||
queue: 6.0.2
|
||||
react: 19.2.0
|
||||
|
||||
'@react-pdf/stylesheet@6.1.1':
|
||||
dependencies:
|
||||
'@react-pdf/fns': 3.1.2
|
||||
'@react-pdf/types': 2.9.1
|
||||
color-string: 1.9.1
|
||||
hsl-to-hex: 1.0.0
|
||||
media-engine: 1.0.3
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
'@react-pdf/textkit@6.0.0':
|
||||
dependencies:
|
||||
'@react-pdf/fns': 3.1.2
|
||||
bidi-js: 1.0.3
|
||||
hyphen: 1.10.6
|
||||
unicode-properties: 1.4.1
|
||||
|
||||
'@react-pdf/types@2.9.1':
|
||||
dependencies:
|
||||
'@react-pdf/font': 4.0.3
|
||||
'@react-pdf/primitives': 4.1.1
|
||||
'@react-pdf/stylesheet': 6.1.1
|
||||
|
||||
'@react-three/fiber@9.3.0(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(three@0.180.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
|
|
@ -6319,6 +6553,8 @@ snapshots:
|
|||
|
||||
'@webgpu/types@0.1.65': {}
|
||||
|
||||
abs-svg-path@0.1.1: {}
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
|
@ -6467,6 +6703,10 @@ snapshots:
|
|||
set-cookie-parser: 2.7.1
|
||||
uncrypto: 0.1.3
|
||||
|
||||
bidi-js@1.0.3:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
|
@ -6484,6 +6724,10 @@ snapshots:
|
|||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
|
||||
browserify-zlib@0.2.0:
|
||||
dependencies:
|
||||
pako: 1.0.11
|
||||
|
||||
browserslist@4.26.3:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.8.16
|
||||
|
|
@ -6576,6 +6820,11 @@ snapshots:
|
|||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
color-string@1.9.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
simple-swizzle: 0.2.4
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
confbox@0.2.2: {}
|
||||
|
|
@ -6746,6 +6995,8 @@ snapshots:
|
|||
|
||||
electron-to-chromium@1.5.234: {}
|
||||
|
||||
emoji-regex-xs@1.0.0: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
|
||||
empathic@2.0.0: {}
|
||||
|
|
@ -7121,6 +7372,8 @@ snapshots:
|
|||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
expect-type@1.2.2: {}
|
||||
|
||||
exsolve@1.0.7: {}
|
||||
|
|
@ -7301,6 +7554,12 @@ snapshots:
|
|||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hsl-to-hex@1.0.0:
|
||||
dependencies:
|
||||
hsl-to-rgb-for-reals: 1.1.1
|
||||
|
||||
hsl-to-rgb-for-reals@1.1.1: {}
|
||||
|
||||
htmlparser2@8.0.2:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
|
@ -7308,6 +7567,8 @@ snapshots:
|
|||
domutils: 3.2.2
|
||||
entities: 4.5.0
|
||||
|
||||
hyphen@1.10.6: {}
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
|
@ -7321,6 +7582,8 @@ snapshots:
|
|||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
|
@ -7335,6 +7598,8 @@ snapshots:
|
|||
call-bound: 1.0.4
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
is-arrayish@0.3.4: {}
|
||||
|
||||
is-async-function@2.1.1:
|
||||
dependencies:
|
||||
async-function: 1.0.0
|
||||
|
|
@ -7432,6 +7697,8 @@ snapshots:
|
|||
dependencies:
|
||||
which-typed-array: 1.1.19
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
||||
is-weakmap@2.0.2: {}
|
||||
|
||||
is-weakref@1.1.1:
|
||||
|
|
@ -7463,6 +7730,10 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
jay-peg@1.1.1:
|
||||
dependencies:
|
||||
restructure: 3.0.2
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jose@6.1.0: {}
|
||||
|
|
@ -7610,6 +7881,8 @@ snapshots:
|
|||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
media-engine@1.0.3: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
meshoptimizer@0.22.0: {}
|
||||
|
|
@ -7677,6 +7950,10 @@ snapshots:
|
|||
|
||||
node-releases@2.0.23: {}
|
||||
|
||||
normalize-svg-path@1.1.0:
|
||||
dependencies:
|
||||
svg-arc-to-cubic-bezier: 3.2.0
|
||||
|
||||
nypm@0.6.2:
|
||||
dependencies:
|
||||
citty: 0.1.6
|
||||
|
|
@ -7756,12 +8033,16 @@ snapshots:
|
|||
|
||||
pako@0.2.9: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
||||
parse-srcset@1.0.2: {}
|
||||
|
||||
parse-svg-path@0.1.2: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
|
@ -7800,6 +8081,8 @@ snapshots:
|
|||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss-value-parser@4.2.0: {}
|
||||
|
||||
postcss@8.4.31:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
|
|
@ -7948,6 +8231,10 @@ snapshots:
|
|||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
queue@6.0.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
|
||||
rc9@2.1.2:
|
||||
dependencies:
|
||||
defu: 6.1.4
|
||||
|
|
@ -8066,6 +8353,8 @@ snapshots:
|
|||
gopd: 1.2.0
|
||||
set-function-name: 2.0.2
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
|
@ -8130,6 +8419,8 @@ snapshots:
|
|||
has-symbols: 1.1.0
|
||||
isarray: 2.0.5
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safe-push-apply@1.0.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
|
@ -8152,6 +8443,8 @@ snapshots:
|
|||
|
||||
scheduler@0.25.0: {}
|
||||
|
||||
scheduler@0.25.0-rc-603e6108-20241029: {}
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
|
@ -8248,6 +8541,10 @@ snapshots:
|
|||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
simple-swizzle@0.2.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.3.4
|
||||
|
||||
sonner@2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
|
|
@ -8316,6 +8613,10 @@ snapshots:
|
|||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
strip-bom@3.0.0: {}
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
|
@ -8335,6 +8636,8 @@ snapshots:
|
|||
dependencies:
|
||||
react: 19.2.0
|
||||
|
||||
svg-arc-to-cubic-bezier@3.2.0: {}
|
||||
|
||||
tailwind-merge@3.3.1: {}
|
||||
|
||||
tailwindcss@4.1.14: {}
|
||||
|
|
@ -8521,6 +8824,8 @@ snapshots:
|
|||
dependencies:
|
||||
react: 19.2.0
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vaul@1.1.2(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
|
|
@ -8547,6 +8852,12 @@ snapshots:
|
|||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-compatible-readable-stream@3.6.1:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
vite-node@2.1.9(@types/node@20.19.19)(lightningcss@1.30.1):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
|
|
@ -8684,6 +8995,8 @@ snapshots:
|
|||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yoga-layout@3.2.1: {}
|
||||
|
||||
zod@4.1.11: {}
|
||||
|
||||
zustand@5.0.8(@types/react@19.2.0)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
|
|
@ -25,33 +25,55 @@ function decodeMachineCookie(value: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
function encodeMachineCookie(payload: {
|
||||
machineId: string
|
||||
persona: string | null
|
||||
assignedUserId: string | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
}) {
|
||||
return Buffer.from(JSON.stringify(payload)).toString("base64url")
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await assertAuthenticatedSession()
|
||||
if (!session || session.user?.role !== "machine") {
|
||||
return NextResponse.json({ error: "Sessão de máquina não encontrada." }, { status: 403 })
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const cookieValue = cookieStore.get(MACHINE_CTX_COOKIE)?.value
|
||||
if (!cookieValue) {
|
||||
return NextResponse.json({ error: "Contexto da máquina ausente." }, { status: 404 })
|
||||
}
|
||||
|
||||
const decoded = decodeMachineCookie(cookieValue)
|
||||
if (!decoded?.machineId) {
|
||||
return NextResponse.json({ error: "Contexto da máquina inválido." }, { status: 400 })
|
||||
}
|
||||
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado." }, { status: 500 })
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
const cookieStore = await cookies()
|
||||
const cookieValue = cookieStore.get(MACHINE_CTX_COOKIE)?.value ?? null
|
||||
|
||||
const decoded = cookieValue ? decodeMachineCookie(cookieValue) : null
|
||||
let machineId: Id<"machines"> | null = decoded?.machineId ? (decoded.machineId as Id<"machines">) : null
|
||||
|
||||
if (!machineId) {
|
||||
try {
|
||||
const lookup = (await client.query(api.machines.findByAuthEmail, {
|
||||
authEmail: session.user.email.toLowerCase(),
|
||||
})) as { id: string } | null
|
||||
|
||||
if (!lookup?.id) {
|
||||
return NextResponse.json({ error: "Máquina não vinculada à sessão atual." }, { status: 404 })
|
||||
}
|
||||
|
||||
machineId = lookup.id as Id<"machines">
|
||||
} catch (error) {
|
||||
console.error("[machines.session] Falha ao localizar máquina por e-mail", error)
|
||||
return NextResponse.json({ error: "Não foi possível localizar a máquina." }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const context = (await client.query(api.machines.getContext, {
|
||||
machineId: decoded.machineId as Id<"machines">,
|
||||
machineId,
|
||||
})) as {
|
||||
id: string
|
||||
tenantId: string
|
||||
|
|
@ -66,10 +88,32 @@ export async function GET() {
|
|||
authEmail: string | null
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
const responsePayload = {
|
||||
machineId: context.id,
|
||||
persona: context.persona,
|
||||
assignedUserId: context.assignedUserId,
|
||||
assignedUserEmail: context.assignedUserEmail,
|
||||
assignedUserName: context.assignedUserName,
|
||||
assignedUserRole: context.assignedUserRole,
|
||||
}
|
||||
|
||||
const response = NextResponse.json({
|
||||
machine: context,
|
||||
cookie: decoded,
|
||||
cookie: responsePayload,
|
||||
})
|
||||
|
||||
const isSecure = request.nextUrl.protocol === "https:"
|
||||
response.cookies.set({
|
||||
name: MACHINE_CTX_COOKIE,
|
||||
value: encodeMachineCookie(responsePayload),
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: isSecure,
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[machines.session] Falha ao obter contexto da máquina", error)
|
||||
return NextResponse.json({ error: "Falha ao obter contexto da máquina." }, { status: 500 })
|
||||
|
|
|
|||
|
|
@ -55,12 +55,13 @@ export async function POST(request: Request) {
|
|||
assignedUserName: session.machine.assignedUserName,
|
||||
assignedUserRole: session.machine.assignedUserRole,
|
||||
}
|
||||
const isSecure = new URL(request.url).protocol === "https:"
|
||||
response.cookies.set({
|
||||
name: "machine_ctx",
|
||||
value: Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url"),
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
secure: isSecure,
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,13 +1,6 @@
|
|||
import { NextResponse } from "next/server"
|
||||
// Use the standalone build to avoid AFM filesystem lookups
|
||||
// and ensure compatibility in serverless/traced environments.
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore – no ambient types for this path; declared in types/
|
||||
import PDFDocument from "pdfkit/js/pdfkit.standalone.js"
|
||||
import { format } from "date-fns"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import fs from "fs"
|
||||
import { NextResponse } from "next/server"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
|
@ -16,161 +9,19 @@ import { env } from "@/lib/env"
|
|||
import { assertAuthenticatedSession } from "@/lib/auth-server"
|
||||
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels"
|
||||
import { renderTicketPdfBuffer } from "@/server/pdf/ticket-pdf-template"
|
||||
|
||||
// Force Node.js runtime for pdfkit compatibility
|
||||
export const runtime = "nodejs"
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
PENDING: "Pendente",
|
||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||
PAUSED: "Pausado",
|
||||
RESOLVED: "Resolvido",
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
PENDING: "#64748B", // slate-500
|
||||
AWAITING_ATTENDANCE: "#0EA5E9", // sky-500
|
||||
PAUSED: "#F59E0B", // amber-500
|
||||
RESOLVED: "#10B981", // emerald-500
|
||||
}
|
||||
|
||||
const priorityLabel: Record<string, string> = {
|
||||
LOW: "Baixa",
|
||||
MEDIUM: "Média",
|
||||
HIGH: "Alta",
|
||||
URGENT: "Urgente",
|
||||
CRITICAL: "Crítica",
|
||||
}
|
||||
|
||||
const channelLabel: Record<string, string> = {
|
||||
EMAIL: "E-mail",
|
||||
PHONE: "Telefone",
|
||||
CHAT: "Chat",
|
||||
PORTAL: "Portal",
|
||||
WEB: "Portal",
|
||||
API: "API",
|
||||
SOCIAL: "Redes sociais",
|
||||
OTHER: "Outro",
|
||||
}
|
||||
|
||||
|
||||
function formatDateTime(date: Date | null | undefined) {
|
||||
if (!date) return "—"
|
||||
return format(date, "dd/MM/yyyy HH:mm", { locale: ptBR })
|
||||
}
|
||||
|
||||
function htmlToPlainText(html?: string | null) {
|
||||
if (!html) return ""
|
||||
const withBreaks = html
|
||||
.replace(/<\s*br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n\n")
|
||||
const stripped = withBreaks.replace(/<[^>]+>/g, "")
|
||||
return decodeHtmlEntities(stripped).replace(/\u00A0/g, " ").trim()
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(input: string) {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
}
|
||||
|
||||
function stringifyPayload(payload: unknown): string | null {
|
||||
if (!payload) return null
|
||||
if (typeof payload === "object") {
|
||||
if (Array.isArray(payload)) {
|
||||
if (payload.length === 0) return null
|
||||
} else if (payload) {
|
||||
if (Object.keys(payload as Record<string, unknown>).length === 0) return null
|
||||
}
|
||||
}
|
||||
if (typeof payload === "string" && payload.trim() === "") return null
|
||||
async function readLogoAsDataUrl() {
|
||||
const logoPath = path.join(process.cwd(), "public", "raven.png")
|
||||
try {
|
||||
return JSON.stringify(payload, null, 2)
|
||||
} catch {
|
||||
return String(payload)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDurationMs(ms: number | null | undefined) {
|
||||
if (!ms || ms <= 0) return null
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`
|
||||
if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
function buildTimelineMessage(type: string, payload: Record<string, unknown> | null | undefined): string | null {
|
||||
const p = payload ?? {}
|
||||
const to = (p.toLabel as string | undefined) ?? (p.to as string | undefined)
|
||||
const assignee = (p.assigneeName as string | undefined) ?? (p.assigneeId as string | undefined)
|
||||
const queue = (p.queueName as string | undefined) ?? (p.queueId as string | undefined)
|
||||
const requester = p.requesterName as string | undefined
|
||||
const author = (p.authorName as string | undefined) ?? (p.authorId as string | undefined)
|
||||
const actor = (p.actorName as string | undefined) ?? (p.actorId as string | undefined)
|
||||
const attachmentName = p.attachmentName as string | undefined
|
||||
const subjectTo = p.to as string | undefined
|
||||
const pauseReason = (p.pauseReasonLabel as string | undefined) ?? (p.pauseReason as string | undefined)
|
||||
const pauseNote = p.pauseNote as string | undefined
|
||||
const sessionDuration = formatDurationMs((p.sessionDurationMs as number | undefined) ?? null)
|
||||
const categoryName = p.categoryName as string | undefined
|
||||
const subcategoryName = p.subcategoryName as string | undefined
|
||||
|
||||
switch (type) {
|
||||
case "STATUS_CHANGED":
|
||||
return to ? `Status alterado para ${to}` : "Status alterado"
|
||||
case "ASSIGNEE_CHANGED":
|
||||
return assignee ? `Responsável alterado para ${assignee}` : "Responsável alterado"
|
||||
case "QUEUE_CHANGED":
|
||||
return queue ? `Fila alterada para ${queue}` : "Fila alterada"
|
||||
case "PRIORITY_CHANGED":
|
||||
return to ? `Prioridade alterada para ${to}` : "Prioridade alterada"
|
||||
case "CREATED":
|
||||
return requester ? `Criado por ${requester}` : "Criado"
|
||||
case "COMMENT_ADDED":
|
||||
return author ? `Comentário adicionado por ${author}` : "Comentário adicionado"
|
||||
case "COMMENT_EDITED": {
|
||||
const who = actor ?? author
|
||||
return who ? `Comentário editado por ${who}` : "Comentário editado"
|
||||
}
|
||||
case "SUBJECT_CHANGED":
|
||||
return subjectTo ? `Assunto alterado para "${subjectTo}"` : "Assunto alterado"
|
||||
case "SUMMARY_CHANGED":
|
||||
return "Resumo atualizado"
|
||||
case "ATTACHMENT_REMOVED":
|
||||
return attachmentName ? `Anexo removido: ${attachmentName}` : "Anexo removido"
|
||||
case "WORK_PAUSED": {
|
||||
const parts: string[] = []
|
||||
if (pauseReason) parts.push(`Motivo: ${pauseReason}`)
|
||||
if (sessionDuration) parts.push(`Tempo registrado: ${sessionDuration}`)
|
||||
if (pauseNote) parts.push(`Observação: ${pauseNote}`)
|
||||
return parts.length > 0 ? parts.join(" • ") : "Atendimento pausado"
|
||||
}
|
||||
case "WORK_STARTED":
|
||||
return "Atendimento iniciado"
|
||||
case "CATEGORY_CHANGED": {
|
||||
if (categoryName || subcategoryName) {
|
||||
return `Categoria alterada para ${categoryName ?? ""}${subcategoryName ? ` • ${subcategoryName}` : ""}`.trim()
|
||||
}
|
||||
return "Categoria removida"
|
||||
}
|
||||
case "MANAGER_NOTIFIED":
|
||||
return "Gestor notificado"
|
||||
case "VISIT_SCHEDULED":
|
||||
return "Visita agendada"
|
||||
case "CSAT_RECEIVED":
|
||||
return "CSAT recebido"
|
||||
case "CSAT_RATED":
|
||||
return "CSAT avaliado"
|
||||
default:
|
||||
return null
|
||||
const buffer = await fs.promises.readFile(logoPath)
|
||||
const base64 = buffer.toString("base64")
|
||||
return `data:image/png;base64,${base64}`
|
||||
} catch (error) {
|
||||
console.warn("[tickets.export.pdf] Logo não encontrado, seguindo sem imagem", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,6 +39,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
||||
let viewerId: string | null = null
|
||||
try {
|
||||
const ensuredUser = await client.mutation(api.users.ensureUser, {
|
||||
|
|
@ -199,8 +51,8 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
})
|
||||
viewerId = ensuredUser?._id ?? null
|
||||
} catch (error) {
|
||||
console.error("Failed to synchronize user with Convex for PDF export", error)
|
||||
return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 })
|
||||
console.error("[tickets.export.pdf] Falha ao sincronizar usuário no Convex", error)
|
||||
return NextResponse.json({ error: "Não foi possível preparar a exportação" }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!viewerId) {
|
||||
|
|
@ -215,12 +67,8 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
viewerId: viewerId as unknown as Id<"users">,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to load ticket from Convex for PDF export", error, {
|
||||
tenantId,
|
||||
ticketId,
|
||||
viewerId,
|
||||
})
|
||||
return NextResponse.json({ error: "Falha ao carregar ticket no Convex" }, { status: 500 })
|
||||
console.error("[tickets.export.pdf] Falha ao carregar ticket", error, { tenantId, ticketId, viewerId })
|
||||
return NextResponse.json({ error: "Não foi possível carregar o ticket" }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!ticketRaw) {
|
||||
|
|
@ -228,284 +76,20 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
}
|
||||
|
||||
const ticket = mapTicketWithDetailsFromServer(ticketRaw)
|
||||
const doc = new PDFDocument({ size: "A4", margin: 56 })
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
doc.on("data", (chunk: unknown) => {
|
||||
const buf = typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer)
|
||||
chunks.push(buf)
|
||||
})
|
||||
|
||||
const pdfBufferPromise = new Promise<Buffer>((resolve, reject) => {
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)))
|
||||
doc.on("error", reject)
|
||||
})
|
||||
|
||||
// Register custom fonts (Inter) if available
|
||||
const logoDataUrl = await readLogoAsDataUrl()
|
||||
try {
|
||||
const pubRegular = path.join(process.cwd(), "public", "fonts", "Inter-Regular.ttf")
|
||||
const pubBold = path.join(process.cwd(), "public", "fonts", "Inter-Bold.ttf")
|
||||
const fontRegular = fs.existsSync(pubRegular)
|
||||
? pubRegular
|
||||
: path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-Regular.ttf")
|
||||
const fontBold = fs.existsSync(pubBold)
|
||||
? pubBold
|
||||
: path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-Bold.ttf")
|
||||
const D = doc as unknown as {
|
||||
registerFont?: (name: string, src: string) => void
|
||||
_fontFamilies?: Record<string, unknown>
|
||||
roundedRect?: (x: number, y: number, w: number, h: number, r: number) => void
|
||||
}
|
||||
if (fs.existsSync(fontRegular)) {
|
||||
D.registerFont?.("Inter", fontRegular)
|
||||
}
|
||||
if (fs.existsSync(fontBold)) {
|
||||
D.registerFont?.("Inter-Bold", fontBold)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const D = doc as unknown as { _fontFamilies?: Record<string, unknown>; roundedRect?: (x:number,y:number,w:number,h:number,r:number)=>void }
|
||||
const hasInter = Boolean(D._fontFamilies && (D._fontFamilies as Record<string, unknown>)["Inter-Bold"])
|
||||
|
||||
// Header with logo and brand bar
|
||||
try {
|
||||
const logoPath = path.join(process.cwd(), "public", "raven.png")
|
||||
if (fs.existsSync(logoPath)) {
|
||||
doc.image(logoPath, doc.page.margins.left, doc.y, { width: 120 })
|
||||
}
|
||||
} catch {}
|
||||
doc.moveDown(0.5)
|
||||
doc
|
||||
.fillColor("#00e8ff")
|
||||
.rect(doc.page.margins.left, doc.y, doc.page.width - doc.page.margins.left - doc.page.margins.right, 3)
|
||||
.fill()
|
||||
doc.moveDown(0.5)
|
||||
|
||||
// Título
|
||||
doc.fillColor("#0F172A").font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference} — ${ticket.subject}`)
|
||||
doc.moveDown(0.25)
|
||||
// Linha abaixo do título
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.page.margins.left, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.stroke()
|
||||
|
||||
// Badge de status
|
||||
doc.moveDown(0.5)
|
||||
const statusText = statusLabel[ticket.status] ?? ticket.status
|
||||
const badgeColor = statusColors[ticket.status] ?? "#475569"
|
||||
const badgeFontSize = 10
|
||||
const badgePaddingX = 6
|
||||
const badgePaddingY = 3
|
||||
const badgeX = doc.page.margins.left
|
||||
const badgeY = doc.y
|
||||
doc.save()
|
||||
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(badgeFontSize)
|
||||
const badgeTextWidth = doc.widthOfString(statusText)
|
||||
const badgeHeight = badgeFontSize + badgePaddingY * 2
|
||||
const badgeWidth = badgeTextWidth + badgePaddingX * 2
|
||||
if (typeof D.roundedRect === "function") {
|
||||
D.roundedRect(badgeX, badgeY, badgeWidth, badgeHeight, 4)
|
||||
} else {
|
||||
doc.rect(badgeX, badgeY, badgeWidth, badgeHeight)
|
||||
}
|
||||
doc.fill(badgeColor)
|
||||
doc.fillColor("#FFFFFF").text(statusText, badgeX + badgePaddingX, badgeY + badgePaddingY)
|
||||
doc.restore()
|
||||
doc.y = badgeY + badgeHeight + 8
|
||||
|
||||
// Metadados em duas colunas
|
||||
const leftX = doc.page.margins.left
|
||||
const colGap = 24
|
||||
const colWidth = (doc.page.width - doc.page.margins.left - doc.page.margins.right - colGap) / 2
|
||||
const rightX = leftX + colWidth + colGap
|
||||
// const startY = doc.y
|
||||
const drawMeta = (x: number, lines: string[]) => {
|
||||
doc.save()
|
||||
doc.x = x
|
||||
doc.fillColor("#0F172A").font(hasInter ? "Inter" : "Helvetica").fontSize(11)
|
||||
for (const line of lines) {
|
||||
doc.text(line, { width: colWidth, lineGap: 2 })
|
||||
}
|
||||
const currY = doc.y
|
||||
doc.restore()
|
||||
return currY
|
||||
}
|
||||
const leftLines = [
|
||||
`Status: ${statusText}`,
|
||||
`Prioridade: ${priorityLabel[ticket.priority] ?? ticket.priority}`,
|
||||
`Canal: ${channelLabel[ticket.channel] ?? ticket.channel ?? "—"}`,
|
||||
`Fila: ${ticket.queue ?? "—"}`,
|
||||
]
|
||||
const rightLines = [
|
||||
`Solicitante: ${ticket.requester.name} (${ticket.requester.email})`,
|
||||
`Responsável: ${ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído"}`,
|
||||
`Criado em: ${formatDateTime(ticket.createdAt)}`,
|
||||
`Atualizado em: ${formatDateTime(ticket.updatedAt)}`,
|
||||
]
|
||||
const leftY = drawMeta(leftX, leftLines)
|
||||
const rightY = drawMeta(rightX, rightLines)
|
||||
doc.y = Math.max(leftY, rightY)
|
||||
doc.moveDown(0.5)
|
||||
|
||||
doc.moveDown(0.75)
|
||||
doc
|
||||
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
|
||||
.fontSize(12)
|
||||
.text("Solicitante")
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.page.margins.left, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.stroke()
|
||||
doc.moveDown(0.3)
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(11)
|
||||
.text(`${ticket.requester.name} (${ticket.requester.email})`)
|
||||
|
||||
doc.moveDown(0.5)
|
||||
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Responsável")
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.page.margins.left, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.stroke()
|
||||
doc.moveDown(0.3)
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(11)
|
||||
.text(ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído")
|
||||
|
||||
doc.moveDown(0.75)
|
||||
doc.font("Helvetica-Bold").fontSize(12).text("Datas")
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.page.margins.left, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.stroke()
|
||||
doc.moveDown(0.3)
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(11)
|
||||
.text(`Criado em: ${formatDateTime(ticket.createdAt)}`)
|
||||
.moveDown(0.15)
|
||||
.text(`Atualizado em: ${formatDateTime(ticket.updatedAt)}`)
|
||||
.moveDown(0.15)
|
||||
.text(`Resolvido em: ${formatDateTime(ticket.resolvedAt ?? null)}`)
|
||||
|
||||
if (ticket.summary) {
|
||||
doc.moveDown(0.75)
|
||||
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Resumo")
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.page.margins.left, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.stroke()
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(11)
|
||||
.text(ticket.summary, { align: "justify", lineGap: 2 })
|
||||
}
|
||||
|
||||
if (ticket.description) {
|
||||
doc.moveDown(0.75)
|
||||
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Descrição")
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.page.margins.left, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.stroke()
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(11)
|
||||
.text(htmlToPlainText(ticket.description), { align: "justify", lineGap: 2 })
|
||||
}
|
||||
|
||||
if (ticket.comments.length > 0) {
|
||||
doc.addPage()
|
||||
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(14).text("Comentários")
|
||||
doc.moveDown(0.6)
|
||||
const commentsSorted = [...ticket.comments].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
commentsSorted.forEach((comment, index) => {
|
||||
const visibility =
|
||||
comment.visibility === "PUBLIC" ? "Público" : "Interno"
|
||||
doc
|
||||
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
|
||||
.fontSize(11)
|
||||
.text(`${comment.author.name} • ${visibility} • ${formatDateTime(comment.createdAt)}`)
|
||||
doc.moveDown(0.15)
|
||||
const body = htmlToPlainText(comment.body)
|
||||
if (body) {
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(11)
|
||||
.text(body, { align: "justify", lineGap: 2, indent: 6 })
|
||||
}
|
||||
if (comment.attachments.length > 0) {
|
||||
doc.moveDown(0.25)
|
||||
doc.font(hasInter ? "Inter" : "Helvetica").fontSize(10).text("Anexos:")
|
||||
comment.attachments.forEach((attachment) => {
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(10)
|
||||
.text(`• ${attachment.name ?? attachment.id}`, { indent: 16, lineGap: 1 })
|
||||
})
|
||||
}
|
||||
if (index < commentsSorted.length - 1) {
|
||||
doc.moveDown(1)
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.x, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.stroke()
|
||||
doc.moveDown(0.9)
|
||||
}
|
||||
const pdfBuffer = await renderTicketPdfBuffer({ ticket, logoDataUrl })
|
||||
const payload = pdfBuffer instanceof Uint8Array ? pdfBuffer : new Uint8Array(pdfBuffer)
|
||||
return new NextResponse(payload as unknown as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="ticket-${ticket.reference}.pdf"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[tickets.export.pdf] Falha ao renderizar PDF", error)
|
||||
return NextResponse.json({ error: "Não foi possível gerar o PDF" }, { status: 500 })
|
||||
}
|
||||
|
||||
if (ticket.timeline.length > 0) {
|
||||
doc.addPage()
|
||||
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(14).text("Linha do tempo")
|
||||
doc.moveDown(0.6)
|
||||
const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
timelineSorted.forEach((event) => {
|
||||
const label = TICKET_TIMELINE_LABELS[event.type] ?? event.type
|
||||
doc
|
||||
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
|
||||
.fontSize(11)
|
||||
.text(`${label} • ${formatDateTime(event.createdAt)}`)
|
||||
doc.moveDown(0.15)
|
||||
|
||||
const friendly = buildTimelineMessage(event.type, event.payload)
|
||||
if (friendly) {
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(10)
|
||||
.text(friendly, { indent: 16, lineGap: 1 })
|
||||
} else {
|
||||
const payloadText = stringifyPayload(event.payload)
|
||||
if (payloadText) {
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(10)
|
||||
.text(payloadText, { indent: 16, lineGap: 1 })
|
||||
}
|
||||
}
|
||||
doc.moveDown(0.7)
|
||||
})
|
||||
}
|
||||
|
||||
doc.end()
|
||||
const pdfBuffer = await pdfBufferPromise
|
||||
const pdfBytes = new Uint8Array(pdfBuffer)
|
||||
|
||||
return new NextResponse(pdfBytes, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="ticket-${ticket.reference}.pdf"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
BIN
src/app/icon.png
Normal file
BIN
src/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
|
|
@ -18,10 +18,10 @@ const jetBrainsMono = JetBrains_Mono({
|
|||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Raven",
|
||||
title: "Raven - Sistema de chamados",
|
||||
description: "Plataforma Raven da Rever",
|
||||
icons: {
|
||||
icon: "/raven.png",
|
||||
icon: "/icon.png",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,12 +67,13 @@ export async function GET(request: NextRequest) {
|
|||
assignedUserRole: session.machine.assignedUserRole,
|
||||
}
|
||||
const encodedContext = Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url")
|
||||
const isSecure = redirectUrl.protocol === "https:"
|
||||
response.cookies.set({
|
||||
name: "machine_ctx",
|
||||
value: encodedContext,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
secure: isSecure,
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
})
|
||||
|
|
|
|||
5
src/app/tickets/resolved/page.tsx
Normal file
5
src/app/tickets/resolved/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { TicketsResolvedPageClient } from "./tickets-resolved-page-client"
|
||||
|
||||
export default function TicketsResolvedPage() {
|
||||
return <TicketsResolvedPageClient />
|
||||
}
|
||||
31
src/app/tickets/resolved/tickets-resolved-page-client.tsx
Normal file
31
src/app/tickets/resolved/tickets-resolved-page-client.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
|
||||
import { AppShell } from "@/components/app-shell"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
|
||||
const TicketsView = dynamic(
|
||||
() =>
|
||||
import("@/components/tickets/tickets-view").then((module) => ({
|
||||
default: module.TicketsView,
|
||||
})),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
export function TicketsResolvedPageClient() {
|
||||
return (
|
||||
<AppShell
|
||||
header={
|
||||
<SiteHeader
|
||||
title="Tickets resolvidos"
|
||||
lead="Histórico de atendimentos concluídos. Pesquise ou filtre por fila, empresa e canal."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<TicketsView initialFilters={{ view: "completed" }} />
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -55,6 +55,25 @@ type Props = {
|
|||
defaultTenantId: string
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
admin: "Administrador",
|
||||
manager: "Gestor",
|
||||
agent: "Agente",
|
||||
collaborator: "Colaborador",
|
||||
machine: "Agente de máquina",
|
||||
}
|
||||
|
||||
function formatRole(role: string) {
|
||||
const key = role?.toLowerCase?.() ?? ""
|
||||
return ROLE_LABELS[key] ?? role
|
||||
}
|
||||
|
||||
function formatTenantLabel(tenantId: string, defaultTenantId: string) {
|
||||
if (!tenantId) return "Principal"
|
||||
if (tenantId === defaultTenantId) return "Principal"
|
||||
return tenantId
|
||||
}
|
||||
|
||||
function formatDate(dateIso: string) {
|
||||
const date = new Date(dateIso)
|
||||
return new Intl.DateTimeFormat("pt-BR", {
|
||||
|
|
@ -206,8 +225,6 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
|
||||
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
|
||||
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
|
||||
<TabsTrigger value="queues" className="rounded-lg">Filas</TabsTrigger>
|
||||
<TabsTrigger value="categories" className="rounded-lg">Categorias</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="invites" className="mt-6 space-y-6">
|
||||
|
|
@ -255,25 +272,23 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<SelectContent>
|
||||
{normalizedRoles.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item === "admin"
|
||||
? "Administrador"
|
||||
: item === "manager"
|
||||
? "Gestor"
|
||||
: item === "agent"
|
||||
? "Agente"
|
||||
: "Colaborador"}
|
||||
{formatRole(item)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-tenant">Tenant</Label>
|
||||
<Label htmlFor="invite-tenant">Espaço (ID interno)</Label>
|
||||
<Input
|
||||
id="invite-tenant"
|
||||
value={tenantId}
|
||||
onChange={(event) => setTenantId(event.target.value)}
|
||||
placeholder="ex.: principal"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Use este campo apenas se trabalhar com múltiplos espaços de clientes. Caso contrário, mantenha o valor padrão.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Expira em</Label>
|
||||
|
|
@ -377,7 +392,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="py-3 pr-4 font-medium">Colaborador</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
<th className="py-3 pr-4 font-medium">Tenant</th>
|
||||
<th className="py-3 pr-4 font-medium">Espaço</th>
|
||||
<th className="py-3 pr-4 font-medium">Expira em</th>
|
||||
<th className="py-3 pr-4 font-medium">Status</th>
|
||||
<th className="py-3 font-medium">Ações</th>
|
||||
|
|
@ -392,8 +407,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<span className="text-xs text-neutral-500">{invite.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 uppercase text-neutral-600">{invite.role}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{invite.tenantId}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatRole(invite.role)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(invite.tenantId, defaultTenantId)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge
|
||||
|
|
@ -449,7 +464,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<th className="py-3 pr-4 font-medium">Nome</th>
|
||||
<th className="py-3 pr-4 font-medium">E-mail</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
<th className="py-3 pr-4 font-medium">Tenant</th>
|
||||
<th className="py-3 pr-4 font-medium">Espaço</th>
|
||||
<th className="py-3 font-medium">Criado em</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -458,8 +473,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
<td className="py-3 pr-4 uppercase text-neutral-600">{user.role}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.tenantId}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(user.tenantId, defaultTenantId)}</td>
|
||||
<td className="py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -476,27 +491,6 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="queues" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gestão de filas</CardTitle>
|
||||
<CardDescription>
|
||||
Em breve será possível criar e reordenar as filas utilizadas na triagem dos tickets.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="categories" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gestão de categorias</CardTitle>
|
||||
<CardDescription>
|
||||
Estamos preparando o painel completo para organizar categorias e subcategorias do catálogo.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
Layers3,
|
||||
UserPlus,
|
||||
BellRing,
|
||||
ChevronDown,
|
||||
} from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ import {
|
|||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { NavUser } from "@/components/nav-user"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
|
|
@ -47,9 +49,10 @@ type NavRoleRequirement = "staff" | "admin"
|
|||
type NavigationItem = {
|
||||
title: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
icon?: LucideIcon
|
||||
requiredRole?: NavRoleRequirement
|
||||
exact?: boolean
|
||||
children?: NavigationItem[]
|
||||
}
|
||||
|
||||
type NavigationGroup = {
|
||||
|
|
@ -65,7 +68,13 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
|
|||
title: "Operação",
|
||||
items: [
|
||||
{ title: "Dashboard", url: "/dashboard", icon: LayoutDashboard, requiredRole: "staff" },
|
||||
{ title: "Tickets", url: "/tickets", icon: Ticket, requiredRole: "staff" },
|
||||
{
|
||||
title: "Tickets",
|
||||
url: "/tickets",
|
||||
icon: Ticket,
|
||||
requiredRole: "staff",
|
||||
children: [{ title: "Resolvidos", url: "/tickets/resolved", requiredRole: "staff" }],
|
||||
},
|
||||
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" },
|
||||
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
|
||||
],
|
||||
|
|
@ -108,6 +117,31 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
const pathname = usePathname()
|
||||
const { session, isLoading, isAdmin, isStaff } = useAuth()
|
||||
const [isHydrated, setIsHydrated] = React.useState(false)
|
||||
const initialExpanded = React.useMemo(() => {
|
||||
const open = new Set<string>()
|
||||
navigation.navMain.forEach((group) => {
|
||||
group.items.forEach((item) => {
|
||||
if (!item.children || item.children.length === 0) return
|
||||
const shouldOpen = item.children.some((child) => {
|
||||
if (!canAccess(child.requiredRole)) return false
|
||||
return pathname === child.url || pathname.startsWith(`${child.url}/`)
|
||||
})
|
||||
if (shouldOpen) {
|
||||
open.add(item.title)
|
||||
}
|
||||
})
|
||||
})
|
||||
return open
|
||||
}, [pathname])
|
||||
const [expanded, setExpanded] = React.useState<Set<string>>(initialExpanded)
|
||||
|
||||
React.useEffect(() => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
initialExpanded.forEach((key) => next.add(key))
|
||||
return next
|
||||
})
|
||||
}, [initialExpanded])
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsHydrated(true)
|
||||
|
|
@ -132,6 +166,18 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
return false
|
||||
}
|
||||
|
||||
const toggleExpanded = React.useCallback((title: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(title)) {
|
||||
next.delete(title)
|
||||
} else {
|
||||
next.add(title)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!isHydrated) {
|
||||
return (
|
||||
<Sidebar {...props}>
|
||||
|
|
@ -180,16 +226,64 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{visibleItems.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild isActive={isActive(item)}>
|
||||
<a href={item.url} className="gap-2">
|
||||
<item.icon className="size-4" />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{visibleItems.map((item) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
const childItems = item.children.filter((child) => canAccess(child.requiredRole))
|
||||
const isExpanded = expanded.has(item.title)
|
||||
const isChildActive = childItems.some((child) => isActive(child))
|
||||
const parentActive = isActive(item) || isChildActive
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.title}>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={parentActive}>
|
||||
<a href={item.url} className={cn("gap-2", "relative pr-7") }>
|
||||
{item.icon ? <item.icon className="size-4" /> : null}
|
||||
<span className="flex-1">{item.title}</span>
|
||||
<span
|
||||
role="button"
|
||||
aria-label={isExpanded ? "Recolher submenu" : "Expandir submenu"}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleExpanded(item.title)
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-1.5 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-neutral-500 transition hover:bg-slate-200 hover:text-neutral-700",
|
||||
isExpanded && "rotate-180"
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{isExpanded
|
||||
? childItems.map((child) => (
|
||||
<SidebarMenuItem key={`${item.title}-${child.title}`}>
|
||||
<SidebarMenuButton asChild isActive={isActive(child)}>
|
||||
<a href={child.url} className="gap-2 pl-7 text-sm">
|
||||
<span>{child.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
: null}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild isActive={isActive(item)}>
|
||||
<a href={item.url} className="gap-2">
|
||||
{item.icon ? <item.icon className="size-4" /> : null}
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
|
|
|||
|
|
@ -26,9 +26,11 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
const { session, machineContext } = useAuth()
|
||||
const [isSigningOut, setIsSigningOut] = useState(false)
|
||||
|
||||
const isMachineSession = session?.user.role === "machine"
|
||||
const personaValue = machineContext?.persona ?? session?.user.machinePersona ?? null
|
||||
const displayName = machineContext?.assignedUserName ?? session?.user.name ?? session?.user.email ?? "Cliente"
|
||||
const displayEmail = machineContext?.assignedUserEmail ?? session?.user.email ?? ""
|
||||
const personaLabel = machineContext?.persona === "manager" ? "Gestor" : "Colaborador"
|
||||
const personaLabel = personaValue === "manager" ? "Gestor" : "Colaborador"
|
||||
|
||||
const initials = useMemo(() => {
|
||||
const name = displayName || displayEmail || "Cliente"
|
||||
|
|
@ -64,7 +66,7 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
<GalleryVerticalEnd className="size-4" />
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.28em] text-neutral-500">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-neutral-500">
|
||||
Portal do cliente
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-neutral-900">Raven</span>
|
||||
|
|
@ -100,12 +102,12 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-neutral-900">{displayName}</span>
|
||||
<span className="text-xs text-neutral-500">{displayEmail}</span>
|
||||
{machineContext ? (
|
||||
{personaValue ? (
|
||||
<span className="text-[10px] uppercase tracking-wide text-neutral-400">{personaLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{!machineContext ? (
|
||||
{!isMachineSession ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -31,11 +31,12 @@ function toHtml(text: string) {
|
|||
|
||||
export function PortalTicketForm() {
|
||||
const router = useRouter()
|
||||
const { convexUserId, session } = useAuth()
|
||||
const { convexUserId, session, machineContext } = useAuth()
|
||||
const createTicket = useMutation(api.tickets.create)
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
|
||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
const viewerId = (convexUserId ?? machineContext?.assignedUserId ?? null) as Id<"users"> | null
|
||||
|
||||
const [subject, setSubject] = useState("")
|
||||
const [summary, setSummary] = useState("")
|
||||
|
|
@ -51,7 +52,7 @@ export function PortalTicketForm() {
|
|||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!convexUserId || !isFormValid || isSubmitting) return
|
||||
if (!viewerId || !isFormValid || isSubmitting) return
|
||||
|
||||
const trimmedSubject = subject.trim()
|
||||
const trimmedSummary = summary.trim()
|
||||
|
|
@ -66,14 +67,14 @@ export function PortalTicketForm() {
|
|||
toast.loading("Abrindo chamado...", { id: "portal-new-ticket" })
|
||||
try {
|
||||
const id = await createTicket({
|
||||
actorId: convexUserId as Id<"users">,
|
||||
actorId: viewerId,
|
||||
tenantId,
|
||||
subject: trimmedSubject,
|
||||
summary: trimmedSummary || undefined,
|
||||
priority: DEFAULT_PRIORITY,
|
||||
channel: "MANUAL",
|
||||
queueId: undefined,
|
||||
requesterId: convexUserId as Id<"users">,
|
||||
requesterId: viewerId,
|
||||
categoryId: categoryId as Id<"ticketCategories">,
|
||||
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
|
||||
})
|
||||
|
|
@ -89,7 +90,7 @@ export function PortalTicketForm() {
|
|||
}))
|
||||
await addComment({
|
||||
ticketId: id as Id<"tickets">,
|
||||
authorId: convexUserId as Id<"users">,
|
||||
authorId: viewerId,
|
||||
visibility: "PUBLIC",
|
||||
body: htmlBody,
|
||||
attachments: typedAttachments,
|
||||
|
|
@ -186,7 +187,7 @@ export function PortalTicketForm() {
|
|||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isFormValid || isSubmitting}
|
||||
disabled={!isFormValid || isSubmitting || !viewerId}
|
||||
className="rounded-full bg-neutral-900 px-6 text-sm font-semibold text-white hover:bg-neutral-900/90"
|
||||
>
|
||||
Registrar chamado
|
||||
|
|
|
|||
|
|
@ -16,14 +16,16 @@ import { Button } from "@/components/ui/button"
|
|||
import { PortalTicketCard } from "@/components/portal/portal-ticket-card"
|
||||
|
||||
export function PortalTicketList() {
|
||||
const { convexUserId, session } = useAuth()
|
||||
const { convexUserId, session, machineContext } = useAuth()
|
||||
|
||||
const viewerId = (convexUserId ?? machineContext?.assignedUserId ?? null) as Id<"users"> | null
|
||||
|
||||
const ticketsRaw = useQuery(
|
||||
api.tickets.list,
|
||||
convexUserId
|
||||
viewerId
|
||||
? {
|
||||
tenantId: session?.user.tenantId ?? DEFAULT_TENANT_ID,
|
||||
viewerId: convexUserId as Id<"users">,
|
||||
viewerId,
|
||||
limit: 100,
|
||||
}
|
||||
: "skip"
|
||||
|
|
@ -34,7 +36,9 @@ export function PortalTicketList() {
|
|||
return mapTicketsFromServerList((ticketsRaw as unknown[]) ?? [])
|
||||
}, [ticketsRaw])
|
||||
|
||||
if (ticketsRaw === undefined) {
|
||||
const isLoading = Boolean(viewerId && ticketsRaw === undefined)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="flex items-center gap-2 px-5 py-5">
|
||||
|
|
@ -48,7 +52,7 @@ export function PortalTicketList() {
|
|||
)
|
||||
}
|
||||
|
||||
if (!tickets.length) {
|
||||
if (!viewerId || !tickets.length) {
|
||||
return (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="px-5 py-5">
|
||||
|
|
|
|||
|
|
@ -15,15 +15,17 @@ import { Input } from "@/components/ui/input"
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
export function CommentTemplatesManager() {
|
||||
const { convexUserId, session } = useAuth()
|
||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
const viewerId = convexUserId as Id<"users"> | undefined
|
||||
const [activeKind, setActiveKind] = useState<"comment" | "closing">("comment")
|
||||
|
||||
const templates = useQuery(
|
||||
viewerId ? api.commentTemplates.list : "skip",
|
||||
viewerId ? { tenantId, viewerId } : "skip"
|
||||
viewerId ? { tenantId, viewerId, kind: activeKind } : "skip"
|
||||
) as
|
||||
| {
|
||||
id: Id<"commentTemplates">
|
||||
|
|
@ -33,6 +35,7 @@ export function CommentTemplatesManager() {
|
|||
updatedAt: number
|
||||
createdBy: Id<"users">
|
||||
updatedBy: Id<"users"> | null
|
||||
kind: "comment" | "closing" | string
|
||||
}[]
|
||||
| undefined
|
||||
|
||||
|
|
@ -48,6 +51,27 @@ export function CommentTemplatesManager() {
|
|||
|
||||
const orderedTemplates = useMemo(() => templates ?? [], [templates])
|
||||
|
||||
const kindLabels: Record<typeof activeKind, { title: string; description: string; placeholder: string; empty: { title: string; description: string } }> = {
|
||||
comment: {
|
||||
title: "Templates de comentário",
|
||||
description: "Mantenha respostas rápidas prontas para uso. Administradores e agentes podem criar, editar e remover templates.",
|
||||
placeholder: "Escreva a mensagem padrão...",
|
||||
empty: {
|
||||
title: "Nenhum template cadastrado",
|
||||
description: "Crie seu primeiro template de comentário usando o formulário acima.",
|
||||
},
|
||||
},
|
||||
closing: {
|
||||
title: "Templates de encerramento",
|
||||
description: "Padronize as mensagens de fechamento de tickets. Os nomes dos clientes podem ser inseridos automaticamente com {{cliente}}.",
|
||||
placeholder: "Conteúdo da mensagem de encerramento...",
|
||||
empty: {
|
||||
title: "Nenhum template de encerramento",
|
||||
description: "Cadastre mensagens padrão para encerrar tickets rapidamente.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!viewerId) return
|
||||
|
|
@ -64,7 +88,7 @@ export function CommentTemplatesManager() {
|
|||
setIsSubmitting(true)
|
||||
toast.loading("Criando template...", { id: "create-template" })
|
||||
try {
|
||||
await createTemplate({ tenantId, actorId: viewerId, title: trimmedTitle, body: sanitizedBody })
|
||||
await createTemplate({ tenantId, actorId: viewerId, title: trimmedTitle, body: sanitizedBody, kind: activeKind })
|
||||
toast.success("Template criado!", { id: "create-template" })
|
||||
setTitle("")
|
||||
setBody("")
|
||||
|
|
@ -76,7 +100,7 @@ export function CommentTemplatesManager() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(templateId: Id<"commentTemplates">, nextTitle: string, nextBody: string) {
|
||||
async function handleUpdate(templateId: Id<"commentTemplates">, nextTitle: string, nextBody: string, kind: "comment" | "closing" | string) {
|
||||
if (!viewerId) return
|
||||
const trimmedTitle = nextTitle.trim()
|
||||
const sanitizedBody = sanitizeEditorHtml(nextBody)
|
||||
|
|
@ -97,6 +121,7 @@ export function CommentTemplatesManager() {
|
|||
actorId: viewerId,
|
||||
title: trimmedTitle,
|
||||
body: sanitizedBody,
|
||||
kind,
|
||||
})
|
||||
toast.success("Template atualizado!", { id: toastId })
|
||||
return true
|
||||
|
|
@ -134,13 +159,24 @@ export function CommentTemplatesManager() {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-slate-200">
|
||||
<CardHeader className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl font-semibold text-neutral-900">Templates de comentário</CardTitle>
|
||||
<CardDescription className="text-sm text-neutral-600">
|
||||
Mantenha respostas rápidas prontas para uso. Administradores e agentes podem criar, editar e remover templates.
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl font-semibold text-neutral-900">Templates rápidos</CardTitle>
|
||||
<CardDescription className="text-sm text-neutral-600">
|
||||
Gerencie mensagens padrão para comentários e encerramentos de tickets.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Tabs value={activeKind} onValueChange={(value) => setActiveKind(value as "comment" | "closing")} className="w-full">
|
||||
<TabsList className="h-10 w-full justify-start rounded-lg bg-slate-100 p-1">
|
||||
<TabsTrigger value="comment" className="rounded-md px-4 py-1.5 text-sm font-medium">Comentários</TabsTrigger>
|
||||
<TabsTrigger value="closing" className="rounded-md px-4 py-1.5 text-sm font-medium">Encerramentos</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="mb-4 text-sm text-neutral-600">
|
||||
{kindLabels[activeKind].description}
|
||||
</CardDescription>
|
||||
<form className="space-y-4" onSubmit={handleCreate}>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="template-title" className="text-sm font-medium text-neutral-800">
|
||||
|
|
@ -158,7 +194,7 @@ export function CommentTemplatesManager() {
|
|||
<label htmlFor="template-body" className="text-sm font-medium text-neutral-800">
|
||||
Conteúdo padrão
|
||||
</label>
|
||||
<RichTextEditor value={body} onChange={setBody} minHeight={180} placeholder="Escreva a mensagem padrão..." />
|
||||
<RichTextEditor value={body} onChange={setBody} minHeight={180} placeholder={kindLabels[activeKind].placeholder} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
{body ? (
|
||||
|
|
@ -189,7 +225,7 @@ export function CommentTemplatesManager() {
|
|||
<IconFileText className="size-5 text-neutral-500" /> Templates cadastrados
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-neutral-600">
|
||||
Gerencie as mensagens prontas utilizadas nos comentários de tickets.
|
||||
Gerencie as mensagens prontas utilizadas nos {activeKind === "comment" ? "comentários" : "encerramentos"} de tickets.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -203,8 +239,8 @@ export function CommentTemplatesManager() {
|
|||
<EmptyMedia variant="icon">
|
||||
<IconFileText className="size-5 text-neutral-500" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum template cadastrado</EmptyTitle>
|
||||
<EmptyDescription>Crie seu primeiro template usando o formulário acima.</EmptyDescription>
|
||||
<EmptyTitle>{kindLabels[activeKind].empty.title}</EmptyTitle>
|
||||
<EmptyDescription>{kindLabels[activeKind].empty.description}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
|
|
@ -231,8 +267,9 @@ type TemplateItemProps = {
|
|||
title: string
|
||||
body: string
|
||||
updatedAt: number
|
||||
kind: "comment" | "closing" | string
|
||||
}
|
||||
onSave: (templateId: Id<"commentTemplates">, title: string, body: string) => Promise<boolean | void>
|
||||
onSave: (templateId: Id<"commentTemplates">, title: string, body: string, kind: "comment" | "closing" | string) => Promise<boolean | void>
|
||||
onDelete: (templateId: Id<"commentTemplates">) => Promise<void>
|
||||
}
|
||||
|
||||
|
|
@ -247,7 +284,7 @@ function TemplateItem({ template, onSave, onDelete }: TemplateItemProps) {
|
|||
|
||||
async function handleSave() {
|
||||
setIsSaving(true)
|
||||
const ok = await onSave(template.id, title, body)
|
||||
const ok = await onSave(template.id, title, body, template.kind ?? "comment")
|
||||
setIsSaving(false)
|
||||
if (ok !== false) {
|
||||
setIsEditing(false)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
|
@ -10,17 +10,28 @@ import { Button } from "@/components/ui/button"
|
|||
import { AlertTriangle, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
||||
export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) {
|
||||
const router = useRouter()
|
||||
const remove = useMutation(api.tickets.remove)
|
||||
const { convexUserId, isAdmin } = useAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const viewerId = useMemo(() => (isAdmin && convexUserId ? (convexUserId as Id<"users">) : null), [isAdmin, convexUserId])
|
||||
|
||||
if (!viewerId) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
setLoading(true)
|
||||
toast.loading("Excluindo ticket...", { id: "del" })
|
||||
try {
|
||||
await remove({ ticketId })
|
||||
if (!viewerId) {
|
||||
throw new Error("missing actor")
|
||||
}
|
||||
await remove({ ticketId, actorId: viewerId })
|
||||
toast.success("Ticket excluído.", { id: "del" })
|
||||
setOpen(false)
|
||||
router.push("/tickets")
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
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 type { TicketStatus } from "@/lib/schemas/ticket"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { RichTextEditor } from "@/components/ui/rich-text-editor"
|
||||
import { toast } from "sonner"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD";
|
||||
|
|
@ -32,44 +37,289 @@ const itemClass = "rounded-md px-2 py-2 text-sm text-neutral-800 transition hove
|
|||
const baseBadgeClass =
|
||||
"inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-semibold transition hover:border-slate-300"
|
||||
|
||||
export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) {
|
||||
type ClosingTemplate = {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
const DEFAULT_PHONE_NUMBER = "(11) 4173-5368"
|
||||
|
||||
const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
|
||||
{
|
||||
id: "default-standard",
|
||||
title: "Encerramento padrão",
|
||||
body: sanitizeEditorHtml(`
|
||||
<p>Olá {{cliente}},</p>
|
||||
<p>A equipe da Raven agradece o contato. Este ticket está sendo encerrado.</p>
|
||||
<p>Se surgirem novas questões, você pode reabrir o ticket em até 7 dias ou nos contatar pelo número <strong>${DEFAULT_PHONE_NUMBER}</strong>. Obrigado.</p>
|
||||
<p>👍 👀 🙌<br />Gabriel Henrique · Raven</p>
|
||||
`),
|
||||
},
|
||||
{
|
||||
id: "default-no-contact",
|
||||
title: "Tentativa de contato sem sucesso",
|
||||
body: sanitizeEditorHtml(`
|
||||
<p>Prezado(a) {{cliente}},</p>
|
||||
<p>Realizamos uma tentativa de contato, mas não obtivemos sucesso.</p>
|
||||
<p>Por favor, retorne assim que possível para seguirmos com as verificações necessárias.</p>
|
||||
<p>Este ticket será encerrado após 3 tentativas realizadas sem sucesso.</p>
|
||||
<p>Telefone para contato: <strong>${DEFAULT_PHONE_NUMBER}</strong>.</p>
|
||||
<p>👍 👀 🙌<br />Gabriel Henrique · Raven</p>
|
||||
`),
|
||||
},
|
||||
{
|
||||
id: "default-closed-after-attempts",
|
||||
title: "Encerramento após 3 tentativas",
|
||||
body: sanitizeEditorHtml(`
|
||||
<p>Prezado(a) {{cliente}},</p>
|
||||
<p>Esse ticket está sendo encerrado pois realizamos 3 tentativas sem retorno.</p>
|
||||
<p>Você pode reabrir este ticket em até 7 dias ou entrar em contato pelo telefone <strong>${DEFAULT_PHONE_NUMBER}</strong> quando preferir.</p>
|
||||
<p>👍 👀 🙌<br />Gabriel Henrique · Raven</p>
|
||||
`),
|
||||
},
|
||||
]
|
||||
|
||||
function applyTemplatePlaceholders(html: string, customerName?: string | null) {
|
||||
const normalizedName = customerName?.trim()
|
||||
const fallback = normalizedName && normalizedName.length > 0 ? normalizedName : "cliente"
|
||||
return html.replace(/{{\s*(cliente|customer|customername|nome|nomecliente)\s*}}/gi, fallback)
|
||||
}
|
||||
|
||||
export function StatusSelect({
|
||||
ticketId,
|
||||
value,
|
||||
tenantId,
|
||||
requesterName,
|
||||
}: {
|
||||
ticketId: string
|
||||
value: TicketStatus
|
||||
tenantId: string
|
||||
requesterName?: string | null
|
||||
}) {
|
||||
const updateStatus = useMutation(api.tickets.updateStatus)
|
||||
const [status, setStatus] = useState<TicketStatus>(value)
|
||||
const { convexUserId } = useAuth()
|
||||
const actorId = (convexUserId ?? null) as Id<"users"> | null
|
||||
const [status, setStatus] = useState<TicketStatus>(value)
|
||||
const [closeDialogOpen, setCloseDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setStatus(value)
|
||||
}, [value])
|
||||
|
||||
const handleStatusChange = async (selected: string) => {
|
||||
const next = selected as TicketStatus
|
||||
if (next === "RESOLVED") {
|
||||
setCloseDialogOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
const previous = status
|
||||
setStatus(next)
|
||||
toast.loading("Atualizando status...", { id: "status" })
|
||||
try {
|
||||
if (!actorId) {
|
||||
throw new Error("missing user")
|
||||
}
|
||||
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId })
|
||||
toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setStatus(previous)
|
||||
toast.error("Não foi possível atualizar o status.", { id: "status" })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={async (selected) => {
|
||||
const previous = status
|
||||
const next = selected as TicketStatus
|
||||
setStatus(next)
|
||||
toast.loading("Atualizando status...", { id: "status" })
|
||||
try {
|
||||
if (!convexUserId) throw new Error("missing user")
|
||||
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId: convexUserId as Id<"users"> })
|
||||
toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
|
||||
} catch {
|
||||
setStatus(previous)
|
||||
toast.error("Não foi possível atualizar o status.", { id: "status" })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
|
||||
<SelectValue asChild>
|
||||
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass ?? statusStyles.PENDING.badgeClass)}>
|
||||
{statusStyles[status]?.label ?? statusStyles.PENDING.label}
|
||||
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
|
||||
</Badge>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option} className={itemClass}>
|
||||
{statusStyles[option].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<>
|
||||
<Select value={status} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
|
||||
<SelectValue asChild>
|
||||
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass ?? statusStyles.PENDING.badgeClass)}>
|
||||
{statusStyles[status]?.label ?? statusStyles.PENDING.label}
|
||||
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
|
||||
</Badge>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option} className={itemClass}>
|
||||
{statusStyles[option].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CloseTicketDialog
|
||||
open={closeDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCloseDialogOpen(false)
|
||||
}}
|
||||
ticketId={ticketId}
|
||||
tenantId={tenantId}
|
||||
actorId={actorId}
|
||||
requesterName={requesterName}
|
||||
onSuccess={() => {
|
||||
setStatus("RESOLVED")
|
||||
setCloseDialogOpen(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type CloseTicketDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
ticketId: string
|
||||
tenantId: string
|
||||
actorId: Id<"users"> | null
|
||||
requesterName?: string | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function CloseTicketDialog({ open, onOpenChange, ticketId, tenantId, actorId, requesterName, onSuccess }: CloseTicketDialogProps) {
|
||||
const updateStatus = useMutation(api.tickets.updateStatus)
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
|
||||
const closingTemplates = useQuery(
|
||||
actorId && open ? api.commentTemplates.list : "skip",
|
||||
actorId && open ? { tenantId, viewerId: actorId, kind: "closing" as const } : "skip"
|
||||
) as { id: string; title: string; body: string }[] | undefined
|
||||
|
||||
const templatesLoading = Boolean(actorId && open && closingTemplates === undefined)
|
||||
|
||||
const templates = useMemo<ClosingTemplate[]>(() => {
|
||||
if (closingTemplates && closingTemplates.length > 0) {
|
||||
return closingTemplates.map((template) => ({ id: template.id, title: template.title, body: template.body }))
|
||||
}
|
||||
return DEFAULT_CLOSING_TEMPLATES
|
||||
}, [closingTemplates])
|
||||
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string>("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedTemplateId(null)
|
||||
setMessage("")
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
if (templates.length > 0 && !selectedTemplateId && !message) {
|
||||
const first = templates[0]
|
||||
const hydrated = sanitizeEditorHtml(applyTemplatePlaceholders(first.body, requesterName))
|
||||
setSelectedTemplateId(first.id)
|
||||
setMessage(hydrated)
|
||||
}
|
||||
}, [open, templates, requesterName, selectedTemplateId, message])
|
||||
|
||||
const handleTemplateSelect = (template: ClosingTemplate) => {
|
||||
setSelectedTemplateId(template.id)
|
||||
const filled = sanitizeEditorHtml(applyTemplatePlaceholders(template.body, requesterName))
|
||||
setMessage(filled)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!actorId) {
|
||||
toast.error("É necessário estar autenticado para encerrar o ticket.")
|
||||
return
|
||||
}
|
||||
setIsSubmitting(true)
|
||||
toast.loading("Encerrando ticket...", { id: "close-ticket" })
|
||||
try {
|
||||
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: "RESOLVED", actorId })
|
||||
const withPlaceholders = applyTemplatePlaceholders(message, requesterName)
|
||||
const sanitized = sanitizeEditorHtml(withPlaceholders)
|
||||
const hasContent = sanitized.replace(/<[^>]*>/g, "").trim().length > 0
|
||||
if (hasContent) {
|
||||
await addComment({
|
||||
ticketId: ticketId as unknown as Id<"tickets">,
|
||||
authorId: actorId,
|
||||
visibility: "PUBLIC",
|
||||
body: sanitized,
|
||||
attachments: [],
|
||||
})
|
||||
}
|
||||
toast.success("Ticket encerrado com sucesso!", { id: "close-ticket" })
|
||||
onOpenChange(false)
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível encerrar o ticket.", { id: "close-ticket" })
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Encerrar ticket</DialogTitle>
|
||||
<DialogDescription>
|
||||
Confirme a mensagem de encerramento que será enviada ao cliente. Você pode personalizar o texto antes de concluir.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-neutral-800">Modelos rápidos</p>
|
||||
{templatesLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
||||
<Spinner className="size-4" /> Carregando templates...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{templates.map((template) => (
|
||||
<Button
|
||||
key={template.id}
|
||||
type="button"
|
||||
variant={selectedTemplateId === template.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
>
|
||||
{template.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500">
|
||||
Use <code className="rounded bg-slate-100 px-1 py-0.5">{"{{cliente}}"}</code> dentro do template para inserir automaticamente o nome do solicitante.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-neutral-800">Mensagem de encerramento</p>
|
||||
<RichTextEditor
|
||||
value={message}
|
||||
onChange={setMessage}
|
||||
minHeight={220}
|
||||
placeholder="Escreva uma mensagem final para o cliente..."
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">Você pode editar o conteúdo antes de enviar. Deixe em branco para encerrar sem comentário adicional.</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex flex-wrap justify-between gap-3 pt-4">
|
||||
<div className="text-xs text-neutral-500">
|
||||
O comentário será público e ficará registrado no histórico do ticket.
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setMessage("")
|
||||
setSelectedTemplateId(null)
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Limpar mensagem
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSubmit} disabled={isSubmitting || !actorId}>
|
||||
{isSubmitting ? <Spinner className="size-4 text-white" /> : "Encerrar ticket"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
|
||||
|
||||
const templateArgs = convexUserId && isStaff
|
||||
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> }
|
||||
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users">, kind: "comment" as const }
|
||||
: "skip"
|
||||
const templatesResult = useQuery(convexUserId && isStaff ? api.commentTemplates.list : "skip", templateArgs) as
|
||||
| { id: string; title: string; body: string }[]
|
||||
|
|
|
|||
|
|
@ -499,7 +499,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
</Badge>
|
||||
) : null}
|
||||
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
||||
<StatusSelect ticketId={ticket.id} value={status} />
|
||||
<StatusSelect
|
||||
ticketId={ticket.id}
|
||||
value={status}
|
||||
tenantId={ticket.tenantId}
|
||||
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
|
||||
/>
|
||||
{isPlaying ? (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -84,12 +84,24 @@ interface TicketsFiltersProps {
|
|||
onChange?: (filters: TicketFiltersState) => void
|
||||
queues?: QueueOption[]
|
||||
companies?: string[]
|
||||
initialState?: Partial<TicketFiltersState>
|
||||
}
|
||||
|
||||
const ALL_VALUE = "ALL"
|
||||
|
||||
export function TicketsFilters({ onChange, queues = [], companies = [] }: TicketsFiltersProps) {
|
||||
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
|
||||
export function TicketsFilters({ onChange, queues = [], companies = [], initialState }: TicketsFiltersProps) {
|
||||
const mergedDefaults = useMemo(
|
||||
() => ({
|
||||
...defaultTicketFilters,
|
||||
...(initialState ?? {}),
|
||||
}),
|
||||
[initialState]
|
||||
)
|
||||
const [filters, setFilters] = useState<TicketFiltersState>(mergedDefaults)
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(mergedDefaults)
|
||||
}, [mergedDefaults])
|
||||
|
||||
function setPartial(partial: Partial<TicketFiltersState>) {
|
||||
setFilters((prev) => ({ ...prev, ...partial }))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
|
@ -12,8 +12,23 @@ import { TicketsTable } from "@/components/tickets/tickets-table"
|
|||
import { useAuth } from "@/lib/auth-client"
|
||||
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
||||
|
||||
export function TicketsView() {
|
||||
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
|
||||
type TicketsViewProps = {
|
||||
initialFilters?: Partial<TicketFiltersState>
|
||||
}
|
||||
|
||||
export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
||||
const mergedInitialFilters = useMemo(
|
||||
() => ({
|
||||
...defaultTicketFilters,
|
||||
...(initialFilters ?? {}),
|
||||
}),
|
||||
[initialFilters]
|
||||
)
|
||||
const [filters, setFilters] = useState<TicketFiltersState>(mergedInitialFilters)
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(mergedInitialFilters)
|
||||
}, [mergedInitialFilters])
|
||||
const { session, convexUserId, isStaff } = useAuth()
|
||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
||||
|
|
@ -73,7 +88,12 @@ export function TicketsView() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q) => q.name)} companies={companies} />
|
||||
<TicketsFilters
|
||||
onChange={setFilters}
|
||||
queues={(queues ?? []).map((q) => q.name)}
|
||||
companies={companies}
|
||||
initialState={mergedInitialFilters}
|
||||
/>
|
||||
{ticketsRaw === undefined ? (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="grid gap-3">
|
||||
|
|
|
|||
511
src/server/pdf/ticket-pdf-template.tsx
Normal file
511
src/server/pdf/ticket-pdf-template.tsx
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
/* eslint-disable jsx-a11y/alt-text */
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { format } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import sanitizeHtml from "sanitize-html"
|
||||
import {
|
||||
Document,
|
||||
Font,
|
||||
Image,
|
||||
Page,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
renderToBuffer,
|
||||
} from "@react-pdf/renderer"
|
||||
|
||||
import type { TicketWithDetails, TicketComment, TicketEvent } from "../../lib/schemas/ticket"
|
||||
import { TICKET_TIMELINE_LABELS } from "../../lib/ticket-timeline-labels"
|
||||
|
||||
Font.register({ family: "Inter", fonts: [] })
|
||||
|
||||
const interRegular = path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-Regular.ttf")
|
||||
const interSemiBold = path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-SemiBold.ttf")
|
||||
const fallbackRegular = path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_18pt-Regular.ttf")
|
||||
|
||||
try {
|
||||
if (fs.existsSync(interRegular)) {
|
||||
Font.register({ family: "Inter", src: interRegular })
|
||||
} else if (fs.existsSync(fallbackRegular)) {
|
||||
Font.register({ family: "Inter", src: fallbackRegular })
|
||||
}
|
||||
|
||||
if (fs.existsSync(interSemiBold)) {
|
||||
Font.register({ family: "Inter-Semi", src: interSemiBold, fontWeight: 600 })
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[pdf] Não foi possível registrar fontes Inter, usando Helvetica", error)
|
||||
}
|
||||
|
||||
const BASE_FONT = Font.getRegisteredFontFamilies().includes("Inter") ? "Inter" : "Helvetica"
|
||||
const SEMI_FONT = Font.getRegisteredFontFamilies().includes("Inter-Semi") ? "Inter-Semi" : BASE_FONT
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 32,
|
||||
paddingBottom: 32,
|
||||
paddingHorizontal: 36,
|
||||
fontFamily: BASE_FONT,
|
||||
backgroundColor: "#F8FAFC",
|
||||
color: "#0F172A",
|
||||
fontSize: 11,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 12,
|
||||
borderColor: "#E2E8F0",
|
||||
borderWidth: 1,
|
||||
padding: 18,
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
alignItems: "center",
|
||||
marginBottom: 18,
|
||||
},
|
||||
headerText: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
headerTitle: {
|
||||
fontFamily: SEMI_FONT,
|
||||
fontSize: 20,
|
||||
color: "#0F172A",
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 10,
|
||||
letterSpacing: 1,
|
||||
textTransform: "uppercase",
|
||||
color: "#64748B",
|
||||
fontFamily: SEMI_FONT,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontFamily: SEMI_FONT,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
section: {
|
||||
marginBottom: 20,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 12,
|
||||
borderColor: "#E2E8F0",
|
||||
borderWidth: 1,
|
||||
padding: 18,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontFamily: SEMI_FONT,
|
||||
fontSize: 13,
|
||||
color: "#1E293B",
|
||||
marginBottom: 10,
|
||||
},
|
||||
metaGrid: {
|
||||
flexDirection: "row",
|
||||
gap: 18,
|
||||
},
|
||||
metaColumn: {
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
metaItem: {
|
||||
gap: 4,
|
||||
},
|
||||
metaLabel: {
|
||||
fontSize: 9,
|
||||
letterSpacing: 1,
|
||||
color: "#64748B",
|
||||
textTransform: "uppercase",
|
||||
fontFamily: SEMI_FONT,
|
||||
},
|
||||
metaValue: {
|
||||
color: "#0F172A",
|
||||
},
|
||||
bodyText: {
|
||||
color: "#334155",
|
||||
lineHeight: 1.45,
|
||||
},
|
||||
chipList: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 6,
|
||||
marginTop: 6,
|
||||
},
|
||||
chip: {
|
||||
backgroundColor: "#E2E8F0",
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 4,
|
||||
fontSize: 9,
|
||||
fontFamily: SEMI_FONT,
|
||||
color: "#475569",
|
||||
},
|
||||
card: {
|
||||
borderColor: "#E2E8F0",
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
marginBottom: 12,
|
||||
backgroundColor: "#FFFFFF",
|
||||
gap: 6,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
cardTitle: {
|
||||
fontFamily: SEMI_FONT,
|
||||
fontSize: 11,
|
||||
color: "#0F172A",
|
||||
},
|
||||
cardSubtitle: {
|
||||
color: "#64748B",
|
||||
fontSize: 9,
|
||||
},
|
||||
cardFooterTitle: {
|
||||
fontFamily: SEMI_FONT,
|
||||
fontSize: 9,
|
||||
marginTop: 6,
|
||||
color: "#475569",
|
||||
},
|
||||
timelineCard: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
timelineDetails: {
|
||||
fontSize: 10,
|
||||
color: "#4B5563",
|
||||
},
|
||||
})
|
||||
|
||||
const statusStyles: Record<string, { backgroundColor: string; color: string; label: string }> = {
|
||||
PENDING: { backgroundColor: "#F1F5F9", color: "#0F172A", label: "Pendente" },
|
||||
AWAITING_ATTENDANCE: { backgroundColor: "#E0F2FE", color: "#0369A1", label: "Aguardando atendimento" },
|
||||
PAUSED: { backgroundColor: "#FEF3C7", color: "#92400E", label: "Pausado" },
|
||||
RESOLVED: { backgroundColor: "#DCFCE7", color: "#166534", label: "Resolvido" },
|
||||
}
|
||||
|
||||
const priorityLabel: Record<string, string> = {
|
||||
LOW: "Baixa",
|
||||
MEDIUM: "Média",
|
||||
HIGH: "Alta",
|
||||
URGENT: "Urgente",
|
||||
CRITICAL: "Crítica",
|
||||
}
|
||||
|
||||
const channelLabel: Record<string, string> = {
|
||||
EMAIL: "E-mail",
|
||||
PHONE: "Telefone",
|
||||
CHAT: "Chat",
|
||||
PORTAL: "Portal",
|
||||
WEB: "Portal",
|
||||
API: "API",
|
||||
SOCIAL: "Redes sociais",
|
||||
OTHER: "Outro",
|
||||
WHATSAPP: "WhatsApp",
|
||||
MANUAL: "Manual",
|
||||
}
|
||||
|
||||
const sanitizeOptions: sanitizeHtml.IOptions = {
|
||||
allowedTags: ["p", "br", "strong", "em", "u", "s", "ul", "ol", "li", "blockquote"],
|
||||
allowedAttributes: {},
|
||||
selfClosing: ["br"],
|
||||
allowVulnerableTags: false,
|
||||
}
|
||||
|
||||
function formatDateTime(date: Date | null | undefined) {
|
||||
if (!date) return "—"
|
||||
return format(date, "dd/MM/yyyy HH:mm", { locale: ptBR })
|
||||
}
|
||||
|
||||
function sanitizeToPlainText(html?: string | null) {
|
||||
if (!html) return ""
|
||||
const stripped = sanitizeHtml(html, sanitizeOptions)
|
||||
return stripped
|
||||
.replace(/<br\s*\/>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n\n")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function buildTimelineMessage(type: string, payload: Record<string, unknown> | null | undefined): string | null {
|
||||
const p = payload ?? {}
|
||||
const to = (p.toLabel as string | undefined) ?? (p.to as string | undefined)
|
||||
const assignee = (p.assigneeName as string | undefined) ?? (p.assigneeId as string | undefined)
|
||||
const queue = (p.queueName as string | undefined) ?? (p.queueId as string | undefined)
|
||||
const requester = p.requesterName as string | undefined
|
||||
const author = (p.authorName as string | undefined) ?? (p.authorId as string | undefined)
|
||||
const actor = (p.actorName as string | undefined) ?? (p.actorId as string | undefined)
|
||||
const attachmentName = p.attachmentName as string | undefined
|
||||
const subjectTo = p.to as string | undefined
|
||||
const pauseReason = (p.pauseReasonLabel as string | undefined) ?? (p.pauseReason as string | undefined)
|
||||
const pauseNote = p.pauseNote as string | undefined
|
||||
const sessionDuration = (p.sessionDurationMs as number | undefined) ?? null
|
||||
const sessionText = sessionDuration ? formatDurationMs(sessionDuration) : null
|
||||
const categoryName = p.categoryName as string | undefined
|
||||
const subcategoryName = p.subcategoryName as string | undefined
|
||||
|
||||
switch (type) {
|
||||
case "STATUS_CHANGED":
|
||||
return to ? `Status alterado para ${to}` : "Status alterado"
|
||||
case "ASSIGNEE_CHANGED":
|
||||
return assignee ? `Responsável alterado para ${assignee}` : "Responsável alterado"
|
||||
case "QUEUE_CHANGED":
|
||||
return queue ? `Fila alterada para ${queue}` : "Fila alterada"
|
||||
case "PRIORITY_CHANGED":
|
||||
return to ? `Prioridade alterada para ${to}` : "Prioridade alterada"
|
||||
case "CREATED":
|
||||
return requester ? `Criado por ${requester}` : "Criado"
|
||||
case "COMMENT_ADDED":
|
||||
return author ? `Comentário adicionado por ${author}` : "Comentário adicionado"
|
||||
case "COMMENT_EDITED": {
|
||||
const who = actor ?? author
|
||||
return who ? `Comentário editado por ${who}` : "Comentário editado"
|
||||
}
|
||||
case "SUBJECT_CHANGED":
|
||||
return subjectTo ? `Assunto alterado para "${subjectTo}"` : "Assunto alterado"
|
||||
case "SUMMARY_CHANGED":
|
||||
return "Resumo atualizado"
|
||||
case "ATTACHMENT_REMOVED":
|
||||
return attachmentName ? `Anexo removido: ${attachmentName}` : "Anexo removido"
|
||||
case "WORK_PAUSED": {
|
||||
const parts: string[] = []
|
||||
if (pauseReason) parts.push(`Motivo: ${pauseReason}`)
|
||||
if (sessionText) parts.push(`Tempo registrado: ${sessionText}`)
|
||||
if (pauseNote) parts.push(`Observação: ${pauseNote}`)
|
||||
return parts.length > 0 ? parts.join(" • ") : "Atendimento pausado"
|
||||
}
|
||||
case "WORK_STARTED":
|
||||
return "Atendimento iniciado"
|
||||
case "CATEGORY_CHANGED": {
|
||||
if (categoryName || subcategoryName) {
|
||||
return `Categoria alterada para ${categoryName ?? ""}${subcategoryName ? ` • ${subcategoryName}` : ""}`.trim()
|
||||
}
|
||||
return "Categoria removida"
|
||||
}
|
||||
case "MANAGER_NOTIFIED":
|
||||
return "Gestor notificado"
|
||||
case "VISIT_SCHEDULED":
|
||||
return "Visita agendada"
|
||||
case "CSAT_RECEIVED":
|
||||
return "CSAT recebido"
|
||||
case "CSAT_RATED":
|
||||
return "CSAT avaliado"
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatDurationMs(ms: number) {
|
||||
if (!ms || ms <= 0) return "0s"
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`
|
||||
if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
function stringifyPayload(payload: unknown) {
|
||||
if (!payload) return null
|
||||
try {
|
||||
return JSON.stringify(payload, null, 2)
|
||||
} catch {
|
||||
return String(payload)
|
||||
}
|
||||
}
|
||||
|
||||
const statusInfo = (status: string) => statusStyles[status] ?? statusStyles.PENDING
|
||||
|
||||
function TicketPdfDocument({ ticket, logoDataUrl }: { ticket: TicketWithDetails; logoDataUrl?: string | null }) {
|
||||
const status = statusInfo(ticket.status)
|
||||
const requester = ticket.requester
|
||||
const assignee = ticket.assignee
|
||||
const comments = [...ticket.comments]
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.map((comment) => ({
|
||||
...comment,
|
||||
safeBody: sanitizeToPlainText(comment.body) || "Sem texto",
|
||||
})) as Array<TicketComment & { safeBody: string }>
|
||||
|
||||
const timeline = [...ticket.timeline]
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.map((event) => {
|
||||
const label = TICKET_TIMELINE_LABELS[event.type] ?? event.type
|
||||
const details = buildTimelineMessage(event.type, event.payload)
|
||||
const raw = details ?? stringifyPayload(event.payload)
|
||||
return {
|
||||
...event,
|
||||
label,
|
||||
description: raw,
|
||||
}
|
||||
}) as Array<TicketEvent & { label: string; description: string | null }>
|
||||
|
||||
const leftMeta = [
|
||||
{ label: "Status", value: status.label },
|
||||
{ label: "Prioridade", value: priorityLabel[ticket.priority] ?? ticket.priority },
|
||||
{ label: "Canal", value: channelLabel[ticket.channel] ?? ticket.channel ?? "—" },
|
||||
{ label: "Fila", value: ticket.queue ?? "—" },
|
||||
]
|
||||
|
||||
if (ticket.company?.name) {
|
||||
leftMeta.push({ label: "Empresa", value: ticket.company.name })
|
||||
}
|
||||
if (ticket.category?.name) {
|
||||
leftMeta.push({
|
||||
label: "Categoria",
|
||||
value: ticket.subcategory?.name ? `${ticket.category.name} • ${ticket.subcategory.name}` : ticket.category.name,
|
||||
})
|
||||
}
|
||||
if (ticket.tags?.length) {
|
||||
leftMeta.push({ label: "Tags", value: ticket.tags.join(", ") })
|
||||
}
|
||||
|
||||
const rightMeta = [
|
||||
{ label: "Solicitante", value: `${requester.name} (${requester.email})` },
|
||||
{ label: "Responsável", value: assignee ? `${assignee.name} (${assignee.email})` : "Não atribuído" },
|
||||
{ label: "Criado em", value: formatDateTime(ticket.createdAt) },
|
||||
{ label: "Atualizado em", value: formatDateTime(ticket.updatedAt) },
|
||||
]
|
||||
if (ticket.resolvedAt) {
|
||||
rightMeta.push({ label: "Resolvido em", value: formatDateTime(ticket.resolvedAt) })
|
||||
}
|
||||
|
||||
const description = sanitizeToPlainText(ticket.description)
|
||||
|
||||
return (
|
||||
<Document title={`Ticket #${ticket.reference}`}>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.header}>
|
||||
{logoDataUrl ? <Image src={logoDataUrl} style={{ width: 72, height: 72, borderRadius: 12 }} /> : null}
|
||||
<View style={styles.headerText}>
|
||||
<Text style={styles.headerSubtitle}>Ticket #{ticket.reference}</Text>
|
||||
<Text style={styles.headerTitle}>{ticket.subject}</Text>
|
||||
</View>
|
||||
<View style={{ paddingLeft: 6 }}>
|
||||
<Text
|
||||
style={{
|
||||
...styles.statusBadge,
|
||||
backgroundColor: status.backgroundColor,
|
||||
color: status.color,
|
||||
}}
|
||||
>
|
||||
{status.label}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.metaGrid}>
|
||||
<View style={styles.metaColumn}>
|
||||
{leftMeta.map((item) => (
|
||||
<View key={item.label} style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>{item.label}</Text>
|
||||
<Text style={styles.metaValue}>{item.value}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.metaColumn}>
|
||||
{rightMeta.map((item) => (
|
||||
<View key={item.label} style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>{item.label}</Text>
|
||||
<Text style={styles.metaValue}>{item.value}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{ticket.summary ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Resumo</Text>
|
||||
<Text style={styles.bodyText}>{ticket.summary}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{description ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Descrição</Text>
|
||||
<Text style={styles.bodyText}>{description}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{ticket.tags && ticket.tags.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Etiquetas</Text>
|
||||
<View style={styles.chipList}>
|
||||
{ticket.tags.map((tag) => (
|
||||
<Text key={tag} style={styles.chip}>
|
||||
{tag}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{comments.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Comentários</Text>
|
||||
{comments.map((comment) => (
|
||||
<View key={comment.id} style={styles.card} wrap={false}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View>
|
||||
<Text style={styles.cardTitle}>{comment.author.name}</Text>
|
||||
<Text style={styles.cardSubtitle}>
|
||||
{formatDateTime(comment.createdAt)} • {comment.visibility === "PUBLIC" ? "Público" : "Interno"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.bodyText}>{comment.safeBody}</Text>
|
||||
{comment.attachments.length > 0 ? (
|
||||
<View>
|
||||
<Text style={styles.cardFooterTitle}>Anexos</Text>
|
||||
{comment.attachments.map((attachment) => (
|
||||
<Text key={attachment.id} style={{ fontSize: 9, color: "#475569" }}>
|
||||
• {attachment.name ?? attachment.id}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{timeline.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Linha do tempo</Text>
|
||||
{timeline.map((event) => (
|
||||
<View key={event.id} style={[styles.card, styles.timelineCard]} wrap={false}>
|
||||
<Text style={styles.cardTitle}>{event.label}</Text>
|
||||
<Text style={styles.cardSubtitle}>{formatDateTime(event.createdAt)}</Text>
|
||||
{event.description ? (
|
||||
<Text style={styles.timelineDetails}>{event.description}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</Page>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderTicketPdfBuffer({
|
||||
ticket,
|
||||
logoDataUrl,
|
||||
}: {
|
||||
ticket: TicketWithDetails
|
||||
logoDataUrl?: string | null
|
||||
}) {
|
||||
const doc = <TicketPdfDocument ticket={ticket} logoDataUrl={logoDataUrl} />
|
||||
const buffer = await renderToBuffer(doc)
|
||||
return buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue