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

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

18
.gitignore vendored
View file

@ -1,9 +1,9 @@
# Root ignore for monorepo # Root ignore for monorepo
web/node_modules/ web/node_modules/
web/.next/ web/.next/
web/.turbo/ web/.turbo/
web/out/ web/out/
web/.env.local web/.env.local
web/.env* web/.env*
.DS_Store .DS_Store
Thumbs.db Thumbs.db

174
agents.md
View file

@ -1,60 +1,126 @@
# Plano de Desenvolvimento - Sistema de Chamados # Plano de Desenvolvimento - Sistema de Chamados
## Meta imediata ## Meta imediata
Construir o nucleo de tickets compartilhado entre web e desktop (Tauri), garantindo base solida para canais, SLAs e automacoes futuras. Construir o nucleo de tickets compartilhado entre web e desktop (Tauri), garantindo base solida para canais, SLAs e automacoes futuras.
## Fase A - Fundamentos da plataforma ## Fase A - Fundamentos da plataforma
1. **Scaffold e DX** 1. **Scaffold e DX**
- Criar projeto Next.js (App Router) com Typescript, ESLint, Tailwind, shadcn/ui. - Criar projeto Next.js (App Router) com Typescript, ESLint, Tailwind, shadcn/ui.
- Configurar alias de paths, lint/prettier opinativo. - Configurar alias de paths, lint/prettier opinativo.
- Ajustar `globals.css` para tokens de cor/tipografia conforme layout base. - Ajustar `globals.css` para tokens de cor/tipografia conforme layout base.
2. **Design system inicial** 2. **Design system inicial**
- Importar componentes `dashboard-01` e `sidebar-01` via shadcn. - Importar componentes `dashboard-01` e `sidebar-01` via shadcn.
- Ajustar paleta (tons de cinza + destaque primario) e tipografia (Inter/Manrope). - Ajustar paleta (tons de cinza + destaque primario) e tipografia (Inter/Manrope).
- Implementar layout shell (sidebar + header) reutilizavel. - Implementar layout shell (sidebar + header) reutilizavel.
3. **Autenticacao placeholder** 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 ### Status da fase
- OK Scaffold Next.js + Tailwind + shadcn/ui criado em `web/`. - OK Scaffold Next.js + Tailwind + shadcn/ui criado em `web/`.
- OK Layout base atualizado (sidebar, header, cards, grafico) com identidade da aplicacao. - 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 ## Fase B - Nucleo de tickets
1. **Modelagem compartilhada** 1. **Modelagem compartilhada**
- Definir esquema Prisma para Ticket, TicketEvent, User (minimo), Queue/View. - Definir esquema Prisma para Ticket, TicketEvent, User (minimo), Queue/View.
- Publicar Zod schemas/Types para uso no frontend. - Publicar Zod schemas/Types para uso no frontend.
2. **Fluxo principal** 2. **Fluxo principal**
- Pagina `tickets` com tabela (TanStack) suportando filtros basicos. - Pagina `tickets` com tabela (TanStack) suportando filtros basicos.
- Pagina de ticket com timeline de eventos/comentarios (dados mockados). - Pagina de ticket com timeline de eventos/comentarios (dados mockados).
- Implementar modo play preliminar (simula proxima tarefa da fila). - Implementar modo play preliminar (simula proxima tarefa da fila).
3. **Mutations** 3. **Mutations**
- Formulario de criacao/edicao com validacao. - Formulario de criacao/edicao com validacao.
- Comentarios publico/privado (UX + componentes). - Comentarios publico/privado (UX + componentes).
### Status parcial ### Status parcial
- OK `prisma/schema.prisma` criado com entidades centrais (User, Team, Ticket, Comment, Event, SLA). - 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 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). - 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) ## Fase C - Servicos complementares (posterior)
- SLAs (BullMQ + Redis), notificacoes, ingest de e-mail, portal cliente, etc. - SLAs (BullMQ + Redis), notificacoes, ingest de e-mail, portal cliente, etc.
## Backlog imediato ## Backlog imediato
- [x] Scaffold Next.js + Tailwind + shadcn/ui. - [x] Scaffold Next.js + Tailwind + shadcn/ui.
- [x] Ajustar layout shell (dashboard + sidebar) com tema solicitado. - [x] Ajustar layout shell (dashboard + sidebar) com tema solicitado.
- [x] Criar modulos base de dominio (schemas Prisma/Zod) ainda com dados mockados. - [x] Criar modulos base de dominio (schemas Prisma/Zod) ainda com dados mockados.
- [x] Preparar estrutura de paginas: `/tickets`, `/tickets/[id]`, `/play`. - [x] Preparar estrutura de paginas: `/tickets`, `/tickets/[id]`, `/play`.
- [ ] Implementar Auth placeholder (Auth.js mock) para rotas protegidas. - [x] Implementar Auth placeholder (cookie + middleware).
- [ ] Conectar APIs/mutations reais (server actions/trpc) e sincronizar com Prisma. - [x] Conectar APIs/mutations reais (Convex) e sincronizar tipos no frontend.
## Proximas entregas sugeridas ## Proximas entregas sugeridas
1. Finalizar Auth placeholder e guardas de rota (Auth.js + middleware). 1. Finalizar Auth placeholder e guardas de rota (Auth.js + middleware).
2. Implementar camada de dados real (Prisma Client + server actions) para tickets. 2. Implementar camada de dados real (Prisma Client + server actions) para tickets.
3. Adicionar formularios de criacao/edicao de ticket com validacao (React Hook Form + Zod). 3. Adicionar formularios de criacao/edicao de ticket com validacao (React Hook Form + Zod).
4. Conectar timeline/comentarios a mutations otimizadas (UI otimista + websockets futuro). 4. Conectar timeline/comentarios a mutations otimizadas (UI otimista + websockets futuro).
5. Preparar testes basicos (unit + e2e mockados) e pipeline de CI inicial. 5. Preparar testes basicos (unit + e2e mockados) e pipeline de CI inicial.
## Acompanhamento ## Acompanhamento
Atualizar este arquivo a cada marco relevante (setup concluido, nucleo funcional, etc.). Atualizar este arquivo a cada marco relevante (setup concluido, nucleo funcional, etc.).
---
# Guia do Projeto (para agentes e contribuidores)
Este repositório foi atualizado para usar Convex como backend em tempo real para o núcleo de tickets. Abaixo, um guia prático conforme o padrão de AGENTS.md para orientar contribuições futuras.
## Decisões técnicas atuais
- Backend: Convex (funções + banco + storage) em `web/convex/`.
- Esquema: `web/convex/schema.ts`.
- Tickets API: `web/convex/tickets.ts` (list/getById/create/addComment/updateStatus/playNext).
- Upload de arquivos: `web/convex/files.ts` (Convex Storage).
- Filas: `web/convex/queues.ts` (resumo por fila).
- Seed/bootstrap: `web/convex/seed.ts`, `web/convex/bootstrap.ts`.
- Auth placeholder: cookie + middleware
- Login: `web/src/app/login/page.tsx`
- Middleware: `web/middleware.ts`
- Provider: `web/src/lib/auth-client.tsx` (garante usuário no Convex)
- Frontend (Next.js + shadcn/ui)
- Páginas principais: `/tickets`, `/tickets/[id]`, `/tickets/new`, `/play`.
- UI ligada ao Convex com `convex/react`.
- Toasts: `sonner` via `Toaster` em `web/src/app/layout.tsx`.
- Mapeamento/validação de dados
- Convex retorna datas como `number` (epoch). A UI usa `Date`.
- Sempre converter/validar via Zod em `web/src/lib/mappers/ticket.ts`.
- Não retornar `Date` a partir de funções do Convex.
- Prisma: mantido apenas como referência de domínio (não é fonte de dados ativa).
## Como rodar
- Prérequisitos: Node LTS + pnpm.
- Passos:
- `cd web && pnpm i`
- `pnpm convex:dev` (mantém gerando tipos e rodando backend dev)
- Criar `.env.local` com `NEXT_PUBLIC_CONVEX_URL=<url exibida pelo convex dev>`
- Em outro terminal: `pnpm dev`
- Login em `/login`; seed opcional em `/dev/seed`.
## Convenções de código
- Não use `Date` em payloads do Convex; use `number` (epoch ms).
- Normalize dados no front via mappers Zod antes de renderizar.
- UI com shadcn/ui; priorize componentes existentes e consistência visual.
- Labels e mensagens em PTBR (status, timeline, toasts, etc.).
- Atualizações otimistas com rollback em erro + toasts de feedback.
## Estrutura útil
- `web/convex/*` — API backend Convex.
- `web/src/lib/mappers/*` — Conversores server→UI com Zod.
- `web/src/components/tickets/*` — Tabela, filtros, detalhe, timeline, comentários, play.
## Scripts (pnpm)
- `pnpm convex:dev` — Convex (dev + geração de tipos)
- `pnpm dev` — Next.js (App Router)
- `pnpm build` / `pnpm start` — build/produção
## Backlog imediato (próximos passos)
- Form “Novo ticket” em Dialog shadcn + React Hook Form + Zod + toasts.
- Atribuição/transferência de fila no detalhe (selects com update otimista).
- Melhorias de layout adicionais no painel “Detalhes” (quebras, largura responsiva) e unificação de textos PTBR.
- Testes unitários dos mapeadores com Vitest.
## Checklist de PRs
- [ ] Funções Convex retornam apenas tipos suportados (sem `Date`).
- [ ] Dados validados/convertidos via Zod mappers antes da UI.
- [ ] Textos/labels em PTBR.
- [ ] Eventos de UI com feedback (toast) e rollback em erro.
- [ ] Documentação atualizada se houver mudanças em fluxo/env.

File diff suppressed because it is too large Load diff

6
package-lock.json generated
View file

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

82
web/.gitignore vendored
View file

@ -1,41 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
/node_modules /node_modules
/.pnp /.pnp
.pnp.* .pnp.*
.yarn/* .yarn/*
!.yarn/patches !.yarn/patches
!.yarn/plugins !.yarn/plugins
!.yarn/releases !.yarn/releases
!.yarn/versions !.yarn/versions
# testing # testing
/coverage /coverage
# next.js # next.js
/.next/ /.next/
/out/ /out/
# production # production
/build /build
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
# debug # debug
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
# vercel # vercel
.vercel .vercel
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts

View file

@ -1,36 +1,58 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, set up Convex (backend + database):
## Getting Started 1. Install dependencies:
First, run the development server:
```bash ```bash
npm run dev npm i
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 2. Start Convex dev in a separate terminal (will create the `convex/` folder and URLs):
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ```bash
npx convex dev
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. Copy the value for the deployment URL and set it in an env var named `NEXT_PUBLIC_CONVEX_URL`.
## Learn More Create a `.env.local` file in `web/` with:
To learn more about Next.js, take a look at the following resources: ```
NEXT_PUBLIC_CONVEX_URL=<your-convex-dev-url>
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. Then, run the Next.js development server:
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
```bash
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! npm run dev
# or
## Deploy on Vercel yarn dev
# or
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. pnpm dev
# or
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View file

@ -1,22 +1,22 @@
{ {
"$schema": "https://ui.shadcn.com/schema.json", "$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york", "style": "new-york",
"rsc": true, "rsc": true,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "", "config": "",
"css": "src/app/globals.css", "css": "src/app/globals.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"iconLibrary": "lucide", "iconLibrary": "lucide",
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
"ui": "@/components/ui", "ui": "@/components/ui",
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"registries": {} "registries": {}
} }

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

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

View file

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

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

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

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

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

View file

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

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

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

View file

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -1,25 +1,25 @@
import { dirname } from "path"; import { dirname } from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc"; import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const compat = new FlatCompat({ const compat = new FlatCompat({
baseDirectory: __dirname, baseDirectory: __dirname,
}); });
const eslintConfig = [ const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"), ...compat.extends("next/core-web-vitals", "next/typescript"),
{ {
ignores: [ ignores: [
"node_modules/**", "node_modules/**",
".next/**", ".next/**",
"out/**", "out/**",
"build/**", "build/**",
"next-env.d.ts", "next-env.d.ts",
], ],
}, },
]; ];
export default eslintConfig; export default eslintConfig;

18
web/middleware.ts Normal file
View file

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

View file

@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
}; };
export default nextConfig; export default nextConfig;

8182
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,59 +1,62 @@
{ {
"name": "web", "name": "web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"prisma:generate": "prisma generate" "prisma:generate": "prisma generate",
"convex:dev": "convex dev"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "^6.16.2", "@prisma/client": "^6.16.2",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-toggle-group": "^1.1.11",
"@tabler/icons-react": "^3.35.0", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3", "@tabler/icons-react": "^3.35.0",
"class-variance-authority": "^0.7.1", "@tanstack/react-table": "^8.21.3",
"clsx": "^2.1.1", "class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0", "clsx": "^2.1.1",
"lucide-react": "^0.544.0", "convex": "^1.27.3",
"next": "15.5.3", "date-fns": "^4.1.0",
"next-themes": "^0.4.6", "lucide-react": "^0.544.0",
"react": "19.1.0", "next": "15.5.3",
"react-dom": "19.1.0", "next-themes": "^0.4.6",
"recharts": "^2.15.4", "react": "19.1.0",
"sonner": "^2.0.7", "react-dom": "19.1.0",
"tailwind-merge": "^3.3.1", "recharts": "^2.15.4",
"vaul": "^1.1.2", "sonner": "^2.0.7",
"zod": "^4.1.9" "tailwind-merge": "^3.3.1",
}, "vaul": "^1.1.2",
"devDependencies": { "zod": "^4.1.9"
"@eslint/eslintrc": "^3", },
"@tailwindcss/postcss": "^4", "devDependencies": {
"@types/node": "^20", "@eslint/eslintrc": "^3",
"@types/react": "^19", "@tailwindcss/postcss": "^4",
"@types/react-dom": "^19", "@types/node": "^20",
"eslint": "^9", "@types/react": "^19",
"eslint-config-next": "15.5.3", "@types/react-dom": "^19",
"prisma": "^6.16.2", "eslint": "^9",
"tailwindcss": "^4", "eslint-config-next": "15.5.3",
"tw-animate-css": "^1.3.8", "prisma": "^6.16.2",
"typescript": "^5" "tailwindcss": "^4",
} "tw-animate-css": "^1.3.8",
} "typescript": "^5"
}
}

5877
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
const config = { const config = {
plugins: ["@tailwindcss/postcss"], plugins: ["@tailwindcss/postcss"],
}; };
export default config; export default config;

View file

@ -1,179 +1,179 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
enum UserRole { enum UserRole {
ADMIN ADMIN
MANAGER MANAGER
AGENT AGENT
COLLABORATOR COLLABORATOR
CUSTOMER CUSTOMER
} }
enum TicketStatus { enum TicketStatus {
NEW NEW
OPEN OPEN
PENDING PENDING
ON_HOLD ON_HOLD
RESOLVED RESOLVED
CLOSED CLOSED
} }
enum TicketPriority { enum TicketPriority {
LOW LOW
MEDIUM MEDIUM
HIGH HIGH
URGENT URGENT
} }
enum TicketChannel { enum TicketChannel {
EMAIL EMAIL
WHATSAPP WHATSAPP
CHAT CHAT
PHONE PHONE
API API
MANUAL MANUAL
} }
enum CommentVisibility { enum CommentVisibility {
PUBLIC PUBLIC
INTERNAL INTERNAL
} }
model Team { model Team {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
name String name String
description String? description String?
members TeamMember[] members TeamMember[]
queues Queue[] queues Queue[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([tenantId, name]) @@index([tenantId, name])
} }
model TeamMember { model TeamMember {
teamId String teamId String
userId String userId String
isLead Boolean @default(false) isLead Boolean @default(false)
assignedAt DateTime @default(now()) assignedAt DateTime @default(now())
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([teamId, userId]) @@id([teamId, userId])
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
name String name String
email String @unique email String @unique
role UserRole role UserRole
timezone String @default("America/Sao_Paulo") timezone String @default("America/Sao_Paulo")
avatarUrl String? avatarUrl String?
teams TeamMember[] teams TeamMember[]
requestedTickets Ticket[] @relation("TicketRequester") requestedTickets Ticket[] @relation("TicketRequester")
assignedTickets Ticket[] @relation("TicketAssignee") assignedTickets Ticket[] @relation("TicketAssignee")
comments TicketComment[] comments TicketComment[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([tenantId, role]) @@index([tenantId, role])
} }
model Queue { model Queue {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
name String name String
slug String slug String
teamId String? teamId String?
tickets Ticket[] tickets Ticket[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
team Team? @relation(fields: [teamId], references: [id]) team Team? @relation(fields: [teamId], references: [id])
@@unique([tenantId, slug]) @@unique([tenantId, slug])
} }
model Ticket { model Ticket {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
reference Int @default(autoincrement()) reference Int @default(autoincrement())
subject String subject String
summary String? summary String?
status TicketStatus @default(NEW) status TicketStatus @default(NEW)
priority TicketPriority @default(MEDIUM) priority TicketPriority @default(MEDIUM)
channel TicketChannel @default(EMAIL) channel TicketChannel @default(EMAIL)
queueId String? queueId String?
requesterId String requesterId String
assigneeId String? assigneeId String?
slaPolicyId String? slaPolicyId String?
dueAt DateTime? dueAt DateTime?
firstResponseAt DateTime? firstResponseAt DateTime?
resolvedAt DateTime? resolvedAt DateTime?
closedAt DateTime? closedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
requester User @relation("TicketRequester", fields: [requesterId], references: [id]) requester User @relation("TicketRequester", fields: [requesterId], references: [id])
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id]) assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
queue Queue? @relation(fields: [queueId], references: [id]) queue Queue? @relation(fields: [queueId], references: [id])
events TicketEvent[] events TicketEvent[]
comments TicketComment[] comments TicketComment[]
@@index([tenantId, status]) @@index([tenantId, status])
@@index([tenantId, queueId]) @@index([tenantId, queueId])
@@index([tenantId, assigneeId]) @@index([tenantId, assigneeId])
} }
model TicketEvent { model TicketEvent {
id String @id @default(cuid()) id String @id @default(cuid())
ticketId String ticketId String
type String type String
payload Json payload Json
createdAt DateTime @default(now()) createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
@@index([ticketId, createdAt]) @@index([ticketId, createdAt])
} }
model TicketComment { model TicketComment {
id String @id @default(cuid()) id String @id @default(cuid())
ticketId String ticketId String
authorId String authorId String
visibility CommentVisibility @default(INTERNAL) visibility CommentVisibility @default(INTERNAL)
body String body String
attachments Json? attachments Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id]) author User @relation(fields: [authorId], references: [id])
@@index([ticketId, visibility]) @@index([ticketId, visibility])
} }
model SlaPolicy { model SlaPolicy {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
name String name String
description String? description String?
timeToFirstResponse Int? timeToFirstResponse Int?
timeToResolution Int? timeToResolution Int?
calendar Json? calendar Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
tickets Ticket[] tickets Ticket[]
@@unique([tenantId, name]) @@unique([tenantId, name])
} }

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,32 +1,32 @@
import { AppShell } from "@/components/app-shell" import { AppShell } from "@/components/app-shell"
import { ChartAreaInteractive } from "@/components/chart-area-interactive" import { ChartAreaInteractive } from "@/components/chart-area-interactive"
import { SectionCards } from "@/components/section-cards" import { SectionCards } from "@/components/section-cards"
import { SiteHeader } from "@/components/site-header" import { SiteHeader } from "@/components/site-header"
import { TicketsTable } from "@/components/tickets/tickets-table" import { TicketsTable } from "@/components/tickets/tickets-table"
import { tickets } from "@/lib/mocks/tickets" import { tickets } from "@/lib/mocks/tickets"
const recentTickets = tickets.slice(0, 10) const recentTickets = tickets.slice(0, 10)
export default function Dashboard() { export default function Dashboard() {
return ( return (
<AppShell <AppShell
header={ header={
<SiteHeader <SiteHeader
title="Central de operações" title="Central de operações"
lead="Monitoramento em tempo real" lead="Monitoramento em tempo real"
secondaryAction={<SiteHeader.SecondaryButton>Abrir ticket</SiteHeader.SecondaryButton>} secondaryAction={<SiteHeader.SecondaryButton>Abrir ticket</SiteHeader.SecondaryButton>}
primaryAction={<SiteHeader.PrimaryButton>Modo play</SiteHeader.PrimaryButton>} primaryAction={<SiteHeader.PrimaryButton>Modo play</SiteHeader.PrimaryButton>}
/> />
} }
> >
<SectionCards /> <SectionCards />
<div className="grid gap-6 px-4 lg:grid-cols-[1.1fr_0.9fr] lg:px-6"> <div className="grid gap-6 px-4 lg:grid-cols-[1.1fr_0.9fr] lg:px-6">
<ChartAreaInteractive /> <ChartAreaInteractive />
<div className="rounded-xl border bg-card"> <div className="rounded-xl border bg-card">
<TicketsTable tickets={recentTickets} /> <TicketsTable tickets={recentTickets} />
</div> </div>
</div> </div>
</AppShell> </AppShell>
) )
} }

View file

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

View file

@ -1,122 +1,122 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card: var(--card); --color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
} }
:root { :root {
--radius: 0.75rem; --radius: 0.75rem;
--background: oklch(0.99 0.004 95.08); --background: oklch(0.99 0.004 95.08);
--foreground: oklch(0.28 0.02 254.6); --foreground: oklch(0.28 0.02 254.6);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.22 0.02 254.6); --card-foreground: oklch(0.22 0.02 254.6);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.22 0.02 254.6); --popover-foreground: oklch(0.22 0.02 254.6);
--primary: oklch(0.63 0.16 254.6); --primary: oklch(0.63 0.16 254.6);
--primary-foreground: oklch(0.99 0.004 95.08); --primary-foreground: oklch(0.99 0.004 95.08);
--secondary: oklch(0.96 0.022 254.6); --secondary: oklch(0.96 0.022 254.6);
--secondary-foreground: oklch(0.3 0.03 254.6); --secondary-foreground: oklch(0.3 0.03 254.6);
--muted: oklch(0.96 0.01 254.6); --muted: oklch(0.96 0.01 254.6);
--muted-foreground: oklch(0.55 0.03 254.6); --muted-foreground: oklch(0.55 0.03 254.6);
--accent: oklch(0.96 0.01 254.6); --accent: oklch(0.96 0.01 254.6);
--accent-foreground: oklch(0.3 0.03 254.6); --accent-foreground: oklch(0.3 0.03 254.6);
--destructive: oklch(0.55 0.23 23.3); --destructive: oklch(0.55 0.23 23.3);
--border: oklch(0.9 0.01 254.6); --border: oklch(0.9 0.01 254.6);
--input: oklch(0.92 0.008 254.6); --input: oklch(0.92 0.008 254.6);
--ring: oklch(0.63 0.16 254.6); --ring: oklch(0.63 0.16 254.6);
--chart-1: oklch(0.63 0.16 254.6); --chart-1: oklch(0.63 0.16 254.6);
--chart-2: oklch(0.66 0.11 200.43); --chart-2: oklch(0.66 0.11 200.43);
--chart-3: oklch(0.73 0.14 146.75); --chart-3: oklch(0.73 0.14 146.75);
--chart-4: oklch(0.7 0.17 95.21); --chart-4: oklch(0.7 0.17 95.21);
--chart-5: oklch(0.73 0.1 18.06); --chart-5: oklch(0.73 0.1 18.06);
--sidebar: oklch(0.985 0.004 95.08); --sidebar: oklch(0.985 0.004 95.08);
--sidebar-foreground: oklch(0.28 0.02 254.6); --sidebar-foreground: oklch(0.28 0.02 254.6);
--sidebar-primary: oklch(0.63 0.16 254.6); --sidebar-primary: oklch(0.63 0.16 254.6);
--sidebar-primary-foreground: oklch(0.99 0.004 95.08); --sidebar-primary-foreground: oklch(0.99 0.004 95.08);
--sidebar-accent: oklch(0.95 0.008 254.6); --sidebar-accent: oklch(0.95 0.008 254.6);
--sidebar-accent-foreground: oklch(0.28 0.02 254.6); --sidebar-accent-foreground: oklch(0.28 0.02 254.6);
--sidebar-border: oklch(0.9 0.01 254.6); --sidebar-border: oklch(0.9 0.01 254.6);
--sidebar-ring: oklch(0.63 0.16 254.6); --sidebar-ring: oklch(0.63 0.16 254.6);
} }
.dark { .dark {
--background: oklch(0.16 0.02 254.6); --background: oklch(0.16 0.02 254.6);
--foreground: oklch(0.96 0.02 254.6); --foreground: oklch(0.96 0.02 254.6);
--card: oklch(0.19 0.02 254.6); --card: oklch(0.19 0.02 254.6);
--card-foreground: oklch(0.96 0.02 254.6); --card-foreground: oklch(0.96 0.02 254.6);
--popover: oklch(0.22 0.02 254.6); --popover: oklch(0.22 0.02 254.6);
--popover-foreground: oklch(0.96 0.02 254.6); --popover-foreground: oklch(0.96 0.02 254.6);
--primary: oklch(0.71 0.15 254.6); --primary: oklch(0.71 0.15 254.6);
--primary-foreground: oklch(0.1 0.01 254.6); --primary-foreground: oklch(0.1 0.01 254.6);
--secondary: oklch(0.32 0.02 254.6); --secondary: oklch(0.32 0.02 254.6);
--secondary-foreground: oklch(0.96 0.02 254.6); --secondary-foreground: oklch(0.96 0.02 254.6);
--muted: oklch(0.3 0.02 254.6); --muted: oklch(0.3 0.02 254.6);
--muted-foreground: oklch(0.68 0.02 254.6); --muted-foreground: oklch(0.68 0.02 254.6);
--accent: oklch(0.29 0.02 254.6); --accent: oklch(0.29 0.02 254.6);
--accent-foreground: oklch(0.96 0.02 254.6); --accent-foreground: oklch(0.96 0.02 254.6);
--destructive: oklch(0.6 0.21 23.3); --destructive: oklch(0.6 0.21 23.3);
--border: oklch(0.32 0.02 254.6); --border: oklch(0.32 0.02 254.6);
--input: oklch(0.32 0.02 254.6); --input: oklch(0.32 0.02 254.6);
--ring: oklch(0.71 0.15 254.6); --ring: oklch(0.71 0.15 254.6);
--chart-1: oklch(0.71 0.15 254.6); --chart-1: oklch(0.71 0.15 254.6);
--chart-2: oklch(0.63 0.12 200.43); --chart-2: oklch(0.63 0.12 200.43);
--chart-3: oklch(0.62 0.14 146.75); --chart-3: oklch(0.62 0.14 146.75);
--chart-4: oklch(0.6 0.17 95.21); --chart-4: oklch(0.6 0.17 95.21);
--chart-5: oklch(0.64 0.1 18.06); --chart-5: oklch(0.64 0.1 18.06);
--sidebar: oklch(0.18 0.02 254.6); --sidebar: oklch(0.18 0.02 254.6);
--sidebar-foreground: oklch(0.96 0.02 254.6); --sidebar-foreground: oklch(0.96 0.02 254.6);
--sidebar-primary: oklch(0.71 0.15 254.6); --sidebar-primary: oklch(0.71 0.15 254.6);
--sidebar-primary-foreground: oklch(0.1 0.01 254.6); --sidebar-primary-foreground: oklch(0.1 0.01 254.6);
--sidebar-accent: oklch(0.26 0.02 254.6); --sidebar-accent: oklch(0.26 0.02 254.6);
--sidebar-accent-foreground: oklch(0.96 0.02 254.6); --sidebar-accent-foreground: oklch(0.96 0.02 254.6);
--sidebar-border: oklch(0.26 0.02 254.6); --sidebar-border: oklch(0.26 0.02 254.6);
--sidebar-ring: oklch(0.71 0.15 254.6); --sidebar-ring: oklch(0.71 0.15 254.6);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground font-sans antialiased; @apply bg-background text-foreground font-sans antialiased;
} }
} }

View file

@ -1,35 +1,51 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import { Inter, JetBrains_Mono } from "next/font/google" import { Inter, JetBrains_Mono } from "next/font/google"
import "./globals.css" import "./globals.css"
import { cookies } from "next/headers"
const inter = Inter({ import { ConvexClientProvider } from "./ConvexClientProvider"
subsets: ["latin"], import { AuthProvider } from "@/lib/auth-client"
variable: "--font-geist-sans", import { Toaster } from "@/components/ui/sonner"
display: "swap",
}) const inter = Inter({
subsets: ["latin"],
const jetBrainsMono = JetBrains_Mono({ variable: "--font-geist-sans",
subsets: ["latin"], display: "swap",
variable: "--font-geist-mono", })
display: "swap",
}) const jetBrainsMono = JetBrains_Mono({
subsets: ["latin"],
export const metadata: Metadata = { variable: "--font-geist-mono",
title: "Atlas Support", display: "swap",
description: "Plataforma omnichannel de gestão de chamados", })
}
export const metadata: Metadata = {
export default function RootLayout({ title: "Atlas Support",
description: "Plataforma omnichannel de gestão de chamados",
}
export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode 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 ( return (
<html lang="pt-BR" className="h-full"> <html lang="pt-BR" className="h-full">
<body <body
className={`${inter.variable} ${jetBrainsMono.variable} min-h-screen bg-background text-foreground antialiased`} className={`${inter.variable} ${jetBrainsMono.variable} min-h-screen bg-background text-foreground antialiased`}
> >
{children} <ConvexClientProvider>
<AuthProvider demoUser={demoUser} tenantId={tenantId}>
{children}
<Toaster position="top-right" richColors />
</AuthProvider>
</ConvexClientProvider>
</body> </body>
</html> </html>
) )

View file

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

View file

@ -1,5 +1,5 @@
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
export default function Home() { export default function Home() {
redirect("/dashboard") redirect("/dashboard")
} }

View file

@ -1,24 +1,24 @@
import { AppShell } from "@/components/app-shell" import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header" import { SiteHeader } from "@/components/site-header"
import { PlayNextTicketCard } from "@/components/tickets/play-next-ticket-card" import { PlayNextTicketCard } from "@/components/tickets/play-next-ticket-card"
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary" import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
export default function PlayPage() { export default function PlayPage() {
return ( return (
<AppShell <AppShell
header={ header={
<SiteHeader <SiteHeader
title="Modo play" title="Modo play"
lead="Distribua tickets automaticamente conforme prioridade" lead="Distribua tickets automaticamente conforme prioridade"
secondaryAction={<SiteHeader.SecondaryButton>Pausar notificacoes</SiteHeader.SecondaryButton>} secondaryAction={<SiteHeader.SecondaryButton>Pausar notificacoes</SiteHeader.SecondaryButton>}
primaryAction={<SiteHeader.PrimaryButton>Iniciar sessao</SiteHeader.PrimaryButton>} primaryAction={<SiteHeader.PrimaryButton>Iniciar sessao</SiteHeader.PrimaryButton>}
/> />
} }
> >
<div className="flex flex-col gap-6 px-4 lg:px-6"> <div className="flex flex-col gap-6 px-4 lg:px-6">
<PlayNextTicketCard /> <PlayNextTicketCard />
<TicketQueueSummaryCards /> <TicketQueueSummaryCards />
</div> </div>
</AppShell> </AppShell>
) )
} }

View file

@ -1,46 +1,26 @@
import { notFound } from "next/navigation"
import { AppShell } from "@/components/app-shell" import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header" import { SiteHeader } from "@/components/site-header"
import { TicketComments } from "@/components/tickets/ticket-comments" import { TicketDetailView } from "@/components/tickets/ticket-detail-view"
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"
type TicketDetailPageProps = { type TicketDetailPageProps = {
params: Promise<{ id: string }> params: Promise<{ id: string }>
} }
export default async function TicketDetailPage({ params }: TicketDetailPageProps) { export default async function TicketDetailPage({ params }: TicketDetailPageProps) {
const { id } = await params const { id } = await params
const ticket = getTicketById(id)
if (!ticket) {
notFound()
}
return ( return (
<AppShell <AppShell
header={ header={
<SiteHeader <SiteHeader
title={`Ticket #${ticket.reference}`} title={`Ticket #${id}`}
lead={ticket.subject} lead={"Detalhes do ticket"}
secondaryAction={<SiteHeader.SecondaryButton>Compartilhar</SiteHeader.SecondaryButton>} secondaryAction={<SiteHeader.SecondaryButton>Compartilhar</SiteHeader.SecondaryButton>}
primaryAction={<SiteHeader.PrimaryButton>Adicionar comentario</SiteHeader.PrimaryButton>} primaryAction={<SiteHeader.PrimaryButton>Adicionar comentario</SiteHeader.PrimaryButton>}
/> />
} }
> >
<div className="flex flex-col gap-6 px-4 lg:px-6"> <TicketDetailView id={id} />
<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>
</AppShell> </AppShell>
) )
} }

View file

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

View file

@ -1,27 +1,28 @@
import Link from "next/link"
import { AppShell } from "@/components/app-shell" import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header" import { SiteHeader } from "@/components/site-header"
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary" import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
import { TicketsView } from "@/components/tickets/tickets-view" import { TicketsView } from "@/components/tickets/tickets-view"
export default function TicketsPage() { export default function TicketsPage() {
return ( return (
<AppShell <AppShell
header={ header={
<SiteHeader <SiteHeader
title="Tickets" title="Tickets"
lead="Visão consolidada de filas e SLAs" lead="Visão consolidada de filas e SLAs"
secondaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>} 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>}
/> />
} }
> >
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="px-4 lg:px-6"> <div className="px-4 lg:px-6">
<TicketQueueSummaryCards /> <TicketQueueSummaryCards />
</div> </div>
<TicketsView /> <TicketsView />
</div> </div>
</AppShell> </AppShell>
) )
} }

View file

@ -1,23 +1,23 @@
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { AppSidebar } from "@/components/app-sidebar" import { AppSidebar } from "@/components/app-sidebar"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
interface AppShellProps { interface AppShellProps {
header: ReactNode header: ReactNode
children: ReactNode children: ReactNode
} }
export function AppShell({ header, children }: AppShellProps) { export function AppShell({ header, children }: AppShellProps) {
return ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<SidebarInset> <SidebarInset>
{header} {header}
<main className="flex flex-1 flex-col gap-8 bg-gradient-to-br from-background via-background to-primary/10 pb-12 pt-6"> <main className="flex flex-1 flex-col gap-8 bg-gradient-to-br from-background via-background to-primary/10 pb-12 pt-6">
{children} {children}
</main> </main>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
) )
} }

View file

@ -1,116 +1,116 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { import {
LayoutDashboard, LayoutDashboard,
LifeBuoy, LifeBuoy,
Ticket, Ticket,
PlayCircle, PlayCircle,
BookOpen, BookOpen,
BarChart3, BarChart3,
Gauge, Gauge,
PanelsTopLeft, PanelsTopLeft,
Users, Users,
Waypoints, Waypoints,
Timer, Timer,
Plug, Plug,
Layers3, Layers3,
} from "lucide-react" } from "lucide-react"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { SearchForm } from "@/components/search-form" import { SearchForm } from "@/components/search-form"
import { VersionSwitcher } from "@/components/version-switcher" import { VersionSwitcher } from "@/components/version-switcher"
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
SidebarGroupLabel, SidebarGroupLabel,
SidebarHeader, SidebarHeader,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarRail, SidebarRail,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
const navigation = { const navigation = {
versions: ["MVP", "Beta", "Roadmap"], versions: ["MVP", "Beta", "Roadmap"],
navMain: [ navMain: [
{ {
title: "Operação", title: "Operação",
items: [ items: [
{ title: "Dashboard", url: "/dashboard", icon: LayoutDashboard }, { title: "Dashboard", url: "/dashboard", icon: LayoutDashboard },
{ title: "Tickets", url: "/tickets", icon: Ticket }, { title: "Tickets", url: "/tickets", icon: Ticket },
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft }, { title: "Visualizações", url: "/views", icon: PanelsTopLeft },
{ title: "Modo Play", url: "/play", icon: PlayCircle }, { title: "Modo Play", url: "/play", icon: PlayCircle },
{ title: "Base de conhecimento", url: "/knowledge", icon: BookOpen }, { title: "Base de conhecimento", url: "/knowledge", icon: BookOpen },
], ],
}, },
{ {
title: "Relatorios", title: "Relatorios",
items: [ items: [
{ title: "SLA e produtividade", url: "/reports/sla", icon: Gauge }, { title: "SLA e produtividade", url: "/reports/sla", icon: Gauge },
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy }, { title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy },
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3 }, { title: "Backlog", url: "/reports/backlog", icon: BarChart3 },
], ],
}, },
{ {
title: "Configuração", title: "Configuração",
items: [ items: [
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints }, { title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints },
{ title: "Times & papéis", url: "/admin/teams", icon: Users }, { title: "Times & papéis", url: "/admin/teams", icon: Users },
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3 }, { title: "Campos personalizados", url: "/admin/fields", icon: Layers3 },
{ title: "SLAs", url: "/admin/slas", icon: Timer }, { title: "SLAs", url: "/admin/slas", icon: Timer },
{ title: "Integrações", url: "/admin/integrations", icon: Plug }, { title: "Integrações", url: "/admin/integrations", icon: Plug },
], ],
}, },
], ],
} as const } as const
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname() const pathname = usePathname()
function isActive(url: string) { function isActive(url: string) {
if (!pathname) return false if (!pathname) return false
if (url === "/dashboard" && pathname === "/") { if (url === "/dashboard" && pathname === "/") {
return true return true
} }
return pathname === url || pathname.startsWith(`${url}/`) return pathname === url || pathname.startsWith(`${url}/`)
} }
return ( return (
<Sidebar {...props}> <Sidebar {...props}>
<SidebarHeader className="gap-3"> <SidebarHeader className="gap-3">
<VersionSwitcher <VersionSwitcher
label="Release" label="Release"
versions={navigation.versions} versions={navigation.versions}
defaultVersion={navigation.versions[0]} defaultVersion={navigation.versions[0]}
/> />
<SearchForm placeholder="Buscar tickets, macros ou artigos" /> <SearchForm placeholder="Buscar tickets, macros ou artigos" />
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
{navigation.navMain.map((group) => ( {navigation.navMain.map((group) => (
<SidebarGroup key={group.title}> <SidebarGroup key={group.title}>
<SidebarGroupLabel>{group.title}</SidebarGroupLabel> <SidebarGroupLabel>{group.title}</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{group.items.map((item) => ( {group.items.map((item) => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive(item.url)}> <SidebarMenuButton asChild isActive={isActive(item.url)}>
<a href={item.url} className="gap-2"> <a href={item.url} className="gap-2">
<item.icon className="size-4" /> <item.icon className="size-4" />
<span>{item.title}</span> <span>{item.title}</span>
</a> </a>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
))} ))}
</SidebarContent> </SidebarContent>
<SidebarRail /> <SidebarRail />
</Sidebar> </Sidebar>
) )
} }

View file

@ -1,263 +1,263 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts" import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile"
import { import {
Card, Card,
CardAction, CardAction,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card" } from "@/components/ui/card"
import { import {
ChartConfig, ChartConfig,
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from "@/components/ui/chart" } from "@/components/ui/chart"
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { import {
ToggleGroup, ToggleGroup,
ToggleGroupItem, ToggleGroupItem,
} from "@/components/ui/toggle-group" } from "@/components/ui/toggle-group"
export const description = "Distribuição semanal de tickets por canal" export const description = "Distribuição semanal de tickets por canal"
const chartData = [ const chartData = [
{ date: "2024-07-01", email: 38, whatsapp: 25 }, { date: "2024-07-01", email: 38, whatsapp: 25 },
{ date: "2024-07-02", email: 42, whatsapp: 28 }, { date: "2024-07-02", email: 42, whatsapp: 28 },
{ date: "2024-07-03", email: 35, whatsapp: 21 }, { date: "2024-07-03", email: 35, whatsapp: 21 },
{ date: "2024-07-04", email: 47, whatsapp: 30 }, { date: "2024-07-04", email: 47, whatsapp: 30 },
{ date: "2024-07-05", email: 51, whatsapp: 32 }, { date: "2024-07-05", email: 51, whatsapp: 32 },
{ date: "2024-07-06", email: 44, whatsapp: 29 }, { date: "2024-07-06", email: 44, whatsapp: 29 },
{ date: "2024-07-07", email: 39, whatsapp: 24 }, { date: "2024-07-07", email: 39, whatsapp: 24 },
{ date: "2024-07-08", email: 48, whatsapp: 31 }, { date: "2024-07-08", email: 48, whatsapp: 31 },
{ date: "2024-07-09", email: 45, whatsapp: 27 }, { date: "2024-07-09", email: 45, whatsapp: 27 },
{ date: "2024-07-10", email: 53, whatsapp: 33 }, { date: "2024-07-10", email: 53, whatsapp: 33 },
{ date: "2024-07-11", email: 56, whatsapp: 35 }, { date: "2024-07-11", email: 56, whatsapp: 35 },
{ date: "2024-07-12", email: 49, whatsapp: 30 }, { date: "2024-07-12", email: 49, whatsapp: 30 },
{ date: "2024-07-13", email: 41, whatsapp: 22 }, { date: "2024-07-13", email: 41, whatsapp: 22 },
{ date: "2024-07-14", email: 37, whatsapp: 20 }, { date: "2024-07-14", email: 37, whatsapp: 20 },
{ date: "2024-07-15", email: 52, whatsapp: 34 }, { date: "2024-07-15", email: 52, whatsapp: 34 },
{ date: "2024-07-16", email: 50, whatsapp: 31 }, { date: "2024-07-16", email: 50, whatsapp: 31 },
{ date: "2024-07-17", email: 47, whatsapp: 29 }, { date: "2024-07-17", email: 47, whatsapp: 29 },
{ date: "2024-07-18", email: 58, whatsapp: 37 }, { date: "2024-07-18", email: 58, whatsapp: 37 },
{ date: "2024-07-19", email: 54, whatsapp: 34 }, { date: "2024-07-19", email: 54, whatsapp: 34 },
{ date: "2024-07-20", email: 43, whatsapp: 26 }, { date: "2024-07-20", email: 43, whatsapp: 26 },
{ date: "2024-07-21", email: 39, whatsapp: 23 }, { date: "2024-07-21", email: 39, whatsapp: 23 },
{ date: "2024-07-22", email: 55, whatsapp: 36 }, { date: "2024-07-22", email: 55, whatsapp: 36 },
{ date: "2024-07-23", email: 52, whatsapp: 33 }, { date: "2024-07-23", email: 52, whatsapp: 33 },
{ date: "2024-07-24", email: 57, whatsapp: 38 }, { date: "2024-07-24", email: 57, whatsapp: 38 },
{ date: "2024-07-25", email: 60, whatsapp: 40 }, { date: "2024-07-25", email: 60, whatsapp: 40 },
{ date: "2024-07-26", email: 49, whatsapp: 31 }, { date: "2024-07-26", email: 49, whatsapp: 31 },
{ date: "2024-07-27", email: 44, whatsapp: 27 }, { date: "2024-07-27", email: 44, whatsapp: 27 },
{ date: "2024-07-28", email: 41, whatsapp: 24 }, { date: "2024-07-28", email: 41, whatsapp: 24 },
{ date: "2024-07-29", email: 58, whatsapp: 37 }, { date: "2024-07-29", email: 58, whatsapp: 37 },
{ date: "2024-07-30", email: 61, whatsapp: 41 }, { date: "2024-07-30", email: 61, whatsapp: 41 },
{ date: "2024-07-31", email: 46, whatsapp: 29 }, { date: "2024-07-31", email: 46, whatsapp: 29 },
{ date: "2024-08-01", email: 52, whatsapp: 33 }, { date: "2024-08-01", email: 52, whatsapp: 33 },
{ date: "2024-08-02", email: 48, whatsapp: 30 }, { date: "2024-08-02", email: 48, whatsapp: 30 },
{ date: "2024-08-03", email: 43, whatsapp: 25 }, { date: "2024-08-03", email: 43, whatsapp: 25 },
{ date: "2024-08-04", email: 40, whatsapp: 24 }, { date: "2024-08-04", email: 40, whatsapp: 24 },
{ date: "2024-08-05", email: 57, whatsapp: 36 }, { date: "2024-08-05", email: 57, whatsapp: 36 },
{ date: "2024-08-06", email: 59, whatsapp: 38 }, { date: "2024-08-06", email: 59, whatsapp: 38 },
{ date: "2024-08-07", email: 62, whatsapp: 41 }, { date: "2024-08-07", email: 62, whatsapp: 41 },
{ date: "2024-08-08", email: 55, whatsapp: 35 }, { date: "2024-08-08", email: 55, whatsapp: 35 },
{ date: "2024-08-09", email: 51, whatsapp: 32 }, { date: "2024-08-09", email: 51, whatsapp: 32 },
{ date: "2024-08-10", email: 45, whatsapp: 27 }, { date: "2024-08-10", email: 45, whatsapp: 27 },
{ date: "2024-08-11", email: 42, whatsapp: 25 }, { date: "2024-08-11", email: 42, whatsapp: 25 },
{ date: "2024-08-12", email: 58, whatsapp: 37 }, { date: "2024-08-12", email: 58, whatsapp: 37 },
{ date: "2024-08-13", email: 56, whatsapp: 34 }, { date: "2024-08-13", email: 56, whatsapp: 34 },
{ date: "2024-08-14", email: 60, whatsapp: 39 }, { date: "2024-08-14", email: 60, whatsapp: 39 },
{ date: "2024-08-15", email: 63, whatsapp: 42 }, { date: "2024-08-15", email: 63, whatsapp: 42 },
{ date: "2024-08-16", email: 49, whatsapp: 30 }, { date: "2024-08-16", email: 49, whatsapp: 30 },
{ date: "2024-08-17", email: 46, whatsapp: 28 }, { date: "2024-08-17", email: 46, whatsapp: 28 },
{ date: "2024-08-18", email: 44, whatsapp: 26 }, { date: "2024-08-18", email: 44, whatsapp: 26 },
{ date: "2024-08-19", email: 61, whatsapp: 40 }, { date: "2024-08-19", email: 61, whatsapp: 40 },
{ date: "2024-08-20", email: 59, whatsapp: 38 }, { date: "2024-08-20", email: 59, whatsapp: 38 },
{ date: "2024-08-21", email: 55, whatsapp: 36 }, { date: "2024-08-21", email: 55, whatsapp: 36 },
{ date: "2024-08-22", email: 63, whatsapp: 42 }, { date: "2024-08-22", email: 63, whatsapp: 42 },
{ date: "2024-08-23", email: 53, whatsapp: 33 }, { date: "2024-08-23", email: 53, whatsapp: 33 },
{ date: "2024-08-24", email: 47, whatsapp: 28 }, { date: "2024-08-24", email: 47, whatsapp: 28 },
{ date: "2024-08-25", email: 43, whatsapp: 26 }, { date: "2024-08-25", email: 43, whatsapp: 26 },
{ date: "2024-08-26", email: 60, whatsapp: 39 }, { date: "2024-08-26", email: 60, whatsapp: 39 },
{ date: "2024-08-27", email: 62, whatsapp: 41 }, { date: "2024-08-27", email: 62, whatsapp: 41 },
{ date: "2024-08-28", email: 65, whatsapp: 43 }, { date: "2024-08-28", email: 65, whatsapp: 43 },
{ date: "2024-08-29", email: 58, whatsapp: 37 }, { date: "2024-08-29", email: 58, whatsapp: 37 },
{ date: "2024-08-30", email: 54, whatsapp: 34 }, { date: "2024-08-30", email: 54, whatsapp: 34 },
{ date: "2024-08-31", email: 48, whatsapp: 29 }, { date: "2024-08-31", email: 48, whatsapp: 29 },
] ]
const chartConfig = { const chartConfig = {
email: { email: {
label: "E-mail", label: "E-mail",
color: "var(--chart-1)", color: "var(--chart-1)",
}, },
whatsapp: { whatsapp: {
label: "WhatsApp", label: "WhatsApp",
color: "var(--chart-2)", color: "var(--chart-2)",
}, },
} satisfies ChartConfig } satisfies ChartConfig
export function ChartAreaInteractive() { export function ChartAreaInteractive() {
const isMobile = useIsMobile() const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("90d") const [timeRange, setTimeRange] = React.useState("90d")
React.useEffect(() => { React.useEffect(() => {
if (isMobile) { if (isMobile) {
setTimeRange("7d") setTimeRange("7d")
} }
}, [isMobile]) }, [isMobile])
const filteredData = chartData.filter((item) => { const filteredData = chartData.filter((item) => {
const date = new Date(item.date) const date = new Date(item.date)
const referenceDate = new Date("2024-08-31") const referenceDate = new Date("2024-08-31")
let daysToSubtract = 90 let daysToSubtract = 90
if (timeRange === "30d") { if (timeRange === "30d") {
daysToSubtract = 30 daysToSubtract = 30
} else if (timeRange === "7d") { } else if (timeRange === "7d") {
daysToSubtract = 7 daysToSubtract = 7
} }
const startDate = new Date(referenceDate) const startDate = new Date(referenceDate)
startDate.setDate(referenceDate.getDate() - daysToSubtract) startDate.setDate(referenceDate.getDate() - daysToSubtract)
return date >= startDate return date >= startDate
}) })
return ( return (
<Card className="@container/card"> <Card className="@container/card">
<CardHeader> <CardHeader>
<CardTitle>Entrada de tickets por canal</CardTitle> <CardTitle>Entrada de tickets por canal</CardTitle>
<CardDescription> <CardDescription>
<span className="hidden @[540px]/card:block"> <span className="hidden @[540px]/card:block">
Comparativo entre e-mail e WhatsApp Comparativo entre e-mail e WhatsApp
</span> </span>
<span className="@[540px]/card:hidden">Últimos 90 dias</span> <span className="@[540px]/card:hidden">Últimos 90 dias</span>
</CardDescription> </CardDescription>
<CardAction> <CardAction>
<ToggleGroup <ToggleGroup
type="single" type="single"
value={timeRange} value={timeRange}
onValueChange={setTimeRange} onValueChange={setTimeRange}
variant="outline" variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex" className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
> >
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem> <ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem> <ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem> <ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}> <Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger <SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden" className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm" size="sm"
aria-label="Selecionar período" aria-label="Selecionar período"
> >
<SelectValue placeholder="Últimos 90 dias" /> <SelectValue placeholder="Últimos 90 dias" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="rounded-xl"> <SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg"> <SelectItem value="90d" className="rounded-lg">
Últimos 90 dias Últimos 90 dias
</SelectItem> </SelectItem>
<SelectItem value="30d" className="rounded-lg"> <SelectItem value="30d" className="rounded-lg">
Últimos 30 dias Últimos 30 dias
</SelectItem> </SelectItem>
<SelectItem value="7d" className="rounded-lg"> <SelectItem value="7d" className="rounded-lg">
Últimos 7 dias Últimos 7 dias
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</CardAction> </CardAction>
</CardHeader> </CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6"> <CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer <ChartContainer
config={chartConfig} config={chartConfig}
className="aspect-auto h-[250px] w-full" className="aspect-auto h-[250px] w-full"
> >
<AreaChart data={filteredData}> <AreaChart data={filteredData}>
<defs> <defs>
<linearGradient id="fillEmail" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="fillEmail" x1="0" y1="0" x2="0" y2="1">
<stop <stop
offset="5%" offset="5%"
stopColor="var(--chart-1)" stopColor="var(--chart-1)"
stopOpacity={0.85} stopOpacity={0.85}
/> />
<stop <stop
offset="95%" offset="95%"
stopColor="var(--chart-1)" stopColor="var(--chart-1)"
stopOpacity={0.1} stopOpacity={0.1}
/> />
</linearGradient> </linearGradient>
<linearGradient id="fillWhatsapp" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="fillWhatsapp" x1="0" y1="0" x2="0" y2="1">
<stop <stop
offset="5%" offset="5%"
stopColor="var(--chart-2)" stopColor="var(--chart-2)"
stopOpacity={0.85} stopOpacity={0.85}
/> />
<stop <stop
offset="95%" offset="95%"
stopColor="var(--chart-2)" stopColor="var(--chart-2)"
stopOpacity={0.1} stopOpacity={0.1}
/> />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<XAxis <XAxis
dataKey="date" dataKey="date"
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickMargin={8} tickMargin={8}
minTickGap={32} minTickGap={32}
tickFormatter={(value) => { tickFormatter={(value) => {
const date = new Date(value) const date = new Date(value)
return date.toLocaleDateString("pt-BR", { return date.toLocaleDateString("pt-BR", {
month: "short", month: "short",
day: "2-digit", day: "2-digit",
}) })
}} }}
/> />
<ChartTooltip <ChartTooltip
cursor={false} cursor={false}
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(value) => labelFormatter={(value) =>
new Date(value).toLocaleDateString("pt-BR", { new Date(value).toLocaleDateString("pt-BR", {
day: "2-digit", day: "2-digit",
month: "long", month: "long",
}) })
} }
indicator="dot" indicator="dot"
/> />
} }
/> />
<Area <Area
dataKey="whatsapp" dataKey="whatsapp"
type="natural" type="natural"
fill="url(#fillWhatsapp)" fill="url(#fillWhatsapp)"
stroke="var(--chart-2)" stroke="var(--chart-2)"
strokeWidth={2} strokeWidth={2}
stackId="a" stackId="a"
name={chartConfig.whatsapp.label} name={chartConfig.whatsapp.label}
/> />
<Area <Area
dataKey="email" dataKey="email"
type="natural" type="natural"
fill="url(#fillEmail)" fill="url(#fillEmail)"
stroke="var(--chart-1)" stroke="var(--chart-1)"
strokeWidth={2} strokeWidth={2}
stackId="a" stackId="a"
name={chartConfig.email.label} name={chartConfig.email.label}
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
</CardContent> </CardContent>
</Card> </Card>
) )
} }

File diff suppressed because it is too large Load diff

View file

@ -1,92 +1,92 @@
"use client" "use client"
import { import {
IconDots, IconDots,
IconFolder, IconFolder,
IconShare3, IconShare3,
IconTrash, IconTrash,
type Icon, type Icon,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupLabel, SidebarGroupLabel,
SidebarMenu, SidebarMenu,
SidebarMenuAction, SidebarMenuAction,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
export function NavDocuments({ export function NavDocuments({
items, items,
}: { }: {
items: { items: {
name: string name: string
url: string url: string
icon: Icon icon: Icon
}[] }[]
}) { }) {
const { isMobile } = useSidebar() const { isMobile } = useSidebar()
return ( return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden"> <SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel> <SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
{items.map((item) => ( {items.map((item) => (
<SidebarMenuItem key={item.name}> <SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild> <SidebarMenuButton asChild>
<a href={item.url}> <a href={item.url}>
<item.icon /> <item.icon />
<span>{item.name}</span> <span>{item.name}</span>
</a> </a>
</SidebarMenuButton> </SidebarMenuButton>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuAction <SidebarMenuAction
showOnHover showOnHover
className="data-[state=open]:bg-accent rounded-sm" className="data-[state=open]:bg-accent rounded-sm"
> >
<IconDots /> <IconDots />
<span className="sr-only">More</span> <span className="sr-only">More</span>
</SidebarMenuAction> </SidebarMenuAction>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="w-24 rounded-lg" className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"} side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"} align={isMobile ? "end" : "start"}
> >
<DropdownMenuItem> <DropdownMenuItem>
<IconFolder /> <IconFolder />
<span>Open</span> <span>Open</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem>
<IconShare3 /> <IconShare3 />
<span>Share</span> <span>Share</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"> <DropdownMenuItem variant="destructive">
<IconTrash /> <IconTrash />
<span>Delete</span> <span>Delete</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70"> <SidebarMenuButton className="text-sidebar-foreground/70">
<IconDots className="text-sidebar-foreground/70" /> <IconDots className="text-sidebar-foreground/70" />
<span>More</span> <span>More</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
) )
} }

View file

@ -1,58 +1,58 @@
"use client" "use client"
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react" import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
export function NavMain({ export function NavMain({
items, items,
}: { }: {
items: { items: {
title: string title: string
url: string url: string
icon?: Icon icon?: Icon
}[] }[]
}) { }) {
return ( return (
<SidebarGroup> <SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2"> <SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2"> <SidebarMenuItem className="flex items-center gap-2">
<SidebarMenuButton <SidebarMenuButton
tooltip="Quick Create" tooltip="Quick Create"
className="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear" className="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
> >
<IconCirclePlusFilled /> <IconCirclePlusFilled />
<span>Quick Create</span> <span>Quick Create</span>
</SidebarMenuButton> </SidebarMenuButton>
<Button <Button
size="icon" size="icon"
className="size-8 group-data-[collapsible=icon]:opacity-0" className="size-8 group-data-[collapsible=icon]:opacity-0"
variant="outline" variant="outline"
> >
<IconMail /> <IconMail />
<span className="sr-only">Inbox</span> <span className="sr-only">Inbox</span>
</Button> </Button>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
<SidebarMenu> <SidebarMenu>
{items.map((item) => ( {items.map((item) => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton tooltip={item.title}> <SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon />} {item.icon && <item.icon />}
<span>{item.title}</span> <span>{item.title}</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
) )
} }

View file

@ -1,42 +1,42 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { type Icon } from "@tabler/icons-react" import { type Icon } from "@tabler/icons-react"
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
export function NavSecondary({ export function NavSecondary({
items, items,
...props ...props
}: { }: {
items: { items: {
title: string title: string
url: string url: string
icon: Icon icon: Icon
}[] }[]
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) { } & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return ( return (
<SidebarGroup {...props}> <SidebarGroup {...props}>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{items.map((item) => ( {items.map((item) => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild> <SidebarMenuButton asChild>
<a href={item.url}> <a href={item.url}>
<item.icon /> <item.icon />
<span>{item.title}</span> <span>{item.title}</span>
</a> </a>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
) )
} }

View file

@ -1,110 +1,110 @@
"use client" "use client"
import { import {
IconCreditCard, IconCreditCard,
IconDotsVertical, IconDotsVertical,
IconLogout, IconLogout,
IconNotification, IconNotification,
IconUserCircle, IconUserCircle,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { import {
Avatar, Avatar,
AvatarFallback, AvatarFallback,
AvatarImage, AvatarImage,
} from "@/components/ui/avatar" } from "@/components/ui/avatar"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
export function NavUser({ export function NavUser({
user, user,
}: { }: {
user: { user: {
name: string name: string
email: string email: string
avatar: string avatar: string
} }
}) { }) {
const { isMobile } = useSidebar() const { isMobile } = useSidebar()
return ( return (
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
size="lg" size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
<Avatar className="h-8 w-8 rounded-lg grayscale"> <Avatar className="h-8 w-8 rounded-lg grayscale">
<AvatarImage src={user.avatar} alt={user.name} /> <AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback> <AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar> </Avatar>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span> <span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs"> <span className="text-muted-foreground truncate text-xs">
{user.email} {user.email}
</span> </span>
</div> </div>
<IconDotsVertical className="ml-auto size-4" /> <IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg" className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"} side={isMobile ? "bottom" : "right"}
align="end" align="end"
sideOffset={4} sideOffset={4}
> >
<DropdownMenuLabel className="p-0 font-normal"> <DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg"> <Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} /> <AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback> <AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar> </Avatar>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span> <span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs"> <span className="text-muted-foreground truncate text-xs">
{user.email} {user.email}
</span> </span>
</div> </div>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem> <DropdownMenuItem>
<IconUserCircle /> <IconUserCircle />
Account Account
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem>
<IconCreditCard /> <IconCreditCard />
Billing Billing
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem>
<IconNotification /> <IconNotification />
Notifications Notifications
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem> <DropdownMenuItem>
<IconLogout /> <IconLogout />
Log out Log out
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
) )
} }

View file

@ -1,42 +1,42 @@
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
interface PageHeaderProps { interface PageHeaderProps {
title: string title: string
description?: string description?: string
actions?: ReactNode actions?: ReactNode
children?: ReactNode children?: ReactNode
} }
export function PageHeader({ title, description, actions, children }: PageHeaderProps) { export function PageHeader({ title, description, actions, children }: PageHeaderProps) {
return ( return (
<div className="flex flex-col gap-6 border-b px-4 pb-6 pt-4 lg:px-6"> <div className="flex flex-col gap-6 border-b px-4 pb-6 pt-4 lg:px-6">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1> <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? ( {description ? (
<p className="mt-1 text-sm text-muted-foreground">{description}</p> <p className="mt-1 text-sm text-muted-foreground">{description}</p>
) : null} ) : null}
</div> </div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null} {actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div> </div>
{children} {children}
</div> </div>
) )
} }
PageHeader.Action = function PageHeaderAction({ children }: { children: ReactNode }) { PageHeader.Action = function PageHeaderAction({ children }: { children: ReactNode }) {
return <div className="flex items-center gap-2">{children}</div> return <div className="flex items-center gap-2">{children}</div>
} }
PageHeader.PrimaryButton = function PageHeaderPrimaryButton({ PageHeader.PrimaryButton = function PageHeaderPrimaryButton({
children, children,
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
return ( return (
<Button size="sm" {...props}> <Button size="sm" {...props}>
{children} {children}
</Button> </Button>
) )
} }

View file

@ -1,28 +1,28 @@
import { Search } from "lucide-react" import { Search } from "lucide-react"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
SidebarInput, SidebarInput,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
type SearchFormProps = React.ComponentProps<"form"> & { type SearchFormProps = React.ComponentProps<"form"> & {
placeholder?: string placeholder?: string
} }
export function SearchForm({ placeholder = "Buscar...", ...props }: SearchFormProps) { export function SearchForm({ placeholder = "Buscar...", ...props }: SearchFormProps) {
return ( return (
<form {...props}> <form {...props}>
<SidebarGroup className="py-0"> <SidebarGroup className="py-0">
<SidebarGroupContent className="relative"> <SidebarGroupContent className="relative">
<Label htmlFor="search" className="sr-only"> <Label htmlFor="search" className="sr-only">
Busca global Busca global
</Label> </Label>
<SidebarInput id="search" placeholder={placeholder} className="pl-8" /> <SidebarInput id="search" placeholder={placeholder} className="pl-8" />
<Search className="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 opacity-50 select-none" /> <Search className="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 opacity-50 select-none" />
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
</form> </form>
) )
} }

View file

@ -1,84 +1,84 @@
import { IconClockHour4, IconMessages, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react" import { IconClockHour4, IconMessages, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { import {
Card, Card,
CardAction, CardAction,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card" } from "@/components/ui/card"
export function SectionCards() { export function SectionCards() {
return ( return (
<div className="grid grid-cols-1 gap-4 px-4 sm:grid-cols-2 xl:grid-cols-4 xl:px-8"> <div className="grid grid-cols-1 gap-4 px-4 sm:grid-cols-2 xl:grid-cols-4 xl:px-8">
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm"> <Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3"> <CardHeader className="gap-3">
<CardDescription>Tickets novos</CardDescription> <CardDescription>Tickets novos</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">128</CardTitle> <CardTitle className="text-3xl font-semibold tabular-nums">128</CardTitle>
<CardAction> <CardAction>
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs"> <Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
<IconTrendingUp className="size-3.5" /> <IconTrendingUp className="size-3.5" />
+8% +8%
</Badge> </Badge>
</CardAction> </CardAction>
</CardHeader> </CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground"> <CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<div className="flex gap-2 text-foreground"> <div className="flex gap-2 text-foreground">
Volume acima da média semanal <IconTrendingUp className="size-4" /> Volume acima da média semanal <IconTrendingUp className="size-4" />
</div> </div>
<span>Últimas 24h considerando e-mail e WhatsApp.</span> <span>Últimas 24h considerando e-mail e WhatsApp.</span>
</CardFooter> </CardFooter>
</Card> </Card>
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm"> <Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3"> <CardHeader className="gap-3">
<CardDescription>Tempo médio da 1ª resposta</CardDescription> <CardDescription>Tempo médio da 1ª resposta</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">12m</CardTitle> <CardTitle className="text-3xl font-semibold tabular-nums">12m</CardTitle>
<CardAction> <CardAction>
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs"> <Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
<IconTrendingDown className="size-3.5" /> <IconTrendingDown className="size-3.5" />
-3m -3m
</Badge> </Badge>
</CardAction> </CardAction>
</CardHeader> </CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground"> <CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<span className="text-foreground">SLAs cumpridos em 92% dos tickets</span> <span className="text-foreground">SLAs cumpridos em 92% dos tickets</span>
<span>Considera filas Prioridade P1P3.</span> <span>Considera filas Prioridade P1P3.</span>
</CardFooter> </CardFooter>
</Card> </Card>
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm"> <Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3"> <CardHeader className="gap-3">
<CardDescription>Tickets aguardando ação</CardDescription> <CardDescription>Tickets aguardando ação</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">45</CardTitle> <CardTitle className="text-3xl font-semibold tabular-nums">45</CardTitle>
<CardAction> <CardAction>
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs"> <Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
<IconClockHour4 className="size-3.5" /> <IconClockHour4 className="size-3.5" />
12 em risco 12 em risco
</Badge> </Badge>
</CardAction> </CardAction>
</CardHeader> </CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground"> <CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<span className="text-foreground">Distribuir entre times prioritários</span> <span className="text-foreground">Distribuir entre times prioritários</span>
<span>Inclui status "Aberto", "Pendente" e "Em espera".</span> <span>Inclui status "Aberto", "Pendente" e "Em espera".</span>
</CardFooter> </CardFooter>
</Card> </Card>
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm"> <Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3"> <CardHeader className="gap-3">
<CardDescription>CSAT das últimas 100 interações</CardDescription> <CardDescription>CSAT das últimas 100 interações</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">4,7</CardTitle> <CardTitle className="text-3xl font-semibold tabular-nums">4,7</CardTitle>
<CardAction> <CardAction>
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs"> <Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
<IconMessages className="size-3.5" /> <IconMessages className="size-3.5" />
63 pesquisas 63 pesquisas
</Badge> </Badge>
</CardAction> </CardAction>
</CardHeader> </CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground"> <CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<span className="text-foreground">Destaque: fila Field Services</span> <span className="text-foreground">Destaque: fila Field Services</span>
<span>CSAT com escala de 1 a 5.</span> <span>CSAT com escala de 1 a 5.</span>
</CardFooter> </CardFooter>
</Card> </Card>
</div> </div>
) )
} }

View file

@ -1,56 +1,56 @@
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { SidebarTrigger } from "@/components/ui/sidebar" import { SidebarTrigger } from "@/components/ui/sidebar"
interface SiteHeaderProps { interface SiteHeaderProps {
title: string title: string
lead?: string lead?: string
primaryAction?: ReactNode primaryAction?: ReactNode
secondaryAction?: ReactNode secondaryAction?: ReactNode
} }
export function SiteHeader({ export function SiteHeader({
title, title,
lead, lead,
primaryAction, primaryAction,
secondaryAction, secondaryAction,
}: SiteHeaderProps) { }: SiteHeaderProps) {
return ( return (
<header className="flex h-(--header-height) shrink-0 items-center gap-3 border-b bg-background/80 px-6 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height) lg:px-8"> <header className="flex h-(--header-height) shrink-0 items-center gap-3 border-b bg-background/80 px-6 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height) lg:px-8">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mx-3 hidden h-6 sm:block" /> <Separator orientation="vertical" className="mx-3 hidden h-6 sm:block" />
<div className="flex flex-1 flex-col gap-1"> <div className="flex flex-1 flex-col gap-1">
{lead ? <span className="text-sm text-muted-foreground">{lead}</span> : null} {lead ? <span className="text-sm text-muted-foreground">{lead}</span> : null}
<h1 className="text-lg font-semibold">{title}</h1> <h1 className="text-lg font-semibold">{title}</h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{secondaryAction} {secondaryAction}
{primaryAction} {primaryAction}
</div> </div>
</header> </header>
) )
} }
SiteHeader.PrimaryButton = function SiteHeaderPrimaryButton({ SiteHeader.PrimaryButton = function SiteHeaderPrimaryButton({
children, children,
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
return ( return (
<Button size="sm" {...props}> <Button size="sm" {...props}>
{children} {children}
</Button> </Button>
) )
} }
SiteHeader.SecondaryButton = function SiteHeaderSecondaryButton({ SiteHeader.SecondaryButton = function SiteHeaderSecondaryButton({
children, children,
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
return ( return (
<Button size="sm" variant="outline" {...props}> <Button size="sm" variant="outline" {...props}>
{children} {children}
</Button> </Button>
) )
} }

View file

@ -1,7 +1,14 @@
import Link from "next/link" "use client"
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
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 type { TicketPlayContext } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -9,13 +16,29 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill" import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge" import { TicketStatusBadge } from "@/components/tickets/status-badge"
interface PlayNextTicketCardProps {
context?: TicketPlayContext
}
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)
interface PlayNextTicketCardProps { const nextTicketFromServer = useQuery(api.tickets.list, {
context?: TicketPlayContext tenantId: DEFAULT_TENANT_ID,
} status: undefined,
priority: undefined,
channel: undefined,
queueId: undefined,
limit: 1,
})?.[0]
export function PlayNextTicketCard({ context = playContext }: PlayNextTicketCardProps) { 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 (!context.nextTicket) {
if (!cardContext || !cardContext.nextTicket) {
return ( return (
<Card className="border-dashed"> <Card className="border-dashed">
<CardHeader> <CardHeader>
@ -28,54 +51,59 @@ export function PlayNextTicketCard({ context = playContext }: PlayNextTicketCard
) )
} }
const ticket = context.nextTicket const ticket = cardContext.nextTicket
return ( return (
<Card className="border-dashed"> <Card className="border-dashed">
<CardHeader className="flex flex-row items-center justify-between gap-2"> <CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-lg font-semibold"> <CardTitle className="text-lg font-semibold">
Proximo ticket #{ticket.reference} Proximo ticket #{ticket.reference}
</CardTitle> </CardTitle>
<TicketPriorityPill priority={ticket.priority} /> <TicketPriorityPill priority={ticket.priority} />
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-medium text-foreground">{ticket.subject}</h2> <h2 className="text-xl font-medium text-foreground">{ticket.subject}</h2>
<p className="text-sm text-muted-foreground">{ticket.summary}</p> <p className="text-sm text-muted-foreground">{ticket.summary}</p>
</div> </div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground"> <div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<Badge variant="outline">{ticket.queue ?? "Sem fila"}</Badge> <Badge variant="outline">{ticket.queue ?? "Sem fila"}</Badge>
<TicketStatusBadge status={ticket.status} /> <TicketStatusBadge status={ticket.status} />
<span>Solicitante: {ticket.requester.name}</span> <span>Solicitante: {ticket.requester.name}</span>
</div> </div>
<Separator /> <Separator />
<div className="flex flex-col gap-3 text-sm text-muted-foreground"> <div className="flex flex-col gap-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>Pendentes na fila</span> <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>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>Em espera</span> <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>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>SLA violado</span> <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>
</div> </div>
<Button asChild className="gap-2"> <Button
<Link href={`/tickets/${ticket.id}`}> className="gap-2"
Iniciar atendimento onClick={async () => {
<IconPlayerPlayFilled className="size-4" /> if (!userId) return
</Link> 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" />
</Button> </Button>
<Button variant="ghost" asChild className="gap-2 text-sm"> <Button variant="ghost" asChild className="gap-2 text-sm">
<Link href="/tickets"> <Link href="/tickets">
Ver lista completa Ver lista completa
<IconArrowRight className="size-4" /> <IconArrowRight className="size-4" />
</Link> </Link>
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
) )
} }

View file

@ -1,40 +1,40 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
const priorityConfig = { const priorityConfig = {
LOW: { LOW: {
label: "Baixa", label: "Baixa",
className: "bg-slate-100 text-slate-600 border-transparent", className: "bg-slate-100 text-slate-600 border-transparent",
}, },
MEDIUM: { MEDIUM: {
label: "Media", label: "Media",
className: "bg-blue-100 text-blue-600 border-transparent", className: "bg-blue-100 text-blue-600 border-transparent",
}, },
HIGH: { HIGH: {
label: "Alta", label: "Alta",
className: "bg-amber-100 text-amber-700 border-transparent", className: "bg-amber-100 text-amber-700 border-transparent",
}, },
URGENT: { URGENT: {
label: "Urgente", label: "Urgente",
className: "bg-red-100 text-red-700 border-transparent", className: "bg-red-100 text-red-700 border-transparent",
}, },
} satisfies Record<string, { label: string; className: string }> } satisfies Record<string, { label: string; className: string }>
type TicketPriorityPillProps = { type TicketPriorityPillProps = {
priority: keyof typeof priorityConfig priority: keyof typeof priorityConfig
} }
export function TicketPriorityPill({ priority }: TicketPriorityPillProps) { export function TicketPriorityPill({ priority }: TicketPriorityPillProps) {
const config = priorityConfig[priority] const config = priorityConfig[priority]
return ( return (
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"rounded-full px-2.5 py-1 text-xs font-medium", "rounded-full px-2.5 py-1 text-xs font-medium",
config?.className ?? "" config?.className ?? ""
)} )}
> >
{config?.label ?? priority} {config?.label ?? priority}
</Badge> </Badge>
) )
} }

View file

@ -1,29 +1,29 @@
"use client" "use client"
import { ticketStatusSchema } from "@/lib/schemas/ticket" import { ticketStatusSchema } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
const statusConfig = { const statusConfig = {
NEW: { label: "Novo", className: "bg-slate-100 text-slate-700 border-transparent" }, NEW: { label: "Novo", className: "bg-slate-100 text-slate-700 border-transparent" },
OPEN: { label: "Aberto", className: "bg-blue-100 text-blue-700 border-transparent" }, OPEN: { label: "Aberto", className: "bg-blue-100 text-blue-700 border-transparent" },
PENDING: { label: "Pendente", className: "bg-amber-100 text-amber-700 border-transparent" }, PENDING: { label: "Pendente", className: "bg-amber-100 text-amber-700 border-transparent" },
ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" }, ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" },
RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" }, RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" },
CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" }, CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" },
} satisfies Record<(typeof ticketStatusSchema)["_type"], { label: string; className: string }> } satisfies Record<(typeof ticketStatusSchema)["_type"], { label: string; className: string }>
type TicketStatusBadgeProps = { type TicketStatusBadgeProps = {
status: (typeof ticketStatusSchema)["_type"] status: (typeof ticketStatusSchema)["_type"]
} }
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) { export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
const config = statusConfig[status] const config = statusConfig[status]
return ( return (
<Badge <Badge
variant="outline" variant="outline"
className={`rounded-full px-2.5 py-1 text-xs font-medium ${config?.className ?? ""}`} className={`rounded-full px-2.5 py-1 text-xs font-medium ${config?.className ?? ""}`}
> >
{config?.label ?? status} {config?.label ?? status}
</Badge> </Badge>
) )
} }

View file

@ -1,17 +1,75 @@
"use client"
import { useMemo, useState } from "react"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage } from "@tabler/icons-react" import { IconLock, IconMessage } from "@tabler/icons-react"
import { 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 type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
interface TicketCommentsProps { import { toast } from "sonner"
ticket: TicketWithDetails
} interface TicketCommentsProps {
ticket: TicketWithDetails
}
export function TicketComments({ ticket }: TicketCommentsProps) { 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 ( return (
<Card className="border-none shadow-none"> <Card className="border-none shadow-none">
<CardHeader className="px-0"> <CardHeader className="px-0">
@ -20,12 +78,12 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6 px-0"> <CardContent className="space-y-6 px-0">
{ticket.comments.length === 0 ? ( {commentsAll.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Ainda sem comentarios. Que tal registrar o proximo passo? Ainda sem comentarios. Que tal registrar o proximo passo?
</p> </p>
) : ( ) : (
ticket.comments.map((comment) => { commentsAll.map((comment) => {
const initials = comment.author.name const initials = comment.author.name
.split(" ") .split(" ")
.slice(0, 2) .slice(0, 2)
@ -49,14 +107,36 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })} {formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span> </span>
</div> </div>
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground"> <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} {comment.body}
</div> </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>
</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> </CardContent>
</Card> </Card>
) )

View file

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

View file

@ -1,53 +1,53 @@
import { format } from "date-fns" import { format } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconAlertTriangle, IconClockHour4, IconTags } from "@tabler/icons-react" import { IconAlertTriangle, IconClockHour4, IconTags } from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket" import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
interface TicketDetailsPanelProps { interface TicketDetailsPanelProps {
ticket: TicketWithDetails ticket: TicketWithDetails
} }
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) { export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
return ( return (
<Card className="border-none shadow-none"> <Card className="border-none shadow-none">
<CardHeader className="px-0"> <CardHeader className="px-0">
<CardTitle className="text-lg font-semibold">Detalhes</CardTitle> <CardTitle className="text-lg font-semibold">Detalhes</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4 px-0 text-sm text-muted-foreground"> <CardContent className="flex flex-col gap-5 px-0 text-sm text-muted-foreground">
<div className="space-y-1"> <div className="space-y-1 break-words">
<p className="text-xs font-semibold uppercase">Fila</p> <p className="text-xs font-semibold uppercase tracking-wide">Fila</p>
<Badge variant="outline">{ticket.queue ?? "Sem fila"}</Badge> <Badge variant="outline" className="max-w-full truncate">{ticket.queue ?? "Sem fila"}</Badge>
</div> </div>
<Separator /> <Separator />
<div className="space-y-2"> <div className="space-y-2 break-words">
<p className="text-xs font-semibold uppercase">SLA</p> <p className="text-xs font-semibold uppercase tracking-wide">SLA</p>
{ticket.slaPolicy ? ( {ticket.slaPolicy ? (
<div className="flex flex-col gap-2 rounded-lg border border-dashed bg-card px-3 py-2"> <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"> <div className="flex flex-col gap-1 text-xs text-muted-foreground">
{ticket.slaPolicy.targetMinutesToFirstResponse ? ( {ticket.slaPolicy.targetMinutesToFirstResponse ? (
<span> <span className="leading-normal">
Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min
</span> </span>
) : null} ) : null}
{ticket.slaPolicy.targetMinutesToResolution ? ( {ticket.slaPolicy.targetMinutesToResolution ? (
<span> <span className="leading-normal">
Resolucao: {ticket.slaPolicy.targetMinutesToResolution} min Resolução: {ticket.slaPolicy.targetMinutesToResolution} min
</span> </span>
) : null} ) : null}
</div> </div>
</div> </div>
) : ( ) : (
<span>Sem politica atribuida.</span> <span>Sem política atribuída.</span>
)} )}
</div> </div>
<Separator /> <Separator />
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase">Metricas</p> <p className="text-xs font-semibold uppercase tracking-wide">tricas</p>
{ticket.metrics ? ( {ticket.metrics ? (
<div className="flex flex-col gap-2 text-xs text-muted-foreground"> <div className="flex flex-col gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@ -62,8 +62,8 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
)} )}
</div> </div>
<Separator /> <Separator />
<div className="space-y-2"> <div className="space-y-2 break-words">
<p className="text-xs font-semibold uppercase">Tags</p> <p className="text-xs font-semibold uppercase tracking-wide">Tags</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{ticket.tags?.length ? ( {ticket.tags?.length ? (
ticket.tags.map((tag) => ( ticket.tags.map((tag) => (
@ -78,7 +78,7 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
</div> </div>
<Separator /> <Separator />
<div className="space-y-1"> <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"> <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>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> <span>Atualizado: {format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>

View file

@ -1,47 +1,55 @@
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 type { TicketQueueSummary } from "@/lib/schemas/ticket"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
interface TicketQueueSummaryProps { interface TicketQueueSummaryProps {
queues?: TicketQueueSummary[] 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 ( return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{queues.map((queue) => { {data.map((queue) => {
const total = queue.pending + queue.waiting const total = queue.pending + queue.waiting
const breachPercent = total === 0 ? 0 : Math.round((queue.breached / total) * 100) const breachPercent = total === 0 ? 0 : Math.round((queue.breached / total) * 100)
return ( return (
<Card key={queue.id} className="border border-border/60 bg-gradient-to-br from-background to-card p-4"> <Card key={queue.id} className="border border-border/60 bg-gradient-to-br from-background to-card p-4">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription>Fila</CardDescription> <CardDescription>Fila</CardDescription>
<CardTitle className="text-lg font-semibold">{queue.name}</CardTitle> <CardTitle className="text-lg font-semibold">{queue.name}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3 text-sm"> <CardContent className="flex flex-col gap-3 text-sm">
<div className="flex justify-between text-muted-foreground"> <div className="flex justify-between text-muted-foreground">
<span>Pendentes</span> <span>Pendentes</span>
<span className="font-medium text-foreground">{queue.pending}</span> <span className="font-medium text-foreground">{queue.pending}</span>
</div> </div>
<div className="flex justify-between text-muted-foreground"> <div className="flex justify-between text-muted-foreground">
<span>Aguardando resposta</span> <span>Aguardando resposta</span>
<span className="font-medium text-foreground">{queue.waiting}</span> <span className="font-medium text-foreground">{queue.waiting}</span>
</div> </div>
<div className="flex items-center justify-between text-muted-foreground"> <div className="flex items-center justify-between text-muted-foreground">
<span>Violados</span> <span>Violados</span>
<span className="font-medium text-destructive">{queue.breached}</span> <span className="font-medium text-destructive">{queue.breached}</span>
</div> </div>
<div className="pt-1.5"> <div className="pt-1.5">
<Progress value={breachPercent} className="h-1.5" /> <Progress value={breachPercent} className="h-1.5" />
<span className="mt-2 block text-xs text-muted-foreground"> <span className="mt-2 block text-xs text-muted-foreground">
{breachPercent}% com SLA violado nesta fila {breachPercent}% com SLA violado nesta fila
</span> </span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) )
})} })}
</div> </div>
) )
} }

View file

@ -1,18 +1,39 @@
"use client"
import { useState } from "react"
import { format } from "date-fns" import { format } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconClock, IconUserCircle } from "@tabler/icons-react" 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 type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill" import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge" import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
interface TicketHeaderProps {
ticket: TicketWithDetails interface TicketHeaderProps {
} ticket: TicketWithDetails
}
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { 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 ( return (
<div className="space-y-4 rounded-xl border bg-card p-6 shadow-sm"> <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"> <div className="flex flex-wrap items-start justify-between gap-3">
@ -22,57 +43,82 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
#{ticket.reference} #{ticket.reference}
</Badge> </Badge>
<TicketPriorityPill priority={ticket.priority} /> <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> </div>
<h1 className="text-2xl font-semibold text-foreground">{ticket.subject}</h1> <h1 className="text-2xl font-semibold text-foreground">{ticket.subject}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">{ticket.summary}</p> <p className="max-w-2xl text-sm text-muted-foreground">{ticket.summary}</p>
</div> </div>
</div> </div>
<Separator /> <Separator />
<div className="grid gap-4 text-sm text-muted-foreground sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 text-sm text-muted-foreground sm:grid-cols-2 lg:grid-cols-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<IconUserCircle className="size-4" /> <IconUserCircle className="size-4" />
Solicitante: Solicitante:
<span className="font-medium text-foreground">{ticket.requester.name}</span> <span className="font-medium text-foreground">{ticket.requester.name}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<IconUserCircle className="size-4" /> <IconUserCircle className="size-4" />
Responsavel: Responsavel:
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{ticket.assignee?.name ?? "Aguardando atribuicao"} {ticket.assignee?.name ?? "Aguardando atribuicao"}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<IconClock className="size-4" /> <IconClock className="size-4" />
Atualizado em: Atualizado em:
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} {format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<IconClock className="size-4" /> <IconClock className="size-4" />
Criado em: Criado em:
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} {format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span> </span>
</div> </div>
{ticket.dueAt ? ( {ticket.dueAt ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<IconClock className="size-4" /> <IconClock className="size-4" />
SLA ate: SLA ate:
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} {format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span> </span>
</div> </div>
) : null} ) : null}
{ticket.slaPolicy ? ( {ticket.slaPolicy ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<IconClock className="size-4" /> <IconClock className="size-4" />
Politica: Politica:
<span className="font-medium text-foreground">{ticket.slaPolicy.name}</span> <span className="font-medium text-foreground">{ticket.slaPolicy.name}</span>
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
) )
} }

View file

@ -1,17 +1,18 @@
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 { ptBR } from "date-fns/locale"
import { import {
IconClockHour4, IconClockHour4,
IconNote, IconNote,
IconSquareCheck, IconSquareCheck,
IconUserCircle, IconUserCircle,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket" import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
const timelineIcons: Record<string, ComponentType<{ className?: string }>> = { const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
CREATED: IconUserCircle, CREATED: IconUserCircle,
STATUS_CHANGED: IconSquareCheck, STATUS_CHANGED: IconSquareCheck,
@ -19,47 +20,54 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
COMMENT_ADDED: IconNote, COMMENT_ADDED: IconNote,
} }
interface TicketTimelineProps { const timelineLabels: Record<string, string> = {
ticket: TicketWithDetails CREATED: "Criado",
STATUS_CHANGED: "Status alterado",
ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Comentário adicionado",
} }
export function TicketTimeline({ ticket }: TicketTimelineProps) { interface TicketTimelineProps {
return ( ticket: TicketWithDetails
<Card className="border-none shadow-none"> }
<CardContent className="space-y-6">
{ticket.timeline.map((entry, index) => { export function TicketTimeline({ ticket }: TicketTimelineProps) {
const Icon = timelineIcons[entry.type] ?? IconClockHour4 return (
const isLast = index === ticket.timeline.length - 1 <Card className="border-none shadow-none">
return ( <CardContent className="space-y-6">
<div key={entry.id} className="relative pl-10"> {ticket.timeline.map((entry, index) => {
{!isLast && ( const Icon = timelineIcons[entry.type] ?? IconClockHour4
<span className="absolute left-[17px] top-6 h-full w-px bg-border" aria-hidden /> const isLast = index === ticket.timeline.length - 1
)} return (
<span className="absolute left-0 top-0 flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground"> <div key={entry.id} className="relative pl-10">
<Icon className="size-4" /> {!isLast && (
</span> <span className="absolute left-[17px] top-6 h-full w-px bg-border" aria-hidden />
<div className="flex flex-col gap-2"> )}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1"> <span className="absolute left-0 top-0 flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Icon className="size-4" />
</span>
<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"> <span className="text-sm font-medium text-foreground">
{entry.type.replaceAll("_", " ")} {timelineLabels[entry.type] ?? entry.type}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })} {format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
</span> </span>
</div> </div>
{entry.payload ? ( {entry.payload ? (
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground">
<pre className="whitespace-pre-wrap leading-relaxed"> <pre className="whitespace-pre-wrap leading-relaxed">
{JSON.stringify(entry.payload, null, 2)} {JSON.stringify(entry.payload, null, 2)}
</pre> </pre>
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
) )
})} })}
</CardContent> </CardContent>
</Card> </Card>
) )
} }

View file

@ -1,125 +1,127 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { IconFilter, IconRefresh } from "@tabler/icons-react" import { IconFilter, IconRefresh } from "@tabler/icons-react"
import { tickets } from "@/lib/mocks/tickets" import {
import { ticketChannelSchema,
ticketChannelSchema, ticketPrioritySchema,
ticketPrioritySchema, ticketStatusSchema,
ticketStatusSchema, } from "@/lib/schemas/ticket"
} from "@/lib/schemas/ticket" import { Badge } from "@/components/ui/badge"
import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button"
import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input"
import { Input } from "@/components/ui/input" import {
import { Popover,
Popover, PopoverContent,
PopoverContent, PopoverTrigger,
PopoverTrigger, } from "@/components/ui/popover"
} from "@/components/ui/popover" import {
import { Select,
Select, SelectContent,
SelectContent, SelectItem,
SelectItem, SelectTrigger,
SelectTrigger, SelectValue,
SelectValue, } from "@/components/ui/select"
} from "@/components/ui/select"
const statusOptions = ticketStatusSchema.options.map((status) => ({
const statusOptions = ticketStatusSchema.options.map((status) => ({ value: status,
value: status, label: {
label: { NEW: "Novo",
NEW: "Novo", OPEN: "Aberto",
OPEN: "Aberto", PENDING: "Pendente",
PENDING: "Pendente", ON_HOLD: "Em espera",
ON_HOLD: "Em espera", RESOLVED: "Resolvido",
RESOLVED: "Resolvido", CLOSED: "Fechado",
CLOSED: "Fechado", }[status],
}[status], }))
}))
const priorityOptions = ticketPrioritySchema.options.map((priority) => ({
const priorityOptions = ticketPrioritySchema.options.map((priority) => ({ value: priority,
value: priority, label: {
label: { LOW: "Baixa",
LOW: "Baixa", MEDIUM: "Media",
MEDIUM: "Media", HIGH: "Alta",
HIGH: "Alta", URGENT: "Urgente",
URGENT: "Urgente", }[priority],
}[priority], }))
}))
const channelOptions = ticketChannelSchema.options.map((channel) => ({
const channelOptions = ticketChannelSchema.options.map((channel) => ({ value: channel,
value: channel, label: {
label: { EMAIL: "E-mail",
EMAIL: "E-mail", WHATSAPP: "WhatsApp",
WHATSAPP: "WhatsApp", CHAT: "Chat",
CHAT: "Chat", PHONE: "Telefone",
PHONE: "Telefone", API: "API",
API: "API", MANUAL: "Manual",
MANUAL: "Manual", }[channel],
}[channel], }))
}))
type QueueOption = string
const queues = Array.from(new Set(tickets.map((ticket) => ticket.queue).filter(Boolean)))
export type TicketFiltersState = {
export type TicketFiltersState = { search: string
search: string status: string | null
status: string | null priority: string | null
priority: string | null queue: string | null
queue: string | null channel: string | null
channel: string | null }
}
export const defaultTicketFilters: TicketFiltersState = {
export const defaultTicketFilters: TicketFiltersState = { search: "",
search: "", status: null,
status: null, priority: null,
priority: null, queue: null,
queue: null, channel: null,
channel: null, }
}
interface TicketsFiltersProps { interface TicketsFiltersProps {
onChange?: (filters: TicketFiltersState) => void onChange?: (filters: TicketFiltersState) => void
queues?: QueueOption[]
} }
const ALL_VALUE = "ALL"
export function TicketsFilters({ onChange }: TicketsFiltersProps) { export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters) const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
function setPartial(partial: Partial<TicketFiltersState>) { function setPartial(partial: Partial<TicketFiltersState>) {
setFilters((prev) => { setFilters((prev) => {
const next = { ...prev, ...partial } const next = { ...prev, ...partial }
onChange?.(next) onChange?.(next)
return next return next
}) })
} }
const activeFilters = useMemo(() => { const activeFilters = useMemo(() => {
const chips: string[] = [] const chips: string[] = []
if (filters.status) chips.push(`Status: ${filters.status}`) if (filters.status) chips.push(`Status: ${filters.status}`)
if (filters.priority) chips.push(`Prioridade: ${filters.priority}`) if (filters.priority) chips.push(`Prioridade: ${filters.priority}`)
if (filters.queue) chips.push(`Fila: ${filters.queue}`) if (filters.queue) chips.push(`Fila: ${filters.queue}`)
if (filters.channel) chips.push(`Canal: ${filters.channel}`) if (filters.channel) chips.push(`Canal: ${filters.channel}`)
return chips return chips
}, [filters]) }, [filters])
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-col gap-2 md:flex-row"> <div className="flex flex-1 flex-col gap-2 md:flex-row">
<Input <Input
placeholder="Buscar por assunto ou #ID" placeholder="Buscar por assunto ou #ID"
value={filters.search} value={filters.search}
onChange={(event) => setPartial({ search: event.target.value })} onChange={(event) => setPartial({ search: event.target.value })}
className="md:max-w-sm" className="md:max-w-sm"
/> />
<Select <Select
value={filters.queue ?? ""} value={filters.queue ?? ALL_VALUE}
onValueChange={(value) => setPartial({ queue: value || null })} onValueChange={(value) => setPartial({ queue: value === ALL_VALUE ? null : value })}
> >
<SelectTrigger className="md:w-[180px]"> <SelectTrigger className="md:w-[180px]">
<SelectValue placeholder="Fila" /> <SelectValue placeholder="Fila" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="">Todas as filas</SelectItem> <SelectItem value={ALL_VALUE}>Todas as filas</SelectItem>
{queues.map((queue) => ( {queues.map((queue) => (
<SelectItem key={queue!} value={queue!}> <SelectItem key={queue!} value={queue!}>
{queue} {queue}
@ -127,29 +129,29 @@ export function TicketsFilters({ onChange }: TicketsFiltersProps) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2"> <Button variant="outline" size="sm" className="gap-2">
<IconFilter className="size-4" /> <IconFilter className="size-4" />
Filtros Filtros
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-64 space-y-4"> <PopoverContent className="w-64 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground"> <p className="text-xs font-semibold uppercase text-muted-foreground">
Status Status
</p> </p>
<Select <Select
value={filters.status ?? ""} value={filters.status ?? ALL_VALUE}
onValueChange={(value) => setPartial({ status: value || null })} onValueChange={(value) => setPartial({ status: value === ALL_VALUE ? null : value })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Todos" /> <SelectValue placeholder="Todos" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="">Todos</SelectItem> <SelectItem value={ALL_VALUE}>Todos</SelectItem>
{statusOptions.map((option) => ( {statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -157,20 +159,20 @@ export function TicketsFilters({ onChange }: TicketsFiltersProps) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground"> <p className="text-xs font-semibold uppercase text-muted-foreground">
Prioridade Prioridade
</p> </p>
<Select <Select
value={filters.priority ?? ""} value={filters.priority ?? ALL_VALUE}
onValueChange={(value) => setPartial({ priority: value || null })} onValueChange={(value) => setPartial({ priority: value === ALL_VALUE ? null : value })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Todas" /> <SelectValue placeholder="Todas" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="">Todas</SelectItem> <SelectItem value={ALL_VALUE}>Todas</SelectItem>
{priorityOptions.map((option) => ( {priorityOptions.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -178,20 +180,20 @@ export function TicketsFilters({ onChange }: TicketsFiltersProps) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground"> <p className="text-xs font-semibold uppercase text-muted-foreground">
Canal Canal
</p> </p>
<Select <Select
value={filters.channel ?? ""} value={filters.channel ?? ALL_VALUE}
onValueChange={(value) => setPartial({ channel: value || null })} onValueChange={(value) => setPartial({ channel: value === ALL_VALUE ? null : value })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Todos" /> <SelectValue placeholder="Todos" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="">Todos</SelectItem> <SelectItem value={ALL_VALUE}>Todos</SelectItem>
{channelOptions.map((option) => ( {channelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -199,29 +201,29 @@ export function TicketsFilters({ onChange }: TicketsFiltersProps) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="gap-2" className="gap-2"
onClick={() => setPartial(defaultTicketFilters)} onClick={() => setPartial(defaultTicketFilters)}
> >
<IconRefresh className="size-4" /> <IconRefresh className="size-4" />
Resetar Resetar
</Button> </Button>
</div> </div>
</div> </div>
{activeFilters.length > 0 && ( {activeFilters.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{activeFilters.map((chip) => ( {activeFilters.map((chip) => (
<Badge key={chip} variant="secondary" className="rounded-full px-3 py-1 text-xs"> <Badge key={chip} variant="secondary" className="rounded-full px-3 py-1 text-xs">
{chip} {chip}
</Badge> </Badge>
))} ))}
</div> </div>
)} )}
</div> </div>
) )
} }

View file

@ -1,172 +1,172 @@
import Link from "next/link" import Link from "next/link"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { tickets as ticketsMock } from "@/lib/mocks/tickets" import { tickets as ticketsMock } from "@/lib/mocks/tickets"
import type { Ticket } from "@/lib/schemas/ticket" import type { Ticket } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { import {
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { TicketPriorityPill } from "@/components/tickets/priority-pill" import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge" import { TicketStatusBadge } from "@/components/tickets/status-badge"
const channelLabel: Record<string, string> = { const channelLabel: Record<string, string> = {
EMAIL: "E-mail", EMAIL: "E-mail",
WHATSAPP: "WhatsApp", WHATSAPP: "WhatsApp",
CHAT: "Chat", CHAT: "Chat",
PHONE: "Telefone", PHONE: "Telefone",
API: "API", API: "API",
MANUAL: "Manual", MANUAL: "Manual",
} }
const cellClass = "py-4 align-top" const cellClass = "py-4 align-top"
function AssigneeCell({ ticket }: { ticket: Ticket }) { function AssigneeCell({ ticket }: { ticket: Ticket }) {
if (!ticket.assignee) { if (!ticket.assignee) {
return <span className="text-sm text-muted-foreground">Sem responsável</span> return <span className="text-sm text-muted-foreground">Sem responsável</span>
} }
const initials = ticket.assignee.name const initials = ticket.assignee.name
.split(" ") .split(" ")
.slice(0, 2) .slice(0, 2)
.map((part) => part[0]?.toUpperCase()) .map((part) => part[0]?.toUpperCase())
.join("") .join("")
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar className="size-8"> <Avatar className="size-8">
<AvatarImage src={ticket.assignee.avatarUrl} alt={ticket.assignee.name} /> <AvatarImage src={ticket.assignee.avatarUrl} alt={ticket.assignee.name} />
<AvatarFallback>{initials}</AvatarFallback> <AvatarFallback>{initials}</AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium leading-none text-foreground"> <span className="text-sm font-medium leading-none text-foreground">
{ticket.assignee.name} {ticket.assignee.name}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{ticket.assignee.teams?.[0] ?? "Agente"} {ticket.assignee.teams?.[0] ?? "Agente"}
</span> </span>
</div> </div>
</div> </div>
) )
} }
type TicketsTableProps = { type TicketsTableProps = {
tickets?: Ticket[] tickets?: Ticket[]
} }
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) { export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
return ( return (
<Card className="border-none shadow-none"> <Card className="border-none shadow-none">
<CardContent className="px-4 py-4 sm:px-6"> <CardContent className="px-4 py-4 sm:px-6">
<Table className="min-w-full"> <Table className="min-w-full">
<TableHeader> <TableHeader>
<TableRow className="text-xs uppercase text-muted-foreground"> <TableRow className="text-xs uppercase text-muted-foreground">
<TableHead className="w-[110px]">Ticket</TableHead> <TableHead className="w-[110px]">Ticket</TableHead>
<TableHead>Assunto</TableHead> <TableHead>Assunto</TableHead>
<TableHead className="hidden lg:table-cell">Fila</TableHead> <TableHead className="hidden lg:table-cell">Fila</TableHead>
<TableHead className="hidden md:table-cell">Canal</TableHead> <TableHead className="hidden md:table-cell">Canal</TableHead>
<TableHead className="hidden md:table-cell">Prioridade</TableHead> <TableHead className="hidden md:table-cell">Prioridade</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="hidden xl:table-cell">Responsável</TableHead> <TableHead className="hidden xl:table-cell">Responsável</TableHead>
<TableHead className="w-[140px]">Atualizado</TableHead> <TableHead className="w-[140px]">Atualizado</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{tickets.map((ticket) => ( {tickets.map((ticket) => (
<TableRow key={ticket.id} className="group"> <TableRow key={ticket.id} className="group">
<TableCell className={cellClass}> <TableCell className={cellClass}>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<Link <Link
href={`/tickets/${ticket.id}`} href={`/tickets/${ticket.id}`}
className="font-semibold tracking-tight text-primary hover:underline" className="font-semibold tracking-tight text-primary hover:underline"
> >
#{ticket.reference} #{ticket.reference}
</Link> </Link>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{ticket.queue ?? "Sem fila"} {ticket.queue ?? "Sem fila"}
</span> </span>
</div> </div>
</TableCell> </TableCell>
<TableCell className={cellClass}> <TableCell className={cellClass}>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Link <Link
href={`/tickets/${ticket.id}`} href={`/tickets/${ticket.id}`}
className="line-clamp-1 font-medium text-foreground hover:underline" className="line-clamp-1 font-medium text-foreground hover:underline"
> >
{ticket.subject} {ticket.subject}
</Link> </Link>
<span className="line-clamp-1 text-sm text-muted-foreground"> <span className="line-clamp-1 text-sm text-muted-foreground">
{ticket.summary ?? "Sem resumo"} {ticket.summary ?? "Sem resumo"}
</span> </span>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{ticket.requester.name}</span> <span>{ticket.requester.name}</span>
{ticket.tags?.map((tag) => ( {ticket.tags?.map((tag) => (
<Badge <Badge
key={tag} key={tag}
variant="outline" variant="outline"
className="rounded-full border-transparent bg-slate-100 px-2 py-1 text-xs text-slate-600" className="rounded-full border-transparent bg-slate-100 px-2 py-1 text-xs text-slate-600"
> >
{tag} {tag}
</Badge> </Badge>
))} ))}
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell className={`${cellClass} hidden lg:table-cell`}> <TableCell className={`${cellClass} hidden lg:table-cell`}>
<Badge className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600"> <Badge className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
{ticket.queue ?? "Sem fila"} {ticket.queue ?? "Sem fila"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className={`${cellClass} hidden md:table-cell`}> <TableCell className={`${cellClass} hidden md:table-cell`}>
<Badge className="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-600"> <Badge className="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-600">
{channelLabel[ticket.channel] ?? ticket.channel} {channelLabel[ticket.channel] ?? ticket.channel}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className={`${cellClass} hidden md:table-cell`}> <TableCell className={`${cellClass} hidden md:table-cell`}>
<TicketPriorityPill priority={ticket.priority} /> <TicketPriorityPill priority={ticket.priority} />
</TableCell> </TableCell>
<TableCell className={cellClass}> <TableCell className={cellClass}>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<TicketStatusBadge status={ticket.status} /> <TicketStatusBadge status={ticket.status} />
{ticket.metrics?.timeWaitingMinutes ? ( {ticket.metrics?.timeWaitingMinutes ? (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Espera {ticket.metrics.timeWaitingMinutes} min Espera {ticket.metrics.timeWaitingMinutes} min
</span> </span>
) : null} ) : null}
</div> </div>
</TableCell> </TableCell>
<TableCell className={`${cellClass} hidden xl:table-cell`}> <TableCell className={`${cellClass} hidden xl:table-cell`}>
<AssigneeCell ticket={ticket} /> <AssigneeCell ticket={ticket} />
</TableCell> </TableCell>
<TableCell className={cellClass}> <TableCell className={cellClass}>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{formatDistanceToNow(ticket.updatedAt, { {formatDistanceToNow(ticket.updatedAt, {
addSuffix: true, addSuffix: true,
locale: ptBR, locale: ptBR,
})} })}
</span> </span>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
{tickets.length === 0 && ( {tickets.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center"> <div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<p className="text-sm font-medium">Nenhum ticket encontrado</p> <p className="text-sm font-medium">Nenhum ticket encontrado</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Ajuste os filtros ou selecione outra fila. Ajuste os filtros ou selecione outra fila.
</p> </p>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
) )
} }

View file

@ -1,41 +1,40 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useQuery } from "convex/react"
import { tickets } from "@/lib/mocks/tickets" // 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 { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
import { TicketsTable } from "@/components/tickets/tickets-table" 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() { export function TicketsView() {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters) 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 ( return (
<div className="flex flex-col gap-6 px-4 lg:px-6"> <div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketsFilters onChange={setFilters} /> <TicketsFilters onChange={setFilters} queues={queues.map((q: any) => q.name)} />
<TicketsTable tickets={filteredTickets} /> <TicketsTable tickets={filteredTickets as any} />
</div> </div>
) )
} }

View file

@ -1,53 +1,53 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar" import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Avatar({ function Avatar({
className, className,
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) { }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return ( return (
<AvatarPrimitive.Root <AvatarPrimitive.Root
data-slot="avatar" data-slot="avatar"
className={cn( className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full", "relative flex size-8 shrink-0 overflow-hidden rounded-full",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function AvatarImage({ function AvatarImage({
className, className,
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) { }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return ( return (
<AvatarPrimitive.Image <AvatarPrimitive.Image
data-slot="avatar-image" data-slot="avatar-image"
className={cn("aspect-square size-full", className)} className={cn("aspect-square size-full", className)}
{...props} {...props}
/> />
) )
} }
function AvatarFallback({ function AvatarFallback({
className, className,
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) { }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return ( return (
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
data-slot="avatar-fallback" data-slot="avatar-fallback"
className={cn( className={cn(
"bg-muted flex size-full items-center justify-center rounded-full", "bg-muted flex size-full items-center justify-center rounded-full",
className className
)} )}
{...props} {...props}
/> />
) )
} }
export { Avatar, AvatarImage, AvatarFallback } export { Avatar, AvatarImage, AvatarFallback }

View file

@ -1,46 +1,46 @@
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive: destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} }
) )
function Badge({ function Badge({
className, className,
variant, variant,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span" const Comp = asChild ? Slot : "span"
return ( return (
<Comp <Comp
data-slot="badge" data-slot="badge"
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
) )
} }
export { Badge, badgeVariants } export { Badge, badgeVariants }

View file

@ -1,109 +1,109 @@
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react" import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} /> return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
} }
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return ( return (
<ol <ol
data-slot="breadcrumb-list" data-slot="breadcrumb-list"
className={cn( className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return ( return (
<li <li
data-slot="breadcrumb-item" data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)} className={cn("inline-flex items-center gap-1.5", className)}
{...props} {...props}
/> />
) )
} }
function BreadcrumbLink({ function BreadcrumbLink({
asChild, asChild,
className, className,
...props ...props
}: React.ComponentProps<"a"> & { }: React.ComponentProps<"a"> & {
asChild?: boolean asChild?: boolean
}) { }) {
const Comp = asChild ? Slot : "a" const Comp = asChild ? Slot : "a"
return ( return (
<Comp <Comp
data-slot="breadcrumb-link" data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)} className={cn("hover:text-foreground transition-colors", className)}
{...props} {...props}
/> />
) )
} }
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="breadcrumb-page" data-slot="breadcrumb-page"
role="link" role="link"
aria-disabled="true" aria-disabled="true"
aria-current="page" aria-current="page"
className={cn("text-foreground font-normal", className)} className={cn("text-foreground font-normal", className)}
{...props} {...props}
/> />
) )
} }
function BreadcrumbSeparator({ function BreadcrumbSeparator({
children, children,
className, className,
...props ...props
}: React.ComponentProps<"li">) { }: React.ComponentProps<"li">) {
return ( return (
<li <li
data-slot="breadcrumb-separator" data-slot="breadcrumb-separator"
role="presentation" role="presentation"
aria-hidden="true" aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)} className={cn("[&>svg]:size-3.5", className)}
{...props} {...props}
> >
{children ?? <ChevronRight />} {children ?? <ChevronRight />}
</li> </li>
) )
} }
function BreadcrumbEllipsis({ function BreadcrumbEllipsis({
className, className,
...props ...props
}: React.ComponentProps<"span">) { }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="breadcrumb-ellipsis" data-slot="breadcrumb-ellipsis"
role="presentation" role="presentation"
aria-hidden="true" aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)} className={cn("flex size-9 items-center justify-center", className)}
{...props} {...props}
> >
<MoreHorizontal className="size-4" /> <MoreHorizontal className="size-4" />
<span className="sr-only">More</span> <span className="sr-only">More</span>
</span> </span>
) )
} }
export { export {
Breadcrumb, Breadcrumb,
BreadcrumbList, BreadcrumbList,
BreadcrumbItem, BreadcrumbItem,
BreadcrumbLink, BreadcrumbLink,
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
BreadcrumbEllipsis, BreadcrumbEllipsis,
} }

View file

@ -1,58 +1,58 @@
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9", icon: "size-9",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} }
) )
function Button({ function Button({
className, className,
variant, variant,
size, size,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean
}) { }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button"
return ( return (
<Comp <Comp
data-slot="button" data-slot="button"
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) )
} }
export { Button, buttonVariants } export { Button, buttonVariants }

View file

@ -1,92 +1,92 @@
import * as React from "react" import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-title" data-slot="card-title"
className={cn("leading-none font-semibold", className)} className={cn("leading-none font-semibold", className)}
{...props} {...props}
/> />
) )
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) )
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-content" data-slot="card-content"
className={cn("px-6", className)} className={cn("px-6", className)}
{...props} {...props}
/> />
) )
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) )
} }
export { export {
Card, Card,
CardHeader, CardHeader,
CardFooter, CardFooter,
CardTitle, CardTitle,
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
} }

View file

@ -1,357 +1,357 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as RechartsPrimitive from "recharts" import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR } // Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = { export type ChartConfig = {
[k in string]: { [k in string]: {
label?: React.ReactNode label?: React.ReactNode
icon?: React.ComponentType icon?: React.ComponentType
} & ( } & (
| { color?: string; theme?: never } | { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> } | { color?: never; theme: Record<keyof typeof THEMES, string> }
) )
} }
type ChartContextProps = { type ChartContextProps = {
config: ChartConfig config: ChartConfig
} }
const ChartContext = React.createContext<ChartContextProps | null>(null) const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() { function useChart() {
const context = React.useContext(ChartContext) const context = React.useContext(ChartContext)
if (!context) { if (!context) {
throw new Error("useChart must be used within a <ChartContainer />") throw new Error("useChart must be used within a <ChartContainer />")
} }
return context return context
} }
function ChartContainer({ function ChartContainer({
id, id,
className, className,
children, children,
config, config,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
config: ChartConfig config: ChartConfig
children: React.ComponentProps< children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer typeof RechartsPrimitive.ResponsiveContainer
>["children"] >["children"]
}) { }) {
const uniqueId = React.useId() const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return ( return (
<ChartContext.Provider value={{ config }}> <ChartContext.Provider value={{ config }}>
<div <div
data-slot="chart" data-slot="chart"
data-chart={chartId} data-chart={chartId}
className={cn( className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className className
)} )}
{...props} {...props}
> >
<ChartStyle id={chartId} config={config} /> <ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer> <RechartsPrimitive.ResponsiveContainer>
{children} {children}
</RechartsPrimitive.ResponsiveContainer> </RechartsPrimitive.ResponsiveContainer>
</div> </div>
</ChartContext.Provider> </ChartContext.Provider>
) )
} }
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter( const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color ([, config]) => config.theme || config.color
) )
if (!colorConfig.length) { if (!colorConfig.length) {
return null return null
} }
return ( return (
<style <style
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: Object.entries(THEMES) __html: Object.entries(THEMES)
.map( .map(
([theme, prefix]) => ` ([theme, prefix]) => `
${prefix} [data-chart=${id}] { ${prefix} [data-chart=${id}] {
${colorConfig ${colorConfig
.map(([key, itemConfig]) => { .map(([key, itemConfig]) => {
const color = const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color itemConfig.color
return color ? ` --color-${key}: ${color};` : null return color ? ` --color-${key}: ${color};` : null
}) })
.join("\n")} .join("\n")}
} }
` `
) )
.join("\n"), .join("\n"),
}} }}
/> />
) )
} }
const ChartTooltip = RechartsPrimitive.Tooltip const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({ function ChartTooltipContent({
active, active,
payload, payload,
className, className,
indicator = "dot", indicator = "dot",
hideLabel = false, hideLabel = false,
hideIndicator = false, hideIndicator = false,
label, label,
labelFormatter, labelFormatter,
labelClassName, labelClassName,
formatter, formatter,
color, color,
nameKey, nameKey,
labelKey, labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> & }: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & { React.ComponentProps<"div"> & {
hideLabel?: boolean hideLabel?: boolean
hideIndicator?: boolean hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed" indicator?: "line" | "dot" | "dashed"
nameKey?: string nameKey?: string
labelKey?: string labelKey?: string
}) { }) {
const { config } = useChart() const { config } = useChart()
const tooltipLabel = React.useMemo(() => { const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) { if (hideLabel || !payload?.length) {
return null return null
} }
const [item] = payload const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}` const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value = const value =
!labelKey && typeof label === "string" !labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label ? config[label as keyof typeof config]?.label || label
: itemConfig?.label : itemConfig?.label
if (labelFormatter) { if (labelFormatter) {
return ( return (
<div className={cn("font-medium", labelClassName)}> <div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)} {labelFormatter(value, payload)}
</div> </div>
) )
} }
if (!value) { if (!value) {
return null return null
} }
return <div className={cn("font-medium", labelClassName)}>{value}</div> return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [ }, [
label, label,
labelFormatter, labelFormatter,
payload, payload,
hideLabel, hideLabel,
labelClassName, labelClassName,
config, config,
labelKey, labelKey,
]) ])
if (!active || !payload?.length) { if (!active || !payload?.length) {
return null return null
} }
const nestLabel = payload.length === 1 && indicator !== "dot" const nestLabel = payload.length === 1 && indicator !== "dot"
return ( return (
<div <div
className={cn( className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl", "border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className className
)} )}
> >
{!nestLabel ? tooltipLabel : null} {!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5"> <div className="grid gap-1.5">
{payload {payload
.filter((item) => item.type !== "none") .filter((item) => item.type !== "none")
.map((item, index) => { .map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}` const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color const indicatorColor = color || item.payload.fill || item.color
return ( return (
<div <div
key={item.dataKey} key={item.dataKey}
className={cn( className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5", "[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center" indicator === "dot" && "items-center"
)} )}
> >
{formatter && item?.value !== undefined && item.name ? ( {formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload) formatter(item.value, item.name, item, index, item.payload)
) : ( ) : (
<> <>
{itemConfig?.icon ? ( {itemConfig?.icon ? (
<itemConfig.icon /> <itemConfig.icon />
) : ( ) : (
!hideIndicator && ( !hideIndicator && (
<div <div
className={cn( className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{ {
"h-2.5 w-2.5": indicator === "dot", "h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line", "w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": "w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed", indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed", "my-0.5": nestLabel && indicator === "dashed",
} }
)} )}
style={ style={
{ {
"--color-bg": indicatorColor, "--color-bg": indicatorColor,
"--color-border": indicatorColor, "--color-border": indicatorColor,
} as React.CSSProperties } as React.CSSProperties
} }
/> />
) )
)} )}
<div <div
className={cn( className={cn(
"flex flex-1 justify-between leading-none", "flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center" nestLabel ? "items-end" : "items-center"
)} )}
> >
<div className="grid gap-1.5"> <div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null} {nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{itemConfig?.label || item.name} {itemConfig?.label || item.name}
</span> </span>
</div> </div>
{item.value && ( {item.value && (
<span className="text-foreground font-mono font-medium tabular-nums"> <span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()} {item.value.toLocaleString()}
</span> </span>
)} )}
</div> </div>
</> </>
)} )}
</div> </div>
) )
})} })}
</div> </div>
</div> </div>
) )
} }
const ChartLegend = RechartsPrimitive.Legend const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({ function ChartLegendContent({
className, className,
hideIcon = false, hideIcon = false,
payload, payload,
verticalAlign = "bottom", verticalAlign = "bottom",
nameKey, nameKey,
}: React.ComponentProps<"div"> & }: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean hideIcon?: boolean
nameKey?: string nameKey?: string
}) { }) {
const { config } = useChart() const { config } = useChart()
if (!payload?.length) { if (!payload?.length) {
return null return null
} }
return ( return (
<div <div
className={cn( className={cn(
"flex items-center justify-center gap-4", "flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3", verticalAlign === "top" ? "pb-3" : "pt-3",
className className
)} )}
> >
{payload {payload
.filter((item) => item.type !== "none") .filter((item) => item.type !== "none")
.map((item) => { .map((item) => {
const key = `${nameKey || item.dataKey || "value"}` const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key)
return ( return (
<div <div
key={item.value} key={item.value}
className={cn( className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3" "[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)} )}
> >
{itemConfig?.icon && !hideIcon ? ( {itemConfig?.icon && !hideIcon ? (
<itemConfig.icon /> <itemConfig.icon />
) : ( ) : (
<div <div
className="h-2 w-2 shrink-0 rounded-[2px]" className="h-2 w-2 shrink-0 rounded-[2px]"
style={{ style={{
backgroundColor: item.color, backgroundColor: item.color,
}} }}
/> />
)} )}
{itemConfig?.label} {itemConfig?.label}
</div> </div>
) )
})} })}
</div> </div>
) )
} }
// Helper to extract item config from a payload. // Helper to extract item config from a payload.
function getPayloadConfigFromPayload( function getPayloadConfigFromPayload(
config: ChartConfig, config: ChartConfig,
payload: unknown, payload: unknown,
key: string key: string
) { ) {
if (typeof payload !== "object" || payload === null) { if (typeof payload !== "object" || payload === null) {
return undefined return undefined
} }
const payloadPayload = const payloadPayload =
"payload" in payload && "payload" in payload &&
typeof payload.payload === "object" && typeof payload.payload === "object" &&
payload.payload !== null payload.payload !== null
? payload.payload ? payload.payload
: undefined : undefined
let configLabelKey: string = key let configLabelKey: string = key
if ( if (
key in payload && key in payload &&
typeof payload[key as keyof typeof payload] === "string" typeof payload[key as keyof typeof payload] === "string"
) { ) {
configLabelKey = payload[key as keyof typeof payload] as string configLabelKey = payload[key as keyof typeof payload] as string
} else if ( } else if (
payloadPayload && payloadPayload &&
key in payloadPayload && key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string" typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) { ) {
configLabelKey = payloadPayload[ configLabelKey = payloadPayload[
key as keyof typeof payloadPayload key as keyof typeof payloadPayload
] as string ] as string
} }
return configLabelKey in config return configLabelKey in config
? config[configLabelKey] ? config[configLabelKey]
: config[key as keyof typeof config] : config[key as keyof typeof config]
} }
export { export {
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
ChartLegend, ChartLegend,
ChartLegendContent, ChartLegendContent,
ChartStyle, ChartStyle,
} }

View file

@ -1,32 +1,32 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react" import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Checkbox({ function Checkbox({
className, className,
...props ...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) { }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
{...props} {...props}
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none" className="flex items-center justify-center text-current transition-none"
> >
<CheckIcon className="size-3.5" /> <CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) )
} }
export { Checkbox } export { Checkbox }

View file

@ -1,135 +1,135 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul" import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Drawer({ function Drawer({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) { }: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} /> return <DrawerPrimitive.Root data-slot="drawer" {...props} />
} }
function DrawerTrigger({ function DrawerTrigger({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) { }: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} /> return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
} }
function DrawerPortal({ function DrawerPortal({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) { }: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} /> return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
} }
function DrawerClose({ function DrawerClose({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) { }: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} /> return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
} }
function DrawerOverlay({ function DrawerOverlay({
className, className,
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) { }: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return ( return (
<DrawerPrimitive.Overlay <DrawerPrimitive.Overlay
data-slot="drawer-overlay" data-slot="drawer-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function DrawerContent({ function DrawerContent({
className, className,
children, children,
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) { }: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return ( return (
<DrawerPortal data-slot="drawer-portal"> <DrawerPortal data-slot="drawer-portal">
<DrawerOverlay /> <DrawerOverlay />
<DrawerPrimitive.Content <DrawerPrimitive.Content
data-slot="drawer-content" data-slot="drawer-content"
className={cn( className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col", "group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b", "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t", "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm", "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm", "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className className
)} )}
{...props} {...props}
> >
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" /> <div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children} {children}
</DrawerPrimitive.Content> </DrawerPrimitive.Content>
</DrawerPortal> </DrawerPortal>
) )
} }
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="drawer-header" data-slot="drawer-header"
className={cn( className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left", "flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="drawer-footer" data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) )
} }
function DrawerTitle({ function DrawerTitle({
className, className,
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) { }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return ( return (
<DrawerPrimitive.Title <DrawerPrimitive.Title
data-slot="drawer-title" data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) )
} }
function DrawerDescription({ function DrawerDescription({
className, className,
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) { }: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return ( return (
<DrawerPrimitive.Description <DrawerPrimitive.Description
data-slot="drawer-description" data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) )
} }
export { export {
Drawer, Drawer,
DrawerPortal, DrawerPortal,
DrawerOverlay, DrawerOverlay,
DrawerTrigger, DrawerTrigger,
DrawerClose, DrawerClose,
DrawerContent, DrawerContent,
DrawerHeader, DrawerHeader,
DrawerFooter, DrawerFooter,
DrawerTitle, DrawerTitle,
DrawerDescription, DrawerDescription,
} }

View file

@ -1,257 +1,257 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function DropdownMenu({ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
) )
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return ( return (
<DropdownMenuPrimitive.Trigger <DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger" data-slot="dropdown-menu-trigger"
{...props} {...props}
/> />
) )
} }
function DropdownMenuContent({ function DropdownMenuContent({
className, className,
sideOffset = 4, sideOffset = 4,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return ( return (
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "bg-popover text-popover-foreground 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className className
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) )
} }
function DropdownMenuGroup({ function DropdownMenuGroup({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return ( return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
) )
} }
function DropdownMenuItem({ function DropdownMenuItem({
className, className,
inset, inset,
variant = "default", variant = "default",
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean
variant?: "default" | "destructive" variant?: "default" | "destructive"
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item" data-slot="dropdown-menu-item"
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
className, className,
children, children,
checked, checked,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return ( return (
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
checked={checked} checked={checked}
{...props} {...props}
> >
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> <CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) )
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return ( return (
<DropdownMenuPrimitive.RadioGroup <DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group" data-slot="dropdown-menu-radio-group"
{...props} {...props}
/> />
) )
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
className, className,
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return ( return (
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
> >
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" /> <CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) )
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
className, className,
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
className, className,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) )
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({
className, className,
...props ...props
}: React.ComponentProps<"span">) { }: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest", "text-muted-foreground ml-auto text-xs tracking-widest",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function DropdownMenuSub({ function DropdownMenuSub({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
className, className,
inset, inset,
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8", "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) )
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
className, className,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return ( return (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", "bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className className
)} )}
{...props} {...props}
/> />
) )
} }
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuPortal, DropdownMenuPortal,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} }

View file

@ -1,21 +1,21 @@
import * as React from "react" import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
<input <input
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
)} )}
{...props} {...props}
/> />
) )
} }
export { Input } export { Input }

View file

@ -1,24 +1,24 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Label({ function Label({
className, className,
...props ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) { }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className className
)} )}
{...props} {...props}
/> />
) )
} }
export { Label } export { Label }

View file

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

View file

@ -1,30 +1,30 @@
import * as React from "react" import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type ProgressProps = React.HTMLAttributes<HTMLDivElement> & { type ProgressProps = React.HTMLAttributes<HTMLDivElement> & {
value?: number value?: number
} }
export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>( export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, ...props }, ref) => { ({ className, value = 0, ...props }, ref) => {
const clamped = Math.min(100, Math.max(0, value)) const clamped = Math.min(100, Math.max(0, value))
return ( return (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-muted", "relative h-2 w-full overflow-hidden rounded-full bg-muted",
className className
)} )}
{...props} {...props}
> >
<div <div
className="h-full w-full flex-1 bg-primary transition-all" className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - clamped}%)` }} style={{ transform: `translateX(-${100 - clamped}%)` }}
/> />
</div> </div>
) )
} }
) )
Progress.displayName = "Progress" Progress.displayName = "Progress"

View file

@ -1,185 +1,185 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Select({ function Select({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) { }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} /> return <SelectPrimitive.Root data-slot="select" {...props} />
} }
function SelectGroup({ function SelectGroup({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) { }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} /> return <SelectPrimitive.Group data-slot="select-group" {...props} />
} }
function SelectValue({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} /> return <SelectPrimitive.Value data-slot="select-value" {...props} />
} }
function SelectTrigger({ function SelectTrigger({
className, className,
size = "default", size = "default",
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: "sm" | "default"
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) )
} }
function SelectContent({ function SelectContent({
className, className,
children, children,
position = "popper", position = "popper",
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) { }: React.ComponentProps<typeof SelectPrimitive.Content>) {
return ( return (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( className={cn(
"bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", "bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className
)} )}
position={position} position={position}
{...props} {...props}
> >
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)} )}
> >
{children} {children}
</SelectPrimitive.Viewport> </SelectPrimitive.Viewport>
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) )
} }
function SelectLabel({ function SelectLabel({
className, className,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) { }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
data-slot="select-label" data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props} {...props}
/> />
) )
} }
function SelectItem({ function SelectItem({
className, className,
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) { }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className
)} )}
{...props} {...props}
> >
<span className="absolute right-2 flex size-3.5 items-center justify-center"> <span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> <CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) )
} }
function SelectSeparator({ function SelectSeparator({
className, className,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) { }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
data-slot="select-separator" data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) )
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
className, className,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return ( return (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className
)} )}
{...props} {...props}
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) )
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
className, className,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return ( return (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className
)} )}
{...props} {...props}
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) )
} }
export { export {
Select, Select,
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectLabel, SelectLabel,
SelectScrollDownButton, SelectScrollDownButton,
SelectScrollUpButton, SelectScrollUpButton,
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} }

View file

@ -1,28 +1,28 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Separator({ function Separator({
className, className,
orientation = "horizontal", orientation = "horizontal",
decorative = true, decorative = true,
...props ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return ( return (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
data-slot="separator" data-slot="separator"
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className className
)} )}
{...props} {...props}
/> />
) )
} }
export { Separator } export { Separator }

View file

@ -1,139 +1,139 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog" import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />
} }
function SheetTrigger({ function SheetTrigger({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
} }
function SheetClose({ function SheetClose({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) { }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
} }
function SheetPortal({ function SheetPortal({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) { }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
} }
function SheetOverlay({ function SheetOverlay({
className, className,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) { }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return ( return (
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function SheetContent({ function SheetContent({
className, className,
children, children,
side = "right", side = "right",
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left"
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay />
<SheetPrimitive.Content <SheetPrimitive.Content
data-slot="sheet-content" data-slot="sheet-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" && side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm", "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" && side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm", "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" && side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" && side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" /> <XIcon className="size-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
) )
} }
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sheet-header" data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} {...props}
/> />
) )
} }
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sheet-footer" data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) )
} }
function SheetTitle({ function SheetTitle({
className, className,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) { }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return ( return (
<SheetPrimitive.Title <SheetPrimitive.Title
data-slot="sheet-title" data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) )
} }
function SheetDescription({ function SheetDescription({
className, className,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) { }: React.ComponentProps<typeof SheetPrimitive.Description>) {
return ( return (
<SheetPrimitive.Description <SheetPrimitive.Description
data-slot="sheet-description" data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) )
} }
export { export {
Sheet, Sheet,
SheetTrigger, SheetTrigger,
SheetClose, SheetClose,
SheetContent, SheetContent,
SheetHeader, SheetHeader,
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} }

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,13 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="skeleton" data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)} className={cn("bg-accent animate-pulse rounded-md", className)}
{...props} {...props}
/> />
) )
} }
export { Skeleton } export { Skeleton }

View file

@ -1,25 +1,25 @@
"use client" "use client"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner" import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme()
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
style={ style={
{ {
"--normal-bg": "var(--popover)", "--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)", "--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)", "--normal-border": "var(--border)",
} as React.CSSProperties } as React.CSSProperties
} }
{...props} {...props}
/> />
) )
} }
export { Toaster } export { Toaster }

View file

@ -1,116 +1,116 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<"table">) {
return ( return (
<div <div
data-slot="table-container" data-slot="table-container"
className="relative w-full overflow-x-auto" className="relative w-full overflow-x-auto"
> >
<table <table
data-slot="table" data-slot="table"
className={cn("w-full caption-bottom text-sm", className)} className={cn("w-full caption-bottom text-sm", className)}
{...props} {...props}
/> />
</div> </div>
) )
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return ( return (
<thead <thead
data-slot="table-header" data-slot="table-header"
className={cn("[&_tr]:border-b", className)} className={cn("[&_tr]:border-b", className)}
{...props} {...props}
/> />
) )
} }
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return ( return (
<tbody <tbody
data-slot="table-body" data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} {...props}
/> />
) )
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return ( return (
<tfoot <tfoot
data-slot="table-footer" data-slot="table-footer"
className={cn( className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return ( return (
<tr <tr
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return ( return (
<th <th
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return ( return (
<td <td
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function TableCaption({ function TableCaption({
className, className,
...props ...props
}: React.ComponentProps<"caption">) { }: React.ComponentProps<"caption">) {
return ( return (
<caption <caption
data-slot="table-caption" data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)} className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props} {...props}
/> />
) )
} }
export { export {
Table, Table,
TableHeader, TableHeader,
TableBody, TableBody,
TableFooter, TableFooter,
TableHead, TableHead,
TableRow, TableRow,
TableCell, TableCell,
TableCaption, TableCaption,
} }

View file

@ -1,66 +1,66 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs" import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Tabs({ function Tabs({
className, className,
...props ...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) { }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return ( return (
<TabsPrimitive.Root <TabsPrimitive.Root
data-slot="tabs" data-slot="tabs"
className={cn("flex flex-col gap-2", className)} className={cn("flex flex-col gap-2", className)}
{...props} {...props}
/> />
) )
} }
function TabsList({ function TabsList({
className, className,
...props ...props
}: React.ComponentProps<typeof TabsPrimitive.List>) { }: React.ComponentProps<typeof TabsPrimitive.List>) {
return ( return (
<TabsPrimitive.List <TabsPrimitive.List
data-slot="tabs-list" data-slot="tabs-list"
className={cn( className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function TabsTrigger({ function TabsTrigger({
className, className,
...props ...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) { }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return ( return (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
/> />
) )
} }
function TabsContent({ function TabsContent({
className, className,
...props ...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) { }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return ( return (
<TabsPrimitive.Content <TabsPrimitive.Content
data-slot="tabs-content" data-slot="tabs-content"
className={cn("flex-1 outline-none", className)} className={cn("flex-1 outline-none", className)}
{...props} {...props}
/> />
) )
} }
export { Tabs, TabsList, TabsTrigger, TabsContent } export { Tabs, TabsList, TabsTrigger, TabsContent }

View file

@ -1,73 +1,73 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority" import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle" import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext< const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> VariantProps<typeof toggleVariants>
>({ >({
size: "default", size: "default",
variant: "default", variant: "default",
}) })
function ToggleGroup({ function ToggleGroup({
className, className,
variant, variant,
size, size,
children, children,
...props ...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & }: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) { VariantProps<typeof toggleVariants>) {
return ( return (
<ToggleGroupPrimitive.Root <ToggleGroupPrimitive.Root
data-slot="toggle-group" data-slot="toggle-group"
data-variant={variant} data-variant={variant}
data-size={size} data-size={size}
className={cn( className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs", "group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className className
)} )}
{...props} {...props}
> >
<ToggleGroupContext.Provider value={{ variant, size }}> <ToggleGroupContext.Provider value={{ variant, size }}>
{children} {children}
</ToggleGroupContext.Provider> </ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root> </ToggleGroupPrimitive.Root>
) )
} }
function ToggleGroupItem({ function ToggleGroupItem({
className, className,
children, children,
variant, variant,
size, size,
...props ...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) { VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext) const context = React.useContext(ToggleGroupContext)
return ( return (
<ToggleGroupPrimitive.Item <ToggleGroupPrimitive.Item
data-slot="toggle-group-item" data-slot="toggle-group-item"
data-variant={context.variant || variant} data-variant={context.variant || variant}
data-size={context.size || size} data-size={context.size || size}
className={cn( className={cn(
toggleVariants({ toggleVariants({
variant: context.variant || variant, variant: context.variant || variant,
size: context.size || size, size: context.size || size,
}), }),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l", "min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className className
)} )}
{...props} {...props}
> >
{children} {children}
</ToggleGroupPrimitive.Item> </ToggleGroupPrimitive.Item>
) )
} }
export { ToggleGroup, ToggleGroupItem } export { ToggleGroup, ToggleGroupItem }

View file

@ -1,47 +1,47 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle" import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const toggleVariants = cva( const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-transparent", default: "bg-transparent",
outline: outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
}, },
size: { size: {
default: "h-9 px-2 min-w-9", default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8", sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10", lg: "h-10 px-2.5 min-w-10",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} }
) )
function Toggle({ function Toggle({
className, className,
variant, variant,
size, size,
...props ...props
}: React.ComponentProps<typeof TogglePrimitive.Root> & }: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) { VariantProps<typeof toggleVariants>) {
return ( return (
<TogglePrimitive.Root <TogglePrimitive.Root
data-slot="toggle" data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))} className={cn(toggleVariants({ variant, size, className }))}
{...props} {...props}
/> />
) )
} }
export { Toggle, toggleVariants } export { Toggle, toggleVariants }

View file

@ -1,61 +1,61 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip" import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) { }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return ( return (
<TooltipPrimitive.Provider <TooltipPrimitive.Provider
data-slot="tooltip-provider" data-slot="tooltip-provider"
delayDuration={delayDuration} delayDuration={delayDuration}
{...props} {...props}
/> />
) )
} }
function Tooltip({ function Tooltip({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) { }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return ( return (
<TooltipProvider> <TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} /> <TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider> </TooltipProvider>
) )
} }
function TooltipTrigger({ function TooltipTrigger({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
} }
function TooltipContent({ function TooltipContent({
className, className,
sideOffset = 0, sideOffset = 0,
children, children,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) { }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return ( return (
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipPrimitive.Content <TooltipPrimitive.Content
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", "bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) )
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View file

@ -1,67 +1,67 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { Check, ChevronsUpDown, GalleryVerticalEnd } from "lucide-react" import { Check, ChevronsUpDown, GalleryVerticalEnd } from "lucide-react"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
type VersionSwitcherProps = { type VersionSwitcherProps = {
versions: string[] versions: string[]
defaultVersion: string defaultVersion: string
label?: string label?: string
} }
export function VersionSwitcher({ export function VersionSwitcher({
versions, versions,
defaultVersion, defaultVersion,
label = "Documentation", label = "Documentation",
}: VersionSwitcherProps) { }: VersionSwitcherProps) {
const [selectedVersion, setSelectedVersion] = React.useState(defaultVersion) const [selectedVersion, setSelectedVersion] = React.useState(defaultVersion)
return ( return (
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
size="lg" size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"> <div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<GalleryVerticalEnd className="size-4" /> <GalleryVerticalEnd className="size-4" />
</div> </div>
<div className="flex flex-col gap-0.5 leading-none"> <div className="flex flex-col gap-0.5 leading-none">
<span className="font-medium">{label}</span> <span className="font-medium">{label}</span>
<span className="text-xs text-muted-foreground">v{selectedVersion}</span> <span className="text-xs text-muted-foreground">v{selectedVersion}</span>
</div> </div>
<ChevronsUpDown className="ml-auto" /> <ChevronsUpDown className="ml-auto" />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width)" className="w-(--radix-dropdown-menu-trigger-width)"
align="start" align="start"
> >
{versions.map((version) => ( {versions.map((version) => (
<DropdownMenuItem <DropdownMenuItem
key={version} key={version}
onSelect={() => setSelectedVersion(version)} onSelect={() => setSelectedVersion(version)}
> >
v{version} {version === selectedVersion && <Check className="ml-auto" />} v{version} {version === selectedVersion && <Check className="ml-auto" />}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
) )
} }

View file

@ -1,19 +1,19 @@
import * as React from "react" import * as React from "react"
const MOBILE_BREAKPOINT = 768 const MOBILE_BREAKPOINT = 768
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => { React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => { const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
} }
mql.addEventListener("change", onChange) mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange) return () => mql.removeEventListener("change", onChange)
}, []) }, [])
return !!isMobile return !!isMobile
} }

View file

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

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

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

View file

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

View file

@ -1,312 +1,312 @@
import { addMinutes, subHours, subMinutes } from "date-fns" import { addMinutes, subHours, subMinutes } from "date-fns"
import { z } from "zod" import { z } from "zod"
import { import {
commentVisibilitySchema, commentVisibilitySchema,
ticketChannelSchema, ticketChannelSchema,
ticketPrioritySchema, ticketPrioritySchema,
ticketSchema, ticketSchema,
ticketStatusSchema, ticketStatusSchema,
ticketWithDetailsSchema, ticketWithDetailsSchema,
ticketPlayContextSchema, ticketPlayContextSchema,
ticketQueueSummarySchema, ticketQueueSummarySchema,
} from "@/lib/schemas/ticket" } from "@/lib/schemas/ticket"
const tenantId = "tenant-atlas" const tenantId = "tenant-atlas"
type UserRecord = z.infer<typeof ticketSchema>["requester"] type UserRecord = z.infer<typeof ticketSchema>["requester"]
const users: Record<string, UserRecord> = { const users: Record<string, UserRecord> = {
ana: { ana: {
id: "user-ana", id: "user-ana",
name: "Ana Souza", name: "Ana Souza",
email: "ana.souza@example.com", email: "ana.souza@example.com",
avatarUrl: "https://avatar.vercel.sh/ana", avatarUrl: "https://avatar.vercel.sh/ana",
teams: ["Suporte N1"], teams: ["Suporte N1"],
}, },
bruno: { bruno: {
id: "user-bruno", id: "user-bruno",
name: "Bruno Lima", name: "Bruno Lima",
email: "bruno.lima@example.com", email: "bruno.lima@example.com",
avatarUrl: "https://avatar.vercel.sh/bruno", avatarUrl: "https://avatar.vercel.sh/bruno",
teams: ["Suporte N1"], teams: ["Suporte N1"],
}, },
carla: { carla: {
id: "user-carla", id: "user-carla",
name: "Carla Menezes", name: "Carla Menezes",
email: "carla.menezes@example.com", email: "carla.menezes@example.com",
avatarUrl: "https://avatar.vercel.sh/carla", avatarUrl: "https://avatar.vercel.sh/carla",
teams: ["Suporte N2"], teams: ["Suporte N2"],
}, },
diego: { diego: {
id: "user-diego", id: "user-diego",
name: "Diego Ramos", name: "Diego Ramos",
email: "diego.ramos@example.com", email: "diego.ramos@example.com",
avatarUrl: "https://avatar.vercel.sh/diego", avatarUrl: "https://avatar.vercel.sh/diego",
teams: ["Field Services"], teams: ["Field Services"],
}, },
eduarda: { eduarda: {
id: "user-eduarda", id: "user-eduarda",
name: "Eduarda Rocha", name: "Eduarda Rocha",
email: "eduarda.rocha@example.com", email: "eduarda.rocha@example.com",
avatarUrl: "https://avatar.vercel.sh/eduarda", avatarUrl: "https://avatar.vercel.sh/eduarda",
teams: ["Clientes"], teams: ["Clientes"],
}, },
} }
const queues = [ const queues = [
{ id: "queue-suporte-n1", name: "Suporte N1", pending: 18, waiting: 4, breached: 2 }, { id: "queue-suporte-n1", name: "Suporte N1", pending: 18, waiting: 4, breached: 2 },
{ id: "queue-suporte-n2", name: "Suporte N2", pending: 9, waiting: 3, breached: 1 }, { id: "queue-suporte-n2", name: "Suporte N2", pending: 9, waiting: 3, breached: 1 },
{ id: "queue-field", name: "Field Services", pending: 5, waiting: 2, breached: 0 }, { id: "queue-field", name: "Field Services", pending: 5, waiting: 2, breached: 0 },
] ]
const baseTickets = [ const baseTickets = [
{ {
id: "ticket-1001", id: "ticket-1001",
tenantId, tenantId,
reference: 41001, reference: 41001,
subject: "Erro 500 ao acessar portal do cliente", subject: "Erro 500 ao acessar portal do cliente",
summary: "Clientes relatam erro intermitente no portal web", summary: "Clientes relatam erro intermitente no portal web",
status: ticketStatusSchema.enum.OPEN, status: ticketStatusSchema.enum.OPEN,
priority: ticketPrioritySchema.enum.URGENT, priority: ticketPrioritySchema.enum.URGENT,
channel: ticketChannelSchema.enum.EMAIL, channel: ticketChannelSchema.enum.EMAIL,
queue: "Suporte N1", queue: "Suporte N1",
requester: users.eduarda, requester: users.eduarda,
assignee: users.ana, assignee: users.ana,
slaPolicy: { slaPolicy: {
id: "sla-critical", id: "sla-critical",
name: "SLA Crítico", name: "SLA Crítico",
targetMinutesToFirstResponse: 15, targetMinutesToFirstResponse: 15,
targetMinutesToResolution: 240, targetMinutesToResolution: 240,
}, },
dueAt: addMinutes(new Date(), 120), dueAt: addMinutes(new Date(), 120),
firstResponseAt: subMinutes(new Date(), 20), firstResponseAt: subMinutes(new Date(), 20),
resolvedAt: null, resolvedAt: null,
updatedAt: subMinutes(new Date(), 10), updatedAt: subMinutes(new Date(), 10),
createdAt: subHours(new Date(), 5), createdAt: subHours(new Date(), 5),
tags: ["portal", "cliente"], tags: ["portal", "cliente"],
lastTimelineEntry: "Prioridade atualizada para URGENT por Bruno", lastTimelineEntry: "Prioridade atualizada para URGENT por Bruno",
metrics: { metrics: {
timeWaitingMinutes: 12, timeWaitingMinutes: 12,
timeOpenedMinutes: 300, timeOpenedMinutes: 300,
}, },
}, },
{ {
id: "ticket-1002", id: "ticket-1002",
tenantId, tenantId,
reference: 41002, reference: 41002,
subject: "Integração ERP parada", subject: "Integração ERP parada",
summary: "Webhook do ERP retornando timeout", summary: "Webhook do ERP retornando timeout",
status: ticketStatusSchema.enum.PENDING, status: ticketStatusSchema.enum.PENDING,
priority: ticketPrioritySchema.enum.HIGH, priority: ticketPrioritySchema.enum.HIGH,
channel: ticketChannelSchema.enum.WHATSAPP, channel: ticketChannelSchema.enum.WHATSAPP,
queue: "Suporte N2", queue: "Suporte N2",
requester: users.eduarda, requester: users.eduarda,
assignee: users.carla, assignee: users.carla,
slaPolicy: { slaPolicy: {
id: "sla-priority", id: "sla-priority",
name: "SLA Prioritário", name: "SLA Prioritário",
targetMinutesToFirstResponse: 30, targetMinutesToFirstResponse: 30,
targetMinutesToResolution: 360, targetMinutesToResolution: 360,
}, },
dueAt: addMinutes(new Date(), 240), dueAt: addMinutes(new Date(), 240),
firstResponseAt: subMinutes(new Date(), 60), firstResponseAt: subMinutes(new Date(), 60),
resolvedAt: null, resolvedAt: null,
updatedAt: subMinutes(new Date(), 30), updatedAt: subMinutes(new Date(), 30),
createdAt: subHours(new Date(), 8), createdAt: subHours(new Date(), 8),
tags: ["Integração", "erp"], tags: ["Integração", "erp"],
lastTimelineEntry: "Aguardando retorno do fornecedor externo", lastTimelineEntry: "Aguardando retorno do fornecedor externo",
metrics: { metrics: {
timeWaitingMinutes: 90, timeWaitingMinutes: 90,
timeOpenedMinutes: 420, timeOpenedMinutes: 420,
}, },
}, },
{ {
id: "ticket-1003", id: "ticket-1003",
tenantId, tenantId,
reference: 41003, reference: 41003,
subject: "Solicitação de acesso VPN", subject: "Solicitação de acesso VPN",
summary: "Novo colaborador precisa de acesso", summary: "Novo colaborador precisa de acesso",
status: ticketStatusSchema.enum.NEW, status: ticketStatusSchema.enum.NEW,
priority: ticketPrioritySchema.enum.MEDIUM, priority: ticketPrioritySchema.enum.MEDIUM,
channel: ticketChannelSchema.enum.MANUAL, channel: ticketChannelSchema.enum.MANUAL,
queue: "Field Services", queue: "Field Services",
requester: users.eduarda, requester: users.eduarda,
assignee: null, assignee: null,
slaPolicy: null, slaPolicy: null,
dueAt: null, dueAt: null,
firstResponseAt: null, firstResponseAt: null,
resolvedAt: null, resolvedAt: null,
updatedAt: subHours(new Date(), 1), updatedAt: subHours(new Date(), 1),
createdAt: subHours(new Date(), 1), createdAt: subHours(new Date(), 1),
tags: ["vpn"], tags: ["vpn"],
metrics: null, metrics: null,
}, },
{ {
id: "ticket-1004", id: "ticket-1004",
tenantId, tenantId,
reference: 41004, reference: 41004,
subject: "Bug no app mobile - upload de foto", subject: "Bug no app mobile - upload de foto",
summary: "Upload trava com arquivos acima de 5MB", summary: "Upload trava com arquivos acima de 5MB",
status: ticketStatusSchema.enum.ON_HOLD, status: ticketStatusSchema.enum.ON_HOLD,
priority: ticketPrioritySchema.enum.HIGH, priority: ticketPrioritySchema.enum.HIGH,
channel: ticketChannelSchema.enum.CHAT, channel: ticketChannelSchema.enum.CHAT,
queue: "Suporte N2", queue: "Suporte N2",
requester: users.eduarda, requester: users.eduarda,
assignee: users.carla, assignee: users.carla,
slaPolicy: { slaPolicy: {
id: "sla-standard", id: "sla-standard",
name: "SLA Padrão", name: "SLA Padrão",
targetMinutesToFirstResponse: 60, targetMinutesToFirstResponse: 60,
targetMinutesToResolution: 720, targetMinutesToResolution: 720,
}, },
dueAt: addMinutes(new Date(), 360), dueAt: addMinutes(new Date(), 360),
firstResponseAt: subMinutes(new Date(), 50), firstResponseAt: subMinutes(new Date(), 50),
resolvedAt: null, resolvedAt: null,
updatedAt: subMinutes(new Date(), 90), updatedAt: subMinutes(new Date(), 90),
createdAt: subHours(new Date(), 12), createdAt: subHours(new Date(), 12),
tags: ["mobile", "upload"], tags: ["mobile", "upload"],
lastTimelineEntry: "Ticket pausado aguardando logs do time Mobile", lastTimelineEntry: "Ticket pausado aguardando logs do time Mobile",
metrics: { metrics: {
timeWaitingMinutes: 180, timeWaitingMinutes: 180,
timeOpenedMinutes: 720, timeOpenedMinutes: 720,
}, },
}, },
{ {
id: "ticket-1005", id: "ticket-1005",
tenantId, tenantId,
reference: 41005, reference: 41005,
subject: "Reclamação de cobranca duplicada", subject: "Reclamação de cobranca duplicada",
summary: "Cliente recebeu boleto duplicado", summary: "Cliente recebeu boleto duplicado",
status: ticketStatusSchema.enum.RESOLVED, status: ticketStatusSchema.enum.RESOLVED,
priority: ticketPrioritySchema.enum.MEDIUM, priority: ticketPrioritySchema.enum.MEDIUM,
channel: ticketChannelSchema.enum.EMAIL, channel: ticketChannelSchema.enum.EMAIL,
queue: "Suporte N1", queue: "Suporte N1",
requester: users.eduarda, requester: users.eduarda,
assignee: users.bruno, assignee: users.bruno,
slaPolicy: { slaPolicy: {
id: "sla-standard", id: "sla-standard",
name: "SLA Padrão", name: "SLA Padrão",
targetMinutesToFirstResponse: 60, targetMinutesToFirstResponse: 60,
targetMinutesToResolution: 720, targetMinutesToResolution: 720,
}, },
dueAt: null, dueAt: null,
firstResponseAt: subMinutes(new Date(), 80), firstResponseAt: subMinutes(new Date(), 80),
resolvedAt: subMinutes(new Date(), 10), resolvedAt: subMinutes(new Date(), 10),
updatedAt: subMinutes(new Date(), 5), updatedAt: subMinutes(new Date(), 5),
createdAt: subHours(new Date(), 20), createdAt: subHours(new Date(), 20),
tags: ["financeiro"], tags: ["financeiro"],
lastTimelineEntry: "Ticket resolvido, aguardando confirmação do cliente", lastTimelineEntry: "Ticket resolvido, aguardando confirmação do cliente",
metrics: { metrics: {
timeWaitingMinutes: 30, timeWaitingMinutes: 30,
timeOpenedMinutes: 1100, timeOpenedMinutes: 1100,
}, },
}, },
] ]
export const tickets = baseTickets as Array<z.infer<typeof ticketSchema>> export const tickets = baseTickets as Array<z.infer<typeof ticketSchema>>
const commentsByTicket: Record<string, z.infer<typeof ticketWithDetailsSchema>["comments"]> = { const commentsByTicket: Record<string, z.infer<typeof ticketWithDetailsSchema>["comments"]> = {
"ticket-1001": [ "ticket-1001": [
{ {
id: "comment-1", id: "comment-1",
author: users.ana, author: users.ana,
visibility: commentVisibilitySchema.enum.INTERNAL, visibility: commentVisibilitySchema.enum.INTERNAL,
body: "Logs coletados e enviados para o time de infraestrutura.", body: "Logs coletados e enviados para o time de infraestrutura.",
attachments: [], attachments: [],
createdAt: subMinutes(new Date(), 40), createdAt: subMinutes(new Date(), 40),
updatedAt: subMinutes(new Date(), 40), updatedAt: subMinutes(new Date(), 40),
}, },
{ {
id: "comment-2", id: "comment-2",
author: users.bruno, author: users.bruno,
visibility: commentVisibilitySchema.enum.PUBLIC, visibility: commentVisibilitySchema.enum.PUBLIC,
body: "Estamos investigando o incidente, retorno em 30 minutos.", body: "Estamos investigando o incidente, retorno em 30 minutos.",
attachments: [], attachments: [],
createdAt: subMinutes(new Date(), 25), createdAt: subMinutes(new Date(), 25),
updatedAt: subMinutes(new Date(), 25), updatedAt: subMinutes(new Date(), 25),
}, },
], ],
"ticket-1002": [ "ticket-1002": [
{ {
id: "comment-3", id: "comment-3",
author: users.carla, author: users.carla,
visibility: commentVisibilitySchema.enum.INTERNAL, visibility: commentVisibilitySchema.enum.INTERNAL,
body: "Contato realizado com fornecedor, aguardando confirmação.", body: "Contato realizado com fornecedor, aguardando confirmação.",
attachments: [], attachments: [],
createdAt: subMinutes(new Date(), 70), createdAt: subMinutes(new Date(), 70),
updatedAt: subMinutes(new Date(), 70), updatedAt: subMinutes(new Date(), 70),
}, },
], ],
} }
const timelineByTicket: Record<string, z.infer<typeof ticketWithDetailsSchema>["timeline"]> = { const timelineByTicket: Record<string, z.infer<typeof ticketWithDetailsSchema>["timeline"]> = {
"ticket-1001": [ "ticket-1001": [
{ {
id: "timeline-1", id: "timeline-1",
type: "STATUS_CHANGED", type: "STATUS_CHANGED",
payload: { from: "NEW", to: "OPEN" }, payload: { from: "NEW", to: "OPEN" },
createdAt: subHours(new Date(), 5), createdAt: subHours(new Date(), 5),
}, },
{ {
id: "timeline-2", id: "timeline-2",
type: "ASSIGNEE_CHANGED", type: "ASSIGNEE_CHANGED",
payload: { assignee: users.ana.name }, payload: { assignee: users.ana.name },
createdAt: subHours(new Date(), 4), createdAt: subHours(new Date(), 4),
}, },
{ {
id: "timeline-3", id: "timeline-3",
type: "COMMENT_ADDED", type: "COMMENT_ADDED",
payload: { author: users.ana.name }, payload: { author: users.ana.name },
createdAt: subHours(new Date(), 1), createdAt: subHours(new Date(), 1),
}, },
], ],
"ticket-1002": [ "ticket-1002": [
{ {
id: "timeline-4", id: "timeline-4",
type: "STATUS_CHANGED", type: "STATUS_CHANGED",
payload: { from: "OPEN", to: "PENDING" }, payload: { from: "OPEN", to: "PENDING" },
createdAt: subHours(new Date(), 3), createdAt: subHours(new Date(), 3),
}, },
], ],
} }
export const ticketDetails = tickets.map((ticket) => ({ export const ticketDetails = tickets.map((ticket) => ({
...ticket, ...ticket,
description: description:
"Incidente reportado automaticamente pelo monitoramento. Logs indicam aumento de latência em chamadas ao servico de autenticação.", "Incidente reportado automaticamente pelo monitoramento. Logs indicam aumento de latência em chamadas ao servico de autenticação.",
customFields: { customFields: {
ambiente: "Produção", ambiente: "Produção",
categoria: "Incidente", categoria: "Incidente",
impacto: "Alto", impacto: "Alto",
}, },
timeline: timeline:
timelineByTicket[ticket.id] ?? timelineByTicket[ticket.id] ??
([ ([
{ {
id: `timeline-${ticket.id}`, id: `timeline-${ticket.id}`,
type: "CREATED", type: "CREATED",
createdAt: ticket.createdAt, createdAt: ticket.createdAt,
payload: { requester: ticket.requester.name }, payload: { requester: ticket.requester.name },
}, },
] as z.infer<typeof ticketWithDetailsSchema>["timeline"]), ] as z.infer<typeof ticketWithDetailsSchema>["timeline"]),
comments: commentsByTicket[ticket.id] ?? [], comments: commentsByTicket[ticket.id] ?? [],
})) as Array<z.infer<typeof ticketWithDetailsSchema>> })) as Array<z.infer<typeof ticketWithDetailsSchema>>
export function getTicketById(id: string) { export function getTicketById(id: string) {
return ticketDetails.find((ticket) => ticket.id === id) return ticketDetails.find((ticket) => ticket.id === id)
} }
export const queueSummaries = queues as Array<z.infer<typeof ticketQueueSummarySchema>> export const queueSummaries = queues as Array<z.infer<typeof ticketQueueSummarySchema>>
export const playContext = { export const playContext = {
queue: queueSummaries[0], queue: queueSummaries[0],
nextTicket: nextTicket:
tickets.find( tickets.find(
(ticket) => (ticket) =>
ticket.status !== ticketStatusSchema.enum.RESOLVED && ticket.status !== ticketStatusSchema.enum.RESOLVED &&
ticket.status !== ticketStatusSchema.enum.CLOSED ticket.status !== ticketStatusSchema.enum.CLOSED
) ?? null, ) ?? null,
} as z.infer<typeof ticketPlayContextSchema> } as z.infer<typeof ticketPlayContextSchema>

View file

@ -1,124 +1,124 @@
import { z } from "zod" import { z } from "zod"
export const ticketStatusSchema = z.enum([ export const ticketStatusSchema = z.enum([
"NEW", "NEW",
"OPEN", "OPEN",
"PENDING", "PENDING",
"ON_HOLD", "ON_HOLD",
"RESOLVED", "RESOLVED",
"CLOSED", "CLOSED",
]) ])
export type TicketStatus = z.infer<typeof ticketStatusSchema> export type TicketStatus = z.infer<typeof ticketStatusSchema>
export const ticketPrioritySchema = z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]) export const ticketPrioritySchema = z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"])
export type TicketPriority = z.infer<typeof ticketPrioritySchema> export type TicketPriority = z.infer<typeof ticketPrioritySchema>
export const ticketChannelSchema = z.enum([ export const ticketChannelSchema = z.enum([
"EMAIL", "EMAIL",
"WHATSAPP", "WHATSAPP",
"CHAT", "CHAT",
"PHONE", "PHONE",
"API", "API",
"MANUAL", "MANUAL",
]) ])
export type TicketChannel = z.infer<typeof ticketChannelSchema> export type TicketChannel = z.infer<typeof ticketChannelSchema>
export const commentVisibilitySchema = z.enum(["PUBLIC", "INTERNAL"]) export const commentVisibilitySchema = z.enum(["PUBLIC", "INTERNAL"])
export type CommentVisibility = z.infer<typeof commentVisibilitySchema> export type CommentVisibility = z.infer<typeof commentVisibilitySchema>
export const userSummarySchema = z.object({ export const userSummarySchema = z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
email: z.string().email(), email: z.string().email(),
avatarUrl: z.string().url().optional(), avatarUrl: z.string().url().optional(),
teams: z.array(z.string()).default([]), teams: z.array(z.string()).default([]),
}) })
export type UserSummary = z.infer<typeof userSummarySchema> export type UserSummary = z.infer<typeof userSummarySchema>
export const ticketCommentSchema = z.object({ export const ticketCommentSchema = z.object({
id: z.string(), id: z.string(),
author: userSummarySchema, author: userSummarySchema,
visibility: commentVisibilitySchema, visibility: commentVisibilitySchema,
body: z.string(), body: z.string(),
attachments: z attachments: z
.array( .array(
z.object({ z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
size: z.number().optional(), size: z.number().optional(),
url: z.string().url().optional(), url: z.string().url().optional(),
}) })
) )
.default([]), .default([]),
createdAt: z.coerce.date(), createdAt: z.coerce.date(),
updatedAt: z.coerce.date(), updatedAt: z.coerce.date(),
}) })
export type TicketComment = z.infer<typeof ticketCommentSchema> export type TicketComment = z.infer<typeof ticketCommentSchema>
export const ticketEventSchema = z.object({ export const ticketEventSchema = z.object({
id: z.string(), id: z.string(),
type: z.string(), type: z.string(),
payload: z.record(z.any()).optional(), payload: z.record(z.any()).optional(),
createdAt: z.coerce.date(), createdAt: z.coerce.date(),
}) })
export type TicketEvent = z.infer<typeof ticketEventSchema> export type TicketEvent = z.infer<typeof ticketEventSchema>
export const ticketSchema = z.object({ export const ticketSchema = z.object({
id: z.string(), id: z.string(),
reference: z.number(), reference: z.number(),
tenantId: z.string(), tenantId: z.string(),
subject: z.string(), subject: z.string(),
summary: z.string().optional(), summary: z.string().optional(),
status: ticketStatusSchema, status: ticketStatusSchema,
priority: ticketPrioritySchema, priority: ticketPrioritySchema,
channel: ticketChannelSchema, channel: ticketChannelSchema,
queue: z.string().nullable(), queue: z.string().nullable(),
requester: userSummarySchema, requester: userSummarySchema,
assignee: userSummarySchema.nullable(), assignee: userSummarySchema.nullable(),
slaPolicy: z slaPolicy: z
.object({ .object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
targetMinutesToFirstResponse: z.number().nullable(), targetMinutesToFirstResponse: z.number().nullable(),
targetMinutesToResolution: z.number().nullable(), targetMinutesToResolution: z.number().nullable(),
}) })
.nullable(), .nullable(),
dueAt: z.coerce.date().nullable(), dueAt: z.coerce.date().nullable(),
firstResponseAt: z.coerce.date().nullable(), firstResponseAt: z.coerce.date().nullable(),
resolvedAt: z.coerce.date().nullable(), resolvedAt: z.coerce.date().nullable(),
updatedAt: z.coerce.date(), updatedAt: z.coerce.date(),
createdAt: z.coerce.date(), createdAt: z.coerce.date(),
tags: z.array(z.string()).default([]), tags: z.array(z.string()).default([]),
lastTimelineEntry: z.string().optional(), lastTimelineEntry: z.string().optional(),
metrics: z metrics: z
.object({ .object({
timeWaitingMinutes: z.number().nullable(), timeWaitingMinutes: z.number().nullable(),
timeOpenedMinutes: z.number().nullable(), timeOpenedMinutes: z.number().nullable(),
}) })
.nullable(), .nullable(),
}) })
export type Ticket = z.infer<typeof ticketSchema> export type Ticket = z.infer<typeof ticketSchema>
export const ticketWithDetailsSchema = ticketSchema.extend({ export const ticketWithDetailsSchema = ticketSchema.extend({
description: z.string().optional(), description: z.string().optional(),
customFields: z.record(z.any()).default({}), customFields: z.record(z.any()).default({}),
timeline: z.array(ticketEventSchema), timeline: z.array(ticketEventSchema),
comments: z.array(ticketCommentSchema), comments: z.array(ticketCommentSchema),
}) })
export type TicketWithDetails = z.infer<typeof ticketWithDetailsSchema> export type TicketWithDetails = z.infer<typeof ticketWithDetailsSchema>
export const ticketQueueSummarySchema = z.object({ export const ticketQueueSummarySchema = z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
pending: z.number(), pending: z.number(),
waiting: z.number(), waiting: z.number(),
breached: z.number(), breached: z.number(),
}) })
export type TicketQueueSummary = z.infer<typeof ticketQueueSummarySchema> export type TicketQueueSummary = z.infer<typeof ticketQueueSummarySchema>
export const ticketPlayContextSchema = z.object({ export const ticketPlayContextSchema = z.object({
queue: ticketQueueSummarySchema, queue: ticketQueueSummarySchema,
nextTicket: ticketSchema.nullable(), nextTicket: ticketSchema.nullable(),
}) })
export type TicketPlayContext = z.infer<typeof ticketPlayContextSchema> export type TicketPlayContext = z.infer<typeof ticketPlayContextSchema>

View file

@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }

View file

@ -1,27 +1,27 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }