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

feat: núcleo de tickets com Convex (CRUD, play, comentários com anexo…
This commit is contained in:
esdrasrenan 2025-10-04 00:40:33 -03:00 committed by GitHub
commit 43eb2d6c0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 15117 additions and 15715 deletions

View file

@ -13,12 +13,12 @@ Construir o nucleo de tickets compartilhado entre web e desktop (Tauri), garanti
- Ajustar paleta (tons de cinza + destaque primario) e tipografia (Inter/Manrope).
- Implementar layout shell (sidebar + header) reutilizavel.
3. **Autenticacao placeholder**
- Configurar stub de sessao/Auth.js (mock) para navegacao protegida.
- Configurar stub de sessao (cookie + middleware) para navegacao protegida.
### Status da fase
- OK Scaffold Next.js + Tailwind + shadcn/ui criado em `web/`.
- OK Layout base atualizado (sidebar, header, cards, grafico) com identidade da aplicacao.
- TODO Auth placeholder pendente (prevista apos modelagem do dominio).
- OK Auth placeholder via cookie + middleware e bootstrap de usuario no Convex.
## Fase B - Nucleo de tickets
1. **Modelagem compartilhada**
@ -36,7 +36,7 @@ Construir o nucleo de tickets compartilhado entre web e desktop (Tauri), garanti
- OK `prisma/schema.prisma` criado com entidades centrais (User, Team, Ticket, Comment, Event, SLA).
- OK Schemas Zod e mocks compartilhados em `src/lib/schemas` e `src/lib/mocks`.
- OK Paginas `/tickets`, `/tickets/[id]` e `/play` prontas com componentes dedicados (filtros, tabela, timeline, modo play).
- TODO Mutations reais + integracao com backend (aguardando Auth e persistencia real).
- OK Integração com backend Convex (consultas/mutações + file storage). Prisma mantido apenas como referência.
## Fase C - Servicos complementares (posterior)
- SLAs (BullMQ + Redis), notificacoes, ingest de e-mail, portal cliente, etc.
@ -46,8 +46,8 @@ Construir o nucleo de tickets compartilhado entre web e desktop (Tauri), garanti
- [x] Ajustar layout shell (dashboard + sidebar) com tema solicitado.
- [x] Criar modulos base de dominio (schemas Prisma/Zod) ainda com dados mockados.
- [x] Preparar estrutura de paginas: `/tickets`, `/tickets/[id]`, `/play`.
- [ ] Implementar Auth placeholder (Auth.js mock) para rotas protegidas.
- [ ] Conectar APIs/mutations reais (server actions/trpc) e sincronizar com Prisma.
- [x] Implementar Auth placeholder (cookie + middleware).
- [x] Conectar APIs/mutations reais (Convex) e sincronizar tipos no frontend.
## Proximas entregas sugeridas
1. Finalizar Auth placeholder e guardas de rota (Auth.js + middleware).
@ -58,3 +58,69 @@ Construir o nucleo de tickets compartilhado entre web e desktop (Tauri), garanti
## Acompanhamento
Atualizar este arquivo a cada marco relevante (setup concluido, nucleo funcional, etc.).
---
# Guia do Projeto (para agentes e contribuidores)
Este repositório foi atualizado para usar Convex como backend em tempo real para o núcleo de tickets. Abaixo, um guia prático conforme o padrão de AGENTS.md para orientar contribuições futuras.
## Decisões técnicas atuais
- Backend: Convex (funções + banco + storage) em `web/convex/`.
- Esquema: `web/convex/schema.ts`.
- Tickets API: `web/convex/tickets.ts` (list/getById/create/addComment/updateStatus/playNext).
- Upload de arquivos: `web/convex/files.ts` (Convex Storage).
- Filas: `web/convex/queues.ts` (resumo por fila).
- Seed/bootstrap: `web/convex/seed.ts`, `web/convex/bootstrap.ts`.
- Auth placeholder: cookie + middleware
- Login: `web/src/app/login/page.tsx`
- Middleware: `web/middleware.ts`
- Provider: `web/src/lib/auth-client.tsx` (garante usuário no Convex)
- Frontend (Next.js + shadcn/ui)
- Páginas principais: `/tickets`, `/tickets/[id]`, `/tickets/new`, `/play`.
- UI ligada ao Convex com `convex/react`.
- Toasts: `sonner` via `Toaster` em `web/src/app/layout.tsx`.
- Mapeamento/validação de dados
- Convex retorna datas como `number` (epoch). A UI usa `Date`.
- Sempre converter/validar via Zod em `web/src/lib/mappers/ticket.ts`.
- Não retornar `Date` a partir de funções do Convex.
- Prisma: mantido apenas como referência de domínio (não é fonte de dados ativa).
## Como rodar
- Prérequisitos: Node LTS + pnpm.
- Passos:
- `cd web && pnpm i`
- `pnpm convex:dev` (mantém gerando tipos e rodando backend dev)
- Criar `.env.local` com `NEXT_PUBLIC_CONVEX_URL=<url exibida pelo convex dev>`
- Em outro terminal: `pnpm dev`
- Login em `/login`; seed opcional em `/dev/seed`.
## Convenções de código
- Não use `Date` em payloads do Convex; use `number` (epoch ms).
- Normalize dados no front via mappers Zod antes de renderizar.
- UI com shadcn/ui; priorize componentes existentes e consistência visual.
- Labels e mensagens em PTBR (status, timeline, toasts, etc.).
- Atualizações otimistas com rollback em erro + toasts de feedback.
## Estrutura útil
- `web/convex/*` — API backend Convex.
- `web/src/lib/mappers/*` — Conversores server→UI com Zod.
- `web/src/components/tickets/*` — Tabela, filtros, detalhe, timeline, comentários, play.
## Scripts (pnpm)
- `pnpm convex:dev` — Convex (dev + geração de tipos)
- `pnpm dev` — Next.js (App Router)
- `pnpm build` / `pnpm start` — build/produção
## Backlog imediato (próximos passos)
- Form “Novo ticket” em Dialog shadcn + React Hook Form + Zod + toasts.
- Atribuição/transferência de fila no detalhe (selects com update otimista).
- Melhorias de layout adicionais no painel “Detalhes” (quebras, largura responsiva) e unificação de textos PTBR.
- Testes unitários dos mapeadores com Vitest.
## Checklist de PRs
- [ ] Funções Convex retornam apenas tipos suportados (sem `Date`).
- [ ] Dados validados/convertidos via Zod mappers antes da UI.
- [ ] Textos/labels em PTBR.
- [ ] Eventos de UI com feedback (toast) e rollback em erro.
- [ ] Documentação atualizada se houver mudanças em fluxo/env.

6
package-lock.json generated
View file

@ -1,6 +0,0 @@
{
"name": "sistema-de-chamados",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View file

@ -2,7 +2,29 @@ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-
## Getting Started
First, run the development server:
First, set up Convex (backend + database):
1. Install dependencies:
```bash
npm i
```
2. Start Convex dev in a separate terminal (will create the `convex/` folder and URLs):
```bash
npx convex dev
```
Copy the value for the deployment URL and set it in an env var named `NEXT_PUBLIC_CONVEX_URL`.
Create a `.env.local` file in `web/` with:
```
NEXT_PUBLIC_CONVEX_URL=<your-convex-dev-url>
```
Then, run the Next.js development server:
```bash
npm run dev

51
web/convex/_generated/api.d.ts vendored Normal file
View file

@ -0,0 +1,51 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as bootstrap from "../bootstrap.js";
import type * as files from "../files.js";
import type * as queues from "../queues.js";
import type * as seed from "../seed.js";
import type * as tickets from "../tickets.js";
import type * as users from "../users.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
declare const fullApi: ApiFromModules<{
bootstrap: typeof bootstrap;
files: typeof files;
queues: typeof queues;
seed: typeof seed;
tickets: typeof tickets;
users: typeof users;
}>;
declare const fullApiWithMounts: typeof fullApi;
export declare const api: FilterApi<
typeof fullApiWithMounts,
FunctionReference<any, "public">
>;
export declare const internal: FilterApi<
typeof fullApiWithMounts,
FunctionReference<any, "internal">
>;
export declare const components: {};

View file

@ -0,0 +1,23 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();

60
web/convex/_generated/dataModel.d.ts vendored Normal file
View file

@ -0,0 +1,60 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

149
web/convex/_generated/server.d.ts vendored Normal file
View file

@ -0,0 +1,149 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
AnyComponents,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
FunctionReference,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
type GenericCtx =
| GenericActionCtx<DataModel>
| GenericMutationCtx<DataModel>
| GenericQueryCtx<DataModel>;
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* This function will be used to respond to HTTP requests received by a Convex
* deployment if the requests matches the path and method where this action
* is routed. Be sure to route your action in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View file

@ -0,0 +1,90 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
componentsGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define a Convex HTTP action.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
* as its second.
* @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
*/
export const httpAction = httpActionGeneric;

23
web/convex/bootstrap.ts Normal file
View file

@ -0,0 +1,23 @@
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const ensureDefaults = mutation({
args: { tenantId: v.string() },
handler: async (ctx, { tenantId }) => {
const existing = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
if (existing.length === 0) {
const queues = [
{ name: "Suporte N1", slug: "suporte-n1" },
{ name: "Suporte N2", slug: "suporte-n2" },
{ name: "Field Services", slug: "field-services" },
];
for (const q of queues) {
await ctx.db.insert("queues", { tenantId, name: q.name, slug: q.slug, teamId: undefined });
}
}
},
});

View file

@ -0,0 +1,9 @@
import { defineApp } from "convex/server";
const app = defineApp();
// You can install Convex Components here in the future, e.g. rate limiter, workflows, etc.
// app.use(componentConfig)
export default app;

18
web/convex/files.ts Normal file
View file

@ -0,0 +1,18 @@
import { action, query } from "./_generated/server";
import { v } from "convex/values";
export const generateUploadUrl = action({
args: {},
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
},
});
export const getUrl = query({
args: { storageId: v.id("_storage") },
handler: async (ctx, { storageId }) => {
const url = await ctx.storage.getUrl(storageId);
return url;
},
});

24
web/convex/queues.ts Normal file
View file

@ -0,0 +1,24 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
export const summary = query({
args: { tenantId: v.string() },
handler: async (ctx, { tenantId }) => {
const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect();
// Compute counts per queue
const result = await Promise.all(
queues.map(async (qItem) => {
const pending = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", qItem._id))
.collect();
const waiting = pending.filter((t) => t.status === "PENDING" || t.status === "ON_HOLD").length;
const open = pending.filter((t) => t.status !== "RESOLVED" && t.status !== "CLOSED").length;
const breached = 0; // Placeholder, SLAs later
return { id: qItem._id, name: qItem.name, pending: open, waiting, breached };
})
);
return result;
},
});

90
web/convex/schema.ts Normal file
View file

@ -0,0 +1,90 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
tenantId: v.string(),
name: v.string(),
email: v.string(),
role: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
})
.index("by_tenant_email", ["tenantId", "email"])
.index("by_tenant_role", ["tenantId", "role"]),
queues: defineTable({
tenantId: v.string(),
name: v.string(),
slug: v.string(),
teamId: v.optional(v.id("teams")),
})
.index("by_tenant_slug", ["tenantId", "slug"])
.index("by_tenant", ["tenantId"]),
teams: defineTable({
tenantId: v.string(),
name: v.string(),
description: v.optional(v.string()),
}).index("by_tenant_name", ["tenantId", "name"]),
slaPolicies: defineTable({
tenantId: v.string(),
name: v.string(),
description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()), // minutes
timeToResolution: v.optional(v.number()), // minutes
}).index("by_tenant_name", ["tenantId", "name"]),
tickets: defineTable({
tenantId: v.string(),
reference: v.number(),
subject: v.string(),
summary: v.optional(v.string()),
status: v.string(),
priority: v.string(),
channel: v.string(),
queueId: v.optional(v.id("queues")),
requesterId: v.id("users"),
assigneeId: v.optional(v.id("users")),
slaPolicyId: v.optional(v.id("slaPolicies")),
dueAt: v.optional(v.number()), // ms since epoch
firstResponseAt: v.optional(v.number()),
resolvedAt: v.optional(v.number()),
closedAt: v.optional(v.number()),
updatedAt: v.number(),
createdAt: v.number(),
tags: v.optional(v.array(v.string())),
})
.index("by_tenant_status", ["tenantId", "status"])
.index("by_tenant_queue", ["tenantId", "queueId"])
.index("by_tenant_assignee", ["tenantId", "assigneeId"])
.index("by_tenant_reference", ["tenantId", "reference"]),
ticketComments: defineTable({
ticketId: v.id("tickets"),
authorId: v.id("users"),
visibility: v.string(), // PUBLIC | INTERNAL
body: v.string(),
attachments: v.optional(
v.array(
v.object({
storageId: v.id("_storage"),
name: v.string(),
size: v.optional(v.number()),
type: v.optional(v.string()),
})
)
),
createdAt: v.number(),
updatedAt: v.number(),
}).index("by_ticket", ["ticketId"]),
ticketEvents: defineTable({
ticketId: v.id("tickets"),
type: v.string(),
payload: v.optional(v.any()),
createdAt: v.number(),
}).index("by_ticket", ["ticketId"]),
});

81
web/convex/seed.ts Normal file
View file

@ -0,0 +1,81 @@
import { mutation } from "./_generated/server";
export const seedDemo = mutation({
args: {},
handler: async (ctx) => {
const tenantId = "tenant-atlas";
// Ensure queues
const existingQueues = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
const queues = existingQueues.length
? existingQueues
: await Promise.all(
[
{ name: "Suporte N1", slug: "suporte-n1" },
{ name: "Suporte N2", slug: "suporte-n2" },
].map((q) => ctx.db.insert("queues", { tenantId, name: q.name, slug: q.slug, teamId: undefined }))
).then((ids) => Promise.all(ids.map((id) => ctx.db.get(id))))
;
// Ensure users
async function ensureUser(name: string, email: string, role = "AGENT") {
const found = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
.first();
if (found) return found._id;
return await ctx.db.insert("users", { tenantId, name, email, role, avatarUrl: `https://avatar.vercel.sh/${name.split(" ")[0]}` });
}
const anaId = await ensureUser("Ana Souza", "ana.souza@example.com");
const brunoId = await ensureUser("Bruno Lima", "bruno.lima@example.com");
const eduardaId = await ensureUser("Eduarda Rocha", "eduarda.rocha@example.com", "CUSTOMER");
// Seed a couple of tickets
const now = Date.now();
const newestRef = await ctx.db
.query("tickets")
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", tenantId))
.order("desc")
.take(1);
let ref = newestRef[0]?.reference ?? 41000;
const queue1 = queues[0]!._id;
const queue2 = queues[1]!._id;
const t1 = await ctx.db.insert("tickets", {
tenantId,
reference: ++ref,
subject: "Erro 500 ao acessar portal do cliente",
summary: "Clientes relatam erro intermitente no portal web",
status: "OPEN",
priority: "URGENT",
channel: "EMAIL",
queueId: queue1,
requesterId: eduardaId,
assigneeId: anaId,
createdAt: now - 1000 * 60 * 60 * 5,
updatedAt: now - 1000 * 60 * 10,
tags: ["portal", "cliente"],
});
await ctx.db.insert("ticketEvents", { ticketId: t1, type: "CREATED", createdAt: now - 1000 * 60 * 60 * 5, payload: {} });
const t2 = await ctx.db.insert("tickets", {
tenantId,
reference: ++ref,
subject: "Integração ERP parada",
summary: "Webhook do ERP retornando timeout",
status: "PENDING",
priority: "HIGH",
channel: "WHATSAPP",
queueId: queue2,
requesterId: eduardaId,
assigneeId: brunoId,
createdAt: now - 1000 * 60 * 60 * 8,
updatedAt: now - 1000 * 60 * 30,
tags: ["Integração", "erp"],
});
await ctx.db.insert("ticketEvents", { ticketId: t2, type: "CREATED", createdAt: now - 1000 * 60 * 60 * 8, payload: {} });
},
});

368
web/convex/tickets.ts Normal file
View file

@ -0,0 +1,368 @@
import { internalMutation, mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { Id } from "./_generated/dataModel";
const STATUS_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
export const list = query({
args: {
tenantId: v.string(),
status: v.optional(v.string()),
priority: v.optional(v.string()),
channel: v.optional(v.string()),
queueId: v.optional(v.id("queues")),
search: v.optional(v.string()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
let q = ctx.db
.query("tickets")
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId));
const all = await q.collect();
let filtered = all;
if (args.status) filtered = filtered.filter((t) => t.status === args.status);
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
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) {
const term = args.search.toLowerCase();
filtered = filtered.filter(
(t) =>
t.subject.toLowerCase().includes(term) ||
t.summary?.toLowerCase().includes(term) ||
`#${t.reference}`.toLowerCase().includes(term)
);
}
const limited = args.limit ? filtered.slice(0, args.limit) : filtered;
// hydrate requester and assignee
const result = await Promise.all(
limited.map(async (t) => {
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,
};
})
);
// sort by updatedAt desc
return result.sort((a, b) => (b.updatedAt as any) - (a.updatedAt as any));
},
});
export const getById = query({
args: { tenantId: v.string(), id: v.id("tickets") },
handler: async (ctx, { tenantId, id }) => {
const t = await ctx.db.get(id);
if (!t || t.tenantId !== tenantId) 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;
const comments = await ctx.db
.query("ticketComments")
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
.collect();
const timeline = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
.collect();
const commentsHydrated = await Promise.all(
comments.map(async (c) => {
const author = await ctx.db.get(c.authorId);
const attachments = await Promise.all(
(c.attachments ?? []).map(async (att) => ({
id: att.storageId,
name: att.name,
size: att.size,
url: await ctx.storage.getUrl(att.storageId),
}))
);
return {
id: c._id,
author: {
id: author!._id,
name: author!.name,
email: author!.email,
avatarUrl: author!.avatarUrl,
teams: author!.teams ?? [],
},
visibility: c.visibility,
body: c.body,
attachments,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
};
})
);
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,
description: undefined,
customFields: {},
timeline: timeline.map((ev) => ({
id: ev._id,
type: ev.type,
payload: ev.payload,
createdAt: ev.createdAt,
})),
comments: commentsHydrated,
};
},
});
export const create = mutation({
args: {
tenantId: v.string(),
subject: v.string(),
summary: v.optional(v.string()),
priority: v.string(),
channel: v.string(),
queueId: v.optional(v.id("queues")),
requesterId: v.id("users"),
},
handler: async (ctx, args) => {
// compute next reference (simple monotonic counter per tenant)
const existing = await ctx.db
.query("tickets")
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", args.tenantId))
.order("desc")
.take(1);
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
const now = Date.now();
const id = await ctx.db.insert("tickets", {
tenantId: args.tenantId,
reference: nextRef,
subject: args.subject,
summary: args.summary,
status: "NEW",
priority: args.priority,
channel: args.channel,
queueId: args.queueId,
requesterId: args.requesterId,
assigneeId: undefined,
createdAt: now,
updatedAt: now,
firstResponseAt: undefined,
resolvedAt: undefined,
closedAt: undefined,
tags: [],
slaPolicyId: undefined,
dueAt: undefined,
});
await ctx.db.insert("ticketEvents", {
ticketId: id,
type: "CREATED",
payload: { requesterId: args.requesterId },
createdAt: now,
});
return id;
},
});
export const addComment = mutation({
args: {
ticketId: v.id("tickets"),
authorId: v.id("users"),
visibility: v.string(),
body: v.string(),
attachments: v.optional(
v.array(
v.object({
storageId: v.id("_storage"),
name: v.string(),
size: v.optional(v.number()),
type: v.optional(v.string()),
})
)
),
},
handler: async (ctx, args) => {
const now = Date.now();
const id = await ctx.db.insert("ticketComments", {
ticketId: args.ticketId,
authorId: args.authorId,
visibility: args.visibility,
body: args.body,
attachments: args.attachments ?? [],
createdAt: now,
updatedAt: now,
});
await ctx.db.insert("ticketEvents", {
ticketId: args.ticketId,
type: "COMMENT_ADDED",
payload: { authorId: args.authorId },
createdAt: now,
});
// bump ticket updatedAt
await ctx.db.patch(args.ticketId, { updatedAt: now });
return id;
},
});
export const updateStatus = mutation({
args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, status, actorId }) => {
const now = Date.now();
await ctx.db.patch(ticketId, { status, updatedAt: now });
await ctx.db.insert("ticketEvents", {
ticketId,
type: "STATUS_CHANGED",
payload: { to: status, actorId },
createdAt: now,
});
},
});
export const playNext = mutation({
args: {
tenantId: v.string(),
queueId: v.optional(v.id("queues")),
agentId: v.id("users"),
},
handler: async (ctx, { tenantId, queueId, agentId }) => {
// Find eligible tickets: not resolved/closed and not assigned
let candidates = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId ?? undefined as any))
.collect();
candidates = candidates.filter(
(t) => t.status !== "RESOLVED" && t.status !== "CLOSED" && !t.assigneeId
);
if (candidates.length === 0) return null;
// prioritize by priority then createdAt
candidates.sort((a, b) => {
const pa = STATUS_ORDER.indexOf(a.priority as any);
const pb = STATUS_ORDER.indexOf(b.priority as any);
if (pa !== pb) return pa - pb;
return a.createdAt - b.createdAt;
});
const chosen = candidates[0];
const now = Date.now();
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now });
await ctx.db.insert("ticketEvents", {
ticketId: chosen._id,
type: "ASSIGNEE_CHANGED",
payload: { assigneeId: agentId },
createdAt: now,
});
return await getPublicById(ctx, chosen._id);
},
});
// 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,
};
};

42
web/convex/users.ts Normal file
View file

@ -0,0 +1,42 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
export const ensureUser = mutation({
args: {
tenantId: v.string(),
email: v.string(),
name: v.string(),
avatarUrl: v.optional(v.string()),
role: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", args.tenantId).eq("email", args.email))
.first();
if (existing) return existing;
const now = Date.now();
const id = await ctx.db.insert("users", {
tenantId: args.tenantId,
email: args.email,
name: args.name,
avatarUrl: args.avatarUrl,
role: args.role ?? "AGENT",
teams: args.teams ?? [],
});
return await ctx.db.get(id);
},
});
export const listAgents = query({
args: { tenantId: v.string() },
handler: async (ctx, { tenantId }) => {
const agents = await ctx.db
.query("users")
.withIndex("by_tenant_role", (q) => q.eq("tenantId", tenantId).eq("role", "AGENT"))
.collect();
return agents;
},
});

18
web/middleware.ts Normal file
View file

@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
export function middleware(req: NextRequest) {
const url = req.nextUrl.clone();
const isPublic = url.pathname.startsWith("/login") || url.pathname.startsWith("/_next") || url.pathname.startsWith("/api") || url.pathname.startsWith("/favicon");
if (isPublic) return NextResponse.next();
const cookie = req.cookies.get("demoUser")?.value;
if (!cookie) {
const redirect = NextResponse.redirect(new URL("/login", req.url));
return redirect;
}
return NextResponse.next();
}
export const config = {
matcher: ["/:path*"],
};

8182
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"prisma:generate": "prisma generate"
"prisma:generate": "prisma generate",
"convex:dev": "convex dev"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -19,6 +20,7 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
@ -31,6 +33,7 @@
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.27.3",
"date-fns": "^4.1.0",
"lucide-react": "^0.544.0",
"next": "15.5.3",

5877
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode, useMemo } from "react";
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
const client = convexUrl ? new ConvexReactClient(convexUrl) : undefined;
export function ConvexClientProvider({ children }: { children: ReactNode }) {
if (!convexUrl) {
return <>{children}</>;
}
return <ConvexProvider client={client!}>{children}</ConvexProvider>;
}

View file

@ -0,0 +1,30 @@
"use client";
import { useState } from "react";
import { useMutation } from "convex/react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../../convex/_generated/api";
export default function SeedPage() {
const seed = useMutation(api.seed.seedDemo);
const [done, setDone] = useState(false);
return (
<div className="flex min-h-dvh items-center justify-center p-6">
<div className="space-y-4 rounded-xl border bg-card p-6 text-center">
<h1 className="text-lg font-semibold">Popular dados de demonstração</h1>
<button
className="inline-flex items-center justify-center rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground"
onClick={async () => {
await seed({});
setDone(true);
}}
>
Executar seed
</button>
{done && <p className="text-sm text-green-600">Ok! Abra a página de Tickets.</p>}
</div>
</div>
);
}

View file

@ -1,6 +1,10 @@
import type { Metadata } from "next"
import { Inter, JetBrains_Mono } from "next/font/google"
import "./globals.css"
import { cookies } from "next/headers"
import { ConvexClientProvider } from "./ConvexClientProvider"
import { AuthProvider } from "@/lib/auth-client"
import { Toaster } from "@/components/ui/sonner"
const inter = Inter({
subsets: ["latin"],
@ -19,17 +23,29 @@ export const metadata: Metadata = {
description: "Plataforma omnichannel de gestão de chamados",
}
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
const cookieStore = await cookies()
const cookie = cookieStore.get("demoUser")?.value
let demoUser: { name: string; email: string } | null = null
try {
demoUser = cookie ? (JSON.parse(cookie) as { name: string; email: string }) : null
} catch {}
const tenantId = "tenant-atlas"
return (
<html lang="pt-BR" className="h-full">
<body
className={`${inter.variable} ${jetBrainsMono.variable} min-h-screen bg-background text-foreground antialiased`}
>
<ConvexClientProvider>
<AuthProvider demoUser={demoUser} tenantId={tenantId}>
{children}
<Toaster position="top-right" richColors />
</AuthProvider>
</ConvexClientProvider>
</body>
</html>
)

View file

@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const router = useRouter();
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name || !email) return;
document.cookie = `demoUser=${JSON.stringify({ name, email })}; path=/; max-age=${60 * 60 * 24 * 365}`;
router.replace("/dashboard");
};
return (
<div className="flex min-h-dvh items-center justify-center p-6">
<form onSubmit={submit} className="w-full max-w-sm space-y-4 rounded-xl border bg-card p-6 shadow-sm">
<h1 className="text-lg font-semibold">Entrar (placeholder)</h1>
<div className="space-y-2">
<label className="text-sm">Nome</label>
<input className="w-full rounded-md border bg-background px-3 py-2" placeholder="Ana Souza" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<label className="text-sm">E-mail</label>
<input className="w-full rounded-md border bg-background px-3 py-2" placeholder="ana@example.com" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<button type="submit" className="inline-flex w-full items-center justify-center rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground">Continuar</button>
<p className="text-center text-xs text-muted-foreground">Apenas para desenvolvimento. Em produção usar Auth.js/Clerk.</p>
</form>
</div>
);
}

View file

@ -1,12 +1,6 @@
import { notFound } from "next/navigation"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { TicketComments } from "@/components/tickets/ticket-comments"
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel"
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header"
import { TicketTimeline } from "@/components/tickets/ticket-timeline"
import { getTicketById } from "@/lib/mocks/tickets"
import { TicketDetailView } from "@/components/tickets/ticket-detail-view"
type TicketDetailPageProps = {
params: Promise<{ id: string }>
@ -14,33 +8,19 @@ type TicketDetailPageProps = {
export default async function TicketDetailPage({ params }: TicketDetailPageProps) {
const { id } = await params
const ticket = getTicketById(id)
if (!ticket) {
notFound()
}
return (
<AppShell
header={
<SiteHeader
title={`Ticket #${ticket.reference}`}
lead={ticket.subject}
title={`Ticket #${id}`}
lead={"Detalhes do ticket"}
secondaryAction={<SiteHeader.SecondaryButton>Compartilhar</SiteHeader.SecondaryButton>}
primaryAction={<SiteHeader.PrimaryButton>Adicionar comentario</SiteHeader.PrimaryButton>}
/>
}
>
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<TicketComments ticket={ticket} />
<TicketTimeline ticket={ticket} />
</div>
<TicketDetailsPanel ticket={ticket} />
</div>
</div>
<TicketDetailView id={id} />
</AppShell>
)
}

View file

@ -0,0 +1,109 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery } from "convex/react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../../convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { useAuth } from "@/lib/auth-client";
export default function NewTicketPage() {
const router = useRouter();
const { userId } = useAuth();
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? [];
const create = useMutation(api.tickets.create);
const ensureDefaults = useMutation(api.bootstrap.ensureDefaults);
const [subject, setSubject] = useState("");
const [summary, setSummary] = useState("");
const [priority, setPriority] = useState("MEDIUM");
const [channel, setChannel] = useState("MANUAL");
const [queueName, setQueueName] = useState<string | null>(null);
const queueOptions = useMemo(() => queues.map((q: any) => q.name), [queues]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!userId) return;
if (queues.length === 0) await ensureDefaults({ tenantId: DEFAULT_TENANT_ID });
// Encontrar a fila pelo nome (simples)
const selQueue = (queues as any[]).find((q: any) => q.name === queueName);
const queueId = selQueue ? selQueue.id : undefined;
const id = await create({
tenantId: DEFAULT_TENANT_ID,
subject,
summary,
priority,
channel,
queueId: queueId as any,
requesterId: userId as any,
});
router.replace(`/tickets/${id}`);
}
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-xl font-semibold">Novo ticket</h1>
<form onSubmit={submit} className="space-y-4 rounded-xl border bg-card p-6">
<div className="space-y-2">
<label className="text-sm">Assunto</label>
<input className="w-full rounded-md border bg-background px-3 py-2" value={subject} onChange={(e) => setSubject(e.target.value)} required />
</div>
<div className="space-y-2">
<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} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm">Prioridade</label>
<select className="w-full rounded-md border bg-background px-3 py-2" value={priority} onChange={(e) => setPriority(e.target.value)}>
{[
["LOW", "Baixa"],
["MEDIUM", "Media"],
["HIGH", "Alta"],
["URGENT", "Urgente"],
].map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm">Canal</label>
<select className="w-full rounded-md border bg-background px-3 py-2" value={channel} onChange={(e) => setChannel(e.target.value)}>
{[
["EMAIL", "E-mail"],
["WHATSAPP", "WhatsApp"],
["CHAT", "Chat"],
["PHONE", "Telefone"],
["API", "API"],
["MANUAL", "Manual"],
].map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<label className="text-sm">Fila</label>
<select className="w-full rounded-md border bg-background px-3 py-2" value={queueName ?? ""} onChange={(e) => setQueueName(e.target.value || null)}>
<option value="">Sem fila</option>
{queueOptions.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</select>
</div>
<div className="flex justify-end">
<button type="submit" className="inline-flex items-center justify-center rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground">Criar</button>
</div>
</form>
</div>
);
}

View file

@ -1,3 +1,4 @@
import Link from "next/link"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
@ -11,7 +12,7 @@ export default function TicketsPage() {
title="Tickets"
lead="Visão consolidada de filas e SLAs"
secondaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>}
primaryAction={<SiteHeader.PrimaryButton>Novo ticket</SiteHeader.PrimaryButton>}
primaryAction={<SiteHeader.PrimaryButton asChild><Link href="/tickets/new">Novo ticket</Link></SiteHeader.PrimaryButton>}
/>
}
>

View file

@ -1,7 +1,14 @@
import Link from "next/link"
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
"use client"
import { playContext } from "@/lib/mocks/tickets"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import type { TicketPlayContext } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@ -14,8 +21,24 @@ interface PlayNextTicketCardProps {
context?: TicketPlayContext
}
export function PlayNextTicketCard({ context = playContext }: PlayNextTicketCardProps) {
if (!context.nextTicket) {
export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
const router = useRouter()
const { userId } = useAuth()
const queueSummary = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
const playNext = useMutation(api.tickets.playNext)
const nextTicketFromServer = useQuery(api.tickets.list, {
tenantId: DEFAULT_TENANT_ID,
status: undefined,
priority: undefined,
channel: undefined,
queueId: undefined,
limit: 1,
})?.[0]
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)
if (!cardContext || !cardContext.nextTicket) {
return (
<Card className="border-dashed">
<CardHeader>
@ -28,7 +51,7 @@ export function PlayNextTicketCard({ context = playContext }: PlayNextTicketCard
)
}
const ticket = context.nextTicket
const ticket = cardContext.nextTicket
return (
<Card className="border-dashed">
@ -52,22 +75,27 @@ export function PlayNextTicketCard({ context = playContext }: PlayNextTicketCard
<div className="flex flex-col gap-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Pendentes na fila</span>
<span className="font-medium text-foreground">{context.queue.pending}</span>
<span className="font-medium text-foreground">{cardContext.queue.pending}</span>
</div>
<div className="flex items-center justify-between">
<span>Em espera</span>
<span className="font-medium text-foreground">{context.queue.waiting}</span>
<span className="font-medium text-foreground">{cardContext.queue.waiting}</span>
</div>
<div className="flex items-center justify-between">
<span>SLA violado</span>
<span className="font-medium text-destructive">{context.queue.breached}</span>
<span className="font-medium text-destructive">{cardContext.queue.breached}</span>
</div>
</div>
<Button asChild className="gap-2">
<Link href={`/tickets/${ticket.id}`}>
<Button
className="gap-2"
onClick={async () => {
if (!userId) return
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: undefined, agentId: userId as any })
if (chosen?.id) router.push(`/tickets/${chosen.id}`)
}}
>
Iniciar atendimento
<IconPlayerPlayFilled className="size-4" />
</Link>
</Button>
<Button variant="ghost" asChild className="gap-2 text-sm">
<Link href="/tickets">

View file

@ -1,17 +1,75 @@
"use client"
import { useMemo, useState } from "react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage } from "@tabler/icons-react"
import { useAction, useMutation } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
interface TicketCommentsProps {
ticket: TicketWithDetails
}
export function TicketComments({ ticket }: TicketCommentsProps) {
const { userId } = useAuth()
const addComment = useMutation(api.tickets.addComment)
const generateUploadUrl = useAction(api.files.generateUploadUrl)
const [body, setBody] = useState("")
const [files, setFiles] = useState<File[]>([])
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id"|"author"|"visibility"|"body"|"attachments"|"createdAt"|"updatedAt">[]>([])
const commentsAll = useMemo(() => {
return [...pending, ...ticket.comments]
}, [pending, ticket.comments])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!userId) return
let attachments: Array<{ storageId: string; name: string; size?: number; type?: string }> = []
if (files.length) {
const url = await generateUploadUrl({})
for (const file of files) {
const form = new FormData()
form.append("file", file)
const res = await fetch(url, { method: "POST", body: form })
const { storageId } = await res.json()
attachments.push({ storageId, name: file.name, size: file.size, type: file.type })
}
}
const now = new Date()
const optimistic = {
id: `temp-${now.getTime()}`,
author: ticket.requester, // placeholder; poderia buscar o próprio usuário se necessário
visibility: "PUBLIC" as const,
body,
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name } as any)),
createdAt: now,
updatedAt: now,
}
setPending((p) => [optimistic, ...p])
setBody("")
setFiles([])
toast.loading("Enviando comentário…", { id: "comment" })
try {
await addComment({ ticketId: ticket.id as any, authorId: userId as any, visibility: "PUBLIC", body: optimistic.body, attachments })
setPending([])
toast.success("Comentário enviado!", { id: "comment" })
} catch (err) {
setPending([])
toast.error("Falha ao enviar comentário.", { id: "comment" })
}
}
return (
<Card className="border-none shadow-none">
<CardHeader className="px-0">
@ -20,12 +78,12 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-0">
{ticket.comments.length === 0 ? (
{commentsAll.length === 0 ? (
<p className="text-sm text-muted-foreground">
Ainda sem comentarios. Que tal registrar o proximo passo?
</p>
) : (
ticket.comments.map((comment) => {
commentsAll.map((comment) => {
const initials = comment.author.name
.split(" ")
.slice(0, 2)
@ -49,14 +107,36 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span>
</div>
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground">
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words whitespace-pre-wrap">
{comment.body}
</div>
{comment.attachments?.length ? (
<div className="flex flex-wrap gap-2">
{comment.attachments.map((a) => (
<a key={(a as any).id} href={(a as any).url} target="_blank" className="text-xs underline">
{(a as any).name}
</a>
))}
</div>
) : null}
</div>
</div>
)
})
)}
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
<textarea
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)}
/>
<div className="flex items-center justify-between">
<input type="file" multiple onChange={(e) => setFiles(Array.from(e.target.files ?? []))} />
<Button type="submit" size="sm">Enviar</Button>
</div>
</form>
</CardContent>
</Card>
)

View file

@ -0,0 +1,30 @@
"use client";
import { useQuery } from "convex/react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
import { TicketComments } from "@/components/tickets/ticket-comments";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
export function TicketDetailView({ id }: { id: string }) {
const t = useQuery(api.tickets.getById, { tenantId: DEFAULT_TENANT_ID, id: id as any });
if (!t) return <div className="px-4 py-8 text-sm text-muted-foreground">Carregando ticket...</div>;
const ticket = mapTicketWithDetailsFromServer(t as any)
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket as any} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<TicketComments ticket={ticket as any} />
<TicketTimeline ticket={ticket as any} />
</div>
<TicketDetailsPanel ticket={ticket as any} />
</div>
</div>
);
}

View file

@ -17,37 +17,37 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
<CardHeader className="px-0">
<CardTitle className="text-lg font-semibold">Detalhes</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 px-0 text-sm text-muted-foreground">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase">Fila</p>
<Badge variant="outline">{ticket.queue ?? "Sem fila"}</Badge>
<CardContent className="flex flex-col gap-5 px-0 text-sm text-muted-foreground">
<div className="space-y-1 break-words">
<p className="text-xs font-semibold uppercase tracking-wide">Fila</p>
<Badge variant="outline" className="max-w-full truncate">{ticket.queue ?? "Sem fila"}</Badge>
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs font-semibold uppercase">SLA</p>
<div className="space-y-2 break-words">
<p className="text-xs font-semibold uppercase tracking-wide">SLA</p>
{ticket.slaPolicy ? (
<div className="flex flex-col gap-2 rounded-lg border border-dashed bg-card px-3 py-2">
<span className="text-foreground">{ticket.slaPolicy.name}</span>
<span className="text-foreground text-sm font-medium leading-tight">{ticket.slaPolicy.name}</span>
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
{ticket.slaPolicy.targetMinutesToFirstResponse ? (
<span>
<span className="leading-normal">
Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min
</span>
) : null}
{ticket.slaPolicy.targetMinutesToResolution ? (
<span>
Resolucao: {ticket.slaPolicy.targetMinutesToResolution} min
<span className="leading-normal">
Resolução: {ticket.slaPolicy.targetMinutesToResolution} min
</span>
) : null}
</div>
</div>
) : (
<span>Sem politica atribuida.</span>
<span>Sem política atribuída.</span>
)}
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs font-semibold uppercase">Metricas</p>
<p className="text-xs font-semibold uppercase tracking-wide">tricas</p>
{ticket.metrics ? (
<div className="flex flex-col gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-2">
@ -62,8 +62,8 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
)}
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs font-semibold uppercase">Tags</p>
<div className="space-y-2 break-words">
<p className="text-xs font-semibold uppercase tracking-wide">Tags</p>
<div className="flex flex-wrap gap-2">
{ticket.tags?.length ? (
ticket.tags.map((tag) => (
@ -78,7 +78,7 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
</div>
<Separator />
<div className="space-y-1">
<p className="text-xs font-semibold uppercase">Historico</p>
<p className="text-xs font-semibold uppercase tracking-wide">Histórico</p>
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<span>Criado: {format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
<span>Atualizado: {format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>

View file

@ -1,4 +1,10 @@
import { queueSummaries } from "@/lib/mocks/tickets"
"use client"
import { useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
@ -7,10 +13,12 @@ interface TicketQueueSummaryProps {
queues?: TicketQueueSummary[]
}
export function TicketQueueSummaryCards({ queues = queueSummaries }: TicketQueueSummaryProps) {
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
const fromServer = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
const data: TicketQueueSummary[] = (queues ?? fromServer) as any
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{queues.map((queue) => {
{data.map((queue) => {
const total = queue.pending + queue.waiting
const breachPercent = total === 0 ? 0 : Math.round((queue.breached / total) * 100)
return (

View file

@ -1,18 +1,39 @@
"use client"
import { useState } from "react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconUserCircle } from "@tabler/icons-react"
import { useMutation } from "convex/react"
import { toast } from "sonner"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
interface TicketHeaderProps {
ticket: TicketWithDetails
}
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { userId } = useAuth()
const updateStatus = useMutation(api.tickets.updateStatus)
const [status, setStatus] = useState(ticket.status)
const statusPt: Record<string, string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
return (
<div className="space-y-4 rounded-xl border bg-card p-6 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
@ -22,7 +43,32 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
#{ticket.reference}
</Badge>
<TicketPriorityPill priority={ticket.priority} />
<TicketStatusBadge status={ticket.status} />
<TicketStatusBadge status={status as any} />
<Select
value={status}
onValueChange={async (value) => {
const prev = status
setStatus(value) // otimista
if (!userId) return
toast.loading("Atualizando status…", { id: "status" })
try {
await updateStatus({ ticketId: ticket.id as any, status: value as any, actorId: userId as any })
toast.success(`Status alterado para ${statusPt[value]}.`, { id: "status" })
} catch (e) {
setStatus(prev)
toast.error("Não foi possível alterar o status.", { id: "status" })
}
}}
>
<SelectTrigger className="h-8 w-[150px]">
<SelectValue placeholder="Alterar status" />
</SelectTrigger>
<SelectContent>
{(["NEW","OPEN","PENDING","ON_HOLD","RESOLVED","CLOSED"]).map((s) => (
<SelectItem key={s} value={s}>{statusPt[s]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<h1 className="text-2xl font-semibold text-foreground">{ticket.subject}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">{ticket.summary}</p>

View file

@ -1,4 +1,5 @@
import { format } from "date-fns"\nimport type { ComponentType } from "react"
import { format } from "date-fns"
import type { ComponentType } from "react"
import { ptBR } from "date-fns/locale"
import {
IconClockHour4,
@ -19,6 +20,13 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
COMMENT_ADDED: IconNote,
}
const timelineLabels: Record<string, string> = {
CREATED: "Criado",
STATUS_CHANGED: "Status alterado",
ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Comentário adicionado",
}
interface TicketTimelineProps {
ticket: TicketWithDetails
}
@ -41,7 +49,7 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium text-foreground">
{entry.type.replaceAll("_", " ")}
{timelineLabels[entry.type] ?? entry.type}
</span>
<span className="text-xs text-muted-foreground">
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}

View file

@ -3,7 +3,6 @@
import { useMemo, useState } from "react"
import { IconFilter, IconRefresh } from "@tabler/icons-react"
import { tickets } from "@/lib/mocks/tickets"
import {
ticketChannelSchema,
ticketPrioritySchema,
@ -59,7 +58,7 @@ const channelOptions = ticketChannelSchema.options.map((channel) => ({
}[channel],
}))
const queues = Array.from(new Set(tickets.map((ticket) => ticket.queue).filter(Boolean)))
type QueueOption = string
export type TicketFiltersState = {
search: string
@ -79,9 +78,12 @@ export const defaultTicketFilters: TicketFiltersState = {
interface TicketsFiltersProps {
onChange?: (filters: TicketFiltersState) => void
queues?: QueueOption[]
}
export function TicketsFilters({ onChange }: TicketsFiltersProps) {
const ALL_VALUE = "ALL"
export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
function setPartial(partial: Partial<TicketFiltersState>) {
@ -112,14 +114,14 @@ export function TicketsFilters({ onChange }: TicketsFiltersProps) {
className="md:max-w-sm"
/>
<Select
value={filters.queue ?? ""}
onValueChange={(value) => setPartial({ queue: value || null })}
value={filters.queue ?? ALL_VALUE}
onValueChange={(value) => setPartial({ queue: value === ALL_VALUE ? null : value })}
>
<SelectTrigger className="md:w-[180px]">
<SelectValue placeholder="Fila" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Todas as filas</SelectItem>
<SelectItem value={ALL_VALUE}>Todas as filas</SelectItem>
{queues.map((queue) => (
<SelectItem key={queue!} value={queue!}>
{queue}
@ -142,14 +144,14 @@ export function TicketsFilters({ onChange }: TicketsFiltersProps) {
Status
</p>
<Select
value={filters.status ?? ""}
onValueChange={(value) => setPartial({ status: value || null })}
value={filters.status ?? ALL_VALUE}
onValueChange={(value) => setPartial({ status: value === ALL_VALUE ? null : value })}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Todos</SelectItem>
<SelectItem value={ALL_VALUE}>Todos</SelectItem>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
@ -163,14 +165,14 @@ export function TicketsFilters({ onChange }: TicketsFiltersProps) {
Prioridade
</p>
<Select
value={filters.priority ?? ""}
onValueChange={(value) => setPartial({ priority: value || null })}
value={filters.priority ?? ALL_VALUE}
onValueChange={(value) => setPartial({ priority: value === ALL_VALUE ? null : value })}
>
<SelectTrigger>
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Todas</SelectItem>
<SelectItem value={ALL_VALUE}>Todas</SelectItem>
{priorityOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
@ -184,14 +186,14 @@ export function TicketsFilters({ onChange }: TicketsFiltersProps) {
Canal
</p>
<Select
value={filters.channel ?? ""}
onValueChange={(value) => setPartial({ channel: value || null })}
value={filters.channel ?? ALL_VALUE}
onValueChange={(value) => setPartial({ channel: value === ALL_VALUE ? null : value })}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Todos</SelectItem>
<SelectItem value={ALL_VALUE}>Todos</SelectItem>
{channelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}

View file

@ -1,40 +1,39 @@
"use client"
import { useMemo, useState } from "react"
import { tickets } from "@/lib/mocks/tickets"
import { useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
import { TicketsTable } from "@/components/tickets/tickets-table"
function applyFilters(base: typeof tickets, filters: TicketFiltersState) {
return base.filter((ticket) => {
if (filters.status && ticket.status !== filters.status) return false
if (filters.priority && ticket.priority !== filters.priority) return false
if (filters.queue && ticket.queue !== filters.queue) return false
if (filters.channel && ticket.channel !== filters.channel) return false
if (filters.search) {
const term = filters.search.toLowerCase()
const reference = `#${ticket.reference}`.toLowerCase()
if (
!ticket.subject.toLowerCase().includes(term) &&
!reference.includes(term)
) {
return false
}
}
return true
})
}
export function TicketsView() {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
const filteredTickets = useMemo(() => applyFilters(tickets, filters), [filters])
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
const ticketsRaw = useQuery(api.tickets.list, {
tenantId: DEFAULT_TENANT_ID,
status: filters.status ?? undefined,
priority: filters.priority ?? undefined,
channel: filters.channel ?? undefined,
queueId: undefined, // simplified: filter by queue name on client
search: filters.search || undefined,
}) ?? []
const tickets = useMemo(() => mapTicketsFromServerList(ticketsRaw as any[]), [ticketsRaw])
const filteredTickets = useMemo(() => {
if (!filters.queue) return tickets
return tickets.filter((t: any) => t.queue === filters.queue)
}, [tickets, filters.queue])
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketsFilters onChange={setFilters} />
<TicketsTable tickets={filteredTickets} />
<TicketsFilters onChange={setFilters} queues={queues.map((q: any) => q.name)} />
<TicketsTable tickets={filteredTickets as any} />
</div>
)
}

View file

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View file

@ -0,0 +1,50 @@
"use client";
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
import { useMutation } from "convex/react";
// Lazy import to avoid build errors before convex is generated
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../convex/_generated/api";
export type DemoUser = { name: string; email: string; avatarUrl?: string } | null;
type AuthContextValue = {
demoUser: DemoUser;
userId: string | null;
setDemoUser: (u: DemoUser) => void;
};
const AuthContext = createContext<AuthContextValue>({ demoUser: null, userId: null, setDemoUser: () => {} });
export function useAuth() {
return useContext(AuthContext);
}
export function AuthProvider({ demoUser, tenantId, children }: { demoUser: DemoUser; tenantId: string; children: React.ReactNode }) {
const [localDemoUser, setLocalDemoUser] = useState<DemoUser>(demoUser);
const [userId, setUserId] = useState<string | null>(null);
const ensureUser = useMutation(api.users.ensureUser);
useEffect(() => {
async function run() {
if (!process.env.NEXT_PUBLIC_CONVEX_URL) return; // allow dev without backend
if (!localDemoUser) return;
try {
const user = await ensureUser({ tenantId, name: localDemoUser.name, email: localDemoUser.email, avatarUrl: localDemoUser.avatarUrl });
// Convex returns a full document
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setUserId((user as any)?._id ?? null);
} catch (e) {
console.error("Failed to ensure user:", e);
}
}
run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localDemoUser?.email, tenantId]);
const value = useMemo(() => ({ demoUser: localDemoUser, setDemoUser: setLocalDemoUser, userId }), [localDemoUser, userId]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

2
web/src/lib/constants.ts Normal file
View file

@ -0,0 +1,2 @@
export const DEFAULT_TENANT_ID = "tenant-atlas";

View file

@ -0,0 +1,103 @@
import { z } from "zod";
import {
ticketSchema,
ticketWithDetailsSchema,
ticketEventSchema,
ticketCommentSchema,
userSummarySchema,
} from "@/lib/schemas/ticket";
// Server shapes: datas como number (epoch ms) e alguns nullables
const serverUserSchema = userSummarySchema;
const serverTicketSchema = z.object({
id: z.string(),
reference: z.number(),
tenantId: z.string(),
subject: z.string(),
summary: z.string().optional().nullable(),
status: z.string(),
priority: z.string(),
channel: z.string(),
queue: z.string().nullable(),
requester: serverUserSchema,
assignee: serverUserSchema.nullable(),
slaPolicy: z.any().nullable().optional(),
dueAt: z.number().nullable().optional(),
firstResponseAt: z.number().nullable().optional(),
resolvedAt: z.number().nullable().optional(),
updatedAt: z.number(),
createdAt: z.number(),
tags: z.array(z.string()).default([]).optional(),
lastTimelineEntry: z.string().nullable().optional(),
metrics: z.any().nullable().optional(),
});
const serverAttachmentSchema = z.object({
id: z.any(),
name: z.string(),
size: z.number().optional(),
url: z.string().url().optional(),
});
const serverCommentSchema = z.object({
id: z.string(),
author: serverUserSchema,
visibility: z.string(),
body: z.string(),
attachments: z.array(serverAttachmentSchema).default([]).optional(),
createdAt: z.number(),
updatedAt: z.number(),
});
const serverEventSchema = z.object({
id: z.string(),
type: z.string(),
payload: z.any().optional(),
createdAt: z.number(),
});
const serverTicketWithDetailsSchema = serverTicketSchema.extend({
description: z.string().optional().nullable(),
customFields: z.record(z.any()).default({}).optional(),
timeline: z.array(serverEventSchema),
comments: z.array(serverCommentSchema),
});
export function mapTicketFromServer(input: unknown) {
const s = serverTicketSchema.parse(input);
const ui = {
...s,
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
updatedAt: new Date(s.updatedAt),
createdAt: new Date(s.createdAt),
dueAt: s.dueAt ? new Date(s.dueAt) : null,
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
};
return ticketSchema.parse(ui);
}
export function mapTicketsFromServerList(arr: unknown[]) {
return arr.map(mapTicketFromServer);
}
export function mapTicketWithDetailsFromServer(input: unknown) {
const s = serverTicketWithDetailsSchema.parse(input);
const ui = {
...s,
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
updatedAt: new Date(s.updatedAt),
createdAt: new Date(s.createdAt),
dueAt: s.dueAt ? new Date(s.dueAt) : null,
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
timeline: s.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })),
comments: s.comments.map((c) => ({
...c,
createdAt: new Date(c.createdAt),
updatedAt: new Date(c.updatedAt),
})),
};
return ticketWithDetailsSchema.parse(ui);
}