feat: preview de imagens com modal, download com nome correto; cartões (Conversa/Detalhes/Timeline) com sombra e padding; alias '@/convex/_generated/api'; payloads legíveis (nome de fila/responsável, label de status) e timeline amigável; Dropzone no 'Novo ticket' com comentário inicial; microtipografia refinada
This commit is contained in:
parent
90c3c8e4d6
commit
44c98fec4a
24 changed files with 1409 additions and 301 deletions
|
|
@ -272,10 +272,18 @@ export const updateStatus = mutation({
|
||||||
handler: async (ctx, { ticketId, status, actorId }) => {
|
handler: async (ctx, { ticketId, status, actorId }) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await ctx.db.patch(ticketId, { status, updatedAt: now });
|
await ctx.db.patch(ticketId, { status, updatedAt: now });
|
||||||
|
const statusPt: Record<string, string> = {
|
||||||
|
NEW: "Novo",
|
||||||
|
OPEN: "Aberto",
|
||||||
|
PENDING: "Pendente",
|
||||||
|
ON_HOLD: "Em espera",
|
||||||
|
RESOLVED: "Resolvido",
|
||||||
|
CLOSED: "Fechado",
|
||||||
|
} as const;
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId,
|
ticketId,
|
||||||
type: "STATUS_CHANGED",
|
type: "STATUS_CHANGED",
|
||||||
payload: { to: status, actorId },
|
payload: { to: status, toLabel: statusPt[status] ?? status, actorId },
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -286,10 +294,11 @@ export const changeAssignee = mutation({
|
||||||
handler: async (ctx, { ticketId, assigneeId, actorId }) => {
|
handler: async (ctx, { ticketId, assigneeId, actorId }) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
|
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
|
||||||
|
const user = await ctx.db.get(assigneeId);
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId,
|
ticketId,
|
||||||
type: "ASSIGNEE_CHANGED",
|
type: "ASSIGNEE_CHANGED",
|
||||||
payload: { assigneeId, actorId },
|
payload: { assigneeId, assigneeName: user?.name, actorId },
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -300,10 +309,11 @@ export const changeQueue = mutation({
|
||||||
handler: async (ctx, { ticketId, queueId, actorId }) => {
|
handler: async (ctx, { ticketId, queueId, actorId }) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await ctx.db.patch(ticketId, { queueId, updatedAt: now });
|
await ctx.db.patch(ticketId, { queueId, updatedAt: now });
|
||||||
|
const queue = await ctx.db.get(queueId);
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId,
|
ticketId,
|
||||||
type: "QUEUE_CHANGED",
|
type: "QUEUE_CHANGED",
|
||||||
payload: { queueId, actorId },
|
payload: { queueId, queueName: queue?.name, actorId },
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -339,10 +349,11 @@ export const playNext = mutation({
|
||||||
const chosen = candidates[0];
|
const chosen = candidates[0];
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now });
|
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now });
|
||||||
|
const agent = await ctx.db.get(agentId);
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId: chosen._id,
|
ticketId: chosen._id,
|
||||||
type: "ASSIGNEE_CHANGED",
|
type: "ASSIGNEE_CHANGED",
|
||||||
payload: { assigneeId: agentId },
|
payload: { assigneeId: agentId, assigneeName: agent?.name },
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|
@ -11,55 +11,55 @@
|
||||||
"convex:dev": "convex dev",
|
"convex:dev": "convex dev",
|
||||||
"test": "vitest"
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@prisma/client": "^6.16.2",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@prisma/client": "^6.16.2",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.7",
|
"@radix-ui/react-popover": "^1.1.7",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@tabler/icons-react": "^3.35.0",
|
||||||
"@tabler/icons-react": "^3.35.0",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"class-variance-authority": "^0.7.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"clsx": "^2.1.1",
|
||||||
"clsx": "^2.1.1",
|
"convex": "^1.27.3",
|
||||||
"convex": "^1.27.3",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns": "^4.1.0",
|
"lucide-react": "^0.544.0",
|
||||||
"lucide-react": "^0.544.0",
|
"next": "15.5.3",
|
||||||
"next": "15.5.3",
|
"next-themes": "^0.4.6",
|
||||||
"next-themes": "^0.4.6",
|
"react": "19.1.0",
|
||||||
"react": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-hook-form": "^7.64.0",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"react-hook-form": "^7.53.0",
|
|
||||||
"@hookform/resolvers": "^3.9.0",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.1.9"
|
"zod": "^4.1.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.3",
|
"eslint-config-next": "15.5.3",
|
||||||
"prisma": "^6.16.2",
|
"prisma": "^6.16.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.3.8",
|
"tw-animate-css": "^1.3.8",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^2.1.4"
|
"vitest": "^2.1.4"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
799
web/pnpm-lock.yaml
generated
799
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +1,8 @@
|
||||||
import { AppShell } from "@/components/app-shell"
|
import { AppShell } from "@/components/app-shell"
|
||||||
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
|
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
|
||||||
import { SectionCards } from "@/components/section-cards"
|
import { SectionCards } from "@/components/section-cards"
|
||||||
import { SiteHeader } from "@/components/site-header"
|
import { SiteHeader } from "@/components/site-header"
|
||||||
import { TicketsTable } from "@/components/tickets/tickets-table"
|
import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel"
|
||||||
import { tickets } from "@/lib/mocks/tickets"
|
|
||||||
|
|
||||||
const recentTickets = tickets.slice(0, 10)
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -22,11 +19,9 @@ export default function Dashboard() {
|
||||||
<SectionCards />
|
<SectionCards />
|
||||||
<div className="grid gap-6 px-4 lg:grid-cols-[1.1fr_0.9fr] lg:px-6">
|
<div className="grid gap-6 px-4 lg:grid-cols-[1.1fr_0.9fr] lg:px-6">
|
||||||
<ChartAreaInteractive />
|
<ChartAreaInteractive />
|
||||||
<div className="rounded-xl border bg-card">
|
<RecentTicketsPanel />
|
||||||
<TicketsTable tickets={recentTickets} />
|
</div>
|
||||||
</div>
|
</AppShell>
|
||||||
</div>
|
)
|
||||||
</AppShell>
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useState } from "react";
|
||||||
import { useMutation } from "convex/react";
|
import { useMutation } from "convex/react";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "../../../../convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
|
|
||||||
export default function SeedPage() {
|
export default function SeedPage() {
|
||||||
const seed = useMutation(api.seed.seedDemo);
|
const seed = useMutation(api.seed.seedDemo);
|
||||||
|
|
@ -27,4 +27,3 @@ export default function SeedPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { AppShell } from "@/components/app-shell"
|
import { AppShell } from "@/components/app-shell"
|
||||||
import { SiteHeader } from "@/components/site-header"
|
import { SiteHeader } from "@/components/site-header"
|
||||||
import { TicketDetailView } from "@/components/tickets/ticket-detail-view"
|
import { TicketDetailView } from "@/components/tickets/ticket-detail-view"
|
||||||
|
import { TicketDetailStatic } from "@/components/tickets/ticket-detail-static"
|
||||||
|
import { getTicketById } from "@/lib/mocks/tickets"
|
||||||
|
|
||||||
type TicketDetailPageProps = {
|
type TicketDetailPageProps = {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
|
|
@ -8,6 +10,8 @@ type TicketDetailPageProps = {
|
||||||
|
|
||||||
export default async function TicketDetailPage({ params }: TicketDetailPageProps) {
|
export default async function TicketDetailPage({ params }: TicketDetailPageProps) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
const isMock = id.startsWith("ticket-")
|
||||||
|
const mock = isMock ? getTicketById(id) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
|
|
@ -20,7 +24,7 @@ export default async function TicketDetailPage({ params }: TicketDetailPageProps
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TicketDetailView id={id} />
|
{isMock && mock ? <TicketDetailStatic ticket={mock as any} /> : <TicketDetailView id={id} />}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { useMutation, useQuery } from "convex/react";
|
import { useMutation, useQuery } from "convex/react";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "../../../../convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
||||||
import { useAuth } from "@/lib/auth-client";
|
import { useAuth } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,19 @@ import { z } from "zod"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "../../../convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { FieldSet, FieldGroup, Field, FieldLabel, FieldError } from "@/components/ui/field"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { Dropzone } from "@/components/ui/dropzone"
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
subject: z.string().min(3, "Informe um assunto"),
|
subject: z.string().min(3, "Informe um assunto"),
|
||||||
|
|
@ -25,18 +29,18 @@ const schema = z.object({
|
||||||
export function NewTicketDialog() {
|
export function NewTicketDialog() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [values, setValues] = useState<z.infer<typeof schema>>({ subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null })
|
const form = useForm<z.infer<typeof schema>>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null },
|
||||||
|
mode: "onTouched",
|
||||||
|
})
|
||||||
const { userId } = useAuth()
|
const { userId } = useAuth()
|
||||||
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
|
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
|
||||||
const create = useMutation(api.tickets.create)
|
const create = useMutation(api.tickets.create)
|
||||||
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
|
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function submit(values: z.infer<typeof schema>) {
|
||||||
e.preventDefault()
|
|
||||||
const parsed = schema.safeParse(values)
|
|
||||||
if (!parsed.success) {
|
|
||||||
toast.error(parsed.error.issues[0]?.message ?? "Preencha o formulário")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
toast.loading("Criando ticket…", { id: "new-ticket" })
|
toast.loading("Criando ticket…", { id: "new-ticket" })
|
||||||
|
|
@ -51,9 +55,13 @@ export function NewTicketDialog() {
|
||||||
queueId: sel?.id,
|
queueId: sel?.id,
|
||||||
requesterId: userId as any,
|
requesterId: userId as any,
|
||||||
})
|
})
|
||||||
|
if (attachments.length > 0 || (values.summary && values.summary.trim().length > 0)) {
|
||||||
|
await addComment({ ticketId: id as any, authorId: userId as any, visibility: "PUBLIC", body: values.summary || "", attachments })
|
||||||
|
}
|
||||||
toast.success("Ticket criado!", { id: "new-ticket" })
|
toast.success("Ticket criado!", { id: "new-ticket" })
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setValues({ subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null })
|
form.reset()
|
||||||
|
setAttachments([])
|
||||||
// Navegar para o ticket recém-criado
|
// Navegar para o ticket recém-criado
|
||||||
window.location.href = `/tickets/${id}`
|
window.location.href = `/tickets/${id}`
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -73,55 +81,65 @@ export function NewTicketDialog() {
|
||||||
<DialogTitle>Novo ticket</DialogTitle>
|
<DialogTitle>Novo ticket</DialogTitle>
|
||||||
<DialogDescription>Preencha as informações básicas para abrir um chamado.</DialogDescription>
|
<DialogDescription>Preencha as informações básicas para abrir um chamado.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="space-y-4" onSubmit={submit}>
|
<form className="space-y-4" onSubmit={form.handleSubmit(submit)}>
|
||||||
<div className="space-y-2">
|
<FieldSet>
|
||||||
<label className="text-sm" htmlFor="subject">Assunto</label>
|
<FieldGroup>
|
||||||
<Input id="subject" value={values.subject} onChange={(e) => setValues((v) => ({ ...v, subject: e.target.value }))} required />
|
<Field>
|
||||||
</div>
|
<FieldLabel htmlFor="subject">Assunto</FieldLabel>
|
||||||
<div className="space-y-2">
|
<Input id="subject" {...form.register("subject")} placeholder="Ex.: Erro 500 no portal" />
|
||||||
<label className="text-sm" htmlFor="summary">Resumo</label>
|
<FieldError errors={form.formState.errors.subject ? [{ message: form.formState.errors.subject.message }] : []} />
|
||||||
<textarea id="summary" className="w-full rounded-md border bg-background p-2 text-sm" rows={3} value={values.summary} onChange={(e) => setValues((v) => ({ ...v, summary: e.target.value }))} />
|
</Field>
|
||||||
</div>
|
<Field>
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<FieldLabel htmlFor="summary">Resumo</FieldLabel>
|
||||||
<div className="space-y-2">
|
<textarea id="summary" className="w-full rounded-md border bg-background p-2 text-sm" rows={3} {...form.register("summary")} />
|
||||||
<label className="text-sm">Prioridade</label>
|
</Field>
|
||||||
<Select value={values.priority} onValueChange={(v) => setValues((s) => ({ ...s, priority: v as any }))}>
|
<Field>
|
||||||
<SelectTrigger><SelectValue placeholder="Prioridade" /></SelectTrigger>
|
<FieldLabel>Anexos</FieldLabel>
|
||||||
<SelectContent>
|
<Dropzone onUploaded={(files) => setAttachments((prev) => [...prev, ...files])} />
|
||||||
<SelectItem value="LOW">Baixa</SelectItem>
|
<FieldError className="mt-1">Formatos comuns de imagem e documentos são aceitos.</FieldError>
|
||||||
<SelectItem value="MEDIUM">Média</SelectItem>
|
</Field>
|
||||||
<SelectItem value="HIGH">Alta</SelectItem>
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<SelectItem value="URGENT">Urgente</SelectItem>
|
<Field>
|
||||||
</SelectContent>
|
<FieldLabel>Prioridade</FieldLabel>
|
||||||
</Select>
|
<Select value={form.watch("priority")} onValueChange={(v) => form.setValue("priority", v as any)}>
|
||||||
</div>
|
<SelectTrigger><SelectValue placeholder="Prioridade" /></SelectTrigger>
|
||||||
<div className="space-y-2">
|
<SelectContent>
|
||||||
<label className="text-sm">Canal</label>
|
<SelectItem value="LOW">Baixa</SelectItem>
|
||||||
<Select value={values.channel} onValueChange={(v) => setValues((s) => ({ ...s, channel: v as any }))}>
|
<SelectItem value="MEDIUM">Média</SelectItem>
|
||||||
<SelectTrigger><SelectValue placeholder="Canal" /></SelectTrigger>
|
<SelectItem value="HIGH">Alta</SelectItem>
|
||||||
<SelectContent>
|
<SelectItem value="URGENT">Urgente</SelectItem>
|
||||||
<SelectItem value="EMAIL">E-mail</SelectItem>
|
</SelectContent>
|
||||||
<SelectItem value="WHATSAPP">WhatsApp</SelectItem>
|
</Select>
|
||||||
<SelectItem value="CHAT">Chat</SelectItem>
|
</Field>
|
||||||
<SelectItem value="PHONE">Telefone</SelectItem>
|
<Field>
|
||||||
<SelectItem value="API">API</SelectItem>
|
<FieldLabel>Canal</FieldLabel>
|
||||||
<SelectItem value="MANUAL">Manual</SelectItem>
|
<Select value={form.watch("channel")} onValueChange={(v) => form.setValue("channel", v as any)}>
|
||||||
</SelectContent>
|
<SelectTrigger><SelectValue placeholder="Canal" /></SelectTrigger>
|
||||||
</Select>
|
<SelectContent>
|
||||||
</div>
|
<SelectItem value="EMAIL">E-mail</SelectItem>
|
||||||
<div className="space-y-2">
|
<SelectItem value="WHATSAPP">WhatsApp</SelectItem>
|
||||||
<label className="text-sm">Fila</label>
|
<SelectItem value="CHAT">Chat</SelectItem>
|
||||||
<Select value={values.queueName ?? ""} onValueChange={(v) => setValues((s) => ({ ...s, queueName: v || null }))}>
|
<SelectItem value="PHONE">Telefone</SelectItem>
|
||||||
<SelectTrigger><SelectValue placeholder="Sem fila" /></SelectTrigger>
|
<SelectItem value="API">API</SelectItem>
|
||||||
<SelectContent>
|
<SelectItem value="MANUAL">Manual</SelectItem>
|
||||||
<SelectItem value="">Sem fila</SelectItem>
|
</SelectContent>
|
||||||
{queues.map((q: any) => (
|
</Select>
|
||||||
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
|
</Field>
|
||||||
))}
|
<Field>
|
||||||
</SelectContent>
|
<FieldLabel>Fila</FieldLabel>
|
||||||
</Select>
|
<Select value={form.watch("queueName") ?? ""} onValueChange={(v) => form.setValue("queueName", v || null)}>
|
||||||
</div>
|
<SelectTrigger><SelectValue placeholder="Sem fila" /></SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
|
<SelectItem value="">Sem fila</SelectItem>
|
||||||
|
{queues.map((q: any) => (
|
||||||
|
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" disabled={loading}>{loading ? (<><Spinner className="me-2" /> Criando…</>) : "Criar"}</Button>
|
<Button type="submit" disabled={loading}>{loading ? (<><Spinner className="me-2" /> Criando…</>) : "Criar"}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -130,4 +148,3 @@ export function NewTicketDialog() {
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "../../../convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import type { TicketPlayContext } from "@/lib/schemas/ticket"
|
import type { TicketPlayContext } from "@/lib/schemas/ticket"
|
||||||
|
|
|
||||||
26
web/src/components/tickets/recent-tickets-panel.tsx
Normal file
26
web/src/components/tickets/recent-tickets-panel.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "convex/react";
|
||||||
|
// @ts-ignore
|
||||||
|
import { api } from "@/convex/_generated/api";
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
||||||
|
import { mapTicketsFromServerList } from "@/lib/mappers/ticket";
|
||||||
|
import { TicketsTable } from "@/components/tickets/tickets-table";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
|
export function RecentTicketsPanel() {
|
||||||
|
const ticketsRaw = useQuery(api.tickets.list, { tenantId: DEFAULT_TENANT_ID, limit: 10 });
|
||||||
|
if (ticketsRaw === undefined) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card p-6 text-sm text-muted-foreground">
|
||||||
|
<Spinner className="me-2" /> Carregando tickets…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const tickets = mapTicketsFromServerList(ticketsRaw as any[]);
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card">
|
||||||
|
<TicketsTable tickets={tickets as any} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,10 +4,11 @@ import { useMemo, useState } from "react"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import { formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { IconLock, IconMessage } from "@tabler/icons-react"
|
import { IconLock, IconMessage } from "@tabler/icons-react"
|
||||||
|
import { Download, ImageIcon, FileIcon } from "lucide-react"
|
||||||
import { useAction, useMutation } from "convex/react"
|
import { useAction, useMutation } from "convex/react"
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "../../../convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
|
@ -15,6 +16,8 @@ import { Badge } from "@/components/ui/badge"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { Dropzone } from "@/components/ui/dropzone"
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||||
|
|
||||||
interface TicketCommentsProps {
|
interface TicketCommentsProps {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
|
|
@ -25,7 +28,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
const addComment = useMutation(api.tickets.addComment)
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
const generateUploadUrl = useAction(api.files.generateUploadUrl)
|
const generateUploadUrl = useAction(api.files.generateUploadUrl)
|
||||||
const [body, setBody] = useState("")
|
const [body, setBody] = useState("")
|
||||||
const [files, setFiles] = useState<File[]>([])
|
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
|
||||||
|
const [preview, setPreview] = useState<string | null>(null)
|
||||||
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id"|"author"|"visibility"|"body"|"attachments"|"createdAt"|"updatedAt">[]>([])
|
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id"|"author"|"visibility"|"body"|"attachments"|"createdAt"|"updatedAt">[]>([])
|
||||||
|
|
||||||
const commentsAll = useMemo(() => {
|
const commentsAll = useMemo(() => {
|
||||||
|
|
@ -35,30 +39,20 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
let attachments: Array<{ storageId: string; name: string; size?: number; type?: string }> = []
|
const attachments = attachmentsToSend
|
||||||
if (files.length) {
|
|
||||||
const url = await generateUploadUrl({})
|
|
||||||
for (const file of files) {
|
|
||||||
const form = new FormData()
|
|
||||||
form.append("file", file)
|
|
||||||
const res = await fetch(url, { method: "POST", body: form })
|
|
||||||
const { storageId } = await res.json()
|
|
||||||
attachments.push({ storageId, name: file.name, size: file.size, type: file.type })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const optimistic = {
|
const optimistic = {
|
||||||
id: `temp-${now.getTime()}`,
|
id: `temp-${now.getTime()}`,
|
||||||
author: ticket.requester, // placeholder; poderia buscar o próprio usuário se necessário
|
author: ticket.requester, // placeholder; poderia buscar o próprio usuário se necessário
|
||||||
visibility: "PUBLIC" as const,
|
visibility: "PUBLIC" as const,
|
||||||
body,
|
body,
|
||||||
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name } as any)),
|
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name, url: a.previewUrl } as any)),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}
|
}
|
||||||
setPending((p) => [optimistic, ...p])
|
setPending((p) => [optimistic, ...p])
|
||||||
setBody("")
|
setBody("")
|
||||||
setFiles([])
|
setAttachmentsToSend([])
|
||||||
toast.loading("Enviando comentário…", { id: "comment" })
|
toast.loading("Enviando comentário…", { id: "comment" })
|
||||||
try {
|
try {
|
||||||
await addComment({ ticketId: ticket.id as any, authorId: userId as any, visibility: "PUBLIC", body: optimistic.body, attachments })
|
await addComment({ ticketId: ticket.id as any, authorId: userId as any, visibility: "PUBLIC", body: optimistic.body, attachments })
|
||||||
|
|
@ -71,13 +65,13 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-none shadow-none">
|
<Card className="rounded-xl border bg-card shadow-sm">
|
||||||
<CardHeader className="px-0">
|
<CardHeader className="px-4">
|
||||||
<CardTitle className="flex items-center gap-2 text-lg font-semibold">
|
<CardTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||||
<IconMessage className="size-5" /> Conversa
|
<IconMessage className="size-5" /> Conversa
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6 px-0">
|
<CardContent className="space-y-6 px-4 pb-6">
|
||||||
{commentsAll.length === 0 ? (
|
{commentsAll.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Ainda sem comentarios. Que tal registrar o proximo passo?
|
Ainda sem comentarios. Que tal registrar o proximo passo?
|
||||||
|
|
@ -110,15 +104,36 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words whitespace-pre-wrap">
|
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words whitespace-pre-wrap">
|
||||||
{comment.body}
|
{comment.body}
|
||||||
</div>
|
</div>
|
||||||
{comment.attachments?.length ? (
|
{comment.attachments?.length ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-3">
|
||||||
{comment.attachments.map((a) => (
|
{comment.attachments.map((a) => {
|
||||||
<a key={(a as any).id} href={(a as any).url} target="_blank" className="text-xs underline">
|
const att = a as any
|
||||||
{(a as any).name}
|
const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
|
||||||
</a>
|
if (isImg && att.url) {
|
||||||
))}
|
return (
|
||||||
</div>
|
<button
|
||||||
) : null}
|
key={att.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPreview(att.url)}
|
||||||
|
className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={att.url} alt={att.name} className="h-24 w-24 rounded-md object-cover" />
|
||||||
|
<div className="mt-1 line-clamp-1 w-24 text-ellipsis text-center text-[11px] text-muted-foreground">
|
||||||
|
{att.name}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a key={att.id} href={att.url} download={att.name} target="_blank" className="flex items-center gap-2 rounded-md border px-2 py-1 text-xs hover:bg-muted">
|
||||||
|
<FileIcon className="size-3.5" /> {att.name}
|
||||||
|
{att.url ? <Download className="size-3.5" /> : null}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -132,11 +147,19 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={(e) => setBody(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between">
|
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
|
||||||
<input type="file" multiple onChange={(e) => setFiles(Array.from(e.target.files ?? []))} />
|
<div className="flex items-center justify-end">
|
||||||
<Button type="submit" size="sm">Enviar</Button>
|
<Button type="submit" size="sm">Enviar</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<Dialog open={!!preview} onOpenChange={(o) => !o && setPreview(null)}>
|
||||||
|
<DialogContent className="max-w-3xl p-0">
|
||||||
|
{preview ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={preview} alt="Preview" className="h-auto w-full rounded-xl" />
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
21
web/src/components/tickets/ticket-detail-static.tsx
Normal file
21
web/src/components/tickets/ticket-detail-static.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
|
||||||
|
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
||||||
|
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
|
||||||
|
import type { TicketWithDetails } from "@/lib/schemas/ticket";
|
||||||
|
|
||||||
|
export function TicketDetailStatic({ ticket }: { ticket: TicketWithDetails }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||||
|
<TicketSummaryHeader ticket={ticket} />
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<TicketTimeline ticket={ticket} />
|
||||||
|
</div>
|
||||||
|
<TicketDetailsPanel ticket={ticket} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -3,18 +3,25 @@
|
||||||
import { useQuery } from "convex/react";
|
import { useQuery } from "convex/react";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "../../../convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
||||||
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
|
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
|
||||||
|
import { getTicketById } from "@/lib/mocks/tickets";
|
||||||
import { TicketComments } from "@/components/tickets/ticket-comments";
|
import { TicketComments } from "@/components/tickets/ticket-comments";
|
||||||
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
||||||
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
|
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
|
||||||
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
|
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
|
||||||
|
|
||||||
export function TicketDetailView({ id }: { id: string }) {
|
export function TicketDetailView({ id }: { id: string }) {
|
||||||
const t = useQuery(api.tickets.getById, { tenantId: DEFAULT_TENANT_ID, id: id as any });
|
const isMockId = id.startsWith("ticket-");
|
||||||
if (!t) return <div className="px-4 py-8 text-sm text-muted-foreground">Carregando ticket...</div>;
|
const t = useQuery(api.tickets.getById, isMockId ? undefined : ({ tenantId: DEFAULT_TENANT_ID, id: id as any }));
|
||||||
const ticket = mapTicketWithDetailsFromServer(t as any)
|
let ticket: any | null = null;
|
||||||
|
if (t) {
|
||||||
|
ticket = mapTicketWithDetailsFromServer(t as any);
|
||||||
|
} else if (isMockId) {
|
||||||
|
ticket = getTicketById(id) ?? null;
|
||||||
|
}
|
||||||
|
if (!ticket) return <div className="px-4 py-8 text-sm text-muted-foreground">Carregando ticket...</div>;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||||
<TicketSummaryHeader ticket={ticket as any} />
|
<TicketSummaryHeader ticket={ticket as any} />
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,11 @@ interface TicketDetailsPanelProps {
|
||||||
|
|
||||||
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-none shadow-none">
|
<Card className="rounded-xl border bg-card shadow-sm">
|
||||||
<CardHeader className="px-0">
|
<CardHeader className="px-4">
|
||||||
<CardTitle className="text-lg font-semibold">Detalhes</CardTitle>
|
<CardTitle className="text-lg font-semibold">Detalhes</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-5 px-0 text-sm text-muted-foreground">
|
<CardContent className="flex flex-col gap-5 px-4 pb-6 text-sm text-muted-foreground">
|
||||||
<div className="space-y-1 break-words">
|
<div className="space-y-1 break-words">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide">Fila</p>
|
<p className="text-xs font-semibold uppercase tracking-wide">Fila</p>
|
||||||
<Badge variant="outline" className="max-w-full truncate">{ticket.queue ?? "Sem fila"}</Badge>
|
<Badge variant="outline" className="max-w-full truncate">{ticket.queue ?? "Sem fila"}</Badge>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "../../../convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
|
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useMutation, useQuery } from "convex/react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "../../../convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
|
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,17 @@ const timelineLabels: Record<string, string> = {
|
||||||
STATUS_CHANGED: "Status alterado",
|
STATUS_CHANGED: "Status alterado",
|
||||||
ASSIGNEE_CHANGED: "Responsável alterado",
|
ASSIGNEE_CHANGED: "Responsável alterado",
|
||||||
COMMENT_ADDED: "Comentário adicionado",
|
COMMENT_ADDED: "Comentário adicionado",
|
||||||
|
QUEUE_CHANGED: "Fila alterada",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TicketTimelineProps {
|
interface TicketTimelineProps {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-none shadow-none">
|
<Card className="rounded-xl border bg-card shadow-sm">
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6 px-4 pb-6">
|
||||||
{ticket.timeline.map((entry, index) => {
|
{ticket.timeline.map((entry, index) => {
|
||||||
const Icon = timelineIcons[entry.type] ?? IconClockHour4
|
const Icon = timelineIcons[entry.type] ?? IconClockHour4
|
||||||
const isLast = index === ticket.timeline.length - 1
|
const isLast = index === ticket.timeline.length - 1
|
||||||
|
|
@ -55,13 +56,19 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
|
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{entry.payload ? (
|
{(() => {
|
||||||
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground">
|
const p: any = entry.payload || {}
|
||||||
<pre className="whitespace-pre-wrap leading-relaxed">
|
let message: string | null = null
|
||||||
{JSON.stringify(entry.payload, null, 2)}
|
if (entry.type === "STATUS_CHANGED" && (p.toLabel || p.to)) message = `Status alterado para ${p.toLabel || p.to}`
|
||||||
</pre>
|
if (entry.type === "ASSIGNEE_CHANGED" && (p.assigneeName || p.assigneeId)) message = `Responsável alterado${p.assigneeName ? ` para ${p.assigneeName}` : ""}`
|
||||||
</div>
|
if (entry.type === "QUEUE_CHANGED" && (p.queueName || p.queueId)) message = `Fila alterada${p.queueName ? ` para ${p.queueName}` : ""}`
|
||||||
) : null}
|
if (!message) return null
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -63,110 +63,110 @@ type TicketsTableProps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-none shadow-none">
|
<Card className="border bg-card/90 shadow-sm">
|
||||||
<CardContent className="px-4 py-4 sm:px-6">
|
<CardContent className="px-4 py-4 sm:px-6">
|
||||||
<Table className="min-w-full">
|
<Table className="min-w-full">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="text-xs uppercase text-muted-foreground">
|
<TableRow className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
<TableHead className="w-[110px]">Ticket</TableHead>
|
<TableHead className="w-[110px]">Ticket</TableHead>
|
||||||
<TableHead>Assunto</TableHead>
|
<TableHead>Assunto</TableHead>
|
||||||
<TableHead className="hidden lg:table-cell">Fila</TableHead>
|
<TableHead className="hidden lg:table-cell">Fila</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Canal</TableHead>
|
<TableHead className="hidden md:table-cell">Canal</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Prioridade</TableHead>
|
<TableHead className="hidden md:table-cell">Prioridade</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="hidden xl:table-cell">Responsável</TableHead>
|
<TableHead className="hidden xl:table-cell">Responsável</TableHead>
|
||||||
<TableHead className="w-[140px]">Atualizado</TableHead>
|
<TableHead className="w-[140px]">Atualizado</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{tickets.map((ticket) => (
|
{tickets.map((ticket) => (
|
||||||
<TableRow key={ticket.id} className="group">
|
<TableRow key={ticket.id} className="group hover:bg-muted/40">
|
||||||
<TableCell className={cellClass}>
|
<TableCell className={cellClass}>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<Link
|
<Link
|
||||||
href={`/tickets/${ticket.id}`}
|
href={`/tickets/${ticket.id}`}
|
||||||
className="font-semibold tracking-tight text-primary hover:underline"
|
className="font-semibold tracking-tight text-primary hover:underline"
|
||||||
>
|
>
|
||||||
#{ticket.reference}
|
#{ticket.reference}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{ticket.queue ?? "Sem fila"}
|
{ticket.queue ?? "Sem fila"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={cellClass}>
|
<TableCell className={cellClass}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<Link
|
<Link
|
||||||
href={`/tickets/${ticket.id}`}
|
href={`/tickets/${ticket.id}`}
|
||||||
className="line-clamp-1 font-medium text-foreground hover:underline"
|
className="line-clamp-1 font-medium text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
{ticket.subject}
|
{ticket.subject}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="line-clamp-1 text-sm text-muted-foreground">
|
<span className="line-clamp-1 text-sm text-muted-foreground">
|
||||||
{ticket.summary ?? "Sem resumo"}
|
{ticket.summary ?? "Sem resumo"}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||||
<span>{ticket.requester.name}</span>
|
<span>{ticket.requester.name}</span>
|
||||||
{ticket.tags?.map((tag) => (
|
{ticket.tags?.map((tag) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={tag}
|
key={tag}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="rounded-full border-transparent bg-slate-100 px-2 py-1 text-xs text-slate-600"
|
className="rounded-full border-transparent bg-slate-100 px-2 py-1 text-xs text-slate-600"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={`${cellClass} hidden lg:table-cell`}>
|
<TableCell className={`${cellClass} hidden lg:table-cell`}>
|
||||||
<Badge className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
|
<Badge className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
|
||||||
{ticket.queue ?? "Sem fila"}
|
{ticket.queue ?? "Sem fila"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
||||||
<Badge className="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-600">
|
<Badge className="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-600">
|
||||||
{channelLabel[ticket.channel] ?? ticket.channel}
|
{channelLabel[ticket.channel] ?? ticket.channel}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
||||||
<TicketPriorityPill priority={ticket.priority} />
|
<TicketPriorityPill priority={ticket.priority} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={cellClass}>
|
<TableCell className={cellClass}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<TicketStatusBadge status={ticket.status} />
|
<TicketStatusBadge status={ticket.status} />
|
||||||
{ticket.metrics?.timeWaitingMinutes ? (
|
{ticket.metrics?.timeWaitingMinutes ? (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Espera {ticket.metrics.timeWaitingMinutes} min
|
Espera {ticket.metrics.timeWaitingMinutes} min
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={`${cellClass} hidden xl:table-cell`}>
|
<TableCell className={`${cellClass} hidden xl:table-cell`}>
|
||||||
<AssigneeCell ticket={ticket} />
|
<AssigneeCell ticket={ticket} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={cellClass}>
|
<TableCell className={cellClass}>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{formatDistanceToNow(ticket.updatedAt, {
|
{formatDistanceToNow(ticket.updatedAt, {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
locale: ptBR,
|
locale: ptBR,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
{tickets.length === 0 && (
|
{tickets.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
|
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-sm">
|
||||||
<p className="text-sm font-medium">Nenhum ticket encontrado</p>
|
<p className="text-sm font-medium">Nenhum ticket encontrado</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Ajuste os filtros ou selecione outra fila.
|
Ajuste os filtros ou selecione outra fila.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useMemo, useState } from "react"
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "../../../convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
|
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
|
||||||
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
|
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
|
||||||
|
|
|
||||||
115
web/src/components/ui/dropzone.tsx
Normal file
115
web/src/components/ui/dropzone.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAction } from "convex/react";
|
||||||
|
// @ts-ignore
|
||||||
|
import { api } from "@/convex/_generated/api";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Upload } from "lucide-react";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
|
||||||
|
type Uploaded = { storageId: string; name: string; size?: number; type?: string; previewUrl?: string };
|
||||||
|
|
||||||
|
export function Dropzone({
|
||||||
|
onUploaded,
|
||||||
|
maxFiles = 5,
|
||||||
|
maxSize = 10 * 1024 * 1024,
|
||||||
|
multiple = true,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
onUploaded?: (files: Uploaded[]) => void;
|
||||||
|
maxFiles?: number;
|
||||||
|
maxSize?: number;
|
||||||
|
multiple?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const generateUrl = useAction(api.files.generateUploadUrl);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [drag, setDrag] = useState(false);
|
||||||
|
const [items, setItems] = useState<Array<{ id: string; name: string; progress: number; status: "idle" | "uploading" | "done" | "error" }>>([]);
|
||||||
|
|
||||||
|
const startUpload = useCallback(async (files: FileList | File[]) => {
|
||||||
|
const list = Array.from(files).slice(0, maxFiles);
|
||||||
|
const url = await generateUrl({});
|
||||||
|
const uploaded: Uploaded[] = [];
|
||||||
|
for (const file of list) {
|
||||||
|
if (file.size > maxSize) continue;
|
||||||
|
const id = `${file.name}-${file.size}-${Date.now()}`;
|
||||||
|
const localPreview = file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined;
|
||||||
|
setItems((prev) => [...prev, { id, name: file.name, progress: 0, status: "uploading" }]);
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", url);
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (!e.lengthComputable) return;
|
||||||
|
const progress = Math.round((e.loaded / e.total) * 100);
|
||||||
|
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, progress } : it)));
|
||||||
|
};
|
||||||
|
xhr.onload = () => {
|
||||||
|
try {
|
||||||
|
const res = JSON.parse(xhr.responseText);
|
||||||
|
if (res?.storageId) {
|
||||||
|
uploaded.push({ storageId: res.storageId, name: file.name, size: file.size, type: file.type, previewUrl: localPreview });
|
||||||
|
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, progress: 100, status: "done" } : it)));
|
||||||
|
} else {
|
||||||
|
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
xhr.onerror = () => {
|
||||||
|
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
xhr.send(form);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (uploaded.length) onUploaded?.(uploaded);
|
||||||
|
}, [generateUrl, maxFiles, maxSize, onUploaded]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-3", className)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative rounded-lg border border-dashed p-6 text-center transition-colors",
|
||||||
|
drag ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"
|
||||||
|
)}
|
||||||
|
onDragEnter={(e) => { e.preventDefault(); setDrag(true); }}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
|
||||||
|
onDragLeave={(e) => { e.preventDefault(); setDrag(false); }}
|
||||||
|
onDrop={(e) => { e.preventDefault(); setDrag(false); startUpload(e.dataTransfer.files); }}
|
||||||
|
>
|
||||||
|
<input ref={inputRef} type="file" className="sr-only" multiple={multiple} onChange={(e) => e.target.files && startUpload(e.target.files)} />
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||||
|
{items.some((it) => it.status === "uploading") ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Upload className="size-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">Arraste arquivos aqui ou <button type="button" className="text-primary underline" onClick={() => inputRef.current?.click()}>selecione</button></p>
|
||||||
|
<p className="text-xs text-muted-foreground">Máximo {Math.round(maxSize/1024/1024)}MB • Até {maxFiles} arquivos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((it) => (
|
||||||
|
<div key={it.id} className="flex items-center justify-between gap-3 rounded-md border p-2 text-sm">
|
||||||
|
<span className="truncate">{it.name}</span>
|
||||||
|
<div className="flex items-center gap-2 min-w-[140px]">
|
||||||
|
<Progress value={it.progress} className="h-1.5 w-24" />
|
||||||
|
<span className="text-xs text-muted-foreground w-10 text-right">{it.progress}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
web/src/components/ui/field.tsx
Normal file
81
web/src/components/ui/field.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const FieldSet = ({ className, ...props }: React.ComponentProps<"fieldset">) => (
|
||||||
|
<fieldset role="group" className={cn("grid gap-3", className)} {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const FieldLegend = ({ className, ...props }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) => {
|
||||||
|
const { variant = "legend", ...rest } = props as any
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
className={cn(
|
||||||
|
variant === "label" ? "text-sm font-medium" : "text-sm font-semibold",
|
||||||
|
"text-foreground", className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FieldGroup = ({ className, ...props }: React.ComponentProps<"div">) => (
|
||||||
|
<div className={cn("@container/field-group grid gap-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const Field = ({ className, ...props }: React.ComponentProps<"div"> & { orientation?: "vertical" | "horizontal" | "responsive" }) => {
|
||||||
|
const { orientation = "vertical", ...rest } = props as any
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex gap-2",
|
||||||
|
orientation === "vertical" && "flex-col",
|
||||||
|
orientation === "horizontal" && "items-center",
|
||||||
|
orientation === "responsive" && "@[480px]/field-group:flex-row @[480px]/field-group:items-center flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FieldContent = ({ className, ...props }: React.ComponentProps<"div">) => (
|
||||||
|
<div className={cn("flex flex-col", className)} {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const FieldLabel = ({ className, ...props }: React.ComponentProps<"label"> & { asChild?: boolean }) => (
|
||||||
|
<label className={cn("text-sm font-medium text-foreground", className)} {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const FieldTitle = ({ className, ...props }: React.ComponentProps<"div">) => (
|
||||||
|
<div className={cn("text-sm font-medium", className)} {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const FieldDescription = ({ className, ...props }: React.ComponentProps<"p">) => (
|
||||||
|
<p className={cn("text-xs text-muted-foreground", className)} {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const FieldError = ({ className, children, errors, ...props }: React.ComponentProps<"div"> & { errors?: Array<{ message?: string }> | undefined }) => {
|
||||||
|
const items = (errors ?? []) as Array<{ message?: string }>
|
||||||
|
if (!children && (!items || items.length === 0)) return null
|
||||||
|
return (
|
||||||
|
<div className={cn("text-xs text-destructive", className)} {...props}>
|
||||||
|
{children ? children : (
|
||||||
|
<ul className="ml-4 list-disc">
|
||||||
|
{items.map((e, i) => (
|
||||||
|
<li key={i}>{e?.message}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FieldSeparator = ({ className, ...props }: React.ComponentProps<"div">) => (
|
||||||
|
<div className={cn("my-2 h-px w-full bg-border", className)} {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export { FieldSet, FieldLegend, FieldGroup, Field, FieldContent, FieldLabel, FieldTitle, FieldDescription, FieldError, FieldSeparator }
|
||||||
|
|
||||||
2
web/src/convex/_generated/api.ts
Normal file
2
web/src/convex/_generated/api.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "../../../convex/_generated/api";
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { useMutation } from "convex/react";
|
||||||
// Lazy import to avoid build errors before convex is generated
|
// Lazy import to avoid build errors before convex is generated
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "../../convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
|
|
||||||
export type DemoUser = { name: string; email: string; avatarUrl?: string } | null;
|
export type DemoUser = { name: string; email: string; avatarUrl?: string } | null;
|
||||||
|
|
||||||
|
|
@ -47,4 +47,3 @@ export function AuthProvider({ demoUser, tenantId, children }: { demoUser: DemoU
|
||||||
const value = useMemo(() => ({ demoUser: localDemoUser, setDemoUser: setLocalDemoUser, userId }), [localDemoUser, userId]);
|
const value = useMemo(() => ({ demoUser: localDemoUser, setDemoUser: setLocalDemoUser, userId }), [localDemoUser, userId]);
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { ticketSchema, ticketWithDetailsSchema } from "@/lib/schemas/ticket";
|
||||||
ticketSchema,
|
|
||||||
ticketWithDetailsSchema,
|
|
||||||
ticketEventSchema,
|
|
||||||
ticketCommentSchema,
|
|
||||||
userSummarySchema,
|
|
||||||
} from "@/lib/schemas/ticket";
|
|
||||||
|
|
||||||
// Server shapes: datas como number (epoch ms) e alguns nullables
|
// Server shapes: datas como number (epoch ms) e alguns nullables
|
||||||
const serverUserSchema = userSummarySchema;
|
// Relaxamos email/urls no shape do servidor para evitar que payloads parciais quebrem o app.
|
||||||
|
const serverUserSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string().optional(),
|
||||||
|
avatarUrl: z.string().optional(),
|
||||||
|
teams: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const serverTicketSchema = z.object({
|
const serverTicketSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
|
@ -75,7 +76,8 @@ export function mapTicketFromServer(input: unknown) {
|
||||||
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
|
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
|
||||||
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
|
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
|
||||||
};
|
};
|
||||||
return ticketSchema.parse(ui);
|
// Já validamos o formato recebido (serverTicketSchema). Retornamos no shape da UI.
|
||||||
|
return ui as unknown as z.infer<typeof ticketSchema>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapTicketsFromServerList(arr: unknown[]) {
|
export function mapTicketsFromServerList(arr: unknown[]) {
|
||||||
|
|
@ -99,5 +101,5 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
|
||||||
updatedAt: new Date(c.updatedAt),
|
updatedAt: new Date(c.updatedAt),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
return ticketWithDetailsSchema.parse(ui);
|
return ui as unknown as z.infer<typeof ticketWithDetailsSchema>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue