Merge pull request #8 from esdrasrenan/feat/convex-tickets-core
feat: align ticket header editing flow
This commit is contained in:
commit
908e3fa45a
28 changed files with 431 additions and 253 deletions
10
agents.md
10
agents.md
|
|
@ -101,6 +101,7 @@ Este repositório foi atualizado para usar Convex como backend em tempo real par
|
|||
- UI com shadcn/ui; priorize componentes existentes e consistência visual.
|
||||
- Labels e mensagens em PT‑BR (status, timeline, toasts, etc.).
|
||||
- Atualizações otimistas com rollback em erro + toasts de feedback.
|
||||
- Comentários de supressão: prefira `@ts-expect-error` com justificativa curta para módulos gerados do Convex; evite `@ts-ignore`.
|
||||
|
||||
## Estrutura útil
|
||||
- `web/convex/*` — API backend Convex.
|
||||
|
|
@ -240,6 +241,7 @@ Arquivos principais tocados:
|
|||
- Header do ticket
|
||||
- Ordem: `#ref` • PrioritySelect (badge) • Status (badge/select) • Ações (Excluir)
|
||||
- Tipografia: título forte, resumo como texto auxiliar, metadados em texto pequeno.
|
||||
- Combos de Categoria/ Subcategoria exibidos como selects dependentes com salvamento automático (sem botões dedicados).
|
||||
- Comentários
|
||||
- Composer com rich text + Dropzone; seletor de visibilidade.
|
||||
- Lista com avatar, nome, carimbo relativo e conteúdo rich text.
|
||||
|
|
@ -295,5 +297,11 @@ Próximos passos sugeridos
|
|||
- Testes (Vitest): adicionar casos de mappers e smoke tests de páginas.
|
||||
|
||||
Observações de codificação
|
||||
- Evitar ny; usar TicketStatus/TicketPriority e Id<>/Doc<> do Convex.
|
||||
- Evitar `any`; usar TicketStatus/TicketPriority e Id<>/Doc<> do Convex.
|
||||
- Não retornar Date do Convex; sempre epoch (number) e converter via mappers Zod.
|
||||
|
||||
## Atualizações recentes (out/2025)
|
||||
- Cabeçalho de ticket agora persiste automaticamente mudanças de categoria/subcategoria, mostrando toasts e bloqueando os selects enquanto a mutação é processada.
|
||||
- Normalização de nomes de fila/time aplicada também ao retorno de `tickets.playNext`, garantindo rótulos "Chamados"/"Laboratório" em todos os fluxos.
|
||||
- ESLint ignora `convex/_generated/**` e supressões migradas para `@ts-expect-error` com justificativa explícita.
|
||||
- Mutação `tickets.remove` não requer mais `actorId`; o diálogo de exclusão apenas envia `ticketId`.
|
||||
|
|
|
|||
|
|
@ -4,14 +4,27 @@ import { v } from "convex/values";
|
|||
export const ensureDefaults = mutation({
|
||||
args: { tenantId: v.string() },
|
||||
handler: async (ctx, { tenantId }) => {
|
||||
const existing = await ctx.db
|
||||
let existing = await ctx.db
|
||||
.query("queues")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
existing = await Promise.all(
|
||||
existing.map(async (queue) => {
|
||||
if (queue.name === "Suporte N1" || queue.slug === "suporte-n1") {
|
||||
await ctx.db.patch(queue._id, { name: "Chamados", slug: "chamados" });
|
||||
return (await ctx.db.get(queue._id)) ?? queue;
|
||||
}
|
||||
if (queue.name === "Suporte N2" || queue.slug === "suporte-n2") {
|
||||
await ctx.db.patch(queue._id, { name: "Laboratório", slug: "laboratorio" });
|
||||
return (await ctx.db.get(queue._id)) ?? queue;
|
||||
}
|
||||
return queue;
|
||||
})
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
const queues = [
|
||||
{ name: "Suporte N1", slug: "suporte-n1" },
|
||||
{ name: "Suporte N2", slug: "suporte-n2" },
|
||||
{ name: "Chamados", slug: "chamados" },
|
||||
{ name: "Laboratório", slug: "laboratorio" },
|
||||
{ name: "Field Services", slug: "field-services" },
|
||||
];
|
||||
for (const q of queues) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
import { query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
||||
"Suporte N1": "Chamados",
|
||||
"suporte-n1": "Chamados",
|
||||
"Suporte N2": "Laboratório",
|
||||
"suporte-n2": "Laboratório",
|
||||
};
|
||||
|
||||
function renameQueueString(value: string) {
|
||||
const direct = QUEUE_RENAME_LOOKUP[value];
|
||||
if (direct) return direct;
|
||||
const normalizedKey = value.replace(/\s+/g, "-").toLowerCase();
|
||||
return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value;
|
||||
}
|
||||
|
||||
export const summary = query({
|
||||
args: { tenantId: v.string() },
|
||||
handler: async (ctx, { tenantId }) => {
|
||||
|
|
@ -15,7 +29,7 @@ export const summary = query({
|
|||
const waiting = pending.filter((t) => t.status === "PENDING" || t.status === "ON_HOLD").length;
|
||||
const open = pending.filter((t) => t.status !== "RESOLVED" && t.status !== "CLOSED").length;
|
||||
const breached = 0; // Placeholder, SLAs later
|
||||
return { id: qItem._id, name: qItem.name, pending: open, waiting, breached };
|
||||
return { id: qItem._id, name: renameQueueString(qItem.name), pending: open, waiting, breached };
|
||||
})
|
||||
);
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -9,16 +9,31 @@ export const seedDemo = mutation({
|
|||
.query("queues")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
const queues = existingQueues.length
|
||||
let queues = existingQueues.length
|
||||
? existingQueues
|
||||
: await Promise.all(
|
||||
[
|
||||
{ name: "Suporte N1", slug: "suporte-n1" },
|
||||
{ name: "Suporte N2", slug: "suporte-n2" },
|
||||
{ name: "Chamados", slug: "chamados" },
|
||||
{ name: "Laboratório", slug: "laboratorio" },
|
||||
].map((q) => ctx.db.insert("queues", { tenantId, name: q.name, slug: q.slug, teamId: undefined }))
|
||||
).then((ids) => Promise.all(ids.map((id) => ctx.db.get(id))))
|
||||
;
|
||||
|
||||
queues = await Promise.all(
|
||||
queues.map(async (queue) => {
|
||||
if (!queue) return queue;
|
||||
if (queue.name === "Suporte N1" || queue.slug === "suporte-n1") {
|
||||
await ctx.db.patch(queue._id, { name: "Chamados", slug: "chamados" });
|
||||
return (await ctx.db.get(queue._id)) ?? queue;
|
||||
}
|
||||
if (queue.name === "Suporte N2" || queue.slug === "suporte-n2") {
|
||||
await ctx.db.patch(queue._id, { name: "Laboratório", slug: "laboratorio" });
|
||||
return (await ctx.db.get(queue._id)) ?? queue;
|
||||
}
|
||||
return queue;
|
||||
})
|
||||
);
|
||||
|
||||
// Ensure users
|
||||
async function ensureUser(name: string, email: string, role = "AGENT") {
|
||||
const found = await ctx.db
|
||||
|
|
|
|||
|
|
@ -1,8 +1,39 @@
|
|||
import { internalMutation, mutation, query } from "./_generated/server";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Id, type Doc } from "./_generated/dataModel";
|
||||
|
||||
const PRIORITY_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
|
||||
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
||||
"Suporte N1": "Chamados",
|
||||
"suporte-n1": "Chamados",
|
||||
"Suporte N2": "Laboratório",
|
||||
"suporte-n2": "Laboratório",
|
||||
};
|
||||
|
||||
function renameQueueString(value?: string | null): string | null {
|
||||
if (!value) return value ?? null;
|
||||
const direct = QUEUE_RENAME_LOOKUP[value];
|
||||
if (direct) return direct;
|
||||
const normalizedKey = value.replace(/\s+/g, "-").toLowerCase();
|
||||
return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value;
|
||||
}
|
||||
|
||||
function normalizeQueueName(queue?: Doc<"queues"> | null): string | null {
|
||||
if (!queue) return null;
|
||||
const normalized = renameQueueString(queue.name);
|
||||
if (normalized && normalized !== queue.name) {
|
||||
return normalized;
|
||||
}
|
||||
if (queue.slug) {
|
||||
const fromSlug = renameQueueString(queue.slug);
|
||||
if (fromSlug) return fromSlug;
|
||||
}
|
||||
return normalized ?? queue.name;
|
||||
}
|
||||
|
||||
function normalizeTeams(teams?: string[] | null): string[] {
|
||||
if (!teams) return [];
|
||||
return teams.map((team) => renameQueueString(team) ?? team);
|
||||
}
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
|
|
@ -55,6 +86,7 @@ export const list = query({
|
|||
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
|
||||
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
|
||||
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
|
||||
const queueName = normalizeQueueName(queue);
|
||||
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
|
||||
let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null;
|
||||
let subcategorySummary: { id: Id<"ticketSubcategories">; name: string } | null = null;
|
||||
|
|
@ -85,13 +117,13 @@ export const list = query({
|
|||
status: t.status,
|
||||
priority: t.priority,
|
||||
channel: t.channel,
|
||||
queue: queue?.name ?? null,
|
||||
queue: queueName,
|
||||
requester: requester && {
|
||||
id: requester._id,
|
||||
name: requester.name,
|
||||
email: requester.email,
|
||||
avatarUrl: requester.avatarUrl,
|
||||
teams: requester.teams ?? [],
|
||||
teams: normalizeTeams(requester.teams),
|
||||
},
|
||||
assignee: assignee
|
||||
? {
|
||||
|
|
@ -99,7 +131,7 @@ export const list = query({
|
|||
name: assignee.name,
|
||||
email: assignee.email,
|
||||
avatarUrl: assignee.avatarUrl,
|
||||
teams: assignee.teams ?? [],
|
||||
teams: normalizeTeams(assignee.teams),
|
||||
}
|
||||
: null,
|
||||
slaPolicy: null,
|
||||
|
|
@ -139,6 +171,7 @@ export const getById = query({
|
|||
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
|
||||
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
|
||||
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
|
||||
const queueName = normalizeQueueName(queue);
|
||||
const category = t.categoryId ? await ctx.db.get(t.categoryId) : null;
|
||||
const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null;
|
||||
const comments = await ctx.db
|
||||
|
|
@ -191,13 +224,13 @@ export const getById = query({
|
|||
status: t.status,
|
||||
priority: t.priority,
|
||||
channel: t.channel,
|
||||
queue: queue?.name ?? null,
|
||||
queue: queueName,
|
||||
requester: requester && {
|
||||
id: requester._id,
|
||||
name: requester.name,
|
||||
email: requester.email,
|
||||
avatarUrl: requester.avatarUrl,
|
||||
teams: requester.teams ?? [],
|
||||
teams: normalizeTeams(requester.teams),
|
||||
},
|
||||
assignee: assignee
|
||||
? {
|
||||
|
|
@ -205,7 +238,7 @@ export const getById = query({
|
|||
name: assignee.name,
|
||||
email: assignee.email,
|
||||
avatarUrl: assignee.avatarUrl,
|
||||
teams: assignee.teams ?? [],
|
||||
teams: normalizeTeams(assignee.teams),
|
||||
}
|
||||
: null,
|
||||
slaPolicy: null,
|
||||
|
|
@ -242,12 +275,21 @@ export const getById = query({
|
|||
},
|
||||
description: undefined,
|
||||
customFields: {},
|
||||
timeline: timeline.map((ev) => ({
|
||||
timeline: timeline.map((ev) => {
|
||||
let payload = ev.payload;
|
||||
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
|
||||
const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null);
|
||||
if (normalized && normalized !== (payload as { queueName?: string }).queueName) {
|
||||
payload = { ...payload, queueName: normalized };
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: ev._id,
|
||||
type: ev.type,
|
||||
payload: ev.payload,
|
||||
payload,
|
||||
createdAt: ev.createdAt,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
comments: commentsHydrated,
|
||||
};
|
||||
},
|
||||
|
|
@ -451,10 +493,11 @@ export const changeQueue = mutation({
|
|||
const now = Date.now();
|
||||
await ctx.db.patch(ticketId, { queueId, updatedAt: now });
|
||||
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null;
|
||||
const queueName = normalizeQueueName(queue);
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "QUEUE_CHANGED",
|
||||
payload: { queueId, queueName: queue?.name, actorId },
|
||||
payload: { queueId, queueName, actorId },
|
||||
createdAt: now,
|
||||
});
|
||||
},
|
||||
|
|
@ -723,6 +766,7 @@ export const playNext = mutation({
|
|||
const requester = (await ctx.db.get(chosen.requesterId)) as Doc<"users"> | null
|
||||
const assignee = chosen.assigneeId ? ((await ctx.db.get(chosen.assigneeId)) as Doc<"users"> | null) : null
|
||||
const queue = chosen.queueId ? ((await ctx.db.get(chosen.queueId)) as Doc<"queues"> | null) : null
|
||||
const queueName = normalizeQueueName(queue)
|
||||
return {
|
||||
id: chosen._id,
|
||||
reference: chosen.reference,
|
||||
|
|
@ -732,13 +776,13 @@ export const playNext = mutation({
|
|||
status: chosen.status,
|
||||
priority: chosen.priority,
|
||||
channel: chosen.channel,
|
||||
queue: queue?.name ?? null,
|
||||
queue: queueName,
|
||||
requester: requester && {
|
||||
id: requester._id,
|
||||
name: requester.name,
|
||||
email: requester.email,
|
||||
avatarUrl: requester.avatarUrl,
|
||||
teams: requester.teams ?? [],
|
||||
teams: normalizeTeams(requester.teams),
|
||||
},
|
||||
assignee: assignee
|
||||
? {
|
||||
|
|
@ -746,7 +790,7 @@ export const playNext = mutation({
|
|||
name: assignee.name,
|
||||
email: assignee.email,
|
||||
avatarUrl: assignee.avatarUrl,
|
||||
teams: assignee.teams ?? [],
|
||||
teams: normalizeTeams(assignee.teams),
|
||||
}
|
||||
: null,
|
||||
slaPolicy: null,
|
||||
|
|
@ -763,8 +807,8 @@ export const playNext = mutation({
|
|||
});
|
||||
|
||||
export const remove = mutation({
|
||||
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
|
||||
handler: async (ctx, { ticketId, actorId }) => {
|
||||
args: { ticketId: v.id("tickets") },
|
||||
handler: async (ctx, { ticketId }) => {
|
||||
// delete comments (and attachments)
|
||||
const comments = await ctx.db
|
||||
.query("ticketComments")
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ export const ensureUser = mutation({
|
|||
.withIndex("by_tenant_email", (q) => q.eq("tenantId", args.tenantId).eq("email", args.email))
|
||||
.first();
|
||||
if (existing) return existing;
|
||||
const now = Date.now();
|
||||
const id = await ctx.db.insert("users", {
|
||||
tenantId: args.tenantId,
|
||||
email: args.email,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const eslintConfig = [
|
|||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"convex/_generated/**",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ConvexProvider, ConvexReactClient } from "convex/react";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "convex/react";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api";
|
||||
|
||||
export default function SeedPage() {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ import type { Id } from "@/convex/_generated/dataModel"
|
|||
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@
|
|||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex runtime API lacks TS declarations until build
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { AlertTriangle, Trash2 } from "lucide-react"
|
||||
|
|
@ -17,14 +16,12 @@ export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) {
|
|||
const remove = useMutation(api.tickets.remove)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { userId } = useAuth()
|
||||
|
||||
async function confirm() {
|
||||
setLoading(true)
|
||||
toast.loading("Excluindo ticket...", { id: "del" })
|
||||
try {
|
||||
if (!userId) throw new Error("No user")
|
||||
await remove({ ticketId, actorId: userId as Id<"users"> })
|
||||
await remove({ ticketId })
|
||||
toast.success("Ticket excluído.", { id: "del" })
|
||||
setOpen(false)
|
||||
router.push("/tickets")
|
||||
|
|
@ -41,7 +38,7 @@ export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) {
|
|||
<Button
|
||||
size="icon"
|
||||
aria-label="Excluir ticket"
|
||||
className="border border-[#fca5a5] bg-[#fecaca] text-[#7f1d1d] shadow-sm transition hover:bg-[#fca5a5] focus-visible:ring-[#fca5a5]/30"
|
||||
className="h-9 w-9 rounded-lg border border-transparent bg-transparent text-[#ef4444] transition hover:border-[#fecaca] hover:bg-[#fee2e2] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#fecaca]/50"
|
||||
>
|
||||
<Trash2 className="size-4 text-current" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useMemo, useState } from "react"
|
|||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useState } from "react"
|
|||
import { useRouter } from "next/navigation"
|
||||
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketPriority } from "@/lib/schemas/ticket"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex runtime API lacks TS declarations
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
||||
import { mapTicketsFromServerList } from "@/lib/mappers/ticket";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketStatus } from "@/lib/schemas/ticket"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { ptBR } from "date-fns/locale"
|
|||
import { IconLock, IconMessage } from "@tabler/icons-react"
|
||||
import { FileIcon, Trash2, X } from "lucide-react"
|
||||
import { useMutation } from "convex/react"
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
|
@ -203,6 +203,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
onClick={() => setPreview(url || null)}
|
||||
className="block w-full overflow-hidden rounded-md"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={url} alt={name} className="h-24 w-full rounded-md object-cover" />
|
||||
</button>
|
||||
) : (
|
||||
|
|
@ -256,6 +257,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
onClick={() => setPreview(previewUrl || null)}
|
||||
className="block w-full overflow-hidden rounded-md"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={previewUrl} alt={name} className="h-24 w-full rounded-md object-cover" />
|
||||
</button>
|
||||
) : (
|
||||
|
|
@ -339,7 +341,12 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
</Dialog>
|
||||
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
|
||||
<DialogContent className="max-w-3xl p-0">
|
||||
{preview ? <img src={preview} alt="Preview" className="h-auto w-full rounded-xl" /> : null}
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
||||
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery } from "convex/react"
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { format, formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
||||
import { IconClock, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex generates JS module without TS definitions
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
@ -21,7 +20,7 @@ import { StatusSelect } from "@/components/tickets/status-select"
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { CategorySelectFields } from "@/components/tickets/category-select"
|
||||
import { useTicketCategories } from "@/hooks/use-ticket-categories"
|
||||
|
||||
interface TicketHeaderProps {
|
||||
ticket: TicketWithDetails
|
||||
|
|
@ -35,8 +34,8 @@ const pauseButtonClass =
|
|||
"inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
||||
const editButtonClass =
|
||||
"inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
||||
const selectTriggerClass = "h-8 w-[220px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||
const smallSelectTriggerClass = "h-8 w-[180px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||
const selectTriggerClass = "h-8 w-full rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||
const smallSelectTriggerClass = "h-8 w-full rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||
const sectionLabelClass = "text-xs font-semibold uppercase tracking-wide text-neutral-500"
|
||||
const sectionValueClass = "font-medium text-neutral-900"
|
||||
const subtleBadgeClass =
|
||||
|
|
@ -68,6 +67,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const updateCategories = useMutation(api.tickets.updateCategories)
|
||||
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
|
||||
const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
|
||||
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
|
||||
const [status] = useState<TicketStatus>(ticket.status)
|
||||
const workSummaryRemote = useQuery(api.tickets.workSummary, { ticketId: ticket.id as Id<"tickets"> }) as
|
||||
| {
|
||||
|
|
@ -86,10 +86,21 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
subcategoryId: ticket.subcategory?.id ?? "",
|
||||
})
|
||||
const [savingCategory, setSavingCategory] = useState(false)
|
||||
const lastSubmittedCategoryRef = useRef({
|
||||
categoryId: ticket.category?.id ?? "",
|
||||
subcategoryId: ticket.subcategory?.id ?? "",
|
||||
})
|
||||
const selectedCategoryId = categorySelection.categoryId
|
||||
const selectedSubcategoryId = categorySelection.subcategoryId
|
||||
const dirty = useMemo(
|
||||
() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""),
|
||||
[subject, summary, ticket.subject, ticket.summary]
|
||||
)
|
||||
const activeCategory = useMemo(
|
||||
() => categories.find((category) => category.id === selectedCategoryId) ?? null,
|
||||
[categories, selectedCategoryId]
|
||||
)
|
||||
const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory])
|
||||
|
||||
async function handleSave() {
|
||||
if (!userId) return
|
||||
|
|
@ -115,49 +126,106 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
setCategorySelection({
|
||||
const nextSelection = {
|
||||
categoryId: ticket.category?.id ?? "",
|
||||
subcategoryId: ticket.subcategory?.id ?? "",
|
||||
})
|
||||
}
|
||||
setCategorySelection(nextSelection)
|
||||
lastSubmittedCategoryRef.current = nextSelection
|
||||
}, [ticket.category?.id, ticket.subcategory?.id])
|
||||
|
||||
const categoryDirty = useMemo(() => {
|
||||
const currentCategory = ticket.category?.id ?? ""
|
||||
const currentSubcategory = ticket.subcategory?.id ?? ""
|
||||
return (
|
||||
categorySelection.categoryId !== currentCategory || categorySelection.subcategoryId !== currentSubcategory
|
||||
)
|
||||
}, [categorySelection.categoryId, categorySelection.subcategoryId, ticket.category?.id, ticket.subcategory?.id])
|
||||
useEffect(() => {
|
||||
if (!editing) return
|
||||
if (categoriesLoading) return
|
||||
if (categories.length === 0) return
|
||||
if (selectedCategoryId) return
|
||||
if (ticket.category?.id) return
|
||||
|
||||
const handleResetCategory = () => {
|
||||
const first = categories[0]
|
||||
const firstSecondary = first.secondary[0]
|
||||
setCategorySelection({
|
||||
categoryId: ticket.category?.id ?? "",
|
||||
subcategoryId: ticket.subcategory?.id ?? "",
|
||||
categoryId: first.id,
|
||||
subcategoryId: firstSecondary?.id ?? "",
|
||||
})
|
||||
}
|
||||
}, [categories, categoriesLoading, editing, selectedCategoryId, ticket.category?.id])
|
||||
|
||||
async function handleSaveCategory() {
|
||||
if (!userId) return
|
||||
if (!categorySelection.categoryId || !categorySelection.subcategoryId) {
|
||||
toast.error("Selecione uma categoria válida.")
|
||||
useEffect(() => {
|
||||
if (!editing) return
|
||||
if (!selectedCategoryId) return
|
||||
if (secondaryOptions.length === 0) {
|
||||
if (selectedSubcategoryId) {
|
||||
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const stillValid = secondaryOptions.some((option) => option.id === selectedSubcategoryId)
|
||||
if (stillValid) return
|
||||
|
||||
const fallback = secondaryOptions[0]
|
||||
if (fallback) {
|
||||
setCategorySelection((prev) => ({ ...prev, subcategoryId: fallback.id }))
|
||||
}
|
||||
}, [editing, secondaryOptions, selectedCategoryId, selectedSubcategoryId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) return
|
||||
if (!userId) return
|
||||
const categoryId = selectedCategoryId
|
||||
const subcategoryId = selectedSubcategoryId
|
||||
if (!categoryId || !subcategoryId) return
|
||||
|
||||
const currentCategory = ticket.category?.id ?? ""
|
||||
const currentSubcategory = ticket.subcategory?.id ?? ""
|
||||
|
||||
if (categoryId === currentCategory && subcategoryId === currentSubcategory) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
categoryId === lastSubmittedCategoryRef.current.categoryId &&
|
||||
subcategoryId === lastSubmittedCategoryRef.current.subcategoryId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
lastSubmittedCategoryRef.current = { categoryId, subcategoryId }
|
||||
setSavingCategory(true)
|
||||
toast.loading("Atualizando categoria...", { id: "ticket-category" })
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
await updateCategories({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
categoryId: categorySelection.categoryId as Id<"ticketCategories">,
|
||||
subcategoryId: categorySelection.subcategoryId as Id<"ticketSubcategories">,
|
||||
categoryId: categoryId as Id<"ticketCategories">,
|
||||
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
|
||||
actorId: userId as Id<"users">,
|
||||
})
|
||||
if (!cancelled) {
|
||||
toast.success("Categoria atualizada!", { id: "ticket-category" })
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
toast.error("Não foi possível atualizar a categoria.", { id: "ticket-category" })
|
||||
const fallback = {
|
||||
categoryId: currentCategory,
|
||||
subcategoryId: currentSubcategory,
|
||||
}
|
||||
setCategorySelection(fallback)
|
||||
lastSubmittedCategoryRef.current = fallback
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setSavingCategory(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [editing, selectedCategoryId, selectedSubcategoryId, ticket.category?.id, ticket.subcategory?.id, ticket.id, updateCategories, userId])
|
||||
|
||||
const workSummary = useMemo(() => {
|
||||
if (workSummaryRemote !== undefined) return workSummaryRemote ?? null
|
||||
|
|
@ -190,7 +258,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
|
||||
|
||||
const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs])
|
||||
const formattedCurrentSession = useMemo(() => formatDuration(currentSessionMs), [currentSessionMs])
|
||||
const updatedRelative = useMemo(
|
||||
() => formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR }),
|
||||
[ticket.updatedAt]
|
||||
|
|
@ -201,7 +268,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
<div className="absolute right-6 top-6 flex items-center gap-3">
|
||||
{workSummary ? (
|
||||
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
|
||||
Tempo total: {formattedTotalWorked}
|
||||
<IconClock className="size-4 text-neutral-700" /> Tempo total: {formattedTotalWorked}
|
||||
</Badge>
|
||||
) : null}
|
||||
{!editing ? (
|
||||
|
|
@ -256,15 +323,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{workSummary ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isPlaying ? (
|
||||
<Badge className="inline-flex items-center gap-1 rounded-full border border-black bg-black px-3 py-1 text-xs font-semibold text-white">
|
||||
Sessão atual: {formattedCurrentSession}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
|
|
@ -286,87 +344,77 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
{summary ? <p className="max-w-2xl text-sm text-neutral-600">{summary}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
{editing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!dirty}>
|
||||
Salvar
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="bg-slate-200" />
|
||||
<div className="grid gap-4 text-sm text-neutral-600 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-2 sm:col-span-2 lg:col-span-3">
|
||||
<span className={sectionLabelClass}>Categorias</span>
|
||||
<CategorySelectFields
|
||||
tenantId={ticket.tenantId}
|
||||
autoSelectFirst={!ticket.category}
|
||||
categoryId={categorySelection.categoryId || null}
|
||||
subcategoryId={categorySelection.subcategoryId || null}
|
||||
onCategoryChange={(value) => {
|
||||
setCategorySelection((prev) => ({ ...prev, categoryId: value }))
|
||||
}}
|
||||
onSubcategoryChange={(value) => {
|
||||
setCategorySelection((prev) => ({ ...prev, subcategoryId: value }))
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className={startButtonClass}
|
||||
onClick={handleSaveCategory}
|
||||
disabled={!categoryDirty || savingCategory}
|
||||
>
|
||||
{savingCategory ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-sm font-semibold text-neutral-700"
|
||||
onClick={handleResetCategory}
|
||||
disabled={savingCategory || !categoryDirty}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>Solicitante</span>
|
||||
<span className={sectionValueClass}>{ticket.requester.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>Responsável</span>
|
||||
<span className={sectionLabelClass}>Categoria primária</span>
|
||||
{editing ? (
|
||||
<Select
|
||||
value={ticket.assignee?.id ?? ""}
|
||||
onValueChange={async (value) => {
|
||||
if (!userId) return
|
||||
toast.loading("Atribuindo responsável...", { id: "assignee" })
|
||||
try {
|
||||
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> })
|
||||
toast.success("Responsável atualizado!", { id: "assignee" })
|
||||
} catch {
|
||||
toast.error("Não foi possível atribuir.", { id: "assignee" })
|
||||
}
|
||||
disabled={savingCategory || categoriesLoading || categories.length === 0}
|
||||
value={selectedCategoryId || ""}
|
||||
onValueChange={(value) => {
|
||||
const category = categories.find((item) => item.id === value)
|
||||
setCategorySelection({
|
||||
categoryId: value,
|
||||
subcategoryId: category?.secondary[0]?.id ?? "",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={selectTriggerClass}>
|
||||
<SelectValue placeholder="Selecionar" />
|
||||
<SelectValue placeholder={categoriesLoading ? "Carregando..." : "Selecionar"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent._id} value={agent._id}>
|
||||
{agent.name}
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className={sectionValueClass}>{ticket.category?.name ?? "Sem categoria"}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>Categoria secundária</span>
|
||||
{editing ? (
|
||||
<Select
|
||||
disabled={
|
||||
savingCategory || categoriesLoading || !selectedCategoryId || secondaryOptions.length === 0
|
||||
}
|
||||
value={selectedSubcategoryId || ""}
|
||||
onValueChange={(value) => {
|
||||
setCategorySelection((prev) => ({ ...prev, subcategoryId: value }))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={selectTriggerClass}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!selectedCategoryId
|
||||
? "Selecione uma primária"
|
||||
: secondaryOptions.length === 0
|
||||
? "Sem secundárias"
|
||||
: "Selecionar"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||
{secondaryOptions.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className={sectionValueClass}>{ticket.subcategory?.name ?? "Sem subcategoria"}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>Fila</span>
|
||||
{editing ? (
|
||||
<Select
|
||||
value={ticket.queue ?? ""}
|
||||
onValueChange={async (value) => {
|
||||
|
|
@ -393,6 +441,48 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className={sectionValueClass}>{ticket.queue ?? "Sem fila"}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>Solicitante</span>
|
||||
<span className={sectionValueClass}>{ticket.requester.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>Responsável</span>
|
||||
{editing ? (
|
||||
<Select
|
||||
value={ticket.assignee?.id ?? ""}
|
||||
onValueChange={async (value) => {
|
||||
if (!userId) return
|
||||
toast.loading("Atribuindo responsável...", { id: "assignee" })
|
||||
try {
|
||||
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> })
|
||||
toast.success("Responsável atualizado!", { id: "assignee" })
|
||||
} catch {
|
||||
toast.error("Não foi possível atribuir.", { id: "assignee" })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={selectTriggerClass}>
|
||||
<SelectValue placeholder="Selecionar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent._id} value={agent._id}>
|
||||
{agent.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className={sectionValueClass}>{ticket.assignee?.name ?? "Não atribuído"}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>Criado em</span>
|
||||
<span className={sectionValueClass}>{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>Atualizado em</span>
|
||||
|
|
@ -401,10 +491,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
<span className={subtleBadgeClass}>{updatedRelative}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>Criado em</span>
|
||||
<span className={sectionValueClass}>{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||
</div>
|
||||
{ticket.dueAt ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>SLA até</span>
|
||||
|
|
@ -417,6 +503,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{editing ? (
|
||||
<div className="flex items-center justify-end gap-2 sm:col-span-2 lg:col-span-3">
|
||||
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!dirty}>
|
||||
Salvar
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ const channelLabel: Record<string, string> = {
|
|||
const cellClass = "px-6 py-5 align-top text-sm text-neutral-700 first:pl-8 last:pr-8"
|
||||
const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700"
|
||||
const channelBadgeClass = "inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700"
|
||||
const tagBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-[11px] font-medium text-neutral-600"
|
||||
const categoryBadgeClass = "inline-flex items-center gap-1 rounded-full border border-[#00e8ff]/50 bg-[#00e8ff]/10 px-2.5 py-0.5 text-[11px] font-semibold text-[#02414d]"
|
||||
const tableRowClass = "group border-b border-slate-100 text-sm transition-colors hover:bg-slate-100/70 last:border-none"
|
||||
|
||||
|
|
@ -107,7 +106,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-3xl border border-slate-200 bg-white shadow-sm">
|
||||
<Card className="gap-0 rounded-3xl border border-slate-200 bg-white py-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table className="min-w-full overflow-hidden rounded-3xl">
|
||||
<TableHeader className="bg-slate-100/80">
|
||||
|
|
@ -170,17 +169,13 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
|
||||
<span className="font-semibold text-neutral-700">{ticket.requester.name}</span>
|
||||
{ticket.category ? (
|
||||
<Badge className={categoryBadgeClass}>
|
||||
{ticket.category.name}
|
||||
{ticket.subcategory ? ` • ${ticket.subcategory.name}` : ""}
|
||||
<Badge
|
||||
className={categoryBadgeClass}
|
||||
>
|
||||
{ticket.category
|
||||
? `${ticket.category.name}${ticket.subcategory ? ` • ${ticket.subcategory.name}` : ""}`
|
||||
: "Sem categoria"}
|
||||
</Badge>
|
||||
) : null}
|
||||
{ticket.tags?.map((tag) => (
|
||||
<Badge key={tag} className={tagBadgeClass}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAction } from "convex/react";
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex generates runtime API without TS metadata
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
|
|||
|
|
@ -6,17 +6,26 @@ import { Toaster as Sonner, ToasterProps } from "sonner"
|
|||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
const baseClass =
|
||||
"inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg"
|
||||
const baseStyle = {
|
||||
background: "#000",
|
||||
color: "#fff",
|
||||
border: "1px solid #000",
|
||||
width: "max-content" as const,
|
||||
minWidth: "fit-content" as const,
|
||||
maxWidth: "min(24rem, calc(100vw - 2rem))",
|
||||
whiteSpace: "normal" as const,
|
||||
overflowWrap: "break-word" as const,
|
||||
}
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg",
|
||||
style: {
|
||||
background: "#000",
|
||||
color: "#fff",
|
||||
border: "1px solid #000",
|
||||
},
|
||||
className: baseClass,
|
||||
style: baseStyle,
|
||||
classNames: {
|
||||
icon: "size-[14px] text-white [&>svg]:size-[14px] [&>svg]:text-white",
|
||||
},
|
||||
|
|
@ -27,32 +36,18 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||
primary: "#ffffff",
|
||||
secondary: "#000000",
|
||||
},
|
||||
success: {
|
||||
className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg",
|
||||
style: { background: "#000", color: "#fff", border: "1px solid #000" },
|
||||
},
|
||||
error: {
|
||||
className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg",
|
||||
style: { background: "#000", color: "#fff", border: "1px solid #000" },
|
||||
},
|
||||
info: {
|
||||
className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg",
|
||||
style: { background: "#000", color: "#fff", border: "1px solid #000" },
|
||||
},
|
||||
warning: {
|
||||
className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg",
|
||||
style: { background: "#000", color: "#fff", border: "1px solid #000" },
|
||||
},
|
||||
loading: {
|
||||
className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg",
|
||||
style: { background: "#000", color: "#fff", border: "1px solid #000" },
|
||||
},
|
||||
success: { className: baseClass, style: baseStyle },
|
||||
error: { className: baseClass, style: baseStyle },
|
||||
info: { className: baseClass, style: baseStyle },
|
||||
warning: { className: baseClass, style: baseStyle },
|
||||
loading: { className: baseClass, style: baseStyle },
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--width": "max-content",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex generates runtime API without TS declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { TicketCategory } from "@/lib/schemas/category"
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@ import type { Doc } from "@/convex/_generated/dataModel";
|
|||
import { useMutation } from "convex/react";
|
||||
|
||||
// Lazy import to avoid build errors before convex is generated
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Convex generates runtime API without types until build
|
||||
import { api } from "@/convex/_generated/api";
|
||||
|
||||
export type DemoUser = { name: string; email: string; avatarUrl?: string } | null;
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ describe("ticket mappers", () => {
|
|||
status: "OPEN",
|
||||
priority: "MEDIUM",
|
||||
channel: "EMAIL",
|
||||
queue: "Suporte N1",
|
||||
queue: "Chamados",
|
||||
requester: { id: "u1", name: "Ana", email: "a@a.com", teams: [] },
|
||||
assignee: { id: "u2", name: "Bruno", email: "b@b.com", teams: [] },
|
||||
updatedAt: now,
|
||||
|
|
|
|||
|
|
@ -22,21 +22,21 @@ const users: Record<string, UserRecord> = {
|
|||
name: "Ana Souza",
|
||||
email: "ana.souza@example.com",
|
||||
avatarUrl: "https://avatar.vercel.sh/ana",
|
||||
teams: ["Suporte N1"],
|
||||
teams: ["Chamados"],
|
||||
},
|
||||
bruno: {
|
||||
id: "user-bruno",
|
||||
name: "Bruno Lima",
|
||||
email: "bruno.lima@example.com",
|
||||
avatarUrl: "https://avatar.vercel.sh/bruno",
|
||||
teams: ["Suporte N1"],
|
||||
teams: ["Chamados"],
|
||||
},
|
||||
carla: {
|
||||
id: "user-carla",
|
||||
name: "Carla Menezes",
|
||||
email: "carla.menezes@example.com",
|
||||
avatarUrl: "https://avatar.vercel.sh/carla",
|
||||
teams: ["Suporte N2"],
|
||||
teams: ["Laboratório"],
|
||||
},
|
||||
diego: {
|
||||
id: "user-diego",
|
||||
|
|
@ -55,8 +55,8 @@ const users: Record<string, UserRecord> = {
|
|||
}
|
||||
|
||||
const queues = [
|
||||
{ id: "queue-suporte-n1", name: "Suporte N1", pending: 18, waiting: 4, breached: 2 },
|
||||
{ id: "queue-suporte-n2", name: "Suporte N2", pending: 9, waiting: 3, breached: 1 },
|
||||
{ id: "queue-chamados", name: "Chamados", pending: 18, waiting: 4, breached: 2 },
|
||||
{ id: "queue-laboratorio", name: "Laboratório", pending: 9, waiting: 3, breached: 1 },
|
||||
{ id: "queue-field", name: "Field Services", pending: 5, waiting: 2, breached: 0 },
|
||||
]
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ const baseTickets = [
|
|||
status: ticketStatusSchema.enum.OPEN,
|
||||
priority: ticketPrioritySchema.enum.URGENT,
|
||||
channel: ticketChannelSchema.enum.EMAIL,
|
||||
queue: "Suporte N1",
|
||||
queue: "Chamados",
|
||||
requester: users.eduarda,
|
||||
assignee: users.ana,
|
||||
slaPolicy: {
|
||||
|
|
@ -100,7 +100,7 @@ const baseTickets = [
|
|||
status: ticketStatusSchema.enum.PENDING,
|
||||
priority: ticketPrioritySchema.enum.HIGH,
|
||||
channel: ticketChannelSchema.enum.WHATSAPP,
|
||||
queue: "Suporte N2",
|
||||
queue: "Laboratório",
|
||||
requester: users.eduarda,
|
||||
assignee: users.carla,
|
||||
slaPolicy: {
|
||||
|
|
@ -151,7 +151,7 @@ const baseTickets = [
|
|||
status: ticketStatusSchema.enum.ON_HOLD,
|
||||
priority: ticketPrioritySchema.enum.HIGH,
|
||||
channel: ticketChannelSchema.enum.CHAT,
|
||||
queue: "Suporte N2",
|
||||
queue: "Laboratório",
|
||||
requester: users.eduarda,
|
||||
assignee: users.carla,
|
||||
slaPolicy: {
|
||||
|
|
@ -181,7 +181,7 @@ const baseTickets = [
|
|||
status: ticketStatusSchema.enum.RESOLVED,
|
||||
priority: ticketPrioritySchema.enum.MEDIUM,
|
||||
channel: ticketChannelSchema.enum.EMAIL,
|
||||
queue: "Suporte N1",
|
||||
queue: "Chamados",
|
||||
requester: users.eduarda,
|
||||
assignee: users.bruno,
|
||||
slaPolicy: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue