feat(filters): ticket company filter + column; reports: company filter in CSVs; dashboard: queue summary; docs: agents.md and roadmap updates
This commit is contained in:
parent
70f91f5bbd
commit
2cf399dcb1
9 changed files with 100 additions and 31 deletions
|
|
@ -28,10 +28,10 @@ Lista priorizada de evoluções propostas para o Sistema de Chamados. Confira `a
|
||||||
|
|
||||||
# 📊 Dashboards e relatórios
|
# 📊 Dashboards e relatórios
|
||||||
|
|
||||||
- [ ] Criar **dashboard inicial com fila de atendimento**
|
- [x] Criar **dashboard inicial com fila de atendimento**
|
||||||
- [ ] Exibir chamados em: atendimento, laboratório, visitas
|
- [x] Exibir chamados em: atendimento, laboratório, visitas
|
||||||
- [ ] Indicadores: abertos, resolvidos, tempo médio, SLA
|
- [x] Indicadores: abertos, resolvidos, tempo médio, SLA
|
||||||
- [ ] Criar **relatório de horas por cliente (CSV/Dashboard)**
|
- [x] Criar **relatório de horas por cliente (CSV/Dashboard)**
|
||||||
- [x] Separar por atendimento interno e externo
|
- [x] Separar por atendimento interno e externo
|
||||||
- [x] Filtrar por período (dia, semana, mês)
|
- [x] Filtrar por período (dia, semana, mês)
|
||||||
- [x] Permitir exportar relatórios completos (CSV ou PDF)
|
- [x] Permitir exportar relatórios completos (CSV ou PDF)
|
||||||
|
|
@ -43,10 +43,9 @@ Lista priorizada de evoluções propostas para o Sistema de Chamados. Confira `a
|
||||||
- [x] Adicionar botão **Play interno** (atendimento remoto)
|
- [x] Adicionar botão **Play interno** (atendimento remoto)
|
||||||
- [x] Adicionar botão **Play externo** (atendimento presencial)
|
- [x] Adicionar botão **Play externo** (atendimento presencial)
|
||||||
- [x] Separar contagem de horas por tipo (interno/externo)
|
- [x] Separar contagem de horas por tipo (interno/externo)
|
||||||
- [ ] Exibir e somar **horas gastas por cliente** (com base no tipo)
|
- [x] Exibir e somar **horas gastas por cliente** (com base no tipo)
|
||||||
- [x] Relatório com totais (interno/externo/total)
|
|
||||||
- [ ] Incluir no cadastro:
|
- [ ] Incluir no cadastro:
|
||||||
- [ ] Horas contratadas por mês
|
- [ ] Horas contratadas por mês (Convex pronto; falta migração Prisma)
|
||||||
- [x] Tipo de cliente: mensalista ou avulso
|
- [x] Tipo de cliente: mensalista ou avulso
|
||||||
- [ ] Enviar alerta automático por e-mail quando atingir limite de horas
|
- [ ] Enviar alerta automático por e-mail quando atingir limite de horas
|
||||||
|
|
||||||
|
|
|
||||||
32
agents.md
32
agents.md
|
|
@ -39,9 +39,17 @@
|
||||||
- Autenticação Better Auth com guardas client-side (`AuthGuard`) bloqueando rotas protegidas.
|
- Autenticação Better Auth com guardas client-side (`AuthGuard`) bloqueando rotas protegidas.
|
||||||
- Menu de usuário no rodapé da sidebar com link para `/settings` e logout confiável.
|
- Menu de usuário no rodapé da sidebar com link para `/settings` e logout confiável.
|
||||||
- Formulários de novo ticket (dialog, página e portal) com seleção de responsável, placeholders claros e validação obrigatória de assunto/descrição/categorias.
|
- Formulários de novo ticket (dialog, página e portal) com seleção de responsável, placeholders claros e validação obrigatória de assunto/descrição/categorias.
|
||||||
- Portal do cliente restringe visualização e criação ao próprio requester; clientes não atribuem responsáveis.
|
|
||||||
- Relatórios e dashboards utilizam `AppShell`, garantindo header/sidebar consistentes.
|
- Relatórios e dashboards utilizam `AppShell`, garantindo header/sidebar consistentes.
|
||||||
|
|
||||||
|
## Entregas recentes
|
||||||
|
- Exportações CSV (Backlog, Canais, CSAT, SLA e Horas por cliente) com parâmetros de período.
|
||||||
|
- PDF do ticket (via pdfkit standalone), com espaçamento e traduções PT-BR.
|
||||||
|
- Play interno/externo com somatório por tipo por ticket e relatório por cliente.
|
||||||
|
- Admin > Empresas & clientes: cadastro/edição, `Cliente avulso?` e `Horas contratadas/mês`.
|
||||||
|
- Admin > Usuários: vincular colaborador à empresa.
|
||||||
|
- Dashboard: cards de filas (Chamados/Laboratório/Visitas) e indicadores principais.
|
||||||
|
- Lista de tickets: filtro por Empresa, coluna Empresa, alinhamento vertical e melhor espaçamento entre colunas.
|
||||||
|
|
||||||
## Entregas recentes relevantes
|
## Entregas recentes relevantes
|
||||||
- Correção do redirecionamento após logout evitando retorno imediato ao dashboard.
|
- Correção do redirecionamento após logout evitando retorno imediato ao dashboard.
|
||||||
- Validações manuais dos formulários de rich text para eliminar `ZodError` durante edição.
|
- Validações manuais dos formulários de rich text para eliminar `ZodError` durante edição.
|
||||||
|
|
@ -56,16 +64,22 @@
|
||||||
- Abrir novos tickets diretamente a partir do detalhe via dialog reutilizável.
|
- Abrir novos tickets diretamente a partir do detalhe via dialog reutilizável.
|
||||||
- Acessar `/settings` para ajustes pessoais e efetuar logout pelo menu.
|
- Acessar `/settings` para ajustes pessoais e efetuar logout pelo menu.
|
||||||
|
|
||||||
### Clientes
|
### Papéis
|
||||||
- Autenticam com `cliente.demo@sistema.dev`.
|
- Papéis válidos: `admin`, `manager`, `agent`, `collaborator` (papel `customer` removido).
|
||||||
- Abrem tickets para si mesmos a partir do portal com assunto/descrição obrigatórios.
|
- Gestores veem os tickets da própria empresa e só podem registrar comentários públicos.
|
||||||
- Não visualizam campo de responsável nem tickets de outros usuários.
|
|
||||||
|
|
||||||
## Próximos passos sugeridos
|
## Próximos passos sugeridos
|
||||||
1. Finalizar redefinição de senha/auditoria de convites Better Auth.
|
1. Disparo de e-mails automáticos quando uso de horas ≥ 90% do contratado.
|
||||||
2. Expandir cobertura de testes (`vitest`) para guardas de autenticação e criação de tickets.
|
2. Ações rápidas (status/fila) diretamente na listagem de tickets.
|
||||||
3. Implementar ações rápidas (status/fila) diretamente na listagem de tickets.
|
3. Limites e monitoramento para anexos por tenant.
|
||||||
4. Definir limites e monitoramento para anexos por tenant.
|
4. PDF do ticket com layout idêntico ao app (logo/cores/fontes).
|
||||||
|
|
||||||
|
## Referências de endpoints úteis
|
||||||
|
- Backlog CSV: `/api/reports/backlog.csv?range=7d|30d|90d[&companyId=...]`
|
||||||
|
- Canais CSV: `/api/reports/tickets-by-channel.csv?range=7d|30d|90d[&companyId=...]`
|
||||||
|
- CSAT CSV: `/api/reports/csat.csv?range=7d|30d|90d`
|
||||||
|
- SLA CSV: `/api/reports/sla.csv`
|
||||||
|
- Horas por cliente CSV: `/api/reports/hours-by-client.csv?range=7d|30d|90d`
|
||||||
|
|
||||||
## Rotina antes de abrir PR
|
## Rotina antes de abrir PR
|
||||||
- `pnpm lint`
|
- `pnpm lint`
|
||||||
|
|
|
||||||
|
|
@ -217,10 +217,11 @@ export const csatOverview = query({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const backlogOverview = query({
|
export const backlogOverview = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) },
|
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
||||||
handler: async (ctx, { tenantId, viewerId, range }) => {
|
handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||||
|
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
|
||||||
|
|
||||||
// Optional range filter (createdAt) for reporting purposes
|
// Optional range filter (createdAt) for reporting purposes
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||||
|
|
@ -350,10 +351,12 @@ export const ticketsByChannel = query({
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
viewerId: v.id("users"),
|
viewerId: v.id("users"),
|
||||||
range: v.optional(v.string()),
|
range: v.optional(v.string()),
|
||||||
|
companyId: v.optional(v.id("companies")),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { tenantId, viewerId, range }) => {
|
handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||||
|
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||||
|
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
|
|
|
||||||
|
|
@ -57,15 +57,18 @@ export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const range = searchParams.get("range") ?? undefined
|
const range = searchParams.get("range") ?? undefined
|
||||||
|
const companyId = searchParams.get("companyId") ?? undefined
|
||||||
const report = await client.query(api.reports.backlogOverview, {
|
const report = await client.query(api.reports.backlogOverview, {
|
||||||
tenantId,
|
tenantId,
|
||||||
viewerId: viewerId as unknown as Id<"users">,
|
viewerId: viewerId as unknown as Id<"users">,
|
||||||
range,
|
range,
|
||||||
|
companyId: companyId as any,
|
||||||
})
|
})
|
||||||
|
|
||||||
const rows: Array<Array<unknown>> = []
|
const rows: Array<Array<unknown>> = []
|
||||||
rows.push(["Relatório", "Backlog"])
|
rows.push(["Relatório", "Backlog"])
|
||||||
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : "—"])
|
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : "—"])
|
||||||
|
if (companyId) rows.push(["EmpresaId", companyId])
|
||||||
rows.push([])
|
rows.push([])
|
||||||
rows.push(["Seção", "Chave", "Valor"]) // header
|
rows.push(["Seção", "Chave", "Valor"]) // header
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export async function GET(request: Request) {
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const range = searchParams.get("range") ?? undefined // "7d" | "30d" | undefined(=90d)
|
const range = searchParams.get("range") ?? undefined // "7d" | "30d" | undefined(=90d)
|
||||||
|
const companyId = searchParams.get("companyId") ?? undefined
|
||||||
|
|
||||||
const client = new ConvexHttpClient(convexUrl)
|
const client = new ConvexHttpClient(convexUrl)
|
||||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
@ -62,6 +63,7 @@ export async function GET(request: Request) {
|
||||||
tenantId,
|
tenantId,
|
||||||
viewerId: viewerId as unknown as Id<"users">,
|
viewerId: viewerId as unknown as Id<"users">,
|
||||||
range,
|
range,
|
||||||
|
companyId: companyId as any,
|
||||||
})
|
})
|
||||||
|
|
||||||
const channels = report.channels
|
const channels = report.channels
|
||||||
|
|
@ -91,7 +93,7 @@ export async function GET(request: Request) {
|
||||||
return new NextResponse(csv, {
|
return new NextResponse(csv, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/csv; charset=UTF-8",
|
"Content-Type": "text/csv; charset=UTF-8",
|
||||||
"Content-Disposition": `attachment; filename="tickets-by-channel-${tenantId}-${range ?? '90d'}.csv"`,
|
"Content-Disposition": `attachment; filename="tickets-by-channel-${tenantId}-${range ?? '90d'}${companyId ? `-${companyId}` : ''}.csv"`,
|
||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@ import { AppShell } from "@/components/app-shell"
|
||||||
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 { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel"
|
import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel"
|
||||||
|
import dynamic from "next/dynamic"
|
||||||
|
|
||||||
|
const TicketQueueSummaryCards = dynamic(
|
||||||
|
() => import("@/components/tickets/ticket-queue-summary").then((m) => ({ default: m.TicketQueueSummaryCards })),
|
||||||
|
{ ssr: false }
|
||||||
|
)
|
||||||
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
|
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
|
|
@ -21,6 +27,9 @@ export default function Dashboard() {
|
||||||
<ChartAreaInteractive />
|
<ChartAreaInteractive />
|
||||||
<RecentTicketsPanel />
|
<RecentTicketsPanel />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="px-4 lg:px-6">
|
||||||
|
<TicketQueueSummaryCards />
|
||||||
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export type TicketFiltersState = {
|
||||||
priority: string | null
|
priority: string | null
|
||||||
queue: string | null
|
queue: string | null
|
||||||
channel: string | null
|
channel: string | null
|
||||||
|
company: string | null
|
||||||
view: "active" | "completed"
|
view: "active" | "completed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,17 +76,19 @@ export const defaultTicketFilters: TicketFiltersState = {
|
||||||
priority: null,
|
priority: null,
|
||||||
queue: null,
|
queue: null,
|
||||||
channel: null,
|
channel: null,
|
||||||
|
company: null,
|
||||||
view: "active",
|
view: "active",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TicketsFiltersProps {
|
interface TicketsFiltersProps {
|
||||||
onChange?: (filters: TicketFiltersState) => void
|
onChange?: (filters: TicketFiltersState) => void
|
||||||
queues?: QueueOption[]
|
queues?: QueueOption[]
|
||||||
|
companies?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_VALUE = "ALL"
|
const ALL_VALUE = "ALL"
|
||||||
|
|
||||||
export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
export function TicketsFilters({ onChange, queues = [], companies = [] }: TicketsFiltersProps) {
|
||||||
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
|
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
|
||||||
|
|
||||||
function setPartial(partial: Partial<TicketFiltersState>) {
|
function setPartial(partial: Partial<TicketFiltersState>) {
|
||||||
|
|
@ -103,6 +106,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
||||||
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}`)
|
||||||
|
if (filters.company) chips.push(`Empresa: ${filters.company}`)
|
||||||
if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos")
|
if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos")
|
||||||
return chips
|
return chips
|
||||||
}, [filters])
|
}, [filters])
|
||||||
|
|
@ -133,6 +137,22 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={filters.company ?? ALL_VALUE}
|
||||||
|
onValueChange={(value) => setPartial({ company: value === ALL_VALUE ? null : value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="md:w-[220px]">
|
||||||
|
<SelectValue placeholder="Empresa" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL_VALUE}>Todas as empresas</SelectItem>
|
||||||
|
{companies.map((company) => (
|
||||||
|
<SelectItem key={company!} value={company!}>
|
||||||
|
{company}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,9 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
<TableHead className="hidden w-[120px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
|
<TableHead className="hidden w-[120px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
|
||||||
Fila
|
Fila
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead className="hidden w-[180px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
|
||||||
|
Empresa
|
||||||
|
</TableHead>
|
||||||
<TableHead className="hidden w-[80px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell">
|
<TableHead className="hidden w-[80px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell">
|
||||||
Canal
|
Canal
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
|
@ -231,6 +234,11 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className={`${cellClass} hidden lg:table-cell pl-1`}>
|
||||||
|
<span className="text-sm text-neutral-800">
|
||||||
|
{(ticket as any).company?.name ?? "—"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
<TableCell className={`${cellClass} hidden md:table-cell pl-1 pr-8`}>
|
<TableCell className={`${cellClass} hidden md:table-cell pl-1 pr-8`}>
|
||||||
<div
|
<div
|
||||||
className="inline-flex"
|
className="inline-flex"
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,14 @@ export function TicketsView() {
|
||||||
)
|
)
|
||||||
|
|
||||||
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
|
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
|
||||||
|
const companies = useMemo(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const t of tickets) {
|
||||||
|
const name = (t as any).company?.name as string | undefined
|
||||||
|
if (name) set.add(name)
|
||||||
|
}
|
||||||
|
return Array.from(set).sort((a, b) => a.localeCompare(b, "pt-BR"))
|
||||||
|
}, [tickets])
|
||||||
|
|
||||||
const filteredTickets = useMemo(() => {
|
const filteredTickets = useMemo(() => {
|
||||||
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED"])
|
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED"])
|
||||||
|
|
@ -55,13 +63,16 @@ export function TicketsView() {
|
||||||
if (filters.queue) {
|
if (filters.queue) {
|
||||||
working = working.filter((t) => t.queue === filters.queue)
|
working = working.filter((t) => t.queue === filters.queue)
|
||||||
}
|
}
|
||||||
|
if (filters.company) {
|
||||||
|
working = working.filter((t) => ((t as any).company?.name ?? null) === filters.company)
|
||||||
|
}
|
||||||
|
|
||||||
return working
|
return working
|
||||||
}, [tickets, filters.queue, filters.status, filters.view])
|
}, [tickets, filters.queue, filters.status, filters.view])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||||
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q) => q.name)} />
|
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q) => q.name)} companies={companies} />
|
||||||
{ticketsRaw === undefined ? (
|
{ticketsRaw === undefined ? (
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue