feat(ui): melhora UX do formulario de tickets
All checks were successful
All checks were successful
- Adiciona skeleton loading no formulario de novo chamado do portal - Remove texto confuso do tipo de solicitacao padrao - Padroniza estilo dos labels Categoria/Subcategoria com os demais campos - Move botao "Criar" do header para parte inferior do modal na web 🤖 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
811ad0641a
commit
385a8ee3df
4 changed files with 150 additions and 50 deletions
|
|
@ -5,7 +5,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { headers } from "next/headers"
|
|
||||||
|
|
||||||
import { getServerSession } from "@/lib/auth-server"
|
import { getServerSession } from "@/lib/auth-server"
|
||||||
import { auth } from "@/lib/auth"
|
import { auth } from "@/lib/auth"
|
||||||
|
|
@ -18,6 +17,23 @@ import type { Id } from "@/convex/_generated/dataModel"
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]
|
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]
|
||||||
|
|
||||||
|
function extractSetCookies(headers: Headers) {
|
||||||
|
const headersWithGetSetCookie = headers as Headers & { getSetCookie?: () => string[] | undefined }
|
||||||
|
let setCookieHeaders =
|
||||||
|
typeof headersWithGetSetCookie.getSetCookie === "function"
|
||||||
|
? headersWithGetSetCookie.getSetCookie() ?? []
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (setCookieHeaders.length === 0) {
|
||||||
|
const single = headers.get("set-cookie")
|
||||||
|
if (single) {
|
||||||
|
setCookieHeaders = [single]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return setCookieHeaders
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession()
|
const session = await getServerSession()
|
||||||
|
|
@ -75,15 +91,23 @@ export async function POST(request: NextRequest) {
|
||||||
return NextResponse.json({ error: "Erro ao obter URL do avatar" }, { status: 500 })
|
return NextResponse.json({ error: "Erro ao obter URL do avatar" }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usa updateUser do Better Auth para invalidar o cache da sessao
|
// Atualiza no Better Auth e propaga Set-Cookie para invalidar o cookieCache da sessao (avatarUrl, etc)
|
||||||
const headerList = await headers()
|
let authSetCookies: string[] = []
|
||||||
const updateResult = await auth.api.updateUser({
|
try {
|
||||||
headers: headerList,
|
const updateResponse = await auth.api.updateUser({
|
||||||
|
request,
|
||||||
|
headers: request.headers,
|
||||||
body: { avatarUrl },
|
body: { avatarUrl },
|
||||||
|
asResponse: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!updateResult) {
|
if (!updateResponse?.ok) {
|
||||||
// Fallback: atualiza diretamente no Prisma
|
throw new Error(`Falha ao atualizar usuario no Better Auth (${updateResponse?.status ?? "sem status"})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
authSetCookies = extractSetCookies(updateResponse.headers)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[profile/avatar] Falha ao atualizar sessao via Better Auth, usando fallback Prisma:", error)
|
||||||
await prisma.authUser.update({
|
await prisma.authUser.update({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
data: { avatarUrl },
|
data: { avatarUrl },
|
||||||
|
|
@ -102,17 +126,23 @@ export async function POST(request: NextRequest) {
|
||||||
console.warn("[profile/avatar] Falha ao sincronizar avatar no Convex:", error)
|
console.warn("[profile/avatar] Falha ao sincronizar avatar no Convex:", error)
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
const response = NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (const cookie of authSetCookies) {
|
||||||
|
response.headers.append("set-cookie", cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[profile/avatar] Erro:", error)
|
console.error("[profile/avatar] Erro:", error)
|
||||||
return NextResponse.json({ error: "Erro interno do servidor" }, { status: 500 })
|
return NextResponse.json({ error: "Erro interno do servidor" }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession()
|
const session = await getServerSession()
|
||||||
|
|
||||||
|
|
@ -120,15 +150,23 @@ export async function DELETE() {
|
||||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usa updateUser do Better Auth para invalidar o cache da sessao
|
// Atualiza no Better Auth e propaga Set-Cookie para invalidar o cookieCache da sessao (avatarUrl, etc)
|
||||||
const headerList = await headers()
|
let authSetCookies: string[] = []
|
||||||
const updateResult = await auth.api.updateUser({
|
try {
|
||||||
headers: headerList,
|
const updateResponse = await auth.api.updateUser({
|
||||||
|
request,
|
||||||
|
headers: request.headers,
|
||||||
body: { avatarUrl: null },
|
body: { avatarUrl: null },
|
||||||
|
asResponse: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!updateResult) {
|
if (!updateResponse?.ok) {
|
||||||
// Fallback: atualiza diretamente no Prisma
|
throw new Error(`Falha ao atualizar usuario no Better Auth (${updateResponse?.status ?? "sem status"})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
authSetCookies = extractSetCookies(updateResponse.headers)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[profile/avatar] Falha ao atualizar sessao via Better Auth, usando fallback Prisma:", error)
|
||||||
await prisma.authUser.update({
|
await prisma.authUser.update({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
data: { avatarUrl: null },
|
data: { avatarUrl: null },
|
||||||
|
|
@ -148,10 +186,16 @@ export async function DELETE() {
|
||||||
console.warn("[profile/avatar] Falha ao sincronizar remoção de avatar no Convex:", error)
|
console.warn("[profile/avatar] Falha ao sincronizar remoção de avatar no Convex:", error)
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
const response = NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Foto removida com sucesso",
|
message: "Foto removida com sucesso",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (const cookie of authSetCookies) {
|
||||||
|
response.headers.append("set-cookie", cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[profile/avatar] Erro ao remover:", error)
|
console.error("[profile/avatar] Erro ao remover:", error)
|
||||||
return NextResponse.json({ error: "Erro interno do servidor" }, { status: 500 })
|
return NextResponse.json({ error: "Erro interno do servidor" }, { status: 500 })
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type { TicketPriority } from "@/lib/schemas/ticket"
|
||||||
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { CategorySelectFields } from "@/components/tickets/category-select"
|
import { CategorySelectFields } from "@/components/tickets/category-select"
|
||||||
|
|
@ -144,6 +145,7 @@ export function PortalTicketForm() {
|
||||||
return true
|
return true
|
||||||
}, [subject, description, categoryId, subcategoryId, machineInactive, customFieldsInvalid])
|
}, [subject, description, categoryId, subcategoryId, machineInactive, customFieldsInvalid])
|
||||||
const isViewerReady = Boolean(viewerId)
|
const isViewerReady = Boolean(viewerId)
|
||||||
|
const isFormLoading = machineContextLoading || (viewerId && formsRemote === undefined)
|
||||||
const viewerErrorMessage = useMemo(() => {
|
const viewerErrorMessage = useMemo(() => {
|
||||||
if (!machineContextError) return null
|
if (!machineContextError) return null
|
||||||
const suffix = machineContextError.status ? ` (status ${machineContextError.status})` : ""
|
const suffix = machineContextError.status ? ` (status ${machineContextError.status})` : ""
|
||||||
|
|
@ -239,6 +241,51 @@ export function PortalTicketForm() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFormLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<CardHeader className="px-5 py-5">
|
||||||
|
<CardTitle className="text-xl font-semibold text-neutral-900">Abrir novo chamado</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6 px-5 pb-6">
|
||||||
|
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-9 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-3 w-48" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-32 w-full rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-28" />
|
||||||
|
<Skeleton className="h-24 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Skeleton className="h-10 w-24 rounded-full" />
|
||||||
|
<Skeleton className="h-10 w-36 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
<CardHeader className="px-5 py-5">
|
<CardHeader className="px-5 py-5">
|
||||||
|
|
@ -279,7 +326,7 @@ export function PortalTicketForm() {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{selectedForm?.description ? (
|
{selectedForm?.description && selectedFormKey !== "default" ? (
|
||||||
<p className="text-xs text-neutral-500">{selectedForm.description}</p>
|
<p className="text-xs text-neutral-500">{selectedForm.description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -322,8 +369,8 @@ export function PortalTicketForm() {
|
||||||
onCategoryChange={setCategoryId}
|
onCategoryChange={setCategoryId}
|
||||||
onSubcategoryChange={setSubcategoryId}
|
onSubcategoryChange={setSubcategoryId}
|
||||||
layout="stacked"
|
layout="stacked"
|
||||||
categoryLabel="Categoria *"
|
categoryRequired
|
||||||
subcategoryLabel="Subcategoria *"
|
subcategoryRequired
|
||||||
secondaryEmptyLabel="Selecione uma categoria"
|
secondaryEmptyLabel="Selecione uma categoria"
|
||||||
/>
|
/>
|
||||||
{selectedForm.fields.length > 0 ? (
|
{selectedForm.fields.length > 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ interface CategorySelectProps {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
categoryLabel?: string
|
categoryLabel?: string
|
||||||
subcategoryLabel?: string
|
subcategoryLabel?: string
|
||||||
|
categoryRequired?: boolean
|
||||||
|
subcategoryRequired?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
secondaryEmptyLabel?: string
|
secondaryEmptyLabel?: string
|
||||||
layout?: "grid" | "stacked"
|
layout?: "grid" | "stacked"
|
||||||
|
|
@ -41,9 +43,11 @@ export function CategorySelectFields({
|
||||||
onSubcategoryChange,
|
onSubcategoryChange,
|
||||||
autoSelectFirst = true,
|
autoSelectFirst = true,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
categoryLabel = "Primária",
|
categoryLabel = "Categoria",
|
||||||
subcategoryLabel = "Secundária",
|
subcategoryLabel = "Subcategoria",
|
||||||
secondaryEmptyLabel = "Selecione uma categoria primária",
|
categoryRequired = false,
|
||||||
|
subcategoryRequired = false,
|
||||||
|
secondaryEmptyLabel = "Selecione uma categoria",
|
||||||
className,
|
className,
|
||||||
layout = "grid",
|
layout = "grid",
|
||||||
}: CategorySelectProps) {
|
}: CategorySelectProps) {
|
||||||
|
|
@ -80,9 +84,11 @@ export function CategorySelectFields({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(containerClass, className)}>
|
<div className={cn(containerClass, className)}>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1">
|
||||||
<label className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
<label className="flex items-center gap-1.5 text-sm font-medium text-neutral-800">
|
||||||
<IconFolders className="size-3.5" /> {categoryLabel}
|
<IconFolders className="size-4 text-neutral-500" />
|
||||||
|
{categoryLabel}
|
||||||
|
{categoryRequired ? <span className="text-red-500">*</span> : null}
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
disabled={disabled || isLoading || categories.length === 0}
|
disabled={disabled || isLoading || categories.length === 0}
|
||||||
|
|
@ -107,9 +113,11 @@ export function CategorySelectFields({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1">
|
||||||
<label className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
<label className="flex items-center gap-1.5 text-sm font-medium text-neutral-800">
|
||||||
<IconFolder className="size-3.5" /> {subcategoryLabel}
|
<IconFolder className="size-4 text-neutral-500" />
|
||||||
|
{subcategoryLabel}
|
||||||
|
{subcategoryRequired ? <span className="text-red-500">*</span> : null}
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
disabled={disabled || secondaryOptions.length === 0}
|
disabled={disabled || secondaryOptions.length === 0}
|
||||||
|
|
|
||||||
|
|
@ -776,28 +776,13 @@ export function NewTicketDialog({
|
||||||
<div className="max-h-[88vh] overflow-y-auto">
|
<div className="max-h-[88vh] overflow-y-auto">
|
||||||
<div className="space-y-5 px-6 pt-7 pb-12 sm:px-8 md:px-10">
|
<div className="space-y-5 px-6 pt-7 pb-12 sm:px-8 md:px-10">
|
||||||
<form className="space-y-6" onSubmit={form.handleSubmit(submit)}>
|
<form className="space-y-6" onSubmit={form.handleSubmit(submit)}>
|
||||||
<div className="flex flex-col gap-4 border-b border-slate-200 pb-5 md:flex-row md:items-start md:justify-between">
|
<div className="border-b border-slate-200 pb-5">
|
||||||
<DialogHeader className="gap-1.5 p-0">
|
<DialogHeader className="gap-1.5 p-0">
|
||||||
<DialogTitle className="text-xl font-semibold text-neutral-900">Novo ticket</DialogTitle>
|
<DialogTitle className="text-xl font-semibold text-neutral-900">Novo ticket</DialogTitle>
|
||||||
<DialogDescription className="text-sm text-neutral-600">
|
<DialogDescription className="text-sm text-neutral-600">
|
||||||
Preencha as informações básicas para abrir um chamado.
|
Preencha as informações básicas para abrir um chamado.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex justify-end md:min-w-[140px]">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Spinner className="me-2" /> Criando…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Criar"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{forms.length > 1 ? (
|
{forms.length > 1 ? (
|
||||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
|
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
|
||||||
|
|
@ -1023,8 +1008,8 @@ export function NewTicketDialog({
|
||||||
subcategoryId={subcategoryIdValue || null}
|
subcategoryId={subcategoryIdValue || null}
|
||||||
onCategoryChange={handleCategoryChange}
|
onCategoryChange={handleCategoryChange}
|
||||||
onSubcategoryChange={handleSubcategoryChange}
|
onSubcategoryChange={handleSubcategoryChange}
|
||||||
categoryLabel="Categoria primária *"
|
categoryRequired
|
||||||
subcategoryLabel="Categoria secundária *"
|
subcategoryRequired
|
||||||
layout="stacked"
|
layout="stacked"
|
||||||
/>
|
/>
|
||||||
{form.formState.errors.categoryId?.message || form.formState.errors.subcategoryId?.message ? (
|
{form.formState.errors.categoryId?.message || form.formState.errors.subcategoryId?.message ? (
|
||||||
|
|
@ -1520,6 +1505,22 @@ export function NewTicketDialog({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-slate-200 pt-5">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="me-2" /> Criando…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Criar"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue