Merge pull request #3 from esdrasrenan/feat/convex-tickets-core

Feat/convex tickets core
This commit is contained in:
esdrasrenan 2025-10-04 14:23:14 -03:00 committed by GitHub
commit 545c16288a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1517 additions and 248 deletions

View file

@ -124,3 +124,83 @@ Este repositório foi atualizado para usar Convex como backend em tempo real par
- [ ] Textos/labels em PTBR. - [ ] Textos/labels em PTBR.
- [ ] Eventos de UI com feedback (toast) e rollback em erro. - [ ] Eventos de UI com feedback (toast) e rollback em erro.
- [ ] Documentação atualizada se houver mudanças em fluxo/env. - [ ] Documentação atualizada se houver mudanças em fluxo/env.
---
## Próximas Entregas (Roadmap detalhado)
1) UX/Visual (shadcn/ui)
- Padronizar cartões em todas as telas (Play, Visualizações) com o mesmo padrão aplicado em Conversa/Detalhes/Timeline (bordas, sombra, paddings).
- Aplicar microtipografia consistente: headings H1/H2, tracking, tamanhos, cores em PTBR.
- Skeletons de carregamento nos principais painéis (lista de tickets, recentes, play next).
- Melhorar tabela: estados hover/focus, ícones de canal, largura de colunas previsível e truncamento.
2) Comentários e anexos
- Dropzone também no “Novo ticket” (já implementado) com registro de comentário inicial e anexos.
- Grid de anexos com miniaturas e legenda; manter atributo `download` com o nome original.
- Preview em modal para imagens (feito) e suporte a múltiplas linhas no grid.
- Botão para copiar link de arquivo (futuro, usar URL do storage).
3) Timeline e eventos
- Mensagens amigáveis em PTBR (feito para CREATED/STATUS/ASSIGNEE/QUEUE).
- Incluir sempre `actorName`/`actorAvatar` no payload; evitar JSON cru na UI.
- Exibir avatar e nome do ator nas entradas (parcialmente feito).
4) Dados e camada Convex
- Sempre retornar datas como `number` (epoch) e converter no front via mappers Zod.
- Padronizar import do Convex com `@/convex/_generated/api` (alias criado).
- Evitar `useQuery` com args vazios — proteger chamadas (gates) e, quando necessário, fallback de mock para IDs `ticket-*`.
5) Autenticação / Sessão (placeholder)
- Cookie `demoUser` e bootstrap de usuário no Convex (feito). Trocar por Auth.js/Clerk quando for o momento.
6) Testes
- Vitest configurado; adicionar casos para mapeadores (já iniciado) e smoke tests básicos de páginas.
- Não usar Date em assertions de payload — sempre comparar epoch ou `instanceof Date` após mapeamento.
7) Acessibilidade e internacionalização
- Labels e mensagens 100% em PTBR; evitar termos como `QUEUE_CHANGED` na UI.
- Navegação por teclado em Dialogs/Selects; aria-labels em botões de ação.
8) Observabilidade (posterior)
- Logs de evento estruturados no Convex; traces simples no client para ações críticas.
---
## Endpoints Convex (resumo)
- `tickets.list({ tenantId, status?, priority?, channel?, queueId?, search?, limit? })`
- `tickets.getById({ tenantId, id })`
- `tickets.create({ tenantId, subject, summary?, priority, channel, queueId?, requesterId })`
- `tickets.addComment({ ticketId, authorId, visibility, body, attachments?[] })`
- `tickets.updateStatus({ ticketId, status, actorId })` — gera evento com `toLabel` e `actorName`.
- `tickets.changeAssignee({ ticketId, assigneeId, actorId })` — gera evento com `assigneeName`.
- `tickets.changeQueue({ ticketId, queueId, actorId })` — gera evento com `queueName`.
- `tickets.playNext({ tenantId, queueId?, agentId })` — atribui ticket e registra evento.
- `queues.summary({ tenantId })`
- `files.generateUploadUrl()` — usar via `useAction`.
- `users.ensureUser({ tenantId, email, name, avatarUrl?, role?, teams? })`
Observações:
- Não retornar `Date` nas funções Convex; usar `number` e converter na UI com os mappers em `src/lib/mappers`.
- Evitar passar `{}` para `useQuery` — args devem estar definidos ou a query não deve ser invocada.
---
## Padrões de Código
- UI: shadcn/ui (Field, Dialog, Select, Badge, Table, Spinner) + Tailwind.
- Dados: Zod para validação; mappers para converter server→UI (epoch→Date, null→undefined).
- Texto: PTBR em labels, toasts e timeline.
- UX: updates otimistas + toasts (status, assignee, fila, comentários).
- Imports do Convex: sempre `@/convex/_generated/api`.
---
## Como abrir PR
- Crie uma branch descritiva (ex.: `feat/tickets-attachments-grid`).
- Preencha a descrição com: contexto, mudanças, como testar (pnpm scripts), screenshots quando útil.
- Checklist:
- [ ] Sem `Date` no retorno Convex.
- [ ] Labels PTBR.
- [ ] Skeleton/Loading onde couber.
- [ ] Mappers atualizados se tocar em payloads.
- [ ] AGENTS.md atualizado se houver mudança de padrões.

128
web/build.log Normal file
View file

@ -0,0 +1,128 @@
> web@0.1.0 build C:\Users\monke\OneDrive\Documentos\Projetos\sistema-de-chamados\web
> next build
Ôû▓ Next.js 15.5.3
- Environments: .env.local
Creating an optimized production build ...
Ô£ô Compiled successfully in 3.0s
Linting and checking validity of types ...
./src/app/ConvexClientProvider.tsx
4:21 Warning: 'useMemo' is defined but never used. @typescript-eslint/no-unused-vars
./src/app/tickets/new/page.tsx
16:9 Warning: The 'queues' logical expression could make the dependencies of useMemo Hook (at line 28) change on every render. To fix this, wrap the initialization of 'queues' in its own useMemo() Hook. react-hooks/exhaustive-deps
28:53 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
35:33 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
35:49 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
43:27 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
44:30 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
48:42 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
48:67 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/app/tickets/[id]/page.tsx
27:61 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/new-ticket-dialog.tsx
6:1 Warning: Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free. @typescript-eslint/ban-ts-comment
50:35 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
58:32 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
63:44 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
63:69 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
63:140 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
71:14 Warning: 'err' is defined but never used. @typescript-eslint/no-unused-vars
116:111 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
128:109 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
150:41 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/play-next-ticket-card.tsx
39:34 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
43:169 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
43:240 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
75:37 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
109:103 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
109:141 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/recent-tickets-panel.tsx
4:1 Warning: Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free. @typescript-eslint/ban-ts-comment
9:10 Warning: 'Spinner' is defined but never used. @typescript-eslint/no-unused-vars
27:58 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
30:41 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/ticket-comments.rich.tsx
31:9 Warning: 'generateUploadUrl' is assigned a value but never used. @typescript-eslint/no-unused-vars
52:100 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
61:49 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
61:74 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
64:14 Warning: 'err' is defined but never used. @typescript-eslint/no-unused-vars
113:34 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
151:83 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/ticket-detail-view.tsx
11:10 Warning: 'Separator' is defined but never used. @typescript-eslint/no-unused-vars
20:108 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
21:15 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
23:50 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
57:46 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
60:45 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
61:45 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
63:47 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/ticket-queue-summary.tsx
18:70 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/ticket-summary-header.tsx
50:50 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
59:63 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
59:85 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
59:109 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
61:26 Warning: 'e' is defined but never used. @typescript-eslint/no-unused-vars
98:63 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
98:89 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
98:113 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
107:31 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
121:42 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
125:60 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
125:82 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
125:106 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
134:31 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/ticket-timeline.tsx
12:10 Warning: 'cn' is defined but never used. @typescript-eslint/no-unused-vars
15:10 Warning: 'Separator' is defined but never used. @typescript-eslint/no-unused-vars
72:28 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/tickets/tickets-view.tsx
12:10 Warning: 'Spinner' is defined but never used. @typescript-eslint/no-unused-vars
27:80 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
31:31 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
36:76 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
49:51 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
./src/components/ui/dropzone.tsx
4:1 Warning: Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free. @typescript-eslint/ban-ts-comment
./src/components/ui/field.tsx
11:52 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
28:58 Warning: Unexpected any. Specify a different type. @typescript-eslint/no-explicit-any
info - Need to disable some ESLint rules? Learn more here: https://nextjs.org/docs/app/api-reference/config/eslint#disabling-rules
Failed to compile.
./src/components/tickets/play-next-ticket-card.tsx:43:9
Type error: Type '{ queue: { id: string; name: string; pending: number; waiting: number; breached: number; }; nextTicket: { id: string; reference: number; tenantId: string; subject: string; status: "RESOLVED" | "CLOSED" | "PENDING" | "ON_HOLD" | "OPEN" | "NEW"; ... 14 more ...; lastTimelineEntry?: string | undefined; } | null; } | { ...' is not assignable to type '{ queue: { id: string; name: string; pending: number; waiting: number; breached: number; }; nextTicket: { id: string; reference: number; tenantId: string; subject: string; status: "RESOLVED" | "CLOSED" | "PENDING" | "ON_HOLD" | "OPEN" | "NEW"; ... 14 more ...; lastTimelineEntry?: string | undefined; } | null; } | null'.
Type '{ queue: { id: string; name: string; pending: number; waiting: number; breached: number; }; nextTicket: { id: any; reference: any; tenantId: any; subject: any; summary: any; status: any; priority: any; channel: any; ... 11 more ...; metrics: null; }; }' is not assignable to type '{ queue: { id: string; name: string; pending: number; waiting: number; breached: number; }; nextTicket: { id: string; reference: number; tenantId: string; subject: string; status: "RESOLVED" | "CLOSED" | "PENDING" | "ON_HOLD" | "OPEN" | "NEW"; ... 14 more ...; lastTimelineEntry?: string | undefined; } | null; }'.
The types of 'nextTicket.lastTimelineEntry' are incompatible between these types.
Type 'null' is not assignable to type 'string | undefined'.
41 | })?.[0]
42 |
> 43 | const cardContext: TicketPlayContext | null = context ?? (nextTicketFromServer ? { queue: { id: "default", name: "Geral", pending: queueSummary.reduce((a: number, b: any) => a + b.pending, 0), waiting: queueSummary.reduce((a: number, b: any) => a + b.waiting, 0), breached: 0 }, nextTicket: nextTicketFromServer } : null)
| ^
44 |
45 | if (!cardContext || !cardContext.nextTicket) {
46 | return (
Next.js build worker exited with code: 1 and signal: null
ÔÇëELIFECYCLEÔÇë Command failed with exit code 1.

View file

@ -41,6 +41,7 @@ export default defineSchema({
reference: v.number(), reference: v.number(),
subject: v.string(), subject: v.string(),
summary: v.optional(v.string()), summary: v.optional(v.string()),
description: v.optional(v.string()),
status: v.string(), status: v.string(),
priority: v.string(), priority: v.string(),
channel: v.string(), channel: v.string(),
@ -59,7 +60,8 @@ export default defineSchema({
.index("by_tenant_status", ["tenantId", "status"]) .index("by_tenant_status", ["tenantId", "status"])
.index("by_tenant_queue", ["tenantId", "queueId"]) .index("by_tenant_queue", ["tenantId", "queueId"])
.index("by_tenant_assignee", ["tenantId", "assigneeId"]) .index("by_tenant_assignee", ["tenantId", "assigneeId"])
.index("by_tenant_reference", ["tenantId", "reference"]), .index("by_tenant_reference", ["tenantId", "reference"])
.index("by_tenant", ["tenantId"]),
ticketComments: defineTable({ ticketComments: defineTable({
ticketId: v.id("tickets"), ticketId: v.id("tickets"),
@ -87,4 +89,3 @@ export default defineSchema({
createdAt: v.number(), createdAt: v.number(),
}).index("by_ticket", ["ticketId"]), }).index("by_ticket", ["ticketId"]),
}); });

View file

@ -1,8 +1,8 @@
import { internalMutation, mutation, query } from "./_generated/server"; import { internalMutation, mutation, query } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { Id } from "./_generated/dataModel"; import { Id, type Doc } from "./_generated/dataModel";
const STATUS_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const; const PRIORITY_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
export const list = query({ export const list = query({
args: { args: {
@ -15,16 +15,28 @@ export const list = query({
limit: v.optional(v.number()), limit: v.optional(v.number()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
let q = ctx.db // Choose best index based on provided args for efficiency
.query("tickets") let base: Doc<"tickets">[] = [];
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId)); if (args.status) {
base = await ctx.db
const all = await q.collect(); .query("tickets")
let filtered = all; .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", args.status!))
if (args.status) filtered = filtered.filter((t) => t.status === args.status); .collect();
} else if (args.queueId) {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!))
.collect();
} else {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
.collect();
}
let filtered = base;
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.queueId) filtered = filtered.filter((t) => t.queueId === args.queueId);
if (args.search) { if (args.search) {
const term = args.search.toLowerCase(); const term = args.search.toLowerCase();
filtered = filtered.filter( filtered = filtered.filter(
@ -38,9 +50,9 @@ export const list = query({
// hydrate requester and assignee // hydrate requester and assignee
const result = await Promise.all( const result = await Promise.all(
limited.map(async (t) => { limited.map(async (t) => {
const requester = await ctx.db.get(t.requesterId); const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : 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) : null; const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
return { return {
id: t._id, id: t._id,
reference: t.reference, reference: t.reference,
@ -80,7 +92,7 @@ export const list = query({
}) })
); );
// sort by updatedAt desc // sort by updatedAt desc
return result.sort((a, b) => (b.updatedAt as any) - (a.updatedAt as any)); return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}, },
}); });
@ -89,9 +101,9 @@ export const getById = query({
handler: async (ctx, { tenantId, id }) => { handler: async (ctx, { tenantId, id }) => {
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;
const requester = await ctx.db.get(t.requesterId); const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : 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) : null; const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
const comments = await ctx.db const comments = await ctx.db
.query("ticketComments") .query("ticketComments")
.withIndex("by_ticket", (q) => q.eq("ticketId", id)) .withIndex("by_ticket", (q) => q.eq("ticketId", id))
@ -103,7 +115,7 @@ export const getById = query({
const commentsHydrated = await Promise.all( const commentsHydrated = await Promise.all(
comments.map(async (c) => { comments.map(async (c) => {
const author = await ctx.db.get(c.authorId); const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null;
const attachments = await Promise.all( const attachments = await Promise.all(
(c.attachments ?? []).map(async (att) => ({ (c.attachments ?? []).map(async (att) => ({
id: att.storageId, id: att.storageId,
@ -256,10 +268,11 @@ export const addComment = mutation({
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
const author = await ctx.db.get(args.authorId);
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId: args.ticketId, ticketId: args.ticketId,
type: "COMMENT_ADDED", type: "COMMENT_ADDED",
payload: { authorId: args.authorId }, payload: { authorId: args.authorId, authorName: author?.name, authorAvatar: author?.avatarUrl },
createdAt: now, createdAt: now,
}); });
// bump ticket updatedAt // bump ticket updatedAt
@ -295,7 +308,7 @@ export const changeAssignee = mutation({
handler: async (ctx, { ticketId, assigneeId, actorId }) => { handler: async (ctx, { ticketId, assigneeId, actorId }) => {
const now = Date.now(); const now = Date.now();
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now }); await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
const user = await ctx.db.get(assigneeId); const user = (await ctx.db.get(assigneeId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId, ticketId,
type: "ASSIGNEE_CHANGED", type: "ASSIGNEE_CHANGED",
@ -310,7 +323,7 @@ export const changeQueue = mutation({
handler: async (ctx, { ticketId, queueId, actorId }) => { handler: async (ctx, { ticketId, queueId, actorId }) => {
const now = Date.now(); const now = Date.now();
await ctx.db.patch(ticketId, { queueId, updatedAt: now }); await ctx.db.patch(ticketId, { queueId, updatedAt: now });
const queue = await ctx.db.get(queueId); const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null;
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId, ticketId,
type: "QUEUE_CHANGED", type: "QUEUE_CHANGED",
@ -328,10 +341,18 @@ export const playNext = mutation({
}, },
handler: async (ctx, { tenantId, queueId, agentId }) => { handler: async (ctx, { tenantId, queueId, agentId }) => {
// Find eligible tickets: not resolved/closed and not assigned // Find eligible tickets: not resolved/closed and not assigned
let candidates = await ctx.db let candidates: Doc<"tickets">[] = []
.query("tickets") if (queueId) {
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId ?? undefined as any)) candidates = await ctx.db
.collect(); .query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId))
.collect()
} else {
candidates = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
}
candidates = candidates.filter( candidates = candidates.filter(
(t) => t.status !== "RESOLVED" && t.status !== "CLOSED" && !t.assigneeId (t) => t.status !== "RESOLVED" && t.status !== "CLOSED" && !t.assigneeId
@ -340,17 +361,18 @@ export const playNext = mutation({
if (candidates.length === 0) return null; if (candidates.length === 0) return null;
// prioritize by priority then createdAt // prioritize by priority then createdAt
const rank: Record<string, number> = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
candidates.sort((a, b) => { candidates.sort((a, b) => {
const pa = STATUS_ORDER.indexOf(a.priority as any); const pa = rank[a.priority] ?? 999
const pb = STATUS_ORDER.indexOf(b.priority as any); const pb = rank[b.priority] ?? 999
if (pa !== pb) return pa - pb; if (pa !== pb) return pa - pb
return a.createdAt - b.createdAt; return a.createdAt - b.createdAt
}); })
const chosen = candidates[0]; const chosen = candidates[0];
const now = Date.now(); const now = Date.now();
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now }); await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now });
const agent = await ctx.db.get(agentId); const agent = (await ctx.db.get(agentId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId: chosen._id, ticketId: chosen._id,
type: "ASSIGNEE_CHANGED", type: "ASSIGNEE_CHANGED",
@ -358,51 +380,45 @@ export const playNext = mutation({
createdAt: now, createdAt: now,
}); });
return await getPublicById(ctx, chosen._id); // hydrate minimal public ticket like in list
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
return {
id: chosen._id,
reference: chosen.reference,
tenantId: chosen.tenantId,
subject: chosen.subject,
summary: chosen.summary,
status: chosen.status,
priority: chosen.priority,
channel: chosen.channel,
queue: queue?.name ?? null,
requester: requester && {
id: requester._id,
name: requester.name,
email: requester.email,
avatarUrl: requester.avatarUrl,
teams: requester.teams ?? [],
},
assignee: assignee
? {
id: assignee._id,
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl,
teams: assignee.teams ?? [],
}
: null,
slaPolicy: null,
dueAt: chosen.dueAt ?? null,
firstResponseAt: chosen.firstResponseAt ?? null,
resolvedAt: chosen.resolvedAt ?? null,
updatedAt: chosen.updatedAt,
createdAt: chosen.createdAt,
tags: chosen.tags ?? [],
lastTimelineEntry: null,
metrics: null,
}
}, },
}); });
// internal helper to hydrate a ticket in the same shape as list/getById
const getPublicById = async (ctx: any, id: Id<"tickets">) => {
const t = await ctx.db.get(id);
if (!t) return null;
const requester = await ctx.db.get(t.requesterId);
const assignee = t.assigneeId ? await ctx.db.get(t.assigneeId) : null;
const queue = t.queueId ? await ctx.db.get(t.queueId) : null;
return {
id: t._id,
reference: t.reference,
tenantId: t.tenantId,
subject: t.subject,
summary: t.summary,
status: t.status,
priority: t.priority,
channel: t.channel,
queue: queue?.name ?? null,
requester: requester && {
id: requester._id,
name: requester.name,
email: requester.email,
avatarUrl: requester.avatarUrl,
teams: requester.teams ?? [],
},
assignee: assignee
? {
id: assignee._id,
name: assignee.name,
email: assignee.email,
avatarUrl: assignee.avatarUrl,
teams: assignee.teams ?? [],
}
: null,
slaPolicy: null,
dueAt: t.dueAt ?? null,
firstResponseAt: t.firstResponseAt ?? null,
resolvedAt: t.resolvedAt ?? null,
updatedAt: t.updatedAt,
createdAt: t.createdAt,
tags: t.tags ?? [],
lastTimelineEntry: null,
metrics: null,
};
};

View file

@ -9,17 +9,24 @@ const compat = new FlatCompat({
baseDirectory: __dirname, baseDirectory: __dirname,
}); });
const eslintConfig = [ const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"), ...compat.extends("next/core-web-vitals", "next/typescript"),
{ {
ignores: [ ignores: [
"node_modules/**", "node_modules/**",
".next/**", ".next/**",
"out/**", "out/**",
"build/**", "build/**",
"next-env.d.ts", "next-env.d.ts",
], ],
}, },
]; {
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/ban-ts-comment": "warn",
"react/no-unescaped-entities": "off",
},
},
];
export default eslintConfig; export default eslintConfig;

View file

@ -33,6 +33,10 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.35.0", "@tabler/icons-react": "^3.35.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tiptap/extension-link": "^3.6.5",
"@tiptap/extension-placeholder": "^3.6.5",
"@tiptap/react": "^3.6.5",
"@tiptap/starter-kit": "^3.6.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"convex": "^1.27.3", "convex": "^1.27.3",
@ -44,6 +48,7 @@
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.64.0", "react-hook-form": "^7.64.0",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"sanitize-html": "^2.17.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"vaul": "^1.1.2", "vaul": "^1.1.2",
@ -55,6 +60,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.3", "eslint-config-next": "15.5.3",
"prisma": "^6.16.2", "prisma": "^6.16.2",

686
web/pnpm-lock.yaml generated
View file

@ -71,6 +71,18 @@ importers:
'@tanstack/react-table': '@tanstack/react-table':
specifier: ^8.21.3 specifier: ^8.21.3
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@tiptap/extension-link':
specifier: ^3.6.5
version: 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-placeholder':
specifier: ^3.6.5
version: 3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
'@tiptap/react':
specifier: ^3.6.5
version: 3.6.5(@floating-ui/dom@1.7.4)(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@tiptap/starter-kit':
specifier: ^3.6.5
version: 3.6.5
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@ -104,6 +116,9 @@ importers:
recharts: recharts:
specifier: ^2.15.4 specifier: ^2.15.4
version: 2.15.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 2.15.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
sanitize-html:
specifier: ^2.17.0
version: 2.17.0
sonner: sonner:
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -132,6 +147,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19 specifier: ^19
version: 19.2.0(@types/react@19.2.0) version: 19.2.0(@types/react@19.2.0)
'@types/sanitize-html':
specifier: ^2.16.0
version: 2.16.0
eslint: eslint:
specifier: ^9 specifier: ^9
version: 9.37.0(jiti@2.6.1) version: 9.37.0(jiti@2.6.1)
@ -1255,6 +1273,9 @@ packages:
'@radix-ui/rect@1.1.1': '@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@rollup/rollup-android-arm-eabi@4.52.4': '@rollup/rollup-android-arm-eabi@4.52.4':
resolution: {integrity: sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==} resolution: {integrity: sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==}
cpu: [arm] cpu: [arm]
@ -1484,6 +1505,160 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'} engines: {node: '>=12'}
'@tiptap/core@3.6.5':
resolution: {integrity: sha512-CgXuhevQbBcPfxaXzGZgIY9+aVMSAd68Q21g3EONz1iZBw026QgiaLhGK6jgGTErZL4GoNL/P+gC5nFCvN7+cA==}
peerDependencies:
'@tiptap/pm': ^3.6.5
'@tiptap/extension-blockquote@3.6.5':
resolution: {integrity: sha512-FOOgkLHXQ3zTiL2V1js5+PfaOHXuyr/GjeFZe+W1AUk58X/qJNOVGvKT1xlMOy9gy2ySgWmco7PhNXRRTimkWg==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-bold@3.6.5':
resolution: {integrity: sha512-8JXC+K4DXtPDbClHxgRAZnXYO2an2I86PbpqUw+S7m17XCr4t39Sw9CeNBohOHS6Cl8uxOKAjSyCZzqdnYkn3g==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-bubble-menu@3.6.5':
resolution: {integrity: sha512-RyCJghtkYZAljZQUfjk3B5tvVVCILsIYMR9XnC152uBiIuWsnz25qfdyBP+cOl6ONrQUvdscs0WmKvzN+nXZYw==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@tiptap/extension-bullet-list@3.6.5':
resolution: {integrity: sha512-AP81hyN7oTyv5zbNVRK35cQA7zuLnI5ItFFyqMQKWh90vfftXi/zhC9C7FWvKtEH7Kk68B338G2mi4tlXDgBFQ==}
peerDependencies:
'@tiptap/extension-list': ^3.6.5
'@tiptap/extension-code-block@3.6.5':
resolution: {integrity: sha512-VPPke3LqZYKPlbDBp8IcTJQwvYb1PP0L+2Qi2n3ebN4+gKn+KGhrjnkO+xNHCySWlqywQmMTIfWX1sxA0eVVdQ==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@tiptap/extension-code@3.6.5':
resolution: {integrity: sha512-U/cJFjE0hqBTbMb5J74e7ni5YReuJgS9NyJgTy94+Xt6vxR1vU4+qOl+3E0fOZtwDrxbLrsCQy3P3LvNb3HXdw==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-document@3.6.5':
resolution: {integrity: sha512-0c7kxWBIEIcoHUG89vpHOF2h4CMa0q6VWXhZ+6iqcI5uyqaKwgcW/TbHZR0nAwEsZLdRCKaryn2kO7jXiCjfnA==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-dropcursor@3.6.5':
resolution: {integrity: sha512-BsO3ufLHsdeV1ddChwQfi2Q4UkeqOF4LeUYPYBKfSg59aRKTSoxj3gZrAsaAm/0O3DmAiKNBiCtNRTJSApPEBQ==}
peerDependencies:
'@tiptap/extensions': ^3.6.5
'@tiptap/extension-floating-menu@3.6.5':
resolution: {integrity: sha512-ASKb5vHkYyB9g3vOAr2E2U+b6MbHk4Ff4PqngafGlWRAmOAmFxTcw9fLa3HKnj4pokSsYAEvYGOso99/W3GzhA==}
peerDependencies:
'@floating-ui/dom': ^1.0.0
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@tiptap/extension-gapcursor@3.6.5':
resolution: {integrity: sha512-SHtp71zhV2bAQS8kaJ/otb2podGusDREZ9/SQ1rZi6yPcDFLS2KvIvsLssDwbjTuH6KefnsN6Vx01tzmXRAQig==}
peerDependencies:
'@tiptap/extensions': ^3.6.5
'@tiptap/extension-hard-break@3.6.5':
resolution: {integrity: sha512-6iMS6SzIn7+X95okRX8y3l/4f1G3lTrq24sbcAX4MHITncDC6g3TrdAxdA67Tqn5NI/OQx0LwF3kFJDO8QTAUg==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-heading@3.6.5':
resolution: {integrity: sha512-jFS5saqTtfG6MM0sW4X6mZlLycT2ud0Oo1GOZkCyBClwSOpZI/EBLNRIgoXgNtWrY917vB7xTQgCpTVHbvVRsQ==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-horizontal-rule@3.6.5':
resolution: {integrity: sha512-yNxcejI25j6NQMQuKQMTVmNYLnrHFCpzGAz1Ndzyar+gItYZXI9BLmMlwpLkIaJMpIKChj+2qHz25fPS5FlNFw==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@tiptap/extension-italic@3.6.5':
resolution: {integrity: sha512-2EtO2uffw5YnTQ1cieLPv9t7OKCfJFbgHRJPXf7Nnfh8XFh5AEyzw0qBNXZyLtlB28+HHSWLc/OHS6xMfwUy0A==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-link@3.6.5':
resolution: {integrity: sha512-VLCDNwxLC1IPnWT3HLLJUg1Hflf8A2jfs7aNF4vyMTWmKnrk1zmN+VyXQTAkrqr27qE5FnmLhHOYF3SNolNucw==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@tiptap/extension-list-item@3.6.5':
resolution: {integrity: sha512-A5JKf2dNG6IRrHmkaqroq/VcD5SnXYXgpQpsF7HrPGIzUSIjvjQu088980NQPHyMuTanDMml+nZgd8RzHhRISA==}
peerDependencies:
'@tiptap/extension-list': ^3.6.5
'@tiptap/extension-list-keymap@3.6.5':
resolution: {integrity: sha512-OHGGTJMdUOBincMgYGEN4WzHrTB/GFeCxLDJraDknPx4VJVa3UVZS8F8xd5cb2WnACEF33Ud/0yK3aN6kHrbtQ==}
peerDependencies:
'@tiptap/extension-list': ^3.6.5
'@tiptap/extension-list@3.6.5':
resolution: {integrity: sha512-2S6wNeaGvvYzJygBhHRLP0YubJAzY00WxQSO3NvHFeLFRFvilCnmh0JGMAqsNU+Owpz0iVrWY0YZskN5gPeR9w==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@tiptap/extension-ordered-list@3.6.5':
resolution: {integrity: sha512-RiBl0Dkw8QtzS7OqUGm84BOyemw/N+hf8DYWsIqVysMRQAGBGhuklbw+DGpCL0nMHW4lh7WtvfKcb0yxLmhbbA==}
peerDependencies:
'@tiptap/extension-list': ^3.6.5
'@tiptap/extension-paragraph@3.6.5':
resolution: {integrity: sha512-AfuaBu+DKrRPspaLsXgo17dhuneISS6QsZTIzPeX21jFJcq3TjtD8wSzS4yRgzAQCEbupkI7t4JbtgxAIBNQHA==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-placeholder@3.6.5':
resolution: {integrity: sha512-9CLixogEb/4UkEyuDr4JdOlLvphcOVfZMdNMKmUVQdqo4MuZCdTDyK5ypfTPQJl8aUo0oCiEhqE0bQerYlueJQ==}
peerDependencies:
'@tiptap/extensions': ^3.6.5
'@tiptap/extension-strike@3.6.5':
resolution: {integrity: sha512-QR7CUmRJ7fJkHtxqKajKIaX/B4xpKFOsAOJHbnqZ8wzOtnEL5IlsmoUnbKBoVn0+2R2YKKvMK3lepGtAcVCfIQ==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-text@3.6.5':
resolution: {integrity: sha512-PVZDWUa25xPzmEN6WWA103yvYJn+NBvWb7WrQwWu9LkKUgd98ZgV3yFaEem/Ybugl/NDPV7q8GGaH+2wEg/VeA==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extension-underline@3.6.5':
resolution: {integrity: sha512-Ul1mO0H1e2vfvN5g48X/YQ8w1xFTpLqce+GUhi0OmXaZnVOTIMtLuN/zAAPjD+uw+79JVGjYa53lbo1dyhOfAw==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/extensions@3.6.5':
resolution: {integrity: sha512-7aadEaRjSbFAIp3WGYR1LXrvtVprmBNxw3FakEUMJ+XKmGNErDJgDMZh+siAYw5MWwCCGa5kKu8Qi/i+DU+ILg==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@tiptap/pm@3.6.5':
resolution: {integrity: sha512-S+j6MPgUXRIQd5/mdaLjaJnOt4ptFwjqGjGMUfBbf9a3uKpXUXaCCzfuC6ZikwaUtoVh4KN9BU3HCYDtgtENPA==}
'@tiptap/react@3.6.5':
resolution: {integrity: sha512-kum9fYzY6qmHuabcXDUTX2sVLdtJtZS0kN91mwD29Ue8HUkjVvEX92PwV2HtgNw3WFMaVxgm/dtm3XPTAlUEwg==}
peerDependencies:
'@tiptap/core': ^3.6.5
'@tiptap/pm': ^3.6.5
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
'@tiptap/starter-kit@3.6.5':
resolution: {integrity: sha512-LNAJQstB/VazmMlRbUyu3rCNVQ9af25Ywkn3Uyuwt3Ks9ZlliIm/x/zertdXTY2adoig+b36zT5Xcx1O4IdJ3A==}
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@ -1523,6 +1698,15 @@ packages:
'@types/json5@0.0.29': '@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/node@20.19.19': '@types/node@20.19.19':
resolution: {integrity: sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==} resolution: {integrity: sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==}
@ -1534,6 +1718,12 @@ packages:
'@types/react@19.2.0': '@types/react@19.2.0':
resolution: {integrity: sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==} resolution: {integrity: sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==}
'@types/sanitize-html@2.16.0':
resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@typescript-eslint/eslint-plugin@8.45.0': '@typescript-eslint/eslint-plugin@8.45.0':
resolution: {integrity: sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==} resolution: {integrity: sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -1910,6 +2100,9 @@ packages:
react: react:
optional: true optional: true
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -2010,6 +2203,10 @@ packages:
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
define-data-property@1.1.4: define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2038,6 +2235,19 @@ packages:
dom-helpers@5.2.1: dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
dotenv@16.6.1: dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -2060,6 +2270,10 @@ packages:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
es-abstract@1.24.0: es-abstract@1.24.0:
resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2393,6 +2607,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@ -2484,6 +2701,10 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'} engines: {node: '>=0.12.0'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-regex@1.2.1: is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2640,6 +2861,12 @@ packages:
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
locate-path@6.0.0: locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2665,10 +2892,17 @@ packages:
magic-string@0.30.19: magic-string@0.30.19:
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
merge2@1.4.1: merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -2785,6 +3019,9 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
own-keys@1.0.1: own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2801,6 +3038,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
path-exists@4.0.0: path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2873,6 +3113,68 @@ packages:
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
prosemirror-changeset@2.3.1:
resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==}
prosemirror-collab@1.3.1:
resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
prosemirror-commands@1.7.1:
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
prosemirror-dropcursor@1.8.2:
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
prosemirror-gapcursor@1.3.2:
resolution: {integrity: sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==}
prosemirror-history@1.4.1:
resolution: {integrity: sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==}
prosemirror-inputrules@1.5.0:
resolution: {integrity: sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==}
prosemirror-keymap@1.2.3:
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
prosemirror-markdown@1.13.2:
resolution: {integrity: sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==}
prosemirror-menu@1.2.5:
resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==}
prosemirror-model@1.25.3:
resolution: {integrity: sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==}
prosemirror-schema-basic@1.2.4:
resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
prosemirror-schema-list@1.5.1:
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
prosemirror-state@1.4.3:
resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==}
prosemirror-tables@1.8.1:
resolution: {integrity: sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug==}
prosemirror-trailing-node@3.0.0:
resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
peerDependencies:
prosemirror-model: ^1.22.1
prosemirror-state: ^1.4.2
prosemirror-view: ^1.33.8
prosemirror-transform@1.10.4:
resolution: {integrity: sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==}
prosemirror-view@1.41.2:
resolution: {integrity: sha512-PGS/jETmh+Qjmre/6vcG7SNHAKiGc4vKOJmHMPRmvcUl7ISuVtrtHmH06UDUwaim4NDJfZfVMl7U7JkMMETa6g==}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -2996,6 +3298,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@ -3011,6 +3316,9 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
sanitize-html@2.17.0:
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
scheduler@0.26.0: scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
@ -3227,6 +3535,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
unbox-primitive@1.1.0: unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -3335,6 +3646,9 @@ packages:
jsdom: jsdom:
optional: true optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
which-boxed-primitive@1.1.1: which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -4297,6 +4611,8 @@ snapshots:
'@radix-ui/rect@1.1.1': {} '@radix-ui/rect@1.1.1': {}
'@remirror/core-constants@3.0.0': {}
'@rollup/rollup-android-arm-eabi@4.52.4': '@rollup/rollup-android-arm-eabi@4.52.4':
optional: true optional: true
@ -4460,6 +4776,187 @@ snapshots:
'@tanstack/table-core@8.21.3': {} '@tanstack/table-core@8.21.3': {}
'@tiptap/core@3.6.5(@tiptap/pm@3.6.5)':
dependencies:
'@tiptap/pm': 3.6.5
'@tiptap/extension-blockquote@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-bold@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-bubble-menu@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
dependencies:
'@floating-ui/dom': 1.7.4
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
optional: true
'@tiptap/extension-bullet-list@3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/extension-list': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-code-block@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
'@tiptap/extension-code@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-document@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-dropcursor@3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/extensions': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-floating-menu@3.6.5(@floating-ui/dom@1.7.4)(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
dependencies:
'@floating-ui/dom': 1.7.4
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
optional: true
'@tiptap/extension-gapcursor@3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/extensions': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-hard-break@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-heading@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-horizontal-rule@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
'@tiptap/extension-italic@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-link@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
linkifyjs: 4.3.2
'@tiptap/extension-list-item@3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/extension-list': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-list-keymap@3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/extension-list': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
'@tiptap/extension-ordered-list@3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/extension-list': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-paragraph@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-placeholder@3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/extensions': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-strike@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-text@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-underline@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
'@tiptap/pm@3.6.5':
dependencies:
prosemirror-changeset: 2.3.1
prosemirror-collab: 1.3.1
prosemirror-commands: 1.7.1
prosemirror-dropcursor: 1.8.2
prosemirror-gapcursor: 1.3.2
prosemirror-history: 1.4.1
prosemirror-inputrules: 1.5.0
prosemirror-keymap: 1.2.3
prosemirror-markdown: 1.13.2
prosemirror-menu: 1.2.5
prosemirror-model: 1.25.3
prosemirror-schema-basic: 1.2.4
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.3
prosemirror-tables: 1.8.1
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.2)
prosemirror-transform: 1.10.4
prosemirror-view: 1.41.2
'@tiptap/react@3.6.5(@floating-ui/dom@1.7.4)(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
'@types/react': 19.2.0
'@types/react-dom': 19.2.0(@types/react@19.2.0)
'@types/use-sync-external-store': 0.0.6
fast-deep-equal: 3.1.3
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
use-sync-external-store: 1.6.0(react@19.1.0)
optionalDependencies:
'@tiptap/extension-bubble-menu': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-floating-menu': 3.6.5(@floating-ui/dom@1.7.4)(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
transitivePeerDependencies:
- '@floating-ui/dom'
'@tiptap/starter-kit@3.6.5':
dependencies:
'@tiptap/core': 3.6.5(@tiptap/pm@3.6.5)
'@tiptap/extension-blockquote': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-bold': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-bullet-list': 3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
'@tiptap/extension-code': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-code-block': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-document': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-dropcursor': 3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
'@tiptap/extension-gapcursor': 3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
'@tiptap/extension-hard-break': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-heading': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-horizontal-rule': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-italic': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-link': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-list': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/extension-list-item': 3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
'@tiptap/extension-list-keymap': 3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
'@tiptap/extension-ordered-list': 3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))
'@tiptap/extension-paragraph': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-strike': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-text': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extension-underline': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))
'@tiptap/extensions': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
'@tiptap/pm': 3.6.5
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -4495,6 +4992,15 @@ snapshots:
'@types/json5@0.0.29': {} '@types/json5@0.0.29': {}
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdurl@2.0.0': {}
'@types/node@20.19.19': '@types/node@20.19.19':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@ -4507,6 +5013,12 @@ snapshots:
dependencies: dependencies:
csstype: 3.1.3 csstype: 3.1.3
'@types/sanitize-html@2.16.0':
dependencies:
htmlparser2: 8.0.2
'@types/use-sync-external-store@0.0.6': {}
'@typescript-eslint/eslint-plugin@8.45.0(@typescript-eslint/parser@8.45.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.45.0(@typescript-eslint/parser@8.45.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.1 '@eslint-community/regexpp': 4.12.1
@ -4911,6 +5423,8 @@ snapshots:
optionalDependencies: optionalDependencies:
react: 19.1.0 react: 19.1.0
crelt@1.0.6: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@ -4995,6 +5509,8 @@ snapshots:
deepmerge-ts@7.1.5: {} deepmerge-ts@7.1.5: {}
deepmerge@4.3.1: {}
define-data-property@1.1.4: define-data-property@1.1.4:
dependencies: dependencies:
es-define-property: 1.0.1 es-define-property: 1.0.1
@ -5024,6 +5540,24 @@ snapshots:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
csstype: 3.1.3 csstype: 3.1.3
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
domelementtype@2.3.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
dotenv@16.6.1: {} dotenv@16.6.1: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
@ -5046,6 +5580,8 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.3.0 tapable: 2.3.0
entities@4.5.0: {}
es-abstract@1.24.0: es-abstract@1.24.0:
dependencies: dependencies:
array-buffer-byte-length: 1.0.2 array-buffer-byte-length: 1.0.2
@ -5572,6 +6108,13 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 4.5.0
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.5: {} ignore@7.0.5: {}
@ -5664,6 +6207,8 @@ snapshots:
is-number@7.0.0: {} is-number@7.0.0: {}
is-plain-object@5.0.0: {}
is-regex@1.2.1: is-regex@1.2.1:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@ -5803,6 +6348,12 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-arm64-msvc: 1.30.1
lightningcss-win32-x64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
linkifyjs@4.3.2: {}
locate-path@6.0.0: locate-path@6.0.0:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
@ -5825,8 +6376,19 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
mdurl@2.0.0: {}
merge2@1.4.1: {} merge2@1.4.1: {}
micromatch@4.0.8: micromatch@4.0.8:
@ -5949,6 +6511,8 @@ snapshots:
type-check: 0.4.0 type-check: 0.4.0
word-wrap: 1.2.5 word-wrap: 1.2.5
orderedmap@2.1.1: {}
own-keys@1.0.1: own-keys@1.0.1:
dependencies: dependencies:
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
@ -5967,6 +6531,8 @@ snapshots:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0
parse-srcset@1.0.2: {}
path-exists@4.0.0: {} path-exists@4.0.0: {}
path-key@3.1.1: {} path-key@3.1.1: {}
@ -6026,6 +6592,111 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
react-is: 16.13.1 react-is: 16.13.1
prosemirror-changeset@2.3.1:
dependencies:
prosemirror-transform: 1.10.4
prosemirror-collab@1.3.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-commands@1.7.1:
dependencies:
prosemirror-model: 1.25.3
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-dropcursor@1.8.2:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-view: 1.41.2
prosemirror-gapcursor@1.3.2:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.3
prosemirror-state: 1.4.3
prosemirror-view: 1.41.2
prosemirror-history@1.4.1:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-view: 1.41.2
rope-sequence: 1.3.4
prosemirror-inputrules@1.5.0:
dependencies:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-keymap@1.2.3:
dependencies:
prosemirror-state: 1.4.3
w3c-keyname: 2.2.8
prosemirror-markdown@1.13.2:
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 14.1.0
prosemirror-model: 1.25.3
prosemirror-menu@1.2.5:
dependencies:
crelt: 1.0.6
prosemirror-commands: 1.7.1
prosemirror-history: 1.4.1
prosemirror-state: 1.4.3
prosemirror-model@1.25.3:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-basic@1.2.4:
dependencies:
prosemirror-model: 1.25.3
prosemirror-schema-list@1.5.1:
dependencies:
prosemirror-model: 1.25.3
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-state@1.4.3:
dependencies:
prosemirror-model: 1.25.3
prosemirror-transform: 1.10.4
prosemirror-view: 1.41.2
prosemirror-tables@1.8.1:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.3
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
prosemirror-view: 1.41.2
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.41.2):
dependencies:
'@remirror/core-constants': 3.0.0
escape-string-regexp: 4.0.0
prosemirror-model: 1.25.3
prosemirror-state: 1.4.3
prosemirror-view: 1.41.2
prosemirror-transform@1.10.4:
dependencies:
prosemirror-model: 1.25.3
prosemirror-view@1.41.2:
dependencies:
prosemirror-model: 1.25.3
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
punycode.js@2.3.1: {}
punycode@2.3.1: {} punycode@2.3.1: {}
pure-rand@6.1.0: {} pure-rand@6.1.0: {}
@ -6181,6 +6852,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.52.4 '@rollup/rollup-win32-x64-msvc': 4.52.4
fsevents: 2.3.3 fsevents: 2.3.3
rope-sequence@1.3.4: {}
run-parallel@1.2.0: run-parallel@1.2.0:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
@ -6204,6 +6877,15 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
is-regex: 1.2.1 is-regex: 1.2.1
sanitize-html@2.17.0:
dependencies:
deepmerge: 4.3.1
escape-string-regexp: 4.0.0
htmlparser2: 8.0.2
is-plain-object: 5.0.0
parse-srcset: 1.0.2
postcss: 8.5.6
scheduler@0.26.0: {} scheduler@0.26.0: {}
semver@6.3.1: {} semver@6.3.1: {}
@ -6472,6 +7154,8 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
uc.micro@2.1.0: {}
unbox-primitive@1.1.0: unbox-primitive@1.1.0:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@ -6617,6 +7301,8 @@ snapshots:
- supports-color - supports-color
- terser - terser
w3c-keyname@2.2.8: {}
which-boxed-primitive@1.1.1: which-boxed-primitive@1.1.1:
dependencies: dependencies:
is-bigint: 1.1.0 is-bigint: 1.1.0

View file

@ -112,11 +112,29 @@
--sidebar-ring: oklch(0.71 0.15 254.6); --sidebar-ring: oklch(0.71 0.15 254.6);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground font-sans antialiased; @apply bg-background text-foreground font-sans antialiased;
} }
} }
@layer components {
/* Tipografia básica para conteúdos rich text (Tiptap) */
.rich-text {
@apply text-foreground;
}
.rich-text p { @apply my-2; }
.rich-text a { @apply text-primary underline; }
.rich-text ul { @apply my-2 list-disc ps-5; }
.rich-text ol { @apply my-2 list-decimal ps-5; }
.rich-text li { @apply my-1; }
.rich-text blockquote { @apply my-3 border-l-2 border-muted-foreground/30 ps-3 text-muted-foreground; }
.rich-text h1 { @apply text-xl font-semibold my-3; }
.rich-text h2 { @apply text-lg font-semibold my-3; }
.rich-text h3 { @apply text-base font-semibold my-2; }
.rich-text code { @apply rounded bg-muted px-1 py-0.5 text-xs; }
.rich-text pre { @apply my-3 overflow-x-auto rounded bg-muted p-3 text-xs; }
}

View file

@ -3,6 +3,7 @@ import { SiteHeader } from "@/components/site-header"
import { TicketDetailView } from "@/components/tickets/ticket-detail-view" import { TicketDetailView } from "@/components/tickets/ticket-detail-view"
import { TicketDetailStatic } from "@/components/tickets/ticket-detail-static" import { TicketDetailStatic } from "@/components/tickets/ticket-detail-static"
import { getTicketById } from "@/lib/mocks/tickets" import { getTicketById } from "@/lib/mocks/tickets"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
type TicketDetailPageProps = { type TicketDetailPageProps = {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@ -24,7 +25,7 @@ export default async function TicketDetailPage({ params }: TicketDetailPageProps
/> />
} }
> >
{isMock && mock ? <TicketDetailStatic ticket={mock as any} /> : <TicketDetailView id={id} />} {isMock && mock ? <TicketDetailStatic ticket={mock as TicketWithDetails} /> : <TicketDetailView id={id} />}
</AppShell> </AppShell>
) )
} }

View file

@ -1,6 +1,8 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { Id } from "@/convex/_generated/dataModel";
import type { TicketQueueSummary } from "@/lib/schemas/ticket";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation, useQuery } from "convex/react"; import { useMutation, useQuery } from "convex/react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -8,12 +10,14 @@ import { useMutation, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants"; import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { useAuth } from "@/lib/auth-client"; import { useAuth } from "@/lib/auth-client";
import { RichTextEditor } from "@/components/ui/rich-text-editor";
export default function NewTicketPage() { export default function NewTicketPage() {
const router = useRouter(); const router = useRouter();
const { userId } = useAuth(); const { userId } = useAuth();
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []; const queues = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? [];
const create = useMutation(api.tickets.create); const create = useMutation(api.tickets.create);
const addComment = useMutation(api.tickets.addComment);
const ensureDefaults = useMutation(api.bootstrap.ensureDefaults); const ensureDefaults = useMutation(api.bootstrap.ensureDefaults);
const [subject, setSubject] = useState(""); const [subject, setSubject] = useState("");
@ -21,25 +25,30 @@ export default function NewTicketPage() {
const [priority, setPriority] = useState("MEDIUM"); const [priority, setPriority] = useState("MEDIUM");
const [channel, setChannel] = useState("MANUAL"); const [channel, setChannel] = useState("MANUAL");
const [queueName, setQueueName] = useState<string | null>(null); const [queueName, setQueueName] = useState<string | null>(null);
const [description, setDescription] = useState("");
const queueOptions = useMemo(() => queues.map((q: any) => q.name), [queues]); const queueOptions = useMemo(() => queues.map((q) => q.name), [queues]);
async function submit(e: React.FormEvent) { async function submit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!userId) return; if (!userId) return;
if (queues.length === 0) await ensureDefaults({ tenantId: DEFAULT_TENANT_ID }); if (queues.length === 0) await ensureDefaults({ tenantId: DEFAULT_TENANT_ID });
// Encontrar a fila pelo nome (simples) // Encontrar a fila pelo nome (simples)
const selQueue = (queues as any[]).find((q: any) => q.name === queueName); const selQueue = queues.find((q) => q.name === queueName);
const queueId = selQueue ? selQueue.id : undefined; const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined;
const id = await create({ const id = await create({
tenantId: DEFAULT_TENANT_ID, tenantId: DEFAULT_TENANT_ID,
subject, subject,
summary, summary,
priority, priority,
channel, channel,
queueId: queueId as any, queueId,
requesterId: userId as any, requesterId: userId as Id<"users">,
}); });
const hasDescription = description.replace(/<[^>]*>/g, "").trim().length > 0
if (hasDescription) {
await addComment({ ticketId: id as Id<"tickets">, authorId: userId as Id<"users">, visibility: "PUBLIC", body: description, attachments: [] })
}
router.replace(`/tickets/${id}`); router.replace(`/tickets/${id}`);
} }
@ -55,6 +64,10 @@ export default function NewTicketPage() {
<label className="text-sm">Resumo</label> <label className="text-sm">Resumo</label>
<textarea className="w-full rounded-md border bg-background px-3 py-2" value={summary} onChange={(e) => setSummary(e.target.value)} rows={3} /> <textarea className="w-full rounded-md border bg-background px-3 py-2" value={summary} onChange={(e) => setSummary(e.target.value)} rows={3} />
</div> </div>
<div className="space-y-2">
<label className="text-sm">Descrição</label>
<RichTextEditor value={description} onChange={setDescription} placeholder="Detalhe o problema, passos para reproduzir, links, etc." />
</div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm">Prioridade</label> <label className="text-sm">Prioridade</label>

View file

@ -81,11 +81,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return ( return (
<Sidebar {...props}> <Sidebar {...props}>
<SidebarHeader className="gap-3"> <SidebarHeader className="gap-3">
<VersionSwitcher <VersionSwitcher
label="Release" label="Release"
versions={navigation.versions} versions={[...navigation.versions]}
defaultVersion={navigation.versions[0]} defaultVersion={navigation.versions[0]}
/> />
<SearchForm placeholder="Buscar tickets, macros ou artigos" /> <SearchForm placeholder="Buscar tickets, macros ou artigos" />
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>

View file

@ -2,6 +2,8 @@
import { z } from "zod" import { z } from "zod"
import { useState } from "react" import { useState } from "react"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
// @ts-ignore // @ts-ignore
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
@ -17,10 +19,12 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner" import { toast } from "sonner"
import { Spinner } from "@/components/ui/spinner" import { Spinner } from "@/components/ui/spinner"
import { Dropzone } from "@/components/ui/dropzone" import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor } from "@/components/ui/rich-text-editor"
const schema = z.object({ const schema = z.object({
subject: z.string().min(3, "Informe um assunto"), subject: z.string().min(3, "Informe um assunto"),
summary: z.string().optional(), summary: z.string().optional(),
description: z.string().optional(),
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"), priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"), channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
queueName: z.string().nullable().optional(), queueName: z.string().nullable().optional(),
@ -31,11 +35,11 @@ export function NewTicketDialog() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const form = useForm<z.infer<typeof schema>>({ const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null }, defaultValues: { subject: "", summary: "", description: "", priority: "MEDIUM", channel: "MANUAL", queueName: null },
mode: "onTouched", mode: "onTouched",
}) })
const { userId } = useAuth() const { userId } = useAuth()
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? [] const queues = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? []
const create = useMutation(api.tickets.create) const create = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment) const addComment = useMutation(api.tickets.addComment)
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([]) const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
@ -45,18 +49,26 @@ export function NewTicketDialog() {
setLoading(true) setLoading(true)
toast.loading("Criando ticket…", { id: "new-ticket" }) toast.loading("Criando ticket…", { id: "new-ticket" })
try { try {
const sel = queues.find((q: any) => q.name === values.queueName) const sel = queues.find((q) => q.name === values.queueName)
const id = await create({ const id = await create({
tenantId: DEFAULT_TENANT_ID, tenantId: DEFAULT_TENANT_ID,
subject: values.subject, subject: values.subject,
summary: values.summary, summary: values.summary,
priority: values.priority, priority: values.priority,
channel: values.channel, channel: values.channel,
queueId: sel?.id, queueId: sel?.id as Id<"queues"> | undefined,
requesterId: userId as any, requesterId: userId as Id<"users">,
}) })
if (attachments.length > 0 || (values.summary && values.summary.trim().length > 0)) { const hasDescription = (values.description ?? "").replace(/<[^>]*>/g, "").trim().length > 0
await addComment({ ticketId: id as any, authorId: userId as any, visibility: "PUBLIC", body: values.summary || "", attachments }) const bodyHtml = hasDescription ? (values.description as string) : (values.summary || "")
if (attachments.length > 0 || bodyHtml.trim().length > 0) {
const typedAttachments = attachments.map((a) => ({
storageId: a.storageId as unknown as Id<"_storage">,
name: a.name,
size: a.size,
type: a.type,
}))
await addComment({ ticketId: id as Id<"tickets">, authorId: userId as Id<"users">, visibility: "PUBLIC", body: bodyHtml, attachments: typedAttachments })
} }
toast.success("Ticket criado!", { id: "new-ticket" }) toast.success("Ticket criado!", { id: "new-ticket" })
setOpen(false) setOpen(false)
@ -93,6 +105,14 @@ export function NewTicketDialog() {
<FieldLabel htmlFor="summary">Resumo</FieldLabel> <FieldLabel htmlFor="summary">Resumo</FieldLabel>
<textarea id="summary" className="w-full rounded-md border bg-background p-2 text-sm" rows={3} {...form.register("summary")} /> <textarea id="summary" className="w-full rounded-md border bg-background p-2 text-sm" rows={3} {...form.register("summary")} />
</Field> </Field>
<Field>
<FieldLabel>Descrição</FieldLabel>
<RichTextEditor
value={form.watch("description") || ""}
onChange={(html) => form.setValue("description", html)}
placeholder="Detalhe o problema, passos para reproduzir, links, etc."
/>
</Field>
<Field> <Field>
<FieldLabel>Anexos</FieldLabel> <FieldLabel>Anexos</FieldLabel>
<Dropzone onUploaded={(files) => setAttachments((prev) => [...prev, ...files])} /> <Dropzone onUploaded={(files) => setAttachments((prev) => [...prev, ...files])} />
@ -101,7 +121,7 @@ export function NewTicketDialog() {
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<Field> <Field>
<FieldLabel>Prioridade</FieldLabel> <FieldLabel>Prioridade</FieldLabel>
<Select value={form.watch("priority")} onValueChange={(v) => form.setValue("priority", v as any)}> <Select value={form.watch("priority")} onValueChange={(v) => form.setValue("priority", v as z.infer<typeof schema>["priority"])}>
<SelectTrigger><SelectValue placeholder="Prioridade" /></SelectTrigger> <SelectTrigger><SelectValue placeholder="Prioridade" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="LOW">Baixa</SelectItem> <SelectItem value="LOW">Baixa</SelectItem>
@ -113,7 +133,7 @@ export function NewTicketDialog() {
</Field> </Field>
<Field> <Field>
<FieldLabel>Canal</FieldLabel> <FieldLabel>Canal</FieldLabel>
<Select value={form.watch("channel")} onValueChange={(v) => form.setValue("channel", v as any)}> <Select value={form.watch("channel")} onValueChange={(v) => form.setValue("channel", v as z.infer<typeof schema>["channel"])}>
<SelectTrigger><SelectValue placeholder="Canal" /></SelectTrigger> <SelectTrigger><SelectValue placeholder="Canal" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="EMAIL">E-mail</SelectItem> <SelectItem value="EMAIL">E-mail</SelectItem>
@ -135,7 +155,7 @@ export function NewTicketDialog() {
<SelectTrigger><SelectValue placeholder="Sem fila" /></SelectTrigger> <SelectTrigger><SelectValue placeholder="Sem fila" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={NONE}>Sem fila</SelectItem> <SelectItem value={NONE}>Sem fila</SelectItem>
{queues.map((q: any) => ( {queues.map((q) => (
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem> <SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
))} ))}
</SelectContent> </SelectContent>

View file

@ -1,6 +1,7 @@
"use client" "use client"
import Link from "next/link" import Link from "next/link"
import { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react" import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
@ -9,7 +10,9 @@ import { useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import type { TicketPlayContext } from "@/lib/schemas/ticket" import type { TicketPlayContext, TicketQueueSummary } from "@/lib/schemas/ticket"
import type { Id } from "@/convex/_generated/dataModel"
import { mapTicketFromServer } from "@/lib/mappers/ticket"
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@ -17,6 +20,7 @@ import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill" import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge" import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { Spinner } from "@/components/ui/spinner" import { Spinner } from "@/components/ui/spinner"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
interface PlayNextTicketCardProps { interface PlayNextTicketCardProps {
context?: TicketPlayContext context?: TicketPlayContext
@ -25,19 +29,21 @@ interface PlayNextTicketCardProps {
export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) { export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
const router = useRouter() const router = useRouter()
const { userId } = useAuth() const { userId } = useAuth()
const queueSummary = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? [] const queueSummary = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? []
const playNext = useMutation(api.tickets.playNext) const playNext = useMutation(api.tickets.playNext)
const [selectedQueueId, setSelectedQueueId] = useState<string | undefined>(undefined)
const nextTicketFromServer = useQuery(api.tickets.list, { const nextTicketFromServer = useQuery(api.tickets.list, {
tenantId: DEFAULT_TENANT_ID, tenantId: DEFAULT_TENANT_ID,
status: undefined, status: undefined,
priority: undefined, priority: undefined,
channel: undefined, channel: undefined,
queueId: undefined, queueId: (selectedQueueId as Id<"queues">) || undefined,
limit: 1, limit: 1,
})?.[0] })?.[0]
const nextTicketUi = nextTicketFromServer ? mapTicketFromServer(nextTicketFromServer as unknown) : null
const cardContext: TicketPlayContext | null = context ?? (nextTicketFromServer ? { queue: { id: "default", name: "Geral", pending: queueSummary.reduce((a: number, b: any) => a + b.pending, 0), waiting: queueSummary.reduce((a: number, b: any) => a + b.waiting, 0), breached: 0 }, nextTicket: nextTicketFromServer } : null) const cardContext: TicketPlayContext | null = context ?? (nextTicketUi ? { queue: { id: "default", name: "Geral", pending: queueSummary.reduce((a, b) => a + b.pending, 0), waiting: queueSummary.reduce((a, b) => a + b.waiting, 0), breached: 0 }, nextTicket: nextTicketUi } : null)
if (!cardContext || !cardContext.nextTicket) { if (!cardContext || !cardContext.nextTicket) {
return ( return (
@ -62,11 +68,23 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
</CardTitle> </CardTitle>
<TicketPriorityPill priority={ticket.priority} /> <TicketPriorityPill priority={ticket.priority} />
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
<div className="space-y-1"> <div className="flex items-center justify-end gap-2">
<h2 className="text-xl font-medium text-foreground">{ticket.subject}</h2> <span className="text-sm text-muted-foreground">Fila:</span>
<p className="text-sm text-muted-foreground">{ticket.summary}</p> <Select value={selectedQueueId ?? "ALL"} onValueChange={(v) => setSelectedQueueId(v === "ALL" ? undefined : v)}>
</div> <SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Todas" /></SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Todas</SelectItem>
{queueSummary.map((q) => (
<SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<h2 className="text-xl font-medium text-foreground">{ticket.subject}</h2>
<p className="text-sm text-muted-foreground">{ticket.summary}</p>
</div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground"> <div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<Badge variant="outline">{ticket.queue ?? "Sem fila"}</Badge> <Badge variant="outline">{ticket.queue ?? "Sem fila"}</Badge>
<TicketStatusBadge status={ticket.status} /> <TicketStatusBadge status={ticket.status} />
@ -91,7 +109,7 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
className="gap-2" className="gap-2"
onClick={async () => { onClick={async () => {
if (!userId) return if (!userId) return
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: undefined, agentId: userId as any }) const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: (selectedQueueId as Id<"queues">) || undefined, agentId: userId as Id<"users"> })
if (chosen?.id) router.push(`/tickets/${chosen.id}`) if (chosen?.id) router.push(`/tickets/${chosen.id}`)
}} }}
> >

View file

@ -7,6 +7,7 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"; import { mapTicketsFromServerList } from "@/lib/mappers/ticket";
import { TicketsTable } from "@/components/tickets/tickets-table"; import { TicketsTable } from "@/components/tickets/tickets-table";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import type { Ticket } from "@/lib/schemas/ticket";
export function RecentTicketsPanel() { export function RecentTicketsPanel() {
const ticketsRaw = useQuery(api.tickets.list, { tenantId: DEFAULT_TENANT_ID, limit: 10 }); const ticketsRaw = useQuery(api.tickets.list, { tenantId: DEFAULT_TENANT_ID, limit: 10 });
@ -24,10 +25,10 @@ export function RecentTicketsPanel() {
</div> </div>
); );
} }
const tickets = mapTicketsFromServerList(ticketsRaw as any[]); const tickets = mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]);
return ( return (
<div className="rounded-xl border bg-card"> <div className="rounded-xl border bg-card">
<TicketsTable tickets={tickets as any} /> <TicketsTable tickets={tickets} />
</div> </div>
); );
} }

View file

@ -1,6 +1,6 @@
"use client" "use client"
import { ticketStatusSchema } from "@/lib/schemas/ticket" import { ticketStatusSchema, type TicketStatus } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
const statusConfig = { const statusConfig = {
@ -10,11 +10,9 @@ const statusConfig = {
ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" }, ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" },
RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" }, RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" },
CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" }, CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" },
} satisfies Record<(typeof ticketStatusSchema)["_type"], { label: string; className: string }> } satisfies Record<TicketStatus, { label: string; className: string }>
type TicketStatusBadgeProps = { type TicketStatusBadgeProps = { status: TicketStatus }
status: (typeof ticketStatusSchema)["_type"]
}
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) { export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
const config = statusConfig[status] const config = statusConfig[status]

View file

@ -4,12 +4,13 @@ import { useMemo, useState } from "react"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage } from "@tabler/icons-react" import { IconLock, IconMessage } from "@tabler/icons-react"
import { Download, ImageIcon, FileIcon } from "lucide-react" import { Download, FileIcon } from "lucide-react"
import { useAction, useMutation } from "convex/react" import { useAction, useMutation } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketWithDetails } from "@/lib/schemas/ticket" import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@ -17,12 +18,14 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { toast } from "sonner" import { toast } from "sonner"
import { Dropzone } from "@/components/ui/dropzone" import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { Dialog, DialogContent } from "@/components/ui/dialog" import { Dialog, DialogContent } from "@/components/ui/dialog"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
interface TicketCommentsProps {
ticket: TicketWithDetails interface TicketCommentsProps {
} ticket: TicketWithDetails
}
export function TicketComments({ ticket }: TicketCommentsProps) { export function TicketComments({ ticket }: TicketCommentsProps) {
const { userId } = useAuth() const { userId } = useAuth()
const addComment = useMutation(api.tickets.addComment) const addComment = useMutation(api.tickets.addComment)
@ -31,6 +34,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([]) const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
const [preview, setPreview] = useState<string | null>(null) const [preview, setPreview] = useState<string | null>(null)
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id"|"author"|"visibility"|"body"|"attachments"|"createdAt"|"updatedAt">[]>([]) const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id"|"author"|"visibility"|"body"|"attachments"|"createdAt"|"updatedAt">[]>([])
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
const commentsAll = useMemo(() => { const commentsAll = useMemo(() => {
return [...pending, ...ticket.comments] return [...pending, ...ticket.comments]
@ -43,19 +47,25 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const now = new Date() const now = new Date()
const optimistic = { const optimistic = {
id: `temp-${now.getTime()}`, id: `temp-${now.getTime()}`,
author: ticket.requester, // placeholder; poderia buscar o próprio usuário se necessário author: ticket.requester,
visibility: "PUBLIC" as const, visibility,
body, body: sanitizeEditorHtml(body),
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name, url: a.previewUrl } as any)), attachments: attachments.map((a) => ({ id: a.storageId, name: a.name, url: a.previewUrl })),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
} }
setPending((p) => [optimistic, ...p]) setPending((p) => [optimistic, ...p])
setBody("") setBody("")
setAttachmentsToSend([]) setAttachmentsToSend([])
toast.loading("Enviando comentário", { id: "comment" }) toast.loading("Enviando comentário.", { id: "comment" })
try { try {
await addComment({ ticketId: ticket.id as any, authorId: userId as any, visibility: "PUBLIC", body: optimistic.body, attachments }) const typedAttachments = attachments.map((a) => ({
storageId: a.storageId as unknown as Id<"_storage">,
name: a.name,
size: a.size,
type: a.type,
}))
await addComment({ ticketId: ticket.id as Id<"tickets">, authorId: userId as Id<"users">, visibility, body: optimistic.body, attachments: typedAttachments })
setPending([]) setPending([])
toast.success("Comentário enviado!", { id: "comment" }) toast.success("Comentário enviado!", { id: "comment" })
} catch (err) { } catch (err) {
@ -74,7 +84,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
<CardContent className="space-y-6 px-4 pb-6"> <CardContent className="space-y-6 px-4 pb-6">
{commentsAll.length === 0 ? ( {commentsAll.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Ainda sem comentarios. Que tal registrar o proximo passo? Ainda sem comentários. Que tal registrar o próximo passo?
</p> </p>
) : ( ) : (
commentsAll.map((comment) => { commentsAll.map((comment) => {
@ -101,20 +111,19 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })} {formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span> </span>
</div> </div>
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words whitespace-pre-wrap"> <div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words">
{comment.body} <RichTextContent html={comment.body} />
</div> </div>
{comment.attachments?.length ? ( {comment.attachments?.length ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3"> <div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{comment.attachments.map((a) => { {comment.attachments.map((att) => {
const att = a as any
const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i) const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
if (isImg && att.url) { if (isImg && att.url) {
return ( return (
<button <button
key={att.id} key={att.id}
type="button" type="button"
onClick={() => setPreview(att.url)} onClick={() => setPreview(att.url || null)}
className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow" className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow"
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
@ -140,15 +149,19 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
}) })
)} )}
<form onSubmit={handleSubmit} className="mt-4 space-y-3"> <form onSubmit={handleSubmit} className="mt-4 space-y-3">
<textarea <RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
className="w-full rounded-md border bg-background p-3 text-sm"
placeholder="Escreva um comentario..."
rows={3}
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} /> <Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
<div className="flex items-center justify-end"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
Visibilidade:
<Select value={visibility} onValueChange={(v) => setVisibility(v as "PUBLIC" | "INTERNAL")}>
<SelectTrigger className="h-8 w-[140px]"><SelectValue placeholder="Visibilidade" /></SelectTrigger>
<SelectContent>
<SelectItem value="PUBLIC">Pública</SelectItem>
<SelectItem value="INTERNAL">Interna</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm">Enviar</Button> <Button type="submit" size="sm">Enviar</Button>
</div> </div>
</form> </form>

View file

@ -6,31 +6,63 @@ import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants"; import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"; import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
import type { Id } from "@/convex/_generated/dataModel";
import type { TicketWithDetails } from "@/lib/schemas/ticket";
import { getTicketById } from "@/lib/mocks/tickets"; import { getTicketById } from "@/lib/mocks/tickets";
import { TicketComments } from "@/components/tickets/ticket-comments"; import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { TicketComments } from "@/components/tickets/ticket-comments.rich";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel"; import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header"; import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketTimeline } from "@/components/tickets/ticket-timeline"; import { TicketTimeline } from "@/components/tickets/ticket-timeline";
export function TicketDetailView({ id }: { id: string }) { export function TicketDetailView({ id }: { id: string }) {
const isMockId = id.startsWith("ticket-"); const isMockId = id.startsWith("ticket-");
const t = useQuery(api.tickets.getById, isMockId ? undefined : ({ tenantId: DEFAULT_TENANT_ID, id: id as any })); const t = useQuery(api.tickets.getById, isMockId ? "skip" : ({ tenantId: DEFAULT_TENANT_ID, id: id as Id<"tickets"> }));
let ticket: any | null = null; let ticket: TicketWithDetails | null = null;
if (t) { if (t) {
ticket = mapTicketWithDetailsFromServer(t as any); ticket = mapTicketWithDetailsFromServer(t as unknown);
} else if (isMockId) { } else if (isMockId) {
ticket = getTicketById(id) ?? null; ticket = getTicketById(id) ?? null;
} }
if (!ticket) return <div className="px-4 py-8 text-sm text-muted-foreground">Carregando ticket...</div>; if (!ticket) return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<Card className="rounded-xl border bg-card shadow-sm">
<CardContent className="space-y-3 p-6">
<div className="flex items-center gap-2"><Skeleton className="h-5 w-24" /><Skeleton className="h-5 w-20" /></div>
<Skeleton className="h-7 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<Card className="rounded-xl border bg-card shadow-sm">
<CardContent className="space-y-4 p-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="flex items-center gap-2"><Skeleton className="h-4 w-28" /><Skeleton className="h-3 w-24" /></div>
<Skeleton className="h-16 w-full" />
</div>
))}
</CardContent>
</Card>
<Card className="rounded-xl border bg-card shadow-sm">
<CardContent className="space-y-3 p-6">
{Array.from({ length: 5 }).map((_, i) => (<Skeleton key={i} className="h-3 w-full" />))}
</CardContent>
</Card>
</div>
</div>
);
return ( return (
<div className="flex flex-col gap-6 px-4 lg:px-6"> <div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket as any} /> <TicketSummaryHeader ticket={ticket} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]"> <div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6"> <div className="space-y-6">
<TicketComments ticket={ticket as any} /> <TicketComments ticket={ticket} />
<TicketTimeline ticket={ticket as any} /> <TicketTimeline ticket={ticket} />
</div> </div>
<TicketDetailsPanel ticket={ticket as any} /> <TicketDetailsPanel ticket={ticket} />
</div> </div>
</div> </div>
); );

View file

@ -14,8 +14,20 @@ interface TicketQueueSummaryProps {
} }
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) { export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
const fromServer = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? [] const fromServer = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID })
const data: TicketQueueSummary[] = (queues ?? fromServer) as any const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? [])
if (!queues && fromServer === undefined) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border bg-card p-4">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="mt-4 h-3 w-full animate-pulse rounded bg-muted" />
</div>
))}
</div>
)
}
return ( return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{data.map((queue) => { {data.map((queue) => {

View file

@ -11,7 +11,8 @@ import { toast } from "sonner"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import type { TicketWithDetails } from "@/lib/schemas/ticket" import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill" import { TicketPriorityPill } from "@/components/tickets/priority-pill"
@ -27,9 +28,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const updateStatus = useMutation(api.tickets.updateStatus) const updateStatus = useMutation(api.tickets.updateStatus)
const changeAssignee = useMutation(api.tickets.changeAssignee) const changeAssignee = useMutation(api.tickets.changeAssignee)
const changeQueue = useMutation(api.tickets.changeQueue) const changeQueue = useMutation(api.tickets.changeQueue)
const agents = useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) ?? [] const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queues = useQuery(api.queues.summary, { tenantId: ticket.tenantId }) ?? [] const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
const [status, setStatus] = useState(ticket.status) const [status, setStatus] = useState<TicketStatus>(ticket.status)
const statusPt: Record<string, string> = { const statusPt: Record<string, string> = {
NEW: "Novo", NEW: "Novo",
OPEN: "Aberto", OPEN: "Aberto",
@ -47,16 +48,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
#{ticket.reference} #{ticket.reference}
</Badge> </Badge>
<TicketPriorityPill priority={ticket.priority} /> <TicketPriorityPill priority={ticket.priority} />
<TicketStatusBadge status={status as any} /> <TicketStatusBadge status={status} />
<Select <Select
value={status} value={status}
onValueChange={async (value) => { onValueChange={async (value) => {
const prev = status const prev = status
setStatus(value) // otimista setStatus(value as import("@/lib/schemas/ticket").TicketStatus) // otimista
if (!userId) return if (!userId) return
toast.loading("Atualizando status…", { id: "status" }) toast.loading("Atualizando status…", { id: "status" })
try { try {
await updateStatus({ ticketId: ticket.id as any, status: value as any, actorId: userId as any }) await updateStatus({ ticketId: ticket.id as Id<"tickets">, status: value, actorId: userId as Id<"users"> })
toast.success(`Status alterado para ${statusPt[value]}.`, { id: "status" }) toast.success(`Status alterado para ${statusPt[value]}.`, { id: "status" })
} catch (e) { } catch (e) {
setStatus(prev) setStatus(prev)
@ -95,7 +96,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
if (!userId) return if (!userId) return
toast.loading("Atribuindo responsável…", { id: "assignee" }) toast.loading("Atribuindo responsável…", { id: "assignee" })
try { try {
await changeAssignee({ ticketId: ticket.id as any, assigneeId: value as any, actorId: userId as any }) 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" }) toast.success("Responsável atualizado!", { id: "assignee" })
} catch { } catch {
toast.error("Não foi possível atribuir.", { id: "assignee" }) toast.error("Não foi possível atribuir.", { id: "assignee" })
@ -104,7 +105,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
> >
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger> <SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent> <SelectContent>
{agents.map((a: any) => ( {agents.map((a) => (
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem> <SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -118,11 +119,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
value={ticket.queue ?? ""} value={ticket.queue ?? ""}
onValueChange={async (value) => { onValueChange={async (value) => {
if (!userId) return if (!userId) return
const q = queues.find((qq: any) => qq.name === value) const q = queues.find((qq) => qq.name === value)
if (!q) return if (!q) return
toast.loading("Atualizando fila…", { id: "queue" }) toast.loading("Atualizando fila…", { id: "queue" })
try { try {
await changeQueue({ ticketId: ticket.id as any, queueId: q.id as any, actorId: userId as any }) await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: q.id as Id<"queues">, actorId: userId as Id<"users"> })
toast.success("Fila atualizada!", { id: "queue" }) toast.success("Fila atualizada!", { id: "queue" })
} catch { } catch {
toast.error("Não foi possível atualizar a fila.", { id: "queue" }) toast.error("Não foi possível atualizar a fila.", { id: "queue" })
@ -131,7 +132,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
> >
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger> <SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent> <SelectContent>
{queues.map((q: any) => ( {queues.map((q) => (
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem> <SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
))} ))}
</SelectContent> </SelectContent>

View file

@ -56,12 +56,12 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
{entry.payload?.actorName ? ( {entry.payload?.actorName ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground"> <span className="flex items-center gap-1 text-xs text-muted-foreground">
<Avatar className="size-5"> <Avatar className="size-5">
<AvatarImage src={entry.payload.actorAvatar} alt={entry.payload.actorName} /> <AvatarImage src={entry.payload?.actorAvatar as string | undefined} alt={String(entry.payload?.actorName ?? "")} />
<AvatarFallback> <AvatarFallback>
{entry.payload.actorName.split(' ').slice(0,2).map((p:string)=>p[0]).join('').toUpperCase()} {String(entry.payload?.actorName ?? '').split(' ').slice(0,2).map((p:string)=>p[0]).join('').toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
por {entry.payload.actorName} por {String(entry.payload?.actorName ?? '')}
</span> </span>
) : null} ) : null}
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@ -69,12 +69,13 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
</span> </span>
</div> </div>
{(() => { {(() => {
const p: any = entry.payload || {} const p = (entry.payload || {}) as { toLabel?: string; to?: string; assigneeName?: string; assigneeId?: string; queueName?: string; queueId?: string; requesterName?: string; authorName?: string; authorId?: string }
let message: string | null = null let message: string | null = null
if (entry.type === "STATUS_CHANGED" && (p.toLabel || p.to)) message = `Status alterado para ${p.toLabel || p.to}` if (entry.type === "STATUS_CHANGED" && (p.toLabel || p.to)) message = `Status alterado para ${p.toLabel || p.to}`
if (entry.type === "ASSIGNEE_CHANGED" && (p.assigneeName || p.assigneeId)) message = `Responsável alterado${p.assigneeName ? ` para ${p.assigneeName}` : ""}` if (entry.type === "ASSIGNEE_CHANGED" && (p.assigneeName || p.assigneeId)) message = `Responsável alterado${p.assigneeName ? ` para ${p.assigneeName}` : ""}`
if (entry.type === "QUEUE_CHANGED" && (p.queueName || p.queueId)) message = `Fila alterada${p.queueName ? ` para ${p.queueName}` : ""}` if (entry.type === "QUEUE_CHANGED" && (p.queueName || p.queueId)) message = `Fila alterada${p.queueName ? ` para ${p.queueName}` : ""}`
if (entry.type === "CREATED" && (p.requesterName)) message = `Criado por ${p.requesterName}` if (entry.type === "CREATED" && (p.requesterName)) message = `Criado por ${p.requesterName}`
if (entry.type === "COMMENT_ADDED" && (p.authorName || p.authorId)) message = `Comentário adicionado${p.authorName ? ` por ${p.authorName}` : ""}`
if (!message) return null if (!message) return null
return ( return (
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground">

View file

@ -7,6 +7,7 @@ import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket" import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket"
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters" import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
import { TicketsTable } from "@/components/tickets/tickets-table" import { TicketsTable } from "@/components/tickets/tickets-table"
import { Spinner } from "@/components/ui/spinner" import { Spinner } from "@/components/ui/spinner"
@ -14,7 +15,7 @@ import { Spinner } from "@/components/ui/spinner"
export function TicketsView() { export function TicketsView() {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters) const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined
const ticketsRaw = useQuery(api.tickets.list, { const ticketsRaw = useQuery(api.tickets.list, {
tenantId: DEFAULT_TENANT_ID, tenantId: DEFAULT_TENANT_ID,
status: filters.status ?? undefined, status: filters.status ?? undefined,
@ -24,16 +25,16 @@ export function TicketsView() {
search: filters.search || undefined, search: filters.search || undefined,
}) })
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as any[]), [ticketsRaw]) const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
const filteredTickets = useMemo(() => { const filteredTickets = useMemo(() => {
if (!filters.queue) return tickets if (!filters.queue) return tickets
return tickets.filter((t: any) => t.queue === filters.queue) return tickets.filter((t: Ticket) => t.queue === filters.queue)
}, [tickets, filters.queue]) }, [tickets, filters.queue])
return ( return (
<div className="flex flex-col gap-6 px-4 lg:px-6"> <div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q: any) => q.name)} /> <TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q) => q.name)} />
{ticketsRaw === undefined ? ( {ticketsRaw === undefined ? (
<div className="rounded-xl border bg-card p-4"> <div className="rounded-xl border bg-card p-4">
<div className="grid gap-3"> <div className="grid gap-3">
@ -46,7 +47,7 @@ export function TicketsView() {
</div> </div>
</div> </div>
) : ( ) : (
<TicketsTable tickets={filteredTickets as any} /> <TicketsTable tickets={filteredTickets} />
)} )}
</div> </div>
) )

View file

@ -7,39 +7,38 @@ const FieldSet = ({ className, ...props }: React.ComponentProps<"fieldset">) =>
<fieldset role="group" className={cn("grid gap-3", className)} {...props} /> <fieldset role="group" className={cn("grid gap-3", className)} {...props} />
) )
const FieldLegend = ({ className, ...props }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) => { const FieldLegend = (
const { variant = "legend", ...rest } = props as any { className, variant = "legend", ...props }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }
return ( ) => (
<legend <legend
className={cn( className={cn(
variant === "label" ? "text-sm font-medium" : "text-sm font-semibold", variant === "label" ? "text-sm font-medium" : "text-sm font-semibold",
"text-foreground", className "text-foreground",
)} className
{...rest} )}
/> {...props}
) />
} )
const FieldGroup = ({ className, ...props }: React.ComponentProps<"div">) => ( const FieldGroup = ({ className, ...props }: React.ComponentProps<"div">) => (
<div className={cn("@container/field-group grid gap-4", className)} {...props} /> <div className={cn("@container/field-group grid gap-4", className)} {...props} />
) )
const Field = ({ className, ...props }: React.ComponentProps<"div"> & { orientation?: "vertical" | "horizontal" | "responsive" }) => { const Field = (
const { orientation = "vertical", ...rest } = props as any { className, orientation = "vertical", ...props }: React.ComponentProps<"div"> & { orientation?: "vertical" | "horizontal" | "responsive" }
return ( ) => (
<div <div
data-orientation={orientation} data-orientation={orientation}
className={cn( className={cn(
"flex gap-2", "flex gap-2",
orientation === "vertical" && "flex-col", orientation === "vertical" && "flex-col",
orientation === "horizontal" && "items-center", orientation === "horizontal" && "items-center",
orientation === "responsive" && "@[480px]/field-group:flex-row @[480px]/field-group:items-center flex-col", orientation === "responsive" && "@[480px]/field-group:flex-row @[480px]/field-group:items-center flex-col",
className className
)} )}
{...rest} {...props}
/> />
) )
}
const FieldContent = ({ className, ...props }: React.ComponentProps<"div">) => ( const FieldContent = ({ className, ...props }: React.ComponentProps<"div">) => (
<div className={cn("flex flex-col", className)} {...props} /> <div className={cn("flex flex-col", className)} {...props} />
@ -78,4 +77,3 @@ const FieldSeparator = ({ className, ...props }: React.ComponentProps<"div">) =>
) )
export { FieldSet, FieldLegend, FieldGroup, Field, FieldContent, FieldLabel, FieldTitle, FieldDescription, FieldError, FieldSeparator } export { FieldSet, FieldLegend, FieldGroup, Field, FieldContent, FieldLabel, FieldTitle, FieldDescription, FieldError, FieldSeparator }

View file

@ -0,0 +1,218 @@
"use client"
import { useEffect } from "react"
import { useEditor, EditorContent } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Link from "@tiptap/extension-link"
import Placeholder from "@tiptap/extension-placeholder"
import { cn } from "@/lib/utils"
import sanitize from "sanitize-html"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import {
Bold,
Italic,
Strikethrough,
List,
ListOrdered,
Quote,
Undo,
Redo,
Link as LinkIcon,
} from "lucide-react"
type RichTextEditorProps = {
value?: string
onChange?: (html: string) => void
className?: string
placeholder?: string
disabled?: boolean
minHeight?: number
}
export function RichTextEditor({
value,
onChange,
className,
placeholder = "Escreva aqui...",
disabled,
minHeight = 120,
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
bulletList: { keepMarks: true },
orderedList: { keepMarks: true },
}),
Link.configure({
openOnClick: true,
autolink: true,
protocols: ["http", "https", "mailto"],
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
}),
Placeholder.configure({ placeholder }),
],
editorProps: {
attributes: {
class:
"prose prose-sm max-w-none focus:outline-none text-foreground",
},
},
content: value || "",
onUpdate({ editor }) {
onChange?.(editor.getHTML())
},
editable: !disabled,
// Avoid SSR hydration mismatches per Tiptap recommendation
immediatelyRender: false,
})
// Keep external value in sync when it changes
useEffect(() => {
if (!editor) return
const current = editor.getHTML()
if ((value ?? "") !== current) {
editor.commands.setContent(value || "", { emitUpdate: false })
}
}, [value, editor])
if (!editor) return null
return (
<div className={cn("rounded-md border bg-background", className)}>
<div className="flex flex-wrap items-center gap-1 border-b px-2 py-1">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive("bold")}
ariaLabel="Negrito"
>
<Bold className="size-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive("italic")}
ariaLabel="Itálico"
>
<Italic className="size-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editor.isActive("strike")}
ariaLabel="Tachado"
>
<Strikethrough className="size-4" />
</ToolbarButton>
<Separator orientation="vertical" className="mx-1 h-5" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive("bulletList")}
ariaLabel="Lista"
>
<List className="size-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive("orderedList")}
ariaLabel="Lista ordenada"
>
<ListOrdered className="size-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive("blockquote")}
ariaLabel="Citação"
>
<Quote className="size-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => {
const prev = editor.getAttributes("link").href as string | undefined
const url = window.prompt("URL do link:", prev || "https://")
if (url === null) return
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
return
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run()
}}
active={editor.isActive("link")}
ariaLabel="Inserir link"
>
<LinkIcon className="size-4" />
</ToolbarButton>
<div className="ms-auto flex items-center gap-1">
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} ariaLabel="Desfazer">
<Undo className="size-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} ariaLabel="Refazer">
<Redo className="size-4" />
</ToolbarButton>
</div>
</div>
<div style={{ minHeight }} className="rich-text p-3">
<EditorContent editor={editor} />
</div>
</div>
)
}
function ToolbarButton({
onClick,
active,
ariaLabel,
children,
}: {
onClick: () => void
active?: boolean
ariaLabel?: string
children: React.ReactNode
}) {
return (
<Button
type="button"
variant={active ? "default" : "ghost"}
size="icon"
className="h-7 w-7"
onClick={onClick}
aria-label={ariaLabel}
>
{children}
</Button>
)
}
// Utilitário simples para renderização segura do HTML do editor.
// Remove tags <script>/<style> e atributos on*.
export function sanitizeEditorHtml(html: string): string {
try {
return sanitize(html || "", {
allowedTags: [
"p","br","a","strong","em","u","s","blockquote","ul","ol","li","code","pre","span","h1","h2","h3"
],
allowedAttributes: {
a: ["href","name","target","rel"],
span: ["style"],
code: ["class"],
pre: ["class"],
},
allowedSchemes: ["http","https","mailto"],
// prevent target=_self phishing
transformTags: {
a: sanitize.simpleTransform("a", { rel: "noopener noreferrer", target: "_blank" }, true),
},
// disallow inline event handlers
allowVulnerableTags: false,
})
} catch {
return ""
}
}
export function RichTextContent({ html, className }: { html: string; className?: string }) {
return (
<div
className={cn("rich-text text-sm leading-relaxed", className)}
dangerouslySetInnerHTML={{ __html: sanitizeEditorHtml(html) }}
/>
)
}

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import React, { createContext, useContext, useEffect, useMemo, useState } from "react"; import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { Doc } from "@/convex/_generated/dataModel";
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
// Lazy import to avoid build errors before convex is generated // Lazy import to avoid build errors before convex is generated
@ -32,10 +33,8 @@ export function AuthProvider({ demoUser, tenantId, children }: { demoUser: DemoU
if (!process.env.NEXT_PUBLIC_CONVEX_URL) return; // allow dev without backend if (!process.env.NEXT_PUBLIC_CONVEX_URL) return; // allow dev without backend
if (!localDemoUser) return; if (!localDemoUser) return;
try { try {
const user = await ensureUser({ tenantId, name: localDemoUser.name, email: localDemoUser.email, avatarUrl: localDemoUser.avatarUrl }); const user = (await ensureUser({ tenantId, name: localDemoUser.name, email: localDemoUser.email, avatarUrl: localDemoUser.avatarUrl })) as Doc<"users"> | null;
// Convex returns a full document setUserId(user?._id ?? null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setUserId((user as any)?._id ?? null);
} catch (e) { } catch (e) {
console.error("Failed to ensure user:", e); console.error("Failed to ensure user:", e);
} }

View file

@ -60,7 +60,7 @@ const serverEventSchema = z.object({
const serverTicketWithDetailsSchema = serverTicketSchema.extend({ const serverTicketWithDetailsSchema = serverTicketSchema.extend({
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
customFields: z.record(z.any()).default({}).optional(), customFields: z.record(z.string(), z.any()).optional(),
timeline: z.array(serverEventSchema), timeline: z.array(serverEventSchema),
comments: z.array(serverCommentSchema), comments: z.array(serverCommentSchema),
}); });
@ -76,7 +76,6 @@ export function mapTicketFromServer(input: unknown) {
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null, firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null, resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
}; };
// Já validamos o formato recebido (serverTicketSchema). Retornamos no shape da UI.
return ui as unknown as z.infer<typeof ticketSchema>; return ui as unknown as z.infer<typeof ticketSchema>;
} }
@ -88,6 +87,7 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
const s = serverTicketWithDetailsSchema.parse(input); const s = serverTicketWithDetailsSchema.parse(input);
const ui = { const ui = {
...s, ...s,
customFields: (s.customFields ?? {}) as Record<string, unknown>,
lastTimelineEntry: s.lastTimelineEntry ?? undefined, lastTimelineEntry: s.lastTimelineEntry ?? undefined,
updatedAt: new Date(s.updatedAt), updatedAt: new Date(s.updatedAt),
createdAt: new Date(s.createdAt), createdAt: new Date(s.createdAt),

View file

@ -56,12 +56,12 @@ export const ticketCommentSchema = z.object({
}) })
export type TicketComment = z.infer<typeof ticketCommentSchema> export type TicketComment = z.infer<typeof ticketCommentSchema>
export const ticketEventSchema = z.object({ export const ticketEventSchema = z.object({
id: z.string(), id: z.string(),
type: z.string(), type: z.string(),
payload: z.record(z.any()).optional(), payload: z.record(z.string(), z.any()).optional(),
createdAt: z.coerce.date(), createdAt: z.coerce.date(),
}) })
export type TicketEvent = z.infer<typeof ticketEventSchema> export type TicketEvent = z.infer<typeof ticketEventSchema>
export const ticketSchema = z.object({ export const ticketSchema = z.object({
@ -100,12 +100,12 @@ export const ticketSchema = z.object({
}) })
export type Ticket = z.infer<typeof ticketSchema> export type Ticket = z.infer<typeof ticketSchema>
export const ticketWithDetailsSchema = ticketSchema.extend({ export const ticketWithDetailsSchema = ticketSchema.extend({
description: z.string().optional(), description: z.string().optional(),
customFields: z.record(z.any()).default({}), customFields: z.record(z.string(), z.any()).optional(),
timeline: z.array(ticketEventSchema), timeline: z.array(ticketEventSchema),
comments: z.array(ticketCommentSchema), comments: z.array(ticketCommentSchema),
}) })
export type TicketWithDetails = z.infer<typeof ticketWithDetailsSchema> export type TicketWithDetails = z.infer<typeof ticketWithDetailsSchema>
export const ticketQueueSummarySchema = z.object({ export const ticketQueueSummarySchema = z.object({

View file

@ -18,9 +18,10 @@
"name": "next" "name": "next"
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"],
} "@/convex/*": ["./convex/*"]
}
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]