feat(ui): melhora UX do formulario de tickets
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m8s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m35s

- 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:
rever-tecnologia 2025-12-17 10:12:02 -03:00
parent 811ad0641a
commit 385a8ee3df
4 changed files with 150 additions and 50 deletions

View file

@ -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 })

View file

@ -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 ? (

View file

@ -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}

View file

@ -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>