feat: núcleo de tickets com Convex (CRUD, play, comentários com anexos) + auth placeholder; docs em AGENTS.md; toasts e updates otimistas; mapeadores Zod; refinos PT-BR e layout do painel de detalhes
This commit is contained in:
parent
2230590e57
commit
27b103cb46
97 changed files with 15117 additions and 15715 deletions
76
agents.md
76
agents.md
|
|
@ -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 PT‑BR (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 PT‑BR.
|
||||
- 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 PT‑BR.
|
||||
- [ ] 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
6
package-lock.json
generated
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "sistema-de-chamados",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
|
|
@ -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
51
web/convex/_generated/api.d.ts
vendored
Normal 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: {};
|
||||
23
web/convex/_generated/api.js
Normal file
23
web/convex/_generated/api.js
Normal 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
60
web/convex/_generated/dataModel.d.ts
vendored
Normal 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
149
web/convex/_generated/server.d.ts
vendored
Normal 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>;
|
||||
90
web/convex/_generated/server.js
Normal file
90
web/convex/_generated/server.js
Normal 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
23
web/convex/bootstrap.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
9
web/convex/convex.config.ts
Normal file
9
web/convex/convex.config.ts
Normal 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
18
web/convex/files.ts
Normal 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
24
web/convex/queues.ts
Normal 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
90
web/convex/schema.ts
Normal 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
81
web/convex/seed.ts
Normal 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
368
web/convex/tickets.ts
Normal 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
42
web/convex/users.ts
Normal 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
18
web/middleware.ts
Normal 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
8182
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
5877
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
16
web/src/app/ConvexClientProvider.tsx
Normal file
16
web/src/app/ConvexClientProvider.tsx
Normal 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>;
|
||||
}
|
||||
|
||||
30
web/src/app/dev/seed/page.tsx
Normal file
30
web/src/app/dev/seed/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
36
web/src/app/login/page.tsx
Normal file
36
web/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
109
web/src/app/tickets/new/page.tsx
Normal file
109
web/src/app/tickets/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
30
web/src/components/tickets/ticket-detail-view.tsx
Normal file
30
web/src/components/tickets/ticket-detail-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">Mé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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
32
web/src/components/ui/popover.tsx
Normal file
32
web/src/components/ui/popover.tsx
Normal 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 }
|
||||
|
||||
50
web/src/lib/auth-client.tsx
Normal file
50
web/src/lib/auth-client.tsx
Normal 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
2
web/src/lib/constants.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const DEFAULT_TENANT_ID = "tenant-atlas";
|
||||
|
||||
103
web/src/lib/mappers/ticket.ts
Normal file
103
web/src/lib/mappers/ticket.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue