Merge pull request #19 from esdrasrenan/feat/turbopack-category-save
feat: improve auth seeding and sidebar ux
This commit is contained in:
commit
9a191abfc1
13 changed files with 842 additions and 601 deletions
15
agents.md
15
agents.md
|
|
@ -6,6 +6,21 @@ Construir o nucleo de tickets compartilhado entre web e desktop (Tauri), garanti
|
||||||
### Contato principal
|
### Contato principal
|
||||||
- **Esdras Renan** — monkeyesdras@gmail.com
|
- **Esdras Renan** — monkeyesdras@gmail.com
|
||||||
|
|
||||||
|
### Credenciais seed (ambiente local)
|
||||||
|
- Administrador padrão: `admin@sistema.dev` / `admin123`
|
||||||
|
- Agentes carregados via seed (senha inicial `agent123`, altere após o primeiro acesso):
|
||||||
|
- Gabriel Oliveira — gabriel.oliveira@rever.com.br
|
||||||
|
- George Araujo — george.araujo@rever.com.br
|
||||||
|
- Hugo Soares — hugo.soares@rever.com.br
|
||||||
|
- Julio Cesar — julio@rever.com.br
|
||||||
|
- Lorena Magalhães — lorena@rever.com.br
|
||||||
|
- Rever — renan.pac@paulicon.com.br
|
||||||
|
- Telão — suporte@rever.com.br
|
||||||
|
- Thiago Medeiros — thiago.medeiros@rever.com.br
|
||||||
|
- Weslei Magalhães — weslei@rever.com.br
|
||||||
|
|
||||||
|
> Observação: todos os usuários acima foram sincronizados com o Convex. Atualize as senhas imediatamente após o primeiro login.
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,11 @@ import { requireAdmin, requireStaff } from "./rbac";
|
||||||
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
||||||
"Suporte N1": "Chamados",
|
"Suporte N1": "Chamados",
|
||||||
"suporte-n1": "Chamados",
|
"suporte-n1": "Chamados",
|
||||||
|
chamados: "Chamados",
|
||||||
"Suporte N2": "Laboratório",
|
"Suporte N2": "Laboratório",
|
||||||
"suporte-n2": "Laboratório",
|
"suporte-n2": "Laboratório",
|
||||||
|
laboratorio: "Laboratório",
|
||||||
|
Laboratorio: "Laboratório",
|
||||||
};
|
};
|
||||||
|
|
||||||
function renameQueueString(value: string) {
|
function renameQueueString(value: string) {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,11 @@ import { requireCustomer, requireStaff, requireUser } from "./rbac";
|
||||||
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
||||||
"Suporte N1": "Chamados",
|
"Suporte N1": "Chamados",
|
||||||
"suporte-n1": "Chamados",
|
"suporte-n1": "Chamados",
|
||||||
|
chamados: "Chamados",
|
||||||
"Suporte N2": "Laboratório",
|
"Suporte N2": "Laboratório",
|
||||||
"suporte-n2": "Laboratório",
|
"suporte-n2": "Laboratório",
|
||||||
|
laboratorio: "Laboratório",
|
||||||
|
Laboratorio: "Laboratório",
|
||||||
};
|
};
|
||||||
|
|
||||||
function renameQueueString(value?: string | null): string | null {
|
function renameQueueString(value?: string | null): string | null {
|
||||||
|
|
@ -770,8 +773,8 @@ export const changeQueue = mutation({
|
||||||
export const updateCategories = mutation({
|
export const updateCategories = mutation({
|
||||||
args: {
|
args: {
|
||||||
ticketId: v.id("tickets"),
|
ticketId: v.id("tickets"),
|
||||||
categoryId: v.id("ticketCategories"),
|
categoryId: v.union(v.id("ticketCategories"), v.null()),
|
||||||
subcategoryId: v.id("ticketSubcategories"),
|
subcategoryId: v.union(v.id("ticketSubcategories"), v.null()),
|
||||||
actorId: v.id("users"),
|
actorId: v.id("users"),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { ticketId, categoryId, subcategoryId, actorId }) => {
|
handler: async (ctx, { ticketId, categoryId, subcategoryId, actorId }) => {
|
||||||
|
|
@ -780,23 +783,60 @@ export const updateCategories = mutation({
|
||||||
throw new ConvexError("Ticket não encontrado")
|
throw new ConvexError("Ticket não encontrado")
|
||||||
}
|
}
|
||||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
await requireStaff(ctx, actorId, ticket.tenantId)
|
||||||
|
|
||||||
|
if (categoryId === null) {
|
||||||
|
if (subcategoryId !== null) {
|
||||||
|
throw new ConvexError("Subcategoria inválida")
|
||||||
|
}
|
||||||
|
if (!ticket.categoryId && !ticket.subcategoryId) {
|
||||||
|
return { status: "unchanged" }
|
||||||
|
}
|
||||||
|
const now = Date.now()
|
||||||
|
await ctx.db.patch(ticketId, {
|
||||||
|
categoryId: undefined,
|
||||||
|
subcategoryId: undefined,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId,
|
||||||
|
type: "CATEGORY_CHANGED",
|
||||||
|
payload: {
|
||||||
|
categoryId: null,
|
||||||
|
categoryName: null,
|
||||||
|
subcategoryId: null,
|
||||||
|
subcategoryName: null,
|
||||||
|
actorId,
|
||||||
|
actorName: actor?.name,
|
||||||
|
actorAvatar: actor?.avatarUrl,
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
return { status: "cleared" }
|
||||||
|
}
|
||||||
|
|
||||||
const category = await ctx.db.get(categoryId)
|
const category = await ctx.db.get(categoryId)
|
||||||
if (!category || category.tenantId !== ticket.tenantId) {
|
if (!category || category.tenantId !== ticket.tenantId) {
|
||||||
throw new ConvexError("Categoria inválida")
|
throw new ConvexError("Categoria inválida")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let subcategoryName: string | null = null
|
||||||
|
if (subcategoryId !== null) {
|
||||||
const subcategory = await ctx.db.get(subcategoryId)
|
const subcategory = await ctx.db.get(subcategoryId)
|
||||||
if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticket.tenantId) {
|
if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticket.tenantId) {
|
||||||
throw new ConvexError("Subcategoria inválida")
|
throw new ConvexError("Subcategoria inválida")
|
||||||
}
|
}
|
||||||
|
subcategoryName = subcategory.name
|
||||||
|
}
|
||||||
|
|
||||||
if (ticket.categoryId === categoryId && ticket.subcategoryId === subcategoryId) {
|
if (ticket.categoryId === categoryId && (ticket.subcategoryId ?? null) === subcategoryId) {
|
||||||
return { status: "unchanged" }
|
return { status: "unchanged" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
await ctx.db.patch(ticketId, {
|
await ctx.db.patch(ticketId, {
|
||||||
categoryId,
|
categoryId,
|
||||||
subcategoryId,
|
subcategoryId: subcategoryId ?? undefined,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -808,7 +848,7 @@ export const updateCategories = mutation({
|
||||||
categoryId,
|
categoryId,
|
||||||
categoryName: category.name,
|
categoryName: category.name,
|
||||||
subcategoryId,
|
subcategoryId,
|
||||||
subcategoryName: subcategory.name,
|
subcategoryName,
|
||||||
actorId,
|
actorId,
|
||||||
actorName: actor?.name,
|
actorName: actor?.name,
|
||||||
actorAvatar: actor?.avatarUrl,
|
actorAvatar: actor?.avatarUrl,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
|
@ -46,10 +46,10 @@
|
||||||
"convex": "^1.27.3",
|
"convex": "^1.27.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.3",
|
"next": "15.5.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.1.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.64.0",
|
"react-hook-form": "^7.64.0",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
|
|
@ -68,7 +68,7 @@
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.3",
|
"eslint-config-next": "15.5.4",
|
||||||
"prisma": "^6.16.2",
|
"prisma": "^6.16.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.3.8",
|
"tw-animate-css": "^1.3.8",
|
||||||
|
|
|
||||||
912
web/pnpm-lock.yaml
generated
912
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
138
web/scripts/seed-agents.mjs
Normal file
138
web/scripts/seed-agents.mjs
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import pkg from "@prisma/client"
|
||||||
|
import { hashPassword } from "better-auth/crypto"
|
||||||
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
|
const { PrismaClient } = pkg
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
const USERS = [
|
||||||
|
{ name: "Administrador", email: "admin@sistema.dev", role: "admin" },
|
||||||
|
{ name: "Gabriel Oliveira", email: "gabriel.oliveira@rever.com.br", role: "agent" },
|
||||||
|
{ name: "George Araujo", email: "george.araujo@rever.com.br", role: "agent" },
|
||||||
|
{ name: "Hugo Soares", email: "hugo.soares@rever.com.br", role: "agent" },
|
||||||
|
{ name: "Julio Cesar", email: "julio@rever.com.br", role: "agent" },
|
||||||
|
{ name: "Lorena Magalhães", email: "lorena@rever.com.br", role: "agent" },
|
||||||
|
{ name: "Rever", email: "renan.pac@paulicon.com.br", role: "agent" },
|
||||||
|
{ name: "Telão", email: "suporte@rever.com.br", role: "agent" },
|
||||||
|
{ name: "Thiago Medeiros", email: "thiago.medeiros@rever.com.br", role: "agent" },
|
||||||
|
{ name: "Weslei Magalhães", email: "weslei@rever.com.br", role: "agent" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TENANT_ID = process.env.SEED_TENANT_ID ?? "tenant-atlas"
|
||||||
|
const DEFAULT_AGENT_PASSWORD = process.env.SEED_AGENT_PASSWORD ?? "agent123"
|
||||||
|
const DEFAULT_ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD ?? "admin123"
|
||||||
|
const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL
|
||||||
|
|
||||||
|
async function syncConvexUsers(users) {
|
||||||
|
if (!CONVEX_URL) {
|
||||||
|
console.warn("NEXT_PUBLIC_CONVEX_URL não definido; sincronização com Convex ignorada.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new ConvexHttpClient(CONVEX_URL)
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
await client.mutation("users:ensureUser", {
|
||||||
|
tenantId: TENANT_ID,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role.toUpperCase(),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Falha ao sincronizar usuário ${user.email} com Convex`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const emails = USERS.map((user) => user.email.toLowerCase())
|
||||||
|
|
||||||
|
const existing = await prisma.authUser.findMany({
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
notIn: emails,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
const ids = existing.map((user) => user.id)
|
||||||
|
await prisma.authSession.deleteMany({ where: { userId: { in: ids } } })
|
||||||
|
await prisma.authAccount.deleteMany({ where: { userId: { in: ids } } })
|
||||||
|
await prisma.authUser.deleteMany({ where: { id: { in: ids } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const seededUsers = []
|
||||||
|
|
||||||
|
for (const definition of USERS) {
|
||||||
|
const email = definition.email.toLowerCase()
|
||||||
|
const role = definition.role ?? "agent"
|
||||||
|
const password = definition.password ?? (role === "admin" ? DEFAULT_ADMIN_PASSWORD : DEFAULT_AGENT_PASSWORD)
|
||||||
|
const hashedPassword = await hashPassword(password)
|
||||||
|
|
||||||
|
const user = await prisma.authUser.upsert({
|
||||||
|
where: { email },
|
||||||
|
update: {
|
||||||
|
name: definition.name,
|
||||||
|
role,
|
||||||
|
tenantId: TENANT_ID,
|
||||||
|
emailVerified: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
email,
|
||||||
|
name: definition.name,
|
||||||
|
role,
|
||||||
|
tenantId: TENANT_ID,
|
||||||
|
emailVerified: true,
|
||||||
|
accounts: {
|
||||||
|
create: {
|
||||||
|
providerId: "credential",
|
||||||
|
accountId: email,
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { accounts: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const credentialAccount = user.accounts.find(
|
||||||
|
(account) => account.providerId === "credential" && account.accountId === email,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (credentialAccount) {
|
||||||
|
await prisma.authAccount.update({
|
||||||
|
where: { id: credentialAccount.id },
|
||||||
|
data: { password: hashedPassword },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await prisma.authAccount.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
providerId: "credential",
|
||||||
|
accountId: email,
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
seededUsers.push({ id: user.id, name: definition.name, email, role })
|
||||||
|
console.log(`✅ Usuário sincronizado: ${definition.name} <${email}> (${role})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncConvexUsers(seededUsers)
|
||||||
|
|
||||||
|
console.log("")
|
||||||
|
console.log(`Senha padrão agentes: ${DEFAULT_AGENT_PASSWORD}`)
|
||||||
|
console.log(`Senha padrão administrador: ${DEFAULT_ADMIN_PASSWORD}`)
|
||||||
|
console.log(`Total de usuários ativos: ${seededUsers.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Erro ao processar agentes", error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
|
|
@ -34,6 +34,8 @@ export function QueuesManager() {
|
||||||
const { session, convexUserId } = useAuth()
|
const { session, convexUserId } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
const NO_TEAM_VALUE = "__none__"
|
||||||
|
|
||||||
const queues = useQuery(
|
const queues = useQuery(
|
||||||
api.queues.list,
|
api.queues.list,
|
||||||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
|
|
@ -202,12 +204,15 @@ export function QueuesManager() {
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Time responsável</Label>
|
<Label>Time responsável</Label>
|
||||||
<Select value={teamId ?? ""} onValueChange={(value) => setTeamId(value || undefined)}>
|
<Select
|
||||||
|
value={teamId ?? NO_TEAM_VALUE}
|
||||||
|
onValueChange={(value) => setTeamId(value === NO_TEAM_VALUE ? undefined : value)}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Selecione um time" />
|
<SelectValue placeholder="Selecione um time" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">Sem time</SelectItem>
|
<SelectItem value={NO_TEAM_VALUE}>Sem time</SelectItem>
|
||||||
{teams?.map((team) => (
|
{teams?.map((team) => (
|
||||||
<SelectItem key={team.id} value={team.id}>
|
<SelectItem key={team.id} value={team.id}>
|
||||||
{team.name}
|
{team.name}
|
||||||
|
|
@ -292,12 +297,15 @@ export function QueuesManager() {
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Time responsável</Label>
|
<Label>Time responsável</Label>
|
||||||
<Select value={teamId ?? ""} onValueChange={(value) => setTeamId(value || undefined)}>
|
<Select
|
||||||
|
value={teamId ?? NO_TEAM_VALUE}
|
||||||
|
onValueChange={(value) => setTeamId(value === NO_TEAM_VALUE ? undefined : value)}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Selecione um time" />
|
<SelectValue placeholder="Selecione um time" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">Sem time</SelectItem>
|
<SelectItem value={NO_TEAM_VALUE}>Sem time</SelectItem>
|
||||||
{teams?.map((team) => (
|
{teams?.map((team) => (
|
||||||
<SelectItem key={team.id} value={team.id}>
|
<SelectItem key={team.id} value={team.id}>
|
||||||
{team.name}
|
{team.name}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
Users,
|
Users,
|
||||||
Waypoints,
|
Waypoints,
|
||||||
Timer,
|
Timer,
|
||||||
Plug,
|
|
||||||
Layers3,
|
Layers3,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Settings,
|
Settings,
|
||||||
|
|
@ -34,6 +33,7 @@ import {
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
|
||||||
import type { LucideIcon } from "lucide-react"
|
import type { LucideIcon } from "lucide-react"
|
||||||
|
|
@ -45,6 +45,7 @@ type NavigationItem = {
|
||||||
url: string
|
url: string
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
requiredRole?: NavRoleRequirement
|
requiredRole?: NavRoleRequirement
|
||||||
|
exact?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type NavigationGroup = {
|
type NavigationGroup = {
|
||||||
|
|
@ -54,7 +55,7 @@ type NavigationGroup = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
|
const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
|
||||||
versions: ["0.0.1"],
|
versions: ["Rever Tecnologia"],
|
||||||
navMain: [
|
navMain: [
|
||||||
{
|
{
|
||||||
title: "Operação",
|
title: "Operação",
|
||||||
|
|
@ -79,12 +80,17 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
|
||||||
title: "Administração",
|
title: "Administração",
|
||||||
requiredRole: "admin",
|
requiredRole: "admin",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Convites e acessos", url: "/admin", icon: UserPlus, requiredRole: "admin" },
|
{
|
||||||
|
title: "Convites e acessos",
|
||||||
|
url: "/admin",
|
||||||
|
icon: UserPlus,
|
||||||
|
requiredRole: "admin",
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
|
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
|
||||||
{ title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" },
|
{ title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" },
|
||||||
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },
|
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },
|
||||||
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
|
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
|
||||||
{ title: "Integrações", url: "/admin/integrations", icon: Plug, requiredRole: "admin" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -98,12 +104,21 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { isAdmin, isStaff, isCustomer } = useAuth()
|
const { isAdmin, isStaff, isCustomer } = useAuth()
|
||||||
|
const [isHydrated, setIsHydrated] = React.useState(false)
|
||||||
|
|
||||||
function isActive(url: string) {
|
React.useEffect(() => {
|
||||||
|
setIsHydrated(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function isActive(item: NavigationItem) {
|
||||||
|
const { url, exact } = item
|
||||||
if (!pathname) return false
|
if (!pathname) return false
|
||||||
if (url === "/dashboard" && pathname === "/") {
|
if (url === "/dashboard" && pathname === "/") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if (exact) {
|
||||||
|
return pathname === url
|
||||||
|
}
|
||||||
return pathname === url || pathname.startsWith(`${url}/`)
|
return pathname === url || pathname.startsWith(`${url}/`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,6 +130,34 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isHydrated) {
|
||||||
|
return (
|
||||||
|
<Sidebar {...props}>
|
||||||
|
<SidebarHeader className="gap-3">
|
||||||
|
<Skeleton className="h-12 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-9 w-full rounded-lg" />
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
{[0, 1, 2].map((group) => (
|
||||||
|
<SidebarGroup key={group}>
|
||||||
|
<SidebarGroupLabel>
|
||||||
|
<Skeleton className="h-3 w-20 rounded" />
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2].map((item) => (
|
||||||
|
<Skeleton key={item} className="h-9 w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
))}
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar {...props}>
|
<Sidebar {...props}>
|
||||||
<SidebarHeader className="gap-3">
|
<SidebarHeader className="gap-3">
|
||||||
|
|
@ -123,7 +166,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
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" />
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{navigation.navMain.map((group) => {
|
{navigation.navMain.map((group) => {
|
||||||
|
|
@ -137,7 +180,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{visibleItems.map((item) => (
|
{visibleItems.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton asChild isActive={isActive(item.url)}>
|
<SidebarMenuButton asChild isActive={isActive(item)}>
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { format, formatDistanceToNow } from "date-fns"
|
import { format, formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { IconClock, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
import { IconClock, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
||||||
|
|
@ -41,6 +41,9 @@ const sectionValueClass = "font-medium text-neutral-900"
|
||||||
const subtleBadgeClass =
|
const subtleBadgeClass =
|
||||||
"inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-[11px] font-medium text-neutral-600"
|
"inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-[11px] font-medium text-neutral-600"
|
||||||
|
|
||||||
|
const EMPTY_CATEGORY_VALUE = "__none__"
|
||||||
|
const EMPTY_SUBCATEGORY_VALUE = "__none__"
|
||||||
|
|
||||||
function formatDuration(durationMs: number) {
|
function formatDuration(durationMs: number) {
|
||||||
if (durationMs <= 0) return "0s"
|
if (durationMs <= 0) return "0s"
|
||||||
const totalSeconds = Math.floor(durationMs / 1000)
|
const totalSeconds = Math.floor(durationMs / 1000)
|
||||||
|
|
@ -91,21 +94,26 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [subject, setSubject] = useState(ticket.subject)
|
const [subject, setSubject] = useState(ticket.subject)
|
||||||
const [summary, setSummary] = useState(ticket.summary ?? "")
|
const [summary, setSummary] = useState(ticket.summary ?? "")
|
||||||
const [categorySelection, setCategorySelection] = useState({
|
const [categorySelection, setCategorySelection] = useState<{ categoryId: string; subcategoryId: string }>(
|
||||||
|
{
|
||||||
categoryId: ticket.category?.id ?? "",
|
categoryId: ticket.category?.id ?? "",
|
||||||
subcategoryId: ticket.subcategory?.id ?? "",
|
subcategoryId: ticket.subcategory?.id ?? "",
|
||||||
})
|
}
|
||||||
const [savingCategory, setSavingCategory] = useState(false)
|
)
|
||||||
const lastSubmittedCategoryRef = useRef({
|
const [saving, setSaving] = useState(false)
|
||||||
categoryId: ticket.category?.id ?? "",
|
|
||||||
subcategoryId: ticket.subcategory?.id ?? "",
|
|
||||||
})
|
|
||||||
const selectedCategoryId = categorySelection.categoryId
|
const selectedCategoryId = categorySelection.categoryId
|
||||||
const selectedSubcategoryId = categorySelection.subcategoryId
|
const selectedSubcategoryId = categorySelection.subcategoryId
|
||||||
const dirty = useMemo(
|
const dirty = useMemo(
|
||||||
() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""),
|
() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""),
|
||||||
[subject, summary, ticket.subject, ticket.summary]
|
[subject, summary, ticket.subject, ticket.summary]
|
||||||
)
|
)
|
||||||
|
const currentCategoryId = ticket.category?.id ?? ""
|
||||||
|
const currentSubcategoryId = ticket.subcategory?.id ?? ""
|
||||||
|
const categoryDirty = useMemo(() => {
|
||||||
|
return selectedCategoryId !== currentCategoryId || selectedSubcategoryId !== currentSubcategoryId
|
||||||
|
}, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId])
|
||||||
|
const formDirty = dirty || categoryDirty
|
||||||
|
|
||||||
const activeCategory = useMemo(
|
const activeCategory = useMemo(
|
||||||
() => categories.find((category) => category.id === selectedCategoryId) ?? null,
|
() => categories.find((category) => category.id === selectedCategoryId) ?? null,
|
||||||
[categories, selectedCategoryId]
|
[categories, selectedCategoryId]
|
||||||
|
|
@ -113,56 +121,81 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory])
|
const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory])
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!convexUserId) return
|
if (!convexUserId || !formDirty) {
|
||||||
toast.loading("Salvando alterações...", { id: "save-header" })
|
setEditing(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (categoryDirty) {
|
||||||
|
toast.loading("Atualizando categoria...", { id: "ticket-category" })
|
||||||
|
try {
|
||||||
|
await updateCategories({
|
||||||
|
ticketId: ticket.id as Id<"tickets">,
|
||||||
|
categoryId: selectedCategoryId ? (selectedCategoryId as Id<"ticketCategories">) : null,
|
||||||
|
subcategoryId: selectedSubcategoryId ? (selectedSubcategoryId as Id<"ticketSubcategories">) : null,
|
||||||
|
actorId: convexUserId as Id<"users">,
|
||||||
|
})
|
||||||
|
toast.success("Categoria atualizada!", { id: "ticket-category" })
|
||||||
|
} catch (categoryError) {
|
||||||
|
toast.error("Não foi possível atualizar a categoria.", { id: "ticket-category" })
|
||||||
|
setCategorySelection({
|
||||||
|
categoryId: currentCategoryId,
|
||||||
|
subcategoryId: currentSubcategoryId,
|
||||||
|
})
|
||||||
|
throw categoryError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirty) {
|
||||||
|
toast.loading("Salvando alterações...", { id: "save-header" })
|
||||||
if (subject !== ticket.subject) {
|
if (subject !== ticket.subject) {
|
||||||
await updateSubject({ ticketId: ticket.id as Id<"tickets">, subject: subject.trim(), actorId: convexUserId as Id<"users"> })
|
await updateSubject({
|
||||||
|
ticketId: ticket.id as Id<"tickets">,
|
||||||
|
subject: subject.trim(),
|
||||||
|
actorId: convexUserId as Id<"users">,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if ((summary ?? "") !== (ticket.summary ?? "")) {
|
if ((summary ?? "") !== (ticket.summary ?? "")) {
|
||||||
await updateSummary({ ticketId: ticket.id as Id<"tickets">, summary: (summary ?? "").trim(), actorId: convexUserId as Id<"users"> })
|
await updateSummary({
|
||||||
|
ticketId: ticket.id as Id<"tickets">,
|
||||||
|
summary: (summary ?? "").trim(),
|
||||||
|
actorId: convexUserId as Id<"users">,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
toast.success("Cabeçalho atualizado!", { id: "save-header" })
|
toast.success("Cabeçalho atualizado!", { id: "save-header" })
|
||||||
|
}
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Não foi possível salvar.", { id: "save-header" })
|
toast.error("Não foi possível salvar.", { id: "save-header" })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
setSubject(ticket.subject)
|
setSubject(ticket.subject)
|
||||||
setSummary(ticket.summary ?? "")
|
setSummary(ticket.summary ?? "")
|
||||||
|
setCategorySelection({
|
||||||
|
categoryId: currentCategoryId,
|
||||||
|
subcategoryId: currentSubcategoryId,
|
||||||
|
})
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextSelection = {
|
if (editing) return
|
||||||
|
setCategorySelection({
|
||||||
categoryId: ticket.category?.id ?? "",
|
categoryId: ticket.category?.id ?? "",
|
||||||
subcategoryId: ticket.subcategory?.id ?? "",
|
subcategoryId: ticket.subcategory?.id ?? "",
|
||||||
}
|
|
||||||
setCategorySelection(nextSelection)
|
|
||||||
lastSubmittedCategoryRef.current = nextSelection
|
|
||||||
}, [ticket.category?.id, ticket.subcategory?.id])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editing) return
|
|
||||||
if (categoriesLoading) return
|
|
||||||
if (categories.length === 0) return
|
|
||||||
if (selectedCategoryId) return
|
|
||||||
if (ticket.category?.id) return
|
|
||||||
|
|
||||||
const first = categories[0]
|
|
||||||
const firstSecondary = first.secondary[0]
|
|
||||||
setCategorySelection({
|
|
||||||
categoryId: first.id,
|
|
||||||
subcategoryId: firstSecondary?.id ?? "",
|
|
||||||
})
|
})
|
||||||
}, [categories, categoriesLoading, editing, selectedCategoryId, ticket.category?.id])
|
}, [editing, ticket.category?.id, ticket.subcategory?.id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing) return
|
if (!editing) return
|
||||||
if (!selectedCategoryId) return
|
if (!selectedCategoryId) {
|
||||||
if (secondaryOptions.length === 0) {
|
|
||||||
if (selectedSubcategoryId) {
|
if (selectedSubcategoryId) {
|
||||||
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
|
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
|
||||||
}
|
}
|
||||||
|
|
@ -170,73 +203,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const stillValid = secondaryOptions.some((option) => option.id === selectedSubcategoryId)
|
const stillValid = secondaryOptions.some((option) => option.id === selectedSubcategoryId)
|
||||||
if (stillValid) return
|
if (!stillValid && selectedSubcategoryId) {
|
||||||
|
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
|
||||||
const fallback = secondaryOptions[0]
|
|
||||||
if (fallback) {
|
|
||||||
setCategorySelection((prev) => ({ ...prev, subcategoryId: fallback.id }))
|
|
||||||
}
|
}
|
||||||
}, [editing, secondaryOptions, selectedCategoryId, selectedSubcategoryId])
|
}, [editing, secondaryOptions, selectedCategoryId, selectedSubcategoryId])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editing) return
|
|
||||||
if (!convexUserId) return
|
|
||||||
const categoryId = selectedCategoryId
|
|
||||||
const subcategoryId = selectedSubcategoryId
|
|
||||||
if (!categoryId || !subcategoryId) return
|
|
||||||
|
|
||||||
const currentCategory = ticket.category?.id ?? ""
|
|
||||||
const currentSubcategory = ticket.subcategory?.id ?? ""
|
|
||||||
|
|
||||||
if (categoryId === currentCategory && subcategoryId === currentSubcategory) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
categoryId === lastSubmittedCategoryRef.current.categoryId &&
|
|
||||||
subcategoryId === lastSubmittedCategoryRef.current.subcategoryId
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
lastSubmittedCategoryRef.current = { categoryId, subcategoryId }
|
|
||||||
setSavingCategory(true)
|
|
||||||
toast.loading("Atualizando categoria...", { id: "ticket-category" })
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
await updateCategories({
|
|
||||||
ticketId: ticket.id as Id<"tickets">,
|
|
||||||
categoryId: categoryId as Id<"ticketCategories">,
|
|
||||||
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
|
|
||||||
actorId: convexUserId as Id<"users">,
|
|
||||||
})
|
|
||||||
if (!cancelled) {
|
|
||||||
toast.success("Categoria atualizada!", { id: "ticket-category" })
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) {
|
|
||||||
toast.error("Não foi possível atualizar a categoria.", { id: "ticket-category" })
|
|
||||||
const fallback = {
|
|
||||||
categoryId: currentCategory,
|
|
||||||
subcategoryId: currentSubcategory,
|
|
||||||
}
|
|
||||||
setCategorySelection(fallback)
|
|
||||||
lastSubmittedCategoryRef.current = fallback
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setSavingCategory(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [editing, selectedCategoryId, selectedSubcategoryId, ticket.category?.id, ticket.subcategory?.id, ticket.id, updateCategories, convexUserId])
|
|
||||||
|
|
||||||
const workSummary = useMemo(() => {
|
const workSummary = useMemo(() => {
|
||||||
if (workSummaryRemote !== undefined) return workSummaryRemote ?? null
|
if (workSummaryRemote !== undefined) return workSummaryRemote ?? null
|
||||||
if (!ticket.workSummary) return null
|
if (!ticket.workSummary) return null
|
||||||
|
|
@ -362,20 +333,25 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
<span className={sectionLabelClass}>Categoria primária</span>
|
<span className={sectionLabelClass}>Categoria primária</span>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<Select
|
<Select
|
||||||
disabled={savingCategory || categoriesLoading || categories.length === 0}
|
disabled={saving || categoriesLoading}
|
||||||
value={selectedCategoryId || ""}
|
value={selectedCategoryId ? selectedCategoryId : EMPTY_CATEGORY_VALUE}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (value === EMPTY_CATEGORY_VALUE) {
|
||||||
|
setCategorySelection({ categoryId: "", subcategoryId: "" })
|
||||||
|
return
|
||||||
|
}
|
||||||
const category = categories.find((item) => item.id === value)
|
const category = categories.find((item) => item.id === value)
|
||||||
setCategorySelection({
|
setCategorySelection({
|
||||||
categoryId: value,
|
categoryId: value,
|
||||||
subcategoryId: category?.secondary[0]?.id ?? "",
|
subcategoryId: category?.secondary.find((option) => option.id === selectedSubcategoryId)?.id ?? "",
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={selectTriggerClass}>
|
<SelectTrigger className={selectTriggerClass}>
|
||||||
<SelectValue placeholder={categoriesLoading ? "Carregando..." : "Selecionar"} />
|
<SelectValue placeholder={categoriesLoading ? "Carregando..." : "Sem categoria"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||||
|
<SelectItem value={EMPTY_CATEGORY_VALUE}>Sem categoria</SelectItem>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<SelectItem key={category.id} value={category.id}>
|
<SelectItem key={category.id} value={category.id}>
|
||||||
{category.name}
|
{category.name}
|
||||||
|
|
@ -392,10 +368,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<Select
|
<Select
|
||||||
disabled={
|
disabled={
|
||||||
savingCategory || categoriesLoading || !selectedCategoryId || secondaryOptions.length === 0
|
saving || categoriesLoading || !selectedCategoryId
|
||||||
}
|
}
|
||||||
value={selectedSubcategoryId || ""}
|
value={selectedSubcategoryId ? selectedSubcategoryId : EMPTY_SUBCATEGORY_VALUE}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (value === EMPTY_SUBCATEGORY_VALUE) {
|
||||||
|
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
setCategorySelection((prev) => ({ ...prev, subcategoryId: value }))
|
setCategorySelection((prev) => ({ ...prev, subcategoryId: value }))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -405,12 +385,13 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
!selectedCategoryId
|
!selectedCategoryId
|
||||||
? "Selecione uma primária"
|
? "Selecione uma primária"
|
||||||
: secondaryOptions.length === 0
|
: secondaryOptions.length === 0
|
||||||
? "Sem secundárias"
|
? "Sem subcategoria"
|
||||||
: "Selecionar"
|
: "Selecionar"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||||
|
<SelectItem value={EMPTY_SUBCATEGORY_VALUE}>Sem subcategoria</SelectItem>
|
||||||
{secondaryOptions.map((option) => (
|
{secondaryOptions.map((option) => (
|
||||||
<SelectItem key={option.id} value={option.id}>
|
<SelectItem key={option.id} value={option.id}>
|
||||||
{option.name}
|
{option.name}
|
||||||
|
|
@ -518,7 +499,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
|
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!dirty}>
|
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!formDirty || saving}>
|
||||||
Salvar
|
Salvar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -156,10 +156,14 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
if (entry.type === "WORK_PAUSED" && typeof payload.sessionDurationMs === "number") {
|
if (entry.type === "WORK_PAUSED" && typeof payload.sessionDurationMs === "number") {
|
||||||
message = `Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`
|
message = `Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`
|
||||||
}
|
}
|
||||||
if (entry.type === "CATEGORY_CHANGED" && (payload.categoryName || payload.subcategoryName)) {
|
if (entry.type === "CATEGORY_CHANGED") {
|
||||||
|
if (payload.categoryName || payload.subcategoryName) {
|
||||||
message = `Categoria alterada para ${payload.categoryName ?? ""}${
|
message = `Categoria alterada para ${payload.categoryName ?? ""}${
|
||||||
payload.subcategoryName ? ` • ${payload.subcategoryName}` : ""
|
payload.subcategoryName ? ` • ${payload.subcategoryName}` : ""
|
||||||
}`
|
}`
|
||||||
|
} else {
|
||||||
|
message = "Categoria removida"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!message) return null
|
if (!message) return null
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,13 @@ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
|
||||||
<div className={cn("flex flex-col gap-1", className)} {...props} />
|
<div className={cn("flex flex-col gap-1", className)} {...props} />
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
const DialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
|
@ -64,4 +71,5 @@ const DialogDescription = React.forwardRef<
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription }
|
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription }
|
||||||
|
export { DialogFooter }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@ function SidebarProvider({
|
||||||
<SidebarContext.Provider value={contextValue}>
|
<SidebarContext.Provider value={contextValue}>
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<div
|
<div
|
||||||
|
suppressHydrationWarning
|
||||||
data-slot="sidebar-wrapper"
|
data-slot="sidebar-wrapper"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export function VersionSwitcher({
|
||||||
</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">{selectedVersion}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDown className="ml-auto" />
|
<ChevronsUpDown className="ml-auto" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|
@ -56,7 +56,7 @@ export function VersionSwitcher({
|
||||||
key={version}
|
key={version}
|
||||||
onSelect={() => setSelectedVersion(version)}
|
onSelect={() => setSelectedVersion(version)}
|
||||||
>
|
>
|
||||||
v{version} {version === selectedVersion && <Check className="ml-auto" />}
|
{version} {version === selectedVersion && <Check className="ml-auto" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue