feat: status + queue updates, filters e UI
- Status renomeados e cores (Em andamento azul, Pausado amarelo) - Transições automáticas: iniciar=Em andamento, pausar=Pausado - Fila padrão: Chamados ao criar ticket - Admin/Empresas: renomeia ‘Slug’ → ‘Apelido’ + mensagens - Dashboard: últimos tickets priorizam sem responsável (mais antigos) - Tickets: filtro por responsável + salvar filtro por usuário - Encerrar ticket: adiciona botão ‘Cancelar’ - Strings atualizadas (PDF, relatórios, badges)
This commit is contained in:
parent
e91192a1f6
commit
5535ba81e6
19 changed files with 399 additions and 86 deletions
|
|
@ -17,7 +17,7 @@ type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RE
|
||||||
|
|
||||||
const STATUS_LABELS: Record<TicketStatusNormalized, string> = {
|
const STATUS_LABELS: Record<TicketStatusNormalized, string> = {
|
||||||
PENDING: "Pendente",
|
PENDING: "Pendente",
|
||||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
AWAITING_ATTENDANCE: "Em andamento",
|
||||||
PAUSED: "Pausado",
|
PAUSED: "Pausado",
|
||||||
RESOLVED: "Resolvido",
|
RESOLVED: "Resolvido",
|
||||||
};
|
};
|
||||||
|
|
@ -36,6 +36,22 @@ const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
|
||||||
const missingRequesterLogCache = new Set<string>();
|
const missingRequesterLogCache = new Set<string>();
|
||||||
const missingCommentAuthorLogCache = new Set<string>();
|
const missingCommentAuthorLogCache = new Set<string>();
|
||||||
|
|
||||||
|
// Character limits (generous but bounded)
|
||||||
|
const MAX_SUMMARY_CHARS = 600;
|
||||||
|
const MAX_COMMENT_CHARS = 20000;
|
||||||
|
|
||||||
|
function plainTextLength(html: string): number {
|
||||||
|
try {
|
||||||
|
const text = String(html)
|
||||||
|
.replace(/<[^>]*>/g, "") // strip tags
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.trim();
|
||||||
|
return text.length;
|
||||||
|
} catch {
|
||||||
|
return String(html ?? "").length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
||||||
if (!status) return "PENDING";
|
if (!status) return "PENDING";
|
||||||
const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
|
const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
|
||||||
|
|
@ -419,6 +435,8 @@ export const list = query({
|
||||||
priority: v.optional(v.string()),
|
priority: v.optional(v.string()),
|
||||||
channel: v.optional(v.string()),
|
channel: v.optional(v.string()),
|
||||||
queueId: v.optional(v.id("queues")),
|
queueId: v.optional(v.id("queues")),
|
||||||
|
assigneeId: v.optional(v.id("users")),
|
||||||
|
requesterId: v.optional(v.id("users")),
|
||||||
search: v.optional(v.string()),
|
search: v.optional(v.string()),
|
||||||
limit: v.optional(v.number()),
|
limit: v.optional(v.number()),
|
||||||
},
|
},
|
||||||
|
|
@ -434,10 +452,21 @@ export const list = query({
|
||||||
if (!user.companyId) {
|
if (!user.companyId) {
|
||||||
throw new ConvexError("Gestor não possui empresa vinculada")
|
throw new ConvexError("Gestor não possui empresa vinculada")
|
||||||
}
|
}
|
||||||
|
// Managers are scoped to company; allow secondary narrowing by requester/assignee
|
||||||
base = await ctx.db
|
base = await ctx.db
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
|
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
|
||||||
.collect();
|
.collect();
|
||||||
|
} else if (args.assigneeId) {
|
||||||
|
base = await ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!))
|
||||||
|
.collect();
|
||||||
|
} else if (args.requesterId) {
|
||||||
|
base = await ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!))
|
||||||
|
.collect();
|
||||||
} else if (args.queueId) {
|
} else if (args.queueId) {
|
||||||
base = await ctx.db
|
base = await ctx.db
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
|
|
@ -445,11 +474,18 @@ export const list = query({
|
||||||
.collect();
|
.collect();
|
||||||
} else if (role === "COLLABORATOR") {
|
} else if (role === "COLLABORATOR") {
|
||||||
// Colaborador: exibir apenas tickets onde ele é o solicitante
|
// Colaborador: exibir apenas tickets onde ele é o solicitante
|
||||||
|
// Compatibilidade por e-mail: inclui tickets com requesterSnapshot.email == e-mail do viewer
|
||||||
const all = await ctx.db
|
const all = await ctx.db
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
|
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
|
||||||
.collect()
|
.collect()
|
||||||
base = all.filter((t) => t.requesterId === args.viewerId)
|
const viewerEmail = user.email.trim().toLowerCase()
|
||||||
|
base = all.filter((t) => {
|
||||||
|
if (t.requesterId === args.viewerId) return true
|
||||||
|
const rs = t.requesterSnapshot as { email?: string } | undefined
|
||||||
|
const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null
|
||||||
|
return Boolean(email && email === viewerEmail)
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
base = await ctx.db
|
base = await ctx.db
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
|
|
@ -468,6 +504,8 @@ export const list = query({
|
||||||
|
|
||||||
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
|
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
|
||||||
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
|
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
|
||||||
|
if (args.assigneeId) filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId));
|
||||||
|
if (args.requesterId) filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId));
|
||||||
if (normalizedStatusFilter) {
|
if (normalizedStatusFilter) {
|
||||||
filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter);
|
filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter);
|
||||||
}
|
}
|
||||||
|
|
@ -585,6 +623,15 @@ export const getById = query({
|
||||||
const { user, role } = await requireUser(ctx, viewerId, tenantId)
|
const { user, role } = await requireUser(ctx, viewerId, tenantId)
|
||||||
const t = await ctx.db.get(id);
|
const t = await ctx.db.get(id);
|
||||||
if (!t || t.tenantId !== tenantId) return null;
|
if (!t || t.tenantId !== tenantId) return null;
|
||||||
|
if (role === "COLLABORATOR") {
|
||||||
|
const isOwnerById = String(t.requesterId) === String(viewerId)
|
||||||
|
const snapshotEmail = (t.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase?.() ?? null
|
||||||
|
const viewerEmail = user.email.trim().toLowerCase()
|
||||||
|
const isOwnerByEmail = Boolean(snapshotEmail && snapshotEmail === viewerEmail)
|
||||||
|
if (!isOwnerById && !isOwnerByEmail) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
// no customer role; managers are constrained to company via ensureManagerTicketAccess
|
// no customer role; managers are constrained to company via ensureManagerTicketAccess
|
||||||
let requester: Doc<"users"> | null = null
|
let requester: Doc<"users"> | null = null
|
||||||
if (role === "MANAGER") {
|
if (role === "MANAGER") {
|
||||||
|
|
@ -841,6 +888,9 @@ export const create = mutation({
|
||||||
if (subject.length < 3) {
|
if (subject.length < 3) {
|
||||||
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
|
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
|
||||||
}
|
}
|
||||||
|
if (args.summary && args.summary.trim().length > MAX_SUMMARY_CHARS) {
|
||||||
|
throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`);
|
||||||
|
}
|
||||||
const category = await ctx.db.get(args.categoryId);
|
const category = await ctx.db.get(args.categoryId);
|
||||||
if (!category || category.tenantId !== args.tenantId) {
|
if (!category || category.tenantId !== args.tenantId) {
|
||||||
throw new ConvexError("Categoria inválida");
|
throw new ConvexError("Categoria inválida");
|
||||||
|
|
@ -893,6 +943,19 @@ export const create = mutation({
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
// default queue: if none provided, prefer "Chamados"
|
||||||
|
let resolvedQueueId = args.queueId as Id<"queues"> | undefined
|
||||||
|
if (!resolvedQueueId) {
|
||||||
|
const queues = await ctx.db
|
||||||
|
.query("queues")
|
||||||
|
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
|
||||||
|
.collect()
|
||||||
|
const preferred = queues.find((q) => q.slug === "chamados") || queues.find((q) => q.name === "Chamados") || null
|
||||||
|
if (preferred) {
|
||||||
|
resolvedQueueId = preferred._id as Id<"queues">
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const id = await ctx.db.insert("tickets", {
|
const id = await ctx.db.insert("tickets", {
|
||||||
tenantId: args.tenantId,
|
tenantId: args.tenantId,
|
||||||
reference: nextRef,
|
reference: nextRef,
|
||||||
|
|
@ -901,7 +964,7 @@ export const create = mutation({
|
||||||
status: initialStatus,
|
status: initialStatus,
|
||||||
priority: args.priority,
|
priority: args.priority,
|
||||||
channel: args.channel,
|
channel: args.channel,
|
||||||
queueId: args.queueId,
|
queueId: resolvedQueueId,
|
||||||
categoryId: args.categoryId,
|
categoryId: args.categoryId,
|
||||||
subcategoryId: args.subcategoryId,
|
subcategoryId: args.subcategoryId,
|
||||||
requesterId: args.requesterId,
|
requesterId: args.requesterId,
|
||||||
|
|
@ -1019,6 +1082,11 @@ export const addComment = mutation({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bodyPlainLen = plainTextLength(args.body)
|
||||||
|
if (bodyPlainLen > MAX_COMMENT_CHARS) {
|
||||||
|
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
||||||
|
}
|
||||||
|
|
||||||
const authorSnapshot: CommentAuthorSnapshot = {
|
const authorSnapshot: CommentAuthorSnapshot = {
|
||||||
name: author.name,
|
name: author.name,
|
||||||
email: author.email,
|
email: author.email,
|
||||||
|
|
@ -1084,6 +1152,11 @@ export const updateComment = mutation({
|
||||||
await requireTicketStaff(ctx, actorId, ticketDoc)
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bodyPlainLen = plainTextLength(body)
|
||||||
|
if (bodyPlainLen > MAX_COMMENT_CHARS) {
|
||||||
|
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await ctx.db.patch(commentId, {
|
await ctx.db.patch(commentId, {
|
||||||
body,
|
body,
|
||||||
|
|
@ -1453,6 +1526,7 @@ export const startWork = mutation({
|
||||||
await ctx.db.patch(ticketId, {
|
await ctx.db.patch(ticketId, {
|
||||||
working: true,
|
working: true,
|
||||||
activeSessionId: sessionId,
|
activeSessionId: sessionId,
|
||||||
|
status: "AWAITING_ATTENDANCE",
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1532,6 +1606,7 @@ export const pauseWork = mutation({
|
||||||
await ctx.db.patch(ticketId, {
|
await ctx.db.patch(ticketId, {
|
||||||
working: false,
|
working: false,
|
||||||
activeSessionId: undefined,
|
activeSessionId: undefined,
|
||||||
|
status: "PAUSED",
|
||||||
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
|
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
|
||||||
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
|
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
|
||||||
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
|
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
|
||||||
|
|
@ -1599,6 +1674,9 @@ export const updateSummary = mutation({
|
||||||
throw new ConvexError("Ticket não encontrado")
|
throw new ConvexError("Ticket não encontrado")
|
||||||
}
|
}
|
||||||
await requireStaff(ctx, actorId, t.tenantId)
|
await requireStaff(ctx, actorId, t.tenantId)
|
||||||
|
if (summary && summary.trim().length > MAX_SUMMARY_CHARS) {
|
||||||
|
throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`)
|
||||||
|
}
|
||||||
await ctx.db.patch(ticketId, { summary, updatedAt: now });
|
await ctx.db.patch(ticketId, { summary, updatedAt: now });
|
||||||
const actor = await ctx.db.get(actorId);
|
const actor = await ctx.db.get(actorId);
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
|
@ -1743,3 +1821,109 @@ export const remove = mutation({
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const reassignTicketsByEmail = mutation({
|
||||||
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
actorId: v.id("users"),
|
||||||
|
fromEmail: v.string(),
|
||||||
|
toUserId: v.id("users"),
|
||||||
|
dryRun: v.optional(v.boolean()),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
updateSnapshot: v.optional(v.boolean()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { tenantId, actorId, fromEmail, toUserId, dryRun, limit, updateSnapshot }) => {
|
||||||
|
await requireAdmin(ctx, actorId, tenantId)
|
||||||
|
|
||||||
|
const normalizedFrom = fromEmail.trim().toLowerCase()
|
||||||
|
if (!normalizedFrom || !normalizedFrom.includes("@")) {
|
||||||
|
throw new ConvexError("E-mail de origem inválido")
|
||||||
|
}
|
||||||
|
|
||||||
|
const toUser = await ctx.db.get(toUserId)
|
||||||
|
if (!toUser || toUser.tenantId !== tenantId) {
|
||||||
|
throw new ConvexError("Usuário de destino inválido para o tenant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coletar tickets por requesterId (quando possível via usuário antigo)
|
||||||
|
const fromUser = await ctx.db
|
||||||
|
.query("users")
|
||||||
|
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalizedFrom))
|
||||||
|
.first()
|
||||||
|
|
||||||
|
const byRequesterId: Doc<"tickets">[] = fromUser
|
||||||
|
? await ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", tenantId).eq("requesterId", fromUser._id))
|
||||||
|
.collect()
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Coletar tickets por e-mail no snapshot para cobrir casos sem user antigo
|
||||||
|
const allTenant = await ctx.db
|
||||||
|
.query("tickets")
|
||||||
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||||
|
.collect()
|
||||||
|
|
||||||
|
const bySnapshotEmail = allTenant.filter((t) => {
|
||||||
|
const rs = t.requesterSnapshot as { email?: string } | undefined
|
||||||
|
const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null
|
||||||
|
if (!email || email !== normalizedFrom) return false
|
||||||
|
// Evita duplicar os já coletados por requesterId
|
||||||
|
if (fromUser && t.requesterId === fromUser._id) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const candidatesMap = new Map<string, Doc<"tickets">>()
|
||||||
|
for (const t of byRequesterId) candidatesMap.set(String(t._id), t)
|
||||||
|
for (const t of bySnapshotEmail) candidatesMap.set(String(t._id), t)
|
||||||
|
const candidates = Array.from(candidatesMap.values())
|
||||||
|
|
||||||
|
const maxToProcess = Math.max(0, Math.min(limit && limit > 0 ? limit : candidates.length, candidates.length))
|
||||||
|
const toProcess = candidates.slice(0, maxToProcess)
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
dryRun: true as const,
|
||||||
|
fromEmail: normalizedFrom,
|
||||||
|
toUserId,
|
||||||
|
candidates: candidates.length,
|
||||||
|
willUpdate: toProcess.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
let updated = 0
|
||||||
|
for (const t of toProcess) {
|
||||||
|
const patch: Record<string, unknown> = { requesterId: toUserId, updatedAt: now }
|
||||||
|
if (updateSnapshot) {
|
||||||
|
patch.requesterSnapshot = {
|
||||||
|
name: toUser.name,
|
||||||
|
email: toUser.email,
|
||||||
|
avatarUrl: toUser.avatarUrl ?? undefined,
|
||||||
|
teams: toUser.teams ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await ctx.db.patch(t._id, patch)
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId: t._id,
|
||||||
|
type: "REQUESTER_CHANGED",
|
||||||
|
payload: {
|
||||||
|
fromUserId: fromUser?._id ?? null,
|
||||||
|
fromEmail: normalizedFrom,
|
||||||
|
toUserId,
|
||||||
|
toUserName: toUser.name,
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
updated += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dryRun: false as const,
|
||||||
|
fromEmail: normalizedFrom,
|
||||||
|
toUserId,
|
||||||
|
candidates: candidates.length,
|
||||||
|
updated,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export async function GET(request: Request) {
|
||||||
// Status
|
// Status
|
||||||
const STATUS_PT: Record<string, string> = {
|
const STATUS_PT: Record<string, string> = {
|
||||||
PENDING: "Pendentes",
|
PENDING: "Pendentes",
|
||||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
AWAITING_ATTENDANCE: "Em andamento",
|
||||||
PAUSED: "Pausados",
|
PAUSED: "Pausados",
|
||||||
RESOLVED: "Resolvidos",
|
RESOLVED: "Resolvidos",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,13 @@ export default function NewTicketPage() {
|
||||||
setAssigneeInitialized(true)
|
setAssigneeInitialized(true)
|
||||||
}, [assigneeInitialized, convexUserId])
|
}, [assigneeInitialized, convexUserId])
|
||||||
|
|
||||||
|
// Default queue to "Chamados" if available
|
||||||
|
useEffect(() => {
|
||||||
|
if (queueName) return
|
||||||
|
const hasChamados = queueOptions.includes("Chamados")
|
||||||
|
if (hasChamados) setQueueName("Chamados")
|
||||||
|
}, [queueOptions, queueName])
|
||||||
|
|
||||||
async function submit(event: React.FormEvent) {
|
async function submit(event: React.FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!convexUserId || loading) return
|
if (!convexUserId || loading) return
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
contractedHoursPerMonth: contractedHours,
|
contractedHoursPerMonth: contractedHours,
|
||||||
}
|
}
|
||||||
if (!payload.name || !payload.slug) {
|
if (!payload.name || !payload.slug) {
|
||||||
toast.error("Informe nome e slug válidos")
|
toast.error("Informe nome e apelido válidos")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
|
|
@ -388,7 +388,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor={slugId}>Slug</Label>
|
<Label htmlFor={slugId}>Apelido</Label>
|
||||||
<Input
|
<Input
|
||||||
id={slugId}
|
id={slugId}
|
||||||
name="companySlug"
|
name="companySlug"
|
||||||
|
|
@ -541,7 +541,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
<Input
|
<Input
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(event) => setSearchTerm(event.target.value)}
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
placeholder="Buscar por nome, slug ou domínio..."
|
placeholder="Buscar por nome, apelido ou domínio..."
|
||||||
className="h-9 pl-9"
|
className="h-9 pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const statusLabel: Record<Ticket["status"], string> = {
|
const statusLabel: Record<Ticket["status"], string> = {
|
||||||
PENDING: "Pendente",
|
PENDING: "Pendente",
|
||||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
AWAITING_ATTENDANCE: "Em andamento",
|
||||||
PAUSED: "Pausado",
|
PAUSED: "Pausado",
|
||||||
RESOLVED: "Resolvido",
|
RESOLVED: "Resolvido",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,11 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
const plainText = typeof window !== "undefined"
|
const plainText = typeof window !== "undefined"
|
||||||
? new DOMParser().parseFromString(sanitizedHtml, "text/html").body.textContent?.replace(/\u00a0/g, " ").trim() ?? ""
|
? new DOMParser().parseFromString(sanitizedHtml, "text/html").body.textContent?.replace(/\u00a0/g, " ").trim() ?? ""
|
||||||
: sanitizedHtml.replace(/<[^>]*>/g, "").replace(/ /g, " ").trim()
|
: sanitizedHtml.replace(/<[^>]*>/g, "").replace(/ /g, " ").trim()
|
||||||
|
const MAX_COMMENT_CHARS = 20000
|
||||||
|
if (plainText.length > MAX_COMMENT_CHARS) {
|
||||||
|
toast.error(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
const hasMeaningfulText = plainText.length > 0
|
const hasMeaningfulText = plainText.length > 0
|
||||||
if (!hasMeaningfulText && attachments.length === 0) {
|
if (!hasMeaningfulText && attachments.length === 0) {
|
||||||
toast.error("Adicione uma mensagem ou anexe ao menos um arquivo antes de enviar.")
|
toast.error("Adicione uma mensagem ou anexe ao menos um arquivo antes de enviar.")
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,12 @@ export function PortalTicketForm() {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (plainDescription.length > 0) {
|
if (plainDescription.length > 0) {
|
||||||
|
const MAX_COMMENT_CHARS = 20000
|
||||||
|
if (plainDescription.length > MAX_COMMENT_CHARS) {
|
||||||
|
toast.error(`Descrição muito longa (máx. ${MAX_COMMENT_CHARS} caracteres)`, { id: "portal-new-ticket" })
|
||||||
|
setIsSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
const htmlBody = sanitizedDescription || toHtml(trimmedSummary || trimmedSubject)
|
const htmlBody = sanitizedDescription || toHtml(trimmedSummary || trimmedSubject)
|
||||||
|
|
||||||
const typedAttachments = attachments.map((file) => ({
|
const typedAttachments = attachments.map((file) => ({
|
||||||
|
|
@ -178,6 +184,7 @@ export function PortalTicketForm() {
|
||||||
value={summary}
|
value={summary}
|
||||||
onChange={(event) => setSummary(event.target.value)}
|
onChange={(event) => setSummary(event.target.value)}
|
||||||
placeholder="Descreva rapidamente o que está acontecendo"
|
placeholder="Descreva rapidamente o que está acontecendo"
|
||||||
|
maxLength={600}
|
||||||
disabled={machineInactive || isSubmitting}
|
disabled={machineInactive || isSubmitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ const PRIORITY_LABELS: Record<string, string> = {
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
PENDING: "Pendentes",
|
PENDING: "Pendentes",
|
||||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
AWAITING_ATTENDANCE: "Em andamento",
|
||||||
PAUSED: "Pausados",
|
PAUSED: "Pausados",
|
||||||
RESOLVED: "Resolvidos",
|
RESOLVED: "Resolvidos",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,17 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
||||||
setAssigneeInitialized(true)
|
setAssigneeInitialized(true)
|
||||||
}, [open, assigneeInitialized, convexUserId, form])
|
}, [open, assigneeInitialized, convexUserId, form])
|
||||||
|
|
||||||
|
// Default queue to "Chamados" if available when opening
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const current = form.getValues("queueName")
|
||||||
|
if (current) return
|
||||||
|
const hasChamados = queues.some((q) => q.name === "Chamados")
|
||||||
|
if (hasChamados) {
|
||||||
|
form.setValue("queueName", "Chamados", { shouldDirty: false, shouldTouch: false })
|
||||||
|
}
|
||||||
|
}, [open, queues, form])
|
||||||
|
|
||||||
const handleCategoryChange = (value: string) => {
|
const handleCategoryChange = (value: string) => {
|
||||||
const previous = form.getValues("categoryId") ?? ""
|
const previous = form.getValues("categoryId") ?? ""
|
||||||
const next = value ?? ""
|
const next = value ?? ""
|
||||||
|
|
@ -166,6 +177,13 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
||||||
})
|
})
|
||||||
const summaryFallback = values.summary?.trim() ?? ""
|
const summaryFallback = values.summary?.trim() ?? ""
|
||||||
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
|
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
|
||||||
|
const MAX_COMMENT_CHARS = 20000
|
||||||
|
const plainForLimit = (plainDescription.length > 0 ? plainDescription : summaryFallback).trim()
|
||||||
|
if (plainForLimit.length > MAX_COMMENT_CHARS) {
|
||||||
|
toast.error(`Descrição muito longa (máx. ${MAX_COMMENT_CHARS} caracteres)`, { id: "new-ticket" })
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (attachments.length > 0 || bodyHtml.trim().length > 0) {
|
if (attachments.length > 0 || bodyHtml.trim().length > 0) {
|
||||||
const typedAttachments = attachments.map((a) => ({
|
const typedAttachments = attachments.map((a) => ({
|
||||||
storageId: a.storageId as unknown as Id<"_storage">,
|
storageId: a.storageId as unknown as Id<"_storage">,
|
||||||
|
|
@ -254,9 +272,15 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
||||||
<FieldLabel htmlFor="summary">Resumo</FieldLabel>
|
<FieldLabel htmlFor="summary">Resumo</FieldLabel>
|
||||||
<textarea
|
<textarea
|
||||||
id="summary"
|
id="summary"
|
||||||
className="min-h-[96px] w-full rounded-lg border border-slate-300 bg-background p-3 text-sm shadow-sm focus-visible:border-[#00d6eb] focus-visible:outline-none"
|
className="min-h-[96px] w-full resize-none overflow-hidden rounded-lg border border-slate-300 bg-background p-3 text-sm shadow-sm focus-visible:border-[#00d6eb] focus-visible:outline-none"
|
||||||
|
maxLength={600}
|
||||||
{...form.register("summary")}
|
{...form.register("summary")}
|
||||||
placeholder="Explique em poucas linhas o contexto do chamado."
|
placeholder="Explique em poucas linhas o contexto do chamado."
|
||||||
|
onInput={(e) => {
|
||||||
|
const el = e.currentTarget
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = `${el.scrollHeight}px`
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
|
|
|
||||||
|
|
@ -91,13 +91,17 @@ export function RecentTicketsPanel() {
|
||||||
const [enteringId, setEnteringId] = useState<string | null>(null)
|
const [enteringId, setEnteringId] = useState<string | null>(null)
|
||||||
const previousIdsRef = useRef<string[]>([])
|
const previousIdsRef = useRef<string[]>([])
|
||||||
|
|
||||||
const tickets = useMemo(
|
const tickets = useMemo(() => {
|
||||||
() =>
|
const all = mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]).filter((t) => t.status !== "RESOLVED")
|
||||||
mapTicketsFromServerList((ticketsRaw ?? []) as unknown[])
|
// Unassigned first (no assignee), oldest first among unassigned; then the rest by updatedAt desc
|
||||||
.filter((ticket) => ticket.status !== "RESOLVED")
|
const unassigned = all
|
||||||
.slice(0, 6),
|
.filter((t) => !t.assignee)
|
||||||
[ticketsRaw]
|
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||||
)
|
const assigned = all
|
||||||
|
.filter((t) => !!t.assignee)
|
||||||
|
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
||||||
|
return [...unassigned, ...assigned].slice(0, 6)
|
||||||
|
}, [ticketsRaw])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ticketsRaw === undefined) {
|
if (ticketsRaw === undefined) {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const statusStyles: Record<TicketStatus, { label: string; className: string }> = {
|
const statusStyles: Record<TicketStatus, { label: string; className: string }> = {
|
||||||
PENDING: { label: "Pendente", className: "border border-slate-200 bg-slate-100 text-slate-700" },
|
PENDING: { label: "Pendente", className: "border border-slate-200 bg-slate-100 text-slate-700" },
|
||||||
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
AWAITING_ATTENDANCE: { label: "Em andamento", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
||||||
PAUSED: { label: "Pausado", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
|
PAUSED: { label: "Pausado", className: "border border-slate-200 bg-[#fff3c4] text-[#7a5901]" },
|
||||||
RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
|
RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { api } from "@/convex/_generated/api"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
import type { TicketStatus } from "@/lib/schemas/ticket"
|
import type { TicketStatus } from "@/lib/schemas/ticket"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
|
@ -15,25 +14,19 @@ import { RichTextEditor } from "@/components/ui/rich-text-editor"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
||||||
import { ChevronDown } from "lucide-react"
|
|
||||||
|
|
||||||
type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD";
|
type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD";
|
||||||
|
|
||||||
const STATUS_OPTIONS: TicketStatus[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"];
|
|
||||||
|
|
||||||
const statusStyles: Record<StatusKey, { label: string; badgeClass: string }> = {
|
const statusStyles: Record<StatusKey, { label: string; badgeClass: string }> = {
|
||||||
PENDING: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
|
PENDING: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
|
||||||
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
AWAITING_ATTENDANCE: { label: "Em andamento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
||||||
PAUSED: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
|
PAUSED: { label: "Pausado", badgeClass: "bg-[#fff3c4] text-[#7a5901]" },
|
||||||
RESOLVED: { label: "Resolvido", badgeClass: "bg-[#dcf4eb] text-[#1f6a45]" },
|
RESOLVED: { label: "Resolvido", badgeClass: "bg-[#dcf4eb] text-[#1f6a45]" },
|
||||||
NEW: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
|
NEW: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
|
||||||
OPEN: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
OPEN: { label: "Em andamento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
||||||
ON_HOLD: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
|
ON_HOLD: { label: "Pausado", badgeClass: "bg-[#fff3c4] text-[#7a5901]" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const triggerClass =
|
|
||||||
"group inline-flex h-auto w-auto items-center justify-center rounded-full border border-transparent bg-transparent p-0 shadow-none ring-0 ring-offset-0 ring-offset-transparent focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:border-transparent data-[state=open]:shadow-none data-[state=open]:ring-0 data-[state=open]:ring-offset-0 data-[state=open]:ring-offset-transparent [&>*:last-child]:hidden"
|
|
||||||
const itemClass = "rounded-md px-2 py-2 text-sm text-neutral-800 transition hover:bg-slate-100 data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
|
|
||||||
const baseBadgeClass =
|
const baseBadgeClass =
|
||||||
"inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-semibold transition hover:border-slate-300"
|
"inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-semibold transition hover:border-slate-300"
|
||||||
|
|
||||||
|
|
@ -97,7 +90,6 @@ export function StatusSelect({
|
||||||
tenantId: string
|
tenantId: string
|
||||||
requesterName?: string | null
|
requesterName?: string | null
|
||||||
}) {
|
}) {
|
||||||
const updateStatus = useMutation(api.tickets.updateStatus)
|
|
||||||
const { convexUserId } = useAuth()
|
const { convexUserId } = useAuth()
|
||||||
const actorId = (convexUserId ?? null) as Id<"users"> | null
|
const actorId = (convexUserId ?? null) as Id<"users"> | null
|
||||||
const [status, setStatus] = useState<TicketStatus>(value)
|
const [status, setStatus] = useState<TicketStatus>(value)
|
||||||
|
|
@ -107,48 +99,22 @@ export function StatusSelect({
|
||||||
setStatus(value)
|
setStatus(value)
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
const handleStatusChange = async (selected: string) => {
|
|
||||||
const next = selected as TicketStatus
|
|
||||||
if (next === "RESOLVED") {
|
|
||||||
setCloseDialogOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const previous = status
|
|
||||||
setStatus(next)
|
|
||||||
toast.loading("Atualizando status...", { id: "status" })
|
|
||||||
try {
|
|
||||||
if (!actorId) {
|
|
||||||
throw new Error("missing user")
|
|
||||||
}
|
|
||||||
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId })
|
|
||||||
toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
setStatus(previous)
|
|
||||||
toast.error("Não foi possível atualizar o status.", { id: "status" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Select value={status} onValueChange={handleStatusChange}>
|
<div className="inline-flex items-center gap-2">
|
||||||
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
|
|
||||||
<SelectValue asChild>
|
|
||||||
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass ?? statusStyles.PENDING.badgeClass)}>
|
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass ?? statusStyles.PENDING.badgeClass)}>
|
||||||
{statusStyles[status]?.label ?? statusStyles.PENDING.label}
|
{statusStyles[status]?.label ?? statusStyles.PENDING.label}
|
||||||
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</SelectValue>
|
<Button
|
||||||
</SelectTrigger>
|
type="button"
|
||||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
variant="outline"
|
||||||
{STATUS_OPTIONS.map((option) => (
|
size="sm"
|
||||||
<SelectItem key={option} value={option} className={itemClass}>
|
onClick={() => setCloseDialogOpen(true)}
|
||||||
{statusStyles[option].label}
|
className="h-8 rounded-full border-slate-300 bg-white px-3 text-xs font-medium text-neutral-800"
|
||||||
</SelectItem>
|
>
|
||||||
))}
|
Encerrar
|
||||||
</SelectContent>
|
</Button>
|
||||||
</Select>
|
</div>
|
||||||
<CloseTicketDialog
|
<CloseTicketDialog
|
||||||
open={closeDialogOpen}
|
open={closeDialogOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|
@ -303,6 +269,14 @@ function CloseTicketDialog({ open, onOpenChange, ticketId, tenantId, actorId, re
|
||||||
O comentário será público e ficará registrado no histórico do ticket.
|
O comentário será público e ficará registrado no histórico do ticket.
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,14 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!convexUserId) return
|
if (!convexUserId) return
|
||||||
|
// Enforce generous max length for comment plain text
|
||||||
|
const sanitized = sanitizeEditorHtml(body)
|
||||||
|
const plain = sanitized.replace(/<[^>]*>/g, "").replace(/ /g, " ").trim()
|
||||||
|
const MAX_COMMENT_CHARS = 20000
|
||||||
|
if (plain.length > MAX_COMMENT_CHARS) {
|
||||||
|
toast.error(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`, { id: "comment" })
|
||||||
|
return
|
||||||
|
}
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const selectedVisibility = canSeeInternalComments ? visibility : "PUBLIC"
|
const selectedVisibility = canSeeInternalComments ? visibility : "PUBLIC"
|
||||||
const attachments = attachmentsToSend.map((item) => ({ ...item }))
|
const attachments = attachmentsToSend.map((item) => ({ ...item }))
|
||||||
|
|
@ -139,7 +147,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
id: `temp-${now.getTime()}`,
|
id: `temp-${now.getTime()}`,
|
||||||
author: ticket.requester,
|
author: ticket.requester,
|
||||||
visibility: selectedVisibility,
|
visibility: selectedVisibility,
|
||||||
body: sanitizeEditorHtml(body),
|
body: sanitized,
|
||||||
attachments: attachments.map((attachment) => ({
|
attachments: attachments.map((attachment) => ({
|
||||||
id: attachment.storageId,
|
id: attachment.storageId,
|
||||||
name: attachment.name,
|
name: attachment.name,
|
||||||
|
|
|
||||||
|
|
@ -735,9 +735,21 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={summary}
|
value={summary}
|
||||||
onChange={(e) => setSummary(e.target.value)}
|
onChange={(e) => {
|
||||||
|
const el = e.currentTarget
|
||||||
|
// auto-resize height based on content
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = `${el.scrollHeight}px`
|
||||||
|
setSummary(e.target.value)
|
||||||
|
}}
|
||||||
|
onInput={(e) => {
|
||||||
|
const el = e.currentTarget
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = `${el.scrollHeight}px`
|
||||||
|
}}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-lg border border-slate-300 bg-white p-3 text-sm text-neutral-800 shadow-sm"
|
maxLength={600}
|
||||||
|
className="w-full resize-none overflow-hidden rounded-lg border border-slate-300 bg-white p-3 text-sm text-neutral-800 shadow-sm"
|
||||||
placeholder="Adicione um resumo opcional"
|
placeholder="Adicione um resumo opcional"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import {
|
||||||
|
|
||||||
const statusOptions: Array<{ value: TicketStatus; label: string }> = [
|
const statusOptions: Array<{ value: TicketStatus; label: string }> = [
|
||||||
{ value: "PENDING", label: "Pendente" },
|
{ value: "PENDING", label: "Pendente" },
|
||||||
{ value: "AWAITING_ATTENDANCE", label: "Aguardando atendimento" },
|
{ value: "AWAITING_ATTENDANCE", label: "Em andamento" },
|
||||||
{ value: "PAUSED", label: "Pausado" },
|
{ value: "PAUSED", label: "Pausado" },
|
||||||
{ value: "RESOLVED", label: "Resolvido" },
|
{ value: "RESOLVED", label: "Resolvido" },
|
||||||
]
|
]
|
||||||
|
|
@ -67,6 +67,7 @@ export type TicketFiltersState = {
|
||||||
queue: string | null
|
queue: string | null
|
||||||
channel: string | null
|
channel: string | null
|
||||||
company: string | null
|
company: string | null
|
||||||
|
assigneeId: string | null
|
||||||
view: "active" | "completed"
|
view: "active" | "completed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,6 +78,7 @@ export const defaultTicketFilters: TicketFiltersState = {
|
||||||
queue: null,
|
queue: null,
|
||||||
channel: null,
|
channel: null,
|
||||||
company: null,
|
company: null,
|
||||||
|
assigneeId: null,
|
||||||
view: "active",
|
view: "active",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,12 +86,13 @@ interface TicketsFiltersProps {
|
||||||
onChange?: (filters: TicketFiltersState) => void
|
onChange?: (filters: TicketFiltersState) => void
|
||||||
queues?: QueueOption[]
|
queues?: QueueOption[]
|
||||||
companies?: string[]
|
companies?: string[]
|
||||||
|
assignees?: Array<{ id: string; name: string }>
|
||||||
initialState?: Partial<TicketFiltersState>
|
initialState?: Partial<TicketFiltersState>
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_VALUE = "ALL"
|
const ALL_VALUE = "ALL"
|
||||||
|
|
||||||
export function TicketsFilters({ onChange, queues = [], companies = [], initialState }: TicketsFiltersProps) {
|
export function TicketsFilters({ onChange, queues = [], companies = [], assignees = [], initialState }: TicketsFiltersProps) {
|
||||||
const mergedDefaults = useMemo(
|
const mergedDefaults = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...defaultTicketFilters,
|
...defaultTicketFilters,
|
||||||
|
|
@ -119,9 +122,13 @@ export function TicketsFilters({ onChange, queues = [], companies = [], initialS
|
||||||
if (filters.queue) chips.push(`Fila: ${filters.queue}`)
|
if (filters.queue) chips.push(`Fila: ${filters.queue}`)
|
||||||
if (filters.channel) chips.push(`Canal: ${filters.channel}`)
|
if (filters.channel) chips.push(`Canal: ${filters.channel}`)
|
||||||
if (filters.company) chips.push(`Empresa: ${filters.company}`)
|
if (filters.company) chips.push(`Empresa: ${filters.company}`)
|
||||||
|
if (filters.assigneeId) {
|
||||||
|
const found = assignees.find((a) => a.id === filters.assigneeId)
|
||||||
|
chips.push(`Responsável: ${found?.name ?? filters.assigneeId}`)
|
||||||
|
}
|
||||||
if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos")
|
if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos")
|
||||||
return chips
|
return chips
|
||||||
}, [filters])
|
}, [filters, assignees])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|
@ -167,6 +174,22 @@ export function TicketsFilters({ onChange, queues = [], companies = [], initialS
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={filters.assigneeId ?? ALL_VALUE}
|
||||||
|
onValueChange={(value) => setPartial({ assigneeId: value === ALL_VALUE ? null : value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="md:w-[220px]">
|
||||||
|
<SelectValue placeholder="Responsável" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL_VALUE}>Todos os responsáveis</SelectItem>
|
||||||
|
{assignees.map((user) => (
|
||||||
|
<SelectItem key={user.id} value={user.id}>
|
||||||
|
{user.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
value={filters.view}
|
value={filters.view}
|
||||||
onValueChange={(value) => setPartial({ view: value as TicketFiltersState["view"] })}
|
onValueChange={(value) => setPartial({ view: value as TicketFiltersState["view"] })}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ const tableRowClass =
|
||||||
|
|
||||||
const statusLabel: Record<TicketStatus, string> = {
|
const statusLabel: Record<TicketStatus, string> = {
|
||||||
PENDING: "Pendente",
|
PENDING: "Pendente",
|
||||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
AWAITING_ATTENDANCE: "Em andamento",
|
||||||
PAUSED: "Pausado",
|
PAUSED: "Pausado",
|
||||||
RESOLVED: "Resolvido",
|
RESOLVED: "Resolvido",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
||||||
queuesEnabled ? api.queues.summary : "skip",
|
queuesEnabled ? api.queues.summary : "skip",
|
||||||
queuesEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
queuesEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
) as TicketQueueSummary[] | undefined
|
) as TicketQueueSummary[] | undefined
|
||||||
|
const agents = useQuery(api.users.listAgents, { tenantId }) as { _id: string; name: string }[] | undefined
|
||||||
const ticketsRaw = useQuery(
|
const ticketsRaw = useQuery(
|
||||||
api.tickets.list,
|
api.tickets.list,
|
||||||
convexUserId
|
convexUserId
|
||||||
|
|
@ -49,20 +50,66 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
||||||
priority: filters.priority ?? undefined,
|
priority: filters.priority ?? undefined,
|
||||||
channel: filters.channel ?? undefined,
|
channel: filters.channel ?? undefined,
|
||||||
queueId: undefined, // simplified: filter by queue name on client
|
queueId: undefined, // simplified: filter by queue name on client
|
||||||
|
assigneeId: filters.assigneeId ? (filters.assigneeId as unknown as Id<"users">) : undefined,
|
||||||
search: filters.search || undefined,
|
search: filters.search || undefined,
|
||||||
}
|
}
|
||||||
: "skip"
|
: "skip"
|
||||||
)
|
)
|
||||||
|
|
||||||
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
|
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
|
||||||
const companies = useMemo(() => {
|
const [companies, setCompanies] = useState<string[]>([])
|
||||||
const set = new Set<string>()
|
useEffect(() => {
|
||||||
for (const t of tickets) {
|
let aborted = false
|
||||||
const name = ((t as unknown as { company?: { name?: string } })?.company?.name) as string | undefined
|
async function loadCompanies() {
|
||||||
if (name) set.add(name)
|
try {
|
||||||
|
const r = await fetch("/api/admin/companies", { credentials: "include" })
|
||||||
|
const json = (await r.json().catch(() => ({}))) as { companies?: Array<{ name: string }> }
|
||||||
|
const names = Array.isArray(json.companies) ? json.companies.map((c) => c.name).filter(Boolean) : []
|
||||||
|
if (!aborted) setCompanies(names.sort((a, b) => a.localeCompare(b, "pt-BR")))
|
||||||
|
} catch {
|
||||||
|
if (!aborted) setCompanies([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadCompanies()
|
||||||
|
return () => {
|
||||||
|
aborted = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// load saved filters as defaults per user
|
||||||
|
useEffect(() => {
|
||||||
|
if (!convexUserId) return
|
||||||
|
try {
|
||||||
|
const key = `tickets:filters:${tenantId}:${String(convexUserId)}`
|
||||||
|
const saved = localStorage.getItem(key)
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved) as Partial<TicketFiltersState>
|
||||||
|
setFilters((prev) => ({ ...prev, ...parsed }))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [tenantId, convexUserId])
|
||||||
|
|
||||||
|
const handleSaveDefault = () => {
|
||||||
|
if (!convexUserId) return
|
||||||
|
try {
|
||||||
|
const key = `tickets:filters:${tenantId}:${String(convexUserId)}`
|
||||||
|
localStorage.setItem(key, JSON.stringify(filters))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearDefault = () => {
|
||||||
|
if (!convexUserId) return
|
||||||
|
try {
|
||||||
|
const key = `tickets:filters:${tenantId}:${String(convexUserId)}`
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Array.from(set).sort((a, b) => a.localeCompare(b, "pt-BR"))
|
|
||||||
}, [tickets])
|
|
||||||
|
|
||||||
const filteredTickets = useMemo(() => {
|
const filteredTickets = useMemo(() => {
|
||||||
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED"])
|
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED"])
|
||||||
|
|
@ -92,8 +139,25 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
||||||
onChange={setFilters}
|
onChange={setFilters}
|
||||||
queues={(queues ?? []).map((q) => q.name)}
|
queues={(queues ?? []).map((q) => q.name)}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
|
assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))}
|
||||||
initialState={mergedInitialFilters}
|
initialState={mergedInitialFilters}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveDefault}
|
||||||
|
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-800 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Salvar filtro como padrão
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearDefault}
|
||||||
|
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Limpar padrão
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{ticketsRaw === undefined ? (
|
{ticketsRaw === undefined ? (
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export const TICKET_TIMELINE_LABELS: Record<string, string> = {
|
||||||
PRIORITY_CHANGED: "Prioridade alterada",
|
PRIORITY_CHANGED: "Prioridade alterada",
|
||||||
ATTACHMENT_REMOVED: "Anexo removido",
|
ATTACHMENT_REMOVED: "Anexo removido",
|
||||||
CATEGORY_CHANGED: "Categoria alterada",
|
CATEGORY_CHANGED: "Categoria alterada",
|
||||||
|
REQUESTER_CHANGED: "Solicitante alterado",
|
||||||
MANAGER_NOTIFIED: "Gestor notificado",
|
MANAGER_NOTIFIED: "Gestor notificado",
|
||||||
VISIT_SCHEDULED: "Visita agendada",
|
VISIT_SCHEDULED: "Visita agendada",
|
||||||
CSAT_RECEIVED: "CSAT recebido",
|
CSAT_RECEIVED: "CSAT recebido",
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ const styles = StyleSheet.create({
|
||||||
|
|
||||||
const statusStyles: Record<string, { backgroundColor: string; color: string; label: string }> = {
|
const statusStyles: Record<string, { backgroundColor: string; color: string; label: string }> = {
|
||||||
PENDING: { backgroundColor: "#F1F5F9", color: "#0F172A", label: "Pendente" },
|
PENDING: { backgroundColor: "#F1F5F9", color: "#0F172A", label: "Pendente" },
|
||||||
AWAITING_ATTENDANCE: { backgroundColor: "#E0F2FE", color: "#0369A1", label: "Aguardando atendimento" },
|
AWAITING_ATTENDANCE: { backgroundColor: "#E0F2FE", color: "#0369A1", label: "Em andamento" },
|
||||||
PAUSED: { backgroundColor: "#FEF3C7", color: "#92400E", label: "Pausado" },
|
PAUSED: { backgroundColor: "#FEF3C7", color: "#92400E", label: "Pausado" },
|
||||||
RESOLVED: { backgroundColor: "#DCFCE7", color: "#166534", label: "Resolvido" },
|
RESOLVED: { backgroundColor: "#DCFCE7", color: "#166534", label: "Resolvido" },
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue