feat: melhora página de perfil e integra preferências de notificação
- Atualiza cores das badges para padrão cyan do projeto - Adiciona degradê no header do card de perfil - Implementa upload de foto de perfil via API Convex - Integra notificações do Convex com preferências do usuário - Cria API /api/notifications/send para verificar preferências - Melhora layout das páginas de login/recuperação com degradê - Adiciona badge "Helpdesk" e título "Raven" consistente 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1bc08d3a5f
commit
ab7dfa81ca
8 changed files with 543 additions and 59 deletions
|
|
@ -8,6 +8,45 @@ import { v } from "convex/values"
|
|||
import { renderSimpleNotificationEmailHtml } from "./reactEmail"
|
||||
import { buildBaseUrl } from "./url"
|
||||
|
||||
// API do Next.js para verificar preferências
|
||||
async function sendViaNextApi(params: {
|
||||
type: string
|
||||
to: { email: string; name?: string; userId?: string }
|
||||
subject: string
|
||||
data: Record<string, unknown>
|
||||
tenantId?: string
|
||||
}): Promise<{ success: boolean; skipped?: boolean; reason?: string }> {
|
||||
const baseUrl = buildBaseUrl()
|
||||
const token = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET
|
||||
|
||||
if (!token) {
|
||||
console.warn("[ticketNotifications] Token interno não configurado, enviando diretamente")
|
||||
return { success: false, reason: "no_token" }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/notifications/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
console.error("[ticketNotifications] Erro na API:", error)
|
||||
return { success: false, reason: "api_error" }
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("[ticketNotifications] Erro ao chamar API:", error)
|
||||
return { success: false, reason: "fetch_error" }
|
||||
}
|
||||
}
|
||||
|
||||
function b64(input: string) {
|
||||
return Buffer.from(input, "utf8").toString("base64")
|
||||
}
|
||||
|
|
@ -284,17 +323,15 @@ async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html:
|
|||
export const sendTicketCreatedEmail = action({
|
||||
args: {
|
||||
to: v.string(),
|
||||
userId: v.optional(v.string()),
|
||||
userName: v.optional(v.string()),
|
||||
ticketId: v.string(),
|
||||
reference: v.number(),
|
||||
subject: v.string(),
|
||||
priority: v.string(),
|
||||
tenantId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (_ctx, { to, ticketId, reference, subject, priority }) => {
|
||||
const smtp = buildSmtpConfig()
|
||||
if (!smtp) {
|
||||
console.warn("SMTP not configured; skipping ticket created email")
|
||||
return { skipped: true }
|
||||
}
|
||||
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, priority, tenantId }) => {
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
|
||||
|
|
@ -305,8 +342,34 @@ export const sendTicketCreatedEmail = action({
|
|||
URGENT: "Urgente",
|
||||
}
|
||||
const priorityLabel = priorityLabels[priority] ?? priority
|
||||
|
||||
const mailSubject = `Novo chamado #${reference} aberto`
|
||||
|
||||
// Tenta usar a API do Next.js para verificar preferências
|
||||
const apiResult = await sendViaNextApi({
|
||||
type: "ticket_created",
|
||||
to: { email: to, name: userName, userId },
|
||||
subject: mailSubject,
|
||||
data: {
|
||||
reference,
|
||||
subject,
|
||||
status: "Pendente",
|
||||
priority: priorityLabel,
|
||||
viewUrl: url,
|
||||
},
|
||||
tenantId,
|
||||
})
|
||||
|
||||
if (apiResult.success || apiResult.skipped) {
|
||||
return apiResult
|
||||
}
|
||||
|
||||
// Fallback: envia diretamente se a API falhar
|
||||
const smtp = buildSmtpConfig()
|
||||
if (!smtp) {
|
||||
console.warn("SMTP not configured; skipping ticket created email")
|
||||
return { skipped: true }
|
||||
}
|
||||
|
||||
const html = await renderSimpleNotificationEmailHtml({
|
||||
title: `Novo chamado #${reference} aberto`,
|
||||
message: `Seu chamado foi registrado com sucesso. Nossa equipe irá analisá-lo em breve.\n\nAssunto: ${subject}\nPrioridade: ${priorityLabel}\nStatus: Pendente`,
|
||||
|
|
@ -321,22 +384,45 @@ export const sendTicketCreatedEmail = action({
|
|||
export const sendPublicCommentEmail = action({
|
||||
args: {
|
||||
to: v.string(),
|
||||
userId: v.optional(v.string()),
|
||||
userName: v.optional(v.string()),
|
||||
ticketId: v.string(),
|
||||
reference: v.number(),
|
||||
subject: v.string(),
|
||||
tenantId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (_ctx, { to, ticketId, reference, subject }) => {
|
||||
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
|
||||
|
||||
// Tenta usar a API do Next.js para verificar preferências
|
||||
const apiResult = await sendViaNextApi({
|
||||
type: "comment_public",
|
||||
to: { email: to, name: userName, userId },
|
||||
subject: mailSubject,
|
||||
data: {
|
||||
reference,
|
||||
subject,
|
||||
viewUrl: url,
|
||||
},
|
||||
tenantId,
|
||||
})
|
||||
|
||||
if (apiResult.success || apiResult.skipped) {
|
||||
return apiResult
|
||||
}
|
||||
|
||||
// Fallback: envia diretamente se a API falhar
|
||||
const smtp = buildSmtpConfig()
|
||||
if (!smtp) {
|
||||
console.warn("SMTP not configured; skipping ticket comment email")
|
||||
return { skipped: true }
|
||||
}
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Atualização no chamado #${reference}: ${subject}`
|
||||
|
||||
const html = await renderSimpleNotificationEmailHtml({
|
||||
title: `Nova atualização no seu chamado #${reference}`,
|
||||
message: `Um novo comentário foi adicionado ao chamado “${subject}”. Clique abaixo para visualizar e responder pelo portal.`,
|
||||
message: `Um novo comentário foi adicionado ao chamado "${subject}". Clique abaixo para visualizar e responder pelo portal.`,
|
||||
ctaLabel: "Abrir e responder",
|
||||
ctaUrl: url,
|
||||
})
|
||||
|
|
@ -348,22 +434,45 @@ export const sendPublicCommentEmail = action({
|
|||
export const sendResolvedEmail = action({
|
||||
args: {
|
||||
to: v.string(),
|
||||
userId: v.optional(v.string()),
|
||||
userName: v.optional(v.string()),
|
||||
ticketId: v.string(),
|
||||
reference: v.number(),
|
||||
subject: v.string(),
|
||||
tenantId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (_ctx, { to, ticketId, reference, subject }) => {
|
||||
handler: async (_ctx, { to, userId, userName, ticketId, reference, subject, tenantId }) => {
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Seu chamado #${reference} foi encerrado`
|
||||
|
||||
// Tenta usar a API do Next.js para verificar preferências
|
||||
const apiResult = await sendViaNextApi({
|
||||
type: "ticket_resolved",
|
||||
to: { email: to, name: userName, userId },
|
||||
subject: mailSubject,
|
||||
data: {
|
||||
reference,
|
||||
subject,
|
||||
viewUrl: url,
|
||||
},
|
||||
tenantId,
|
||||
})
|
||||
|
||||
if (apiResult.success || apiResult.skipped) {
|
||||
return apiResult
|
||||
}
|
||||
|
||||
// Fallback: envia diretamente se a API falhar
|
||||
const smtp = buildSmtpConfig()
|
||||
if (!smtp) {
|
||||
console.warn("SMTP not configured; skipping ticket resolution email")
|
||||
return { skipped: true }
|
||||
}
|
||||
const baseUrl = buildBaseUrl()
|
||||
const url = `${baseUrl}/portal/tickets/${ticketId}`
|
||||
const mailSubject = `Seu chamado #${reference} foi encerrado`
|
||||
|
||||
const html = await renderSimpleNotificationEmailHtml({
|
||||
title: `Chamado #${reference} encerrado`,
|
||||
message: `O chamado “${subject}” foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
|
||||
message: `O chamado "${subject}" foi marcado como concluído. Caso necessário, você pode responder pelo portal para reabrir dentro do prazo.`,
|
||||
ctaLabel: "Ver detalhes",
|
||||
ctaUrl: url,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2464,10 +2464,13 @@ export const create = mutation({
|
|||
if (typeof schedulerRunAfter === "function") {
|
||||
await schedulerRunAfter(0, api.ticketNotifications.sendTicketCreatedEmail, {
|
||||
to: requesterEmail,
|
||||
userId: String(requester._id),
|
||||
userName: requester.name ?? undefined,
|
||||
ticketId: String(id),
|
||||
reference: nextRef,
|
||||
subject,
|
||||
priority: args.priority,
|
||||
tenantId: args.tenantId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2870,15 +2873,19 @@ export const addComment = mutation({
|
|||
await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch });
|
||||
// Notificação por e-mail: comentário público para o solicitante
|
||||
try {
|
||||
const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email
|
||||
const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
|
||||
const snapshotEmail = requesterSnapshot?.email
|
||||
if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
|
||||
const schedulerRunAfter = ctx.scheduler?.runAfter
|
||||
if (typeof schedulerRunAfter === "function") {
|
||||
await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
|
||||
to: snapshotEmail,
|
||||
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
|
||||
userName: requesterSnapshot?.name ?? undefined,
|
||||
ticketId: String(ticketDoc._id),
|
||||
reference: ticketDoc.reference ?? 0,
|
||||
subject: ticketDoc.subject ?? "",
|
||||
tenantId: ticketDoc.tenantId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -3146,16 +3153,21 @@ export async function resolveTicketHandler(
|
|||
|
||||
// Notificação por e-mail: encerramento do chamado
|
||||
try {
|
||||
const requesterDoc = await ctx.db.get(ticketDoc.requesterId)
|
||||
const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null
|
||||
const requesterDoc = await ctx.db.get(ticketDoc.requesterId) as Doc<"users"> | null
|
||||
const requesterSnapshot = ticketDoc.requesterSnapshot as { email?: string; name?: string } | undefined
|
||||
const email = requesterDoc?.email || requesterSnapshot?.email || null
|
||||
const userName = requesterDoc?.name || requesterSnapshot?.name || undefined
|
||||
if (email) {
|
||||
const schedulerRunAfter = ctx.scheduler?.runAfter
|
||||
if (typeof schedulerRunAfter === "function") {
|
||||
await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, {
|
||||
to: email,
|
||||
userId: ticketDoc.requesterId ? String(ticketDoc.requesterId) : undefined,
|
||||
userName,
|
||||
ticketId: String(ticketId),
|
||||
reference: ticketDoc.reference ?? 0,
|
||||
subject: ticketDoc.subject ?? "",
|
||||
tenantId: ticketDoc.tenantId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
168
src/app/api/notifications/send/route.ts
Normal file
168
src/app/api/notifications/send/route.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* API de Envio de Notificações
|
||||
* Chamada pelo Convex para enviar e-mails respeitando preferências do usuário
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { sendEmail, type NotificationType, type TemplateName, NOTIFICATION_TYPES } from "@/server/email"
|
||||
|
||||
// Token de autenticação interna (deve ser o mesmo usado no Convex)
|
||||
const INTERNAL_TOKEN = process.env.INTERNAL_HEALTH_TOKEN ?? process.env.REPORTS_CRON_SECRET
|
||||
|
||||
const sendNotificationSchema = z.object({
|
||||
type: z.enum([
|
||||
"ticket_created",
|
||||
"ticket_assigned",
|
||||
"ticket_resolved",
|
||||
"ticket_reopened",
|
||||
"ticket_status_changed",
|
||||
"ticket_priority_changed",
|
||||
"comment_public",
|
||||
"comment_response",
|
||||
"sla_at_risk",
|
||||
"sla_breached",
|
||||
"automation",
|
||||
]),
|
||||
to: z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
}),
|
||||
subject: z.string(),
|
||||
data: z.record(z.any()),
|
||||
tenantId: z.string().optional(),
|
||||
skipPreferenceCheck: z.boolean().optional(),
|
||||
})
|
||||
|
||||
async function shouldSendNotification(
|
||||
userId: string | undefined,
|
||||
notificationType: NotificationType | "automation",
|
||||
tenantId?: string
|
||||
): Promise<boolean> {
|
||||
// Automações sempre passam (são configuradas separadamente)
|
||||
if (notificationType === "automation") return true
|
||||
|
||||
// Se não tem userId, não pode verificar preferências
|
||||
if (!userId) return true
|
||||
|
||||
try {
|
||||
const prefs = await prisma.notificationPreferences.findUnique({
|
||||
where: { userId },
|
||||
})
|
||||
|
||||
// Se não tem preferências, usa os defaults
|
||||
if (!prefs) return true
|
||||
|
||||
// Se e-mail está desabilitado globalmente
|
||||
if (!prefs.emailEnabled) return false
|
||||
|
||||
// Verifica se é um tipo obrigatório
|
||||
const config = NOTIFICATION_TYPES[notificationType as NotificationType]
|
||||
if (config?.required) return true
|
||||
|
||||
// Verifica preferências por tipo
|
||||
const typePrefs = prefs.typePreferences
|
||||
? JSON.parse(prefs.typePreferences as string)
|
||||
: {}
|
||||
|
||||
if (notificationType in typePrefs) {
|
||||
return typePrefs[notificationType] !== false
|
||||
}
|
||||
|
||||
// Usa o default do tipo
|
||||
return config?.defaultEnabled ?? true
|
||||
} catch (error) {
|
||||
console.error("[notifications/send] Erro ao verificar preferências:", error)
|
||||
// Em caso de erro, envia a notificação
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function getTemplateForType(type: string): string {
|
||||
const templateMap: Record<string, string> = {
|
||||
ticket_created: "ticket_created",
|
||||
ticket_assigned: "ticket_assigned",
|
||||
ticket_resolved: "ticket_resolved",
|
||||
ticket_reopened: "ticket_status",
|
||||
ticket_status_changed: "ticket_status",
|
||||
ticket_priority_changed: "ticket_status",
|
||||
comment_public: "ticket_comment",
|
||||
comment_response: "ticket_comment",
|
||||
sla_at_risk: "sla_warning",
|
||||
sla_breached: "sla_breached",
|
||||
automation: "automation",
|
||||
}
|
||||
return templateMap[type] ?? "simple_notification"
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Verifica autenticação
|
||||
const authHeader = request.headers.get("authorization")
|
||||
const token = authHeader?.replace("Bearer ", "")
|
||||
|
||||
if (!INTERNAL_TOKEN || token !== INTERNAL_TOKEN) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = sendNotificationSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Dados inválidos", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { type, to, subject, data, tenantId, skipPreferenceCheck } = parsed.data
|
||||
|
||||
// Verifica preferências do usuário
|
||||
if (!skipPreferenceCheck) {
|
||||
const shouldSend = await shouldSendNotification(
|
||||
to.userId,
|
||||
type as NotificationType | "automation",
|
||||
tenantId
|
||||
)
|
||||
|
||||
if (!shouldSend) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: "user_preference_disabled",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Envia o e-mail
|
||||
const template = getTemplateForType(type)
|
||||
const result = await sendEmail({
|
||||
to: {
|
||||
email: to.email,
|
||||
name: to.name,
|
||||
userId: to.userId,
|
||||
},
|
||||
subject,
|
||||
template,
|
||||
data,
|
||||
notificationType: type === "automation" ? undefined : (type as NotificationType),
|
||||
tenantId,
|
||||
skipPreferenceCheck: true, // Já verificamos acima
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: result.success,
|
||||
skipped: result.skipped,
|
||||
reason: result.reason,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[notifications/send] Erro:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Erro interno do servidor" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
88
src/app/api/profile/avatar/route.ts
Normal file
88
src/app/api/profile/avatar/route.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* API de Upload de Avatar
|
||||
* POST - Faz upload de uma nova foto de perfil
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
import { getServerSession } from "@/lib/auth-server"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { createConvexClient } from "@/server/convex-client"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get("file") as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "Nenhum arquivo enviado" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Valida tipo
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Tipo de arquivo não permitido. Use JPG, PNG, WebP ou GIF." },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Valida tamanho
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: "Arquivo muito grande. Máximo 5MB." },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const convex = createConvexClient()
|
||||
|
||||
// Gera URL de upload
|
||||
const uploadUrl = await convex.action(api.files.generateUploadUrl, {})
|
||||
|
||||
// Faz upload do arquivo
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": file.type },
|
||||
body: file,
|
||||
})
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
console.error("[profile/avatar] Erro no upload:", await uploadResponse.text())
|
||||
return NextResponse.json({ error: "Erro ao fazer upload" }, { status: 500 })
|
||||
}
|
||||
|
||||
const { storageId } = (await uploadResponse.json()) as { storageId: Id<"_storage"> }
|
||||
|
||||
// Obtém URL pública do arquivo
|
||||
const avatarUrl = await convex.action(api.files.getUrl, { storageId })
|
||||
|
||||
if (!avatarUrl) {
|
||||
return NextResponse.json({ error: "Erro ao obter URL do avatar" }, { status: 500 })
|
||||
}
|
||||
|
||||
// Atualiza o usuário no banco
|
||||
await prisma.authUser.update({
|
||||
where: { id: session.user.id },
|
||||
data: { image: avatarUrl },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
avatarUrl,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[profile/avatar] Erro:", error)
|
||||
return NextResponse.json({ error: "Erro interno do servidor" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -54,12 +54,19 @@ export function LoginPageClient() {
|
|||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-6 p-6 md:p-10">
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<Link href="/" className="text-xl font-semibold text-neutral-900">
|
||||
<div className="flex flex-col leading-none items-center">
|
||||
<span>Sistema de chamados</span>
|
||||
<span className="mt-0.5 text-xs text-muted-foreground">Por Rever Tecnologia</span>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Link href="/" className="group flex flex-col items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold tracking-tight text-neutral-900">
|
||||
Raven
|
||||
</span>
|
||||
<span className="rounded-full bg-cyan-100 px-2.5 py-0.5 text-xs font-medium text-cyan-700 border border-cyan-200">
|
||||
Helpdesk
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-neutral-500">
|
||||
Por Rever Tecnologia
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
|
|
@ -81,8 +88,16 @@ export function LoginPageClient() {
|
|||
Desenvolvido por Esdras Renan
|
||||
</footer>
|
||||
</div>
|
||||
<div className="relative hidden overflow-hidden lg:flex">
|
||||
<ShaderBackground className="h-full w-full" />
|
||||
<div className="relative hidden overflow-hidden bg-gradient-to-br from-neutral-800 via-neutral-700 to-neutral-600 lg:flex">
|
||||
<ShaderBackground className="h-full w-full opacity-50" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-white">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Bem-vindo de volta</h2>
|
||||
<p className="mt-2 text-neutral-300">
|
||||
Gerencie seus chamados e tickets de forma simples
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -63,12 +63,19 @@ export function ForgotPasswordPageClient() {
|
|||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-6 p-6 md:p-10">
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<Link href="/" className="text-xl font-semibold text-neutral-900">
|
||||
<div className="flex flex-col leading-none items-center">
|
||||
<span>Sistema de chamados</span>
|
||||
<span className="mt-0.5 text-xs text-muted-foreground">Por Rever Tecnologia</span>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Link href="/" className="group flex flex-col items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold tracking-tight text-neutral-900">
|
||||
Raven
|
||||
</span>
|
||||
<span className="rounded-full bg-cyan-100 px-2.5 py-0.5 text-xs font-medium text-cyan-700 border border-cyan-200">
|
||||
Helpdesk
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-neutral-500">
|
||||
Por Rever Tecnologia
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
|
|
@ -99,8 +106,16 @@ export function ForgotPasswordPageClient() {
|
|||
Desenvolvido por Esdras Renan
|
||||
</footer>
|
||||
</div>
|
||||
<div className="relative hidden overflow-hidden lg:flex">
|
||||
<ShaderBackground className="h-full w-full" />
|
||||
<div className="relative hidden overflow-hidden bg-gradient-to-br from-neutral-800 via-neutral-700 to-neutral-600 lg:flex">
|
||||
<ShaderBackground className="h-full w-full opacity-50" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-white">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Recuperar acesso</h2>
|
||||
<p className="mt-2 text-neutral-300">
|
||||
Enviaremos as instruções para seu e-mail
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -96,12 +96,19 @@ export function ResetPasswordPageClient() {
|
|||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-6 p-6 md:p-10">
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<Link href="/" className="text-xl font-semibold text-neutral-900">
|
||||
<div className="flex flex-col leading-none items-center">
|
||||
<span>Sistema de chamados</span>
|
||||
<span className="mt-0.5 text-xs text-muted-foreground">Por Rever Tecnologia</span>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Link href="/" className="group flex flex-col items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold tracking-tight text-neutral-900">
|
||||
Raven
|
||||
</span>
|
||||
<span className="rounded-full bg-cyan-100 px-2.5 py-0.5 text-xs font-medium text-cyan-700 border border-cyan-200">
|
||||
Helpdesk
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-neutral-500">
|
||||
Por Rever Tecnologia
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
|
|
@ -135,8 +142,16 @@ export function ResetPasswordPageClient() {
|
|||
Desenvolvido por Esdras Renan
|
||||
</footer>
|
||||
</div>
|
||||
<div className="relative hidden overflow-hidden lg:flex">
|
||||
<ShaderBackground className="h-full w-full" />
|
||||
<div className="relative hidden overflow-hidden bg-gradient-to-br from-neutral-800 via-neutral-700 to-neutral-600 lg:flex">
|
||||
<ShaderBackground className="h-full w-full opacity-50" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-white">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Nova senha</h2>
|
||||
<p className="mt-2 text-neutral-300">
|
||||
Crie uma senha segura para sua conta
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
"use client"
|
||||
|
||||
import { FormEvent, useMemo, useState } from "react"
|
||||
import { FormEvent, useMemo, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Settings2,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
UserPlus,
|
||||
|
|
@ -18,10 +17,10 @@ import {
|
|||
Mail,
|
||||
Key,
|
||||
User,
|
||||
Building2,
|
||||
Shield,
|
||||
Clock,
|
||||
Camera,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
|
|
@ -55,11 +54,11 @@ const ROLE_LABELS: Record<string, string> = {
|
|||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
admin: "bg-violet-100 text-violet-700 border-violet-200",
|
||||
manager: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
agent: "bg-cyan-100 text-cyan-700 border-cyan-200",
|
||||
collaborator: "bg-slate-100 text-slate-700 border-slate-200",
|
||||
customer: "bg-slate-100 text-slate-700 border-slate-200",
|
||||
admin: "bg-cyan-100 text-cyan-800 border-cyan-300",
|
||||
manager: "bg-cyan-50 text-cyan-700 border-cyan-200",
|
||||
agent: "bg-cyan-50 text-cyan-600 border-cyan-200",
|
||||
collaborator: "bg-neutral-100 text-neutral-600 border-neutral-200",
|
||||
customer: "bg-neutral-100 text-neutral-600 border-neutral-200",
|
||||
}
|
||||
|
||||
const SETTINGS_ACTIONS: SettingsAction[] = [
|
||||
|
|
@ -326,6 +325,55 @@ function ProfileEditCard({
|
|||
const [newPassword, setNewPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [localAvatarUrl, setLocalAvatarUrl] = useState(avatarUrl)
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function handleAvatarUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Valida tamanho (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error("Arquivo muito grande. Máximo 5MB.")
|
||||
return
|
||||
}
|
||||
|
||||
// Valida tipo
|
||||
if (!["image/jpeg", "image/png", "image/webp", "image/gif"].includes(file.type)) {
|
||||
toast.error("Tipo de arquivo não permitido. Use JPG, PNG, WebP ou GIF.")
|
||||
return
|
||||
}
|
||||
|
||||
setIsUploadingAvatar(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
|
||||
const res = await fetch("/api/profile/avatar", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: "Erro ao fazer upload" }))
|
||||
throw new Error(data.error || "Erro ao fazer upload")
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setLocalAvatarUrl(data.avatarUrl)
|
||||
toast.success("Foto atualizada com sucesso!")
|
||||
} catch (error) {
|
||||
console.error("Erro ao fazer upload:", error)
|
||||
toast.error(error instanceof Error ? error.message : "Erro ao fazer upload da foto")
|
||||
} finally {
|
||||
setIsUploadingAvatar(false)
|
||||
// Limpa o input para permitir reselecionar o mesmo arquivo
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
const nameChanged = editName.trim() !== name
|
||||
|
|
@ -387,25 +435,39 @@ function ProfileEditCard({
|
|||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Card className="rounded-2xl border border-border/60 bg-white shadow-sm overflow-hidden">
|
||||
{/* Header com degradê */}
|
||||
<div className="h-20 bg-gradient-to-r from-neutral-800 via-neutral-700 to-neutral-600" />
|
||||
<CardHeader className="pb-4 -mt-10">
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="relative group">
|
||||
<Avatar className="size-16 border-2 border-white shadow-md">
|
||||
<AvatarImage src={avatarUrl ?? undefined} alt={name} />
|
||||
<AvatarFallback className="bg-gradient-to-br from-cyan-500 to-blue-600 text-lg font-semibold text-white">
|
||||
<Avatar className="size-20 border-4 border-white shadow-lg ring-2 ring-neutral-200">
|
||||
<AvatarImage src={localAvatarUrl ?? undefined} alt={name} />
|
||||
<AvatarFallback className="bg-gradient-to-br from-cyan-500 to-cyan-600 text-xl font-semibold text-white">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={handleAvatarUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={() => toast.info("Upload de foto em breve!")}
|
||||
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100 disabled:cursor-not-allowed"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploadingAvatar}
|
||||
>
|
||||
<Camera className="size-5 text-white" />
|
||||
{isUploadingAvatar ? (
|
||||
<Loader2 className="size-5 text-white animate-spin" />
|
||||
) : (
|
||||
<Camera className="size-5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 pb-1">
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">{name || "Usuário"}</CardTitle>
|
||||
<CardDescription className="text-sm text-neutral-500">{email}</CardDescription>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue