Reorganiza gestão de usuários e remove dados mock

This commit is contained in:
Esdras Renan 2025-10-18 01:15:15 -03:00
parent 630110bf3a
commit dded6d1927
20 changed files with 1863 additions and 1368 deletions

View file

@ -1,4 +1,3 @@
import { randomBytes } from "@noble/hashes/utils"
import type { Id } from "./_generated/dataModel"
import { mutation } from "./_generated/server"
@ -59,17 +58,6 @@ export const seedDemo = mutation({
}
// Ensure users
function slugify(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.toLowerCase();
}
function defaultAvatar(name: string, email: string, role: string) {
const normalizedRole = role.toUpperCase();
if (normalizedRole === "MANAGER") {
@ -78,54 +66,6 @@ export const seedDemo = mutation({
const first = name.split(" ")[0] ?? email;
return `https://avatar.vercel.sh/${encodeURIComponent(first)}`;
}
async function ensureCompany(def: {
name: string;
slug?: string;
cnpj?: string;
domain?: string;
phone?: string;
description?: string;
address?: string;
provisioningCode?: string;
}): Promise<Id<"companies">> {
const slug = def.slug ?? slugify(def.name);
const existing = await ctx.db
.query("companies")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first();
const now = Date.now();
const payload = {
tenantId,
name: def.name,
slug,
provisioningCode: def.provisioningCode ?? existing?.provisioningCode ?? generateCode(),
cnpj: def.cnpj ?? undefined,
domain: def.domain ?? undefined,
phone: def.phone ?? undefined,
description: def.description ?? undefined,
address: def.address ?? undefined,
createdAt: now,
updatedAt: now,
};
if (existing) {
const updates: Record<string, unknown> = {};
if (existing.name !== payload.name) updates.name = payload.name;
if (existing.cnpj !== payload.cnpj) updates.cnpj = payload.cnpj;
if (existing.domain !== payload.domain) updates.domain = payload.domain;
if (existing.phone !== payload.phone) updates.phone = payload.phone;
if (existing.description !== payload.description) updates.description = payload.description;
if (existing.address !== payload.address) updates.address = payload.address;
if (existing.provisioningCode !== payload.provisioningCode) updates.provisioningCode = payload.provisioningCode;
if (Object.keys(updates).length > 0) {
updates.updatedAt = now;
await ctx.db.patch(existing._id, updates);
}
return existing._id;
}
return await ctx.db.insert("companies", payload);
}
async function ensureUser(params: {
name: string;
email: string;
@ -161,42 +101,6 @@ export const seedDemo = mutation({
});
}
const companiesSeed: Array<{
name: string;
slug: string;
cnpj?: string;
domain?: string;
phone?: string;
description?: string;
address?: string;
provisioningCode?: string;
}> = [
{
name: "Atlas Engenharia Digital",
slug: "atlas-engenharia",
cnpj: "12.345.678/0001-90",
domain: "atlasengenharia.com.br",
phone: "+55 11 4002-8922",
description: "Transformação digital para empresas de engenharia e construção.",
address: "Av. Paulista, 1234 - Bela Vista, São Paulo/SP",
},
{
name: "Omni Saúde Integrada",
slug: "omni-saude",
cnpj: "45.678.912/0001-05",
domain: "omnisaude.com.br",
phone: "+55 31 3555-7788",
description: "Rede de clínicas com serviços de telemedicina e prontuário eletrônico.",
address: "Rua da Bahia, 845 - Centro, Belo Horizonte/MG",
},
];
const companyIds = new Map<string, Id<"companies">>();
for (const company of companiesSeed) {
const id = await ensureCompany(company);
companyIds.set(company.slug, id);
}
const adminId = await ensureUser({ name: "Administrador", email: "admin@sistema.dev", role: "ADMIN" });
const staffRoster = [
{ name: "Gabriel Oliveira", email: "gabriel.oliveira@rever.com.br" },
@ -209,62 +113,9 @@ export const seedDemo = mutation({
{ name: "Weslei Magalhães", email: "weslei@rever.com.br" },
];
const staffIds = await Promise.all(
await Promise.all(
staffRoster.map((staff) => ensureUser({ name: staff.name, email: staff.email, role: "AGENT" })),
);
const defaultAssigneeId = staffIds[0] ?? adminId;
const atlasCompanyId = companyIds.get("atlas-engenharia");
const omniCompanyId = companyIds.get("omni-saude");
if (!atlasCompanyId || !omniCompanyId) {
throw new Error("Empresas padrão não foram inicializadas");
}
const atlasManagerId = await ensureUser({
name: "Mariana Andrade",
email: "mariana.andrade@atlasengenharia.com.br",
role: "MANAGER",
companyId: atlasCompanyId,
});
const joaoAtlasId = await ensureUser({
name: "João Pedro Ramos",
email: "joao.ramos@atlasengenharia.com.br",
role: "MANAGER",
companyId: atlasCompanyId,
});
await ensureUser({
name: "Aline Rezende",
email: "aline.rezende@atlasengenharia.com.br",
role: "MANAGER",
companyId: atlasCompanyId,
});
const omniManagerId = await ensureUser({
name: "Fernanda Lima",
email: "fernanda.lima@omnisaude.com.br",
role: "MANAGER",
companyId: omniCompanyId,
});
const ricardoOmniId = await ensureUser({
name: "Ricardo Matos",
email: "ricardo.matos@omnisaude.com.br",
role: "MANAGER",
companyId: omniCompanyId,
});
await ensureUser({
name: "Luciana Prado",
email: "luciana.prado@omnisaude.com.br",
role: "MANAGER",
companyId: omniCompanyId,
});
const clienteDemoId = await ensureUser({
name: "Cliente Demo",
email: "cliente.demo@sistema.dev",
role: "MANAGER",
companyId: omniCompanyId,
});
const templateDefinitions = [
{
@ -301,103 +152,6 @@ export const seedDemo = mutation({
});
}
// 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 = queueChamados._id;
const queue2 = queueLaboratorio._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: "AWAITING_ATTENDANCE",
priority: "URGENT",
channel: "EMAIL",
queueId: queue1,
requesterId: joaoAtlasId,
assigneeId: defaultAssigneeId,
companyId: atlasCompanyId,
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: {},
});
await ctx.db.insert("ticketEvents", {
ticketId: t1,
type: "MANAGER_NOTIFIED",
createdAt: now - 1000 * 60 * 60 * 4,
payload: { managerUserId: atlasManagerId },
});
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: ricardoOmniId,
assigneeId: defaultAssigneeId,
companyId: omniCompanyId,
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: {},
});
await ctx.db.insert("ticketEvents", {
ticketId: t2,
type: "MANAGER_NOTIFIED",
createdAt: now - 1000 * 60 * 60 * 7,
payload: { managerUserId: omniManagerId },
});
const t3 = await ctx.db.insert("tickets", {
tenantId,
reference: ++ref,
subject: "Visita técnica para instalação de roteadores",
summary: "Equipe Omni solicita agenda para instalação de novos pontos de rede",
status: "AWAITING_ATTENDANCE",
priority: "MEDIUM",
channel: "PHONE",
queueId: queueVisitas._id,
requesterId: clienteDemoId,
assigneeId: defaultAssigneeId,
companyId: omniCompanyId,
createdAt: now - 1000 * 60 * 60 * 3,
updatedAt: now - 1000 * 60 * 20,
tags: ["visita", "infraestrutura"],
});
await ctx.db.insert("ticketEvents", {
ticketId: t3,
type: "CREATED",
createdAt: now - 1000 * 60 * 60 * 3,
payload: {},
});
await ctx.db.insert("ticketEvents", {
ticketId: t3,
type: "VISIT_SCHEDULED",
createdAt: now - 1000 * 60 * 15,
payload: { scheduledFor: now + 1000 * 60 * 60 * 24 },
});
// No demo tickets are seeded; rely on real data from the database.
},
});
const generateCode = () => Array.from(randomBytes(32), (b) => b.toString(16).padStart(2, "0")).join("")

View file

@ -1,614 +0,0 @@
[
{
"id": 1,
"header": "Cover page",
"type": "Cover page",
"status": "In Process",
"target": "18",
"limit": "5",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Table of contents",
"type": "Table of contents",
"status": "Done",
"target": "29",
"limit": "24",
"reviewer": "Eddie Lake"
},
{
"id": 3,
"header": "Executive summary",
"type": "Narrative",
"status": "Done",
"target": "10",
"limit": "13",
"reviewer": "Eddie Lake"
},
{
"id": 4,
"header": "Technical approach",
"type": "Narrative",
"status": "Done",
"target": "27",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 5,
"header": "Design",
"type": "Narrative",
"status": "In Process",
"target": "2",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 6,
"header": "Capabilities",
"type": "Narrative",
"status": "In Process",
"target": "20",
"limit": "8",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 7,
"header": "Integration with existing systems",
"type": "Narrative",
"status": "In Process",
"target": "19",
"limit": "21",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 8,
"header": "Innovation and Advantages",
"type": "Narrative",
"status": "Done",
"target": "25",
"limit": "26",
"reviewer": "Assign reviewer"
},
{
"id": 9,
"header": "Overview of EMR's Innovative Solutions",
"type": "Technical content",
"status": "Done",
"target": "7",
"limit": "23",
"reviewer": "Assign reviewer"
},
{
"id": 10,
"header": "Advanced Algorithms and Machine Learning",
"type": "Narrative",
"status": "Done",
"target": "30",
"limit": "28",
"reviewer": "Assign reviewer"
},
{
"id": 11,
"header": "Adaptive Communication Protocols",
"type": "Narrative",
"status": "Done",
"target": "9",
"limit": "31",
"reviewer": "Assign reviewer"
},
{
"id": 12,
"header": "Advantages Over Current Technologies",
"type": "Narrative",
"status": "Done",
"target": "12",
"limit": "0",
"reviewer": "Assign reviewer"
},
{
"id": 13,
"header": "Past Performance",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "33",
"reviewer": "Assign reviewer"
},
{
"id": 14,
"header": "Customer Feedback and Satisfaction Levels",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "34",
"reviewer": "Assign reviewer"
},
{
"id": 15,
"header": "Implementation Challenges and Solutions",
"type": "Narrative",
"status": "Done",
"target": "3",
"limit": "35",
"reviewer": "Assign reviewer"
},
{
"id": 16,
"header": "Security Measures and Data Protection Policies",
"type": "Narrative",
"status": "In Process",
"target": "6",
"limit": "36",
"reviewer": "Assign reviewer"
},
{
"id": 17,
"header": "Scalability and Future Proofing",
"type": "Narrative",
"status": "Done",
"target": "4",
"limit": "37",
"reviewer": "Assign reviewer"
},
{
"id": 18,
"header": "Cost-Benefit Analysis",
"type": "Plain language",
"status": "Done",
"target": "14",
"limit": "38",
"reviewer": "Assign reviewer"
},
{
"id": 19,
"header": "User Training and Onboarding Experience",
"type": "Narrative",
"status": "Done",
"target": "17",
"limit": "39",
"reviewer": "Assign reviewer"
},
{
"id": 20,
"header": "Future Development Roadmap",
"type": "Narrative",
"status": "Done",
"target": "11",
"limit": "40",
"reviewer": "Assign reviewer"
},
{
"id": 21,
"header": "System Architecture Overview",
"type": "Technical content",
"status": "In Process",
"target": "24",
"limit": "18",
"reviewer": "Maya Johnson"
},
{
"id": 22,
"header": "Risk Management Plan",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "22",
"reviewer": "Carlos Rodriguez"
},
{
"id": 23,
"header": "Compliance Documentation",
"type": "Legal",
"status": "In Process",
"target": "31",
"limit": "27",
"reviewer": "Sarah Chen"
},
{
"id": 24,
"header": "API Documentation",
"type": "Technical content",
"status": "Done",
"target": "8",
"limit": "12",
"reviewer": "Raj Patel"
},
{
"id": 25,
"header": "User Interface Mockups",
"type": "Visual",
"status": "In Process",
"target": "19",
"limit": "25",
"reviewer": "Leila Ahmadi"
},
{
"id": 26,
"header": "Database Schema",
"type": "Technical content",
"status": "Done",
"target": "22",
"limit": "20",
"reviewer": "Thomas Wilson"
},
{
"id": 27,
"header": "Testing Methodology",
"type": "Technical content",
"status": "In Process",
"target": "17",
"limit": "14",
"reviewer": "Assign reviewer"
},
{
"id": 28,
"header": "Deployment Strategy",
"type": "Narrative",
"status": "Done",
"target": "26",
"limit": "30",
"reviewer": "Eddie Lake"
},
{
"id": 29,
"header": "Budget Breakdown",
"type": "Financial",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 30,
"header": "Market Analysis",
"type": "Research",
"status": "Done",
"target": "29",
"limit": "32",
"reviewer": "Sophia Martinez"
},
{
"id": 31,
"header": "Competitor Comparison",
"type": "Research",
"status": "In Process",
"target": "21",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 32,
"header": "Maintenance Plan",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "23",
"reviewer": "Alex Thompson"
},
{
"id": 33,
"header": "User Personas",
"type": "Research",
"status": "In Process",
"target": "27",
"limit": "24",
"reviewer": "Nina Patel"
},
{
"id": 34,
"header": "Accessibility Compliance",
"type": "Legal",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 35,
"header": "Performance Metrics",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "David Kim"
},
{
"id": 36,
"header": "Disaster Recovery Plan",
"type": "Technical content",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 37,
"header": "Third-party Integrations",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Eddie Lake"
},
{
"id": 38,
"header": "User Feedback Summary",
"type": "Research",
"status": "Done",
"target": "20",
"limit": "15",
"reviewer": "Assign reviewer"
},
{
"id": 39,
"header": "Localization Strategy",
"type": "Narrative",
"status": "In Process",
"target": "12",
"limit": "19",
"reviewer": "Maria Garcia"
},
{
"id": 40,
"header": "Mobile Compatibility",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "James Wilson"
},
{
"id": 41,
"header": "Data Migration Plan",
"type": "Technical content",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Assign reviewer"
},
{
"id": 42,
"header": "Quality Assurance Protocols",
"type": "Technical content",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Priya Singh"
},
{
"id": 43,
"header": "Stakeholder Analysis",
"type": "Research",
"status": "In Process",
"target": "11",
"limit": "14",
"reviewer": "Eddie Lake"
},
{
"id": 44,
"header": "Environmental Impact Assessment",
"type": "Research",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Assign reviewer"
},
{
"id": 45,
"header": "Intellectual Property Rights",
"type": "Legal",
"status": "In Process",
"target": "17",
"limit": "20",
"reviewer": "Sarah Johnson"
},
{
"id": 46,
"header": "Customer Support Framework",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 47,
"header": "Version Control Strategy",
"type": "Technical content",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 48,
"header": "Continuous Integration Pipeline",
"type": "Technical content",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Michael Chen"
},
{
"id": 49,
"header": "Regulatory Compliance",
"type": "Legal",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Assign reviewer"
},
{
"id": 50,
"header": "User Authentication System",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "Eddie Lake"
},
{
"id": 51,
"header": "Data Analytics Framework",
"type": "Technical content",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 52,
"header": "Cloud Infrastructure",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 53,
"header": "Network Security Measures",
"type": "Technical content",
"status": "In Process",
"target": "29",
"limit": "32",
"reviewer": "Lisa Wong"
},
{
"id": 54,
"header": "Project Timeline",
"type": "Planning",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Eddie Lake"
},
{
"id": 55,
"header": "Resource Allocation",
"type": "Planning",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Assign reviewer"
},
{
"id": 56,
"header": "Team Structure and Roles",
"type": "Planning",
"status": "Done",
"target": "20",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 57,
"header": "Communication Protocols",
"type": "Planning",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 58,
"header": "Success Metrics",
"type": "Planning",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Eddie Lake"
},
{
"id": 59,
"header": "Internationalization Support",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 60,
"header": "Backup and Recovery Procedures",
"type": "Technical content",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 61,
"header": "Monitoring and Alerting System",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Daniel Park"
},
{
"id": 62,
"header": "Code Review Guidelines",
"type": "Technical content",
"status": "Done",
"target": "12",
"limit": "15",
"reviewer": "Eddie Lake"
},
{
"id": 63,
"header": "Documentation Standards",
"type": "Technical content",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 64,
"header": "Release Management Process",
"type": "Planning",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Assign reviewer"
},
{
"id": 65,
"header": "Feature Prioritization Matrix",
"type": "Planning",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Emma Davis"
},
{
"id": 66,
"header": "Technical Debt Assessment",
"type": "Technical content",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Eddie Lake"
},
{
"id": 67,
"header": "Capacity Planning",
"type": "Planning",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 68,
"header": "Service Level Agreements",
"type": "Legal",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Assign reviewer"
}
]

View file

@ -16,12 +16,7 @@ export default async function Dashboard() {
<SiteHeader
title="Central de operações"
lead="Monitoramento em tempo real"
primaryAlignment="center"
primaryAction={
<div className="flex w-full justify-center sm:w-auto">
<NewTicketDialogDeferred triggerClassName="w-full max-w-xs text-base sm:w-auto sm:px-6 sm:py-2" />
</div>
}
primaryAction={<NewTicketDialogDeferred />}
/>
}
>

View file

@ -7,20 +7,20 @@ import { requireAuthenticatedSession } from "@/lib/auth-server"
export default async function PlayPage() {
await requireAuthenticatedSession()
return (
<AppShell
header={
<SiteHeader
title="Modo play"
lead="Distribua tickets automaticamente conforme prioridade"
secondaryAction={<SiteHeader.SecondaryButton>Pausar notificacoes</SiteHeader.SecondaryButton>}
primaryAction={<SiteHeader.PrimaryButton>Iniciar sessao</SiteHeader.PrimaryButton>}
/>
}
>
<div className="flex flex-col gap-6 px-4 lg:px-6">
<PlayNextTicketCard />
<TicketQueueSummaryCards />
</div>
</AppShell>
)
}
<AppShell
header={
<SiteHeader
title="Modo play"
lead="Distribua tickets automaticamente conforme prioridade"
secondaryAction={<SiteHeader.SecondaryButton>Pausar notificações</SiteHeader.SecondaryButton>}
primaryAction={<SiteHeader.PrimaryButton>Iniciar sessão</SiteHeader.PrimaryButton>}
/>
}
>
<div className="flex flex-col gap-6 px-4 lg:px-6">
<PlayNextTicketCard />
<TicketQueueSummaryCards />
</div>
</AppShell>
)
}

View file

@ -1,10 +1,7 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { TicketDetailView } from "@/components/tickets/ticket-detail-view"
import { TicketDetailStatic } from "@/components/tickets/ticket-detail-static"
import { NewTicketDialogDeferred } from "@/components/tickets/new-ticket-dialog.client"
import { getTicketById } from "@/lib/mocks/tickets"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { requireAuthenticatedSession } from "@/lib/auth-server"
type TicketDetailPageProps = {
@ -14,8 +11,6 @@ type TicketDetailPageProps = {
export default async function TicketDetailPage({ params }: TicketDetailPageProps) {
await requireAuthenticatedSession()
const { id } = await params
const isMock = id.startsWith("ticket-")
const mock = isMock ? getTicketById(id) : null
return (
<AppShell
@ -28,7 +23,7 @@ export default async function TicketDetailPage({ params }: TicketDetailPageProps
/>
}
>
{isMock && mock ? <TicketDetailStatic ticket={mock as TicketWithDetails} /> : <TicketDetailView id={id} />}
<TicketDetailView id={id} />
</AppShell>
)
}

View file

@ -36,8 +36,8 @@ export function TicketsPageClient() {
<SiteHeader
title="Tickets"
lead="Visão consolidada de filas e SLAs"
secondaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>}
primaryAction={<NewTicketDialog />}
secondaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>}
/>
}
>

View file

@ -2,6 +2,7 @@
import Link from "next/link"
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
import { IconSearch, IconUserPlus } from "@tabler/icons-react"
import { toast } from "sonner"
@ -215,6 +216,50 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const teamUsers = useMemo(() => users.filter((user) => user.role !== "machine"), [users])
const machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users])
const defaultCreateRole = (selectableRoles.find((role) => role !== "machine") ?? "agent") as RoleOption
const [teamSearch, setTeamSearch] = useState("")
const [teamRoleFilter, setTeamRoleFilter] = useState<"all" | RoleOption>("all")
const [machineSearch, setMachineSearch] = useState("")
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [createForm, setCreateForm] = useState({
name: "",
email: "",
role: defaultCreateRole,
tenantId: defaultTenantId,
companyId: NO_COMPANY_ID,
})
const [isCreatingUser, setIsCreatingUser] = useState(false)
const [createPassword, setCreatePassword] = useState<string | null>(null)
const filteredTeamUsers = useMemo(() => {
const term = teamSearch.trim().toLowerCase()
return teamUsers.filter((user) => {
if (teamRoleFilter !== "all" && coerceRole(user.role) !== teamRoleFilter) return false
if (!term) return true
const haystack = [
user.name ?? "",
user.email ?? "",
user.companyName ?? "",
formatRole(user.role),
]
.join(" ")
.toLowerCase()
return haystack.includes(term)
})
}, [teamUsers, teamSearch, teamRoleFilter])
const filteredMachineUsers = useMemo(() => {
const term = machineSearch.trim().toLowerCase()
if (!term) return machineUsers
return machineUsers.filter((user) => {
return (
(user.name ?? "").toLowerCase().includes(term) ||
user.email.toLowerCase().includes(term) ||
(user.machinePersona ?? "").toLowerCase().includes(term)
)
})
}, [machineUsers, machineSearch])
useEffect(() => {
void (async () => {
try {
@ -248,6 +293,14 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
})
setPasswordPreview(null)
}, [editUser, defaultTenantId])
useEffect(() => {
setCreateForm((prev) => ({
...prev,
role: defaultCreateRole,
tenantId: defaultTenantId,
}))
}, [defaultCreateRole, defaultTenantId])
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
@ -405,6 +458,115 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
}
}
const resetCreateForm = useCallback(() => {
setCreateForm({
name: "",
email: "",
role: defaultCreateRole,
tenantId: defaultTenantId,
companyId: NO_COMPANY_ID,
})
setCreatePassword(null)
}, [defaultCreateRole, defaultTenantId])
const handleOpenCreateUser = useCallback(() => {
resetCreateForm()
setCreateDialogOpen(true)
}, [resetCreateForm])
const handleCloseCreateDialog = useCallback(() => {
resetCreateForm()
setCreateDialogOpen(false)
}, [resetCreateForm])
async function handleCreateUser(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const payload = {
name: createForm.name.trim(),
email: createForm.email.trim().toLowerCase(),
role: createForm.role,
tenantId: createForm.tenantId.trim() || defaultTenantId,
}
if (!payload.name) {
toast.error("Informe o nome do usuário")
return
}
if (!payload.email || !payload.email.includes("@")) {
toast.error("Informe um e-mail válido")
return
}
setIsCreatingUser(true)
setCreatePassword(null)
try {
const response = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Não foi possível criar o usuário")
}
const data = (await response.json()) as { user: { id: string; email: string; name: string | null; role: string; tenantId: string | null; createdAt: string }; temporaryPassword: string }
const normalizedRole = coerceRole(data.user.role)
const normalizedUser: AdminUser = {
id: data.user.id,
email: data.user.email,
name: data.user.name ?? data.user.email,
role: normalizedRole,
tenantId: data.user.tenantId ?? defaultTenantId,
createdAt: data.user.createdAt,
updatedAt: null,
companyId: null,
companyName: null,
machinePersona: null,
}
if (createForm.companyId && createForm.companyId !== NO_COMPANY_ID) {
try {
const assignResponse = await fetch("/api/admin/users/assign-company", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: normalizedUser.email, companyId: createForm.companyId }),
})
if (assignResponse.ok) {
const company = companies.find((company) => company.id === createForm.companyId)
normalizedUser.companyId = createForm.companyId
normalizedUser.companyName = company?.name ?? null
} else {
const assignData = await assignResponse.json().catch(() => ({}))
toast.error(assignData.error ?? "Não foi possível vincular a empresa")
}
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao vincular empresa"
toast.error(message)
}
}
setUsers((previous) => [normalizedUser, ...previous])
setCreatePassword(data.temporaryPassword)
toast.success("Usuário criado com sucesso")
setCreateForm((prev) => ({
...prev,
name: "",
email: "",
companyId: NO_COMPANY_ID,
}))
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao criar usuário"
toast.error(message)
} finally {
setIsCreatingUser(false)
}
}
async function handleSaveUser(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!editUser) return
@ -549,6 +711,56 @@ async function handleDeleteUser() {
</TabsList>
<TabsContent value="users" className="mt-6 space-y-6">
<div className="flex flex-col gap-4 rounded-xl border border-slate-200 bg-white p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-neutral-900">Equipe cadastrada</p>
<p className="text-xs text-neutral-500">
{filteredTeamUsers.length} {filteredTeamUsers.length === 1 ? "colaborador" : "colaboradores"}
</p>
</div>
<Button onClick={handleOpenCreateUser} size="sm" className="gap-2 self-start sm:self-auto">
<IconUserPlus className="size-4" />
Novo usuário
</Button>
</div>
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-xs">
<IconSearch className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
<Input
value={teamSearch}
onChange={(event) => setTeamSearch(event.target.value)}
placeholder="Buscar por nome, e-mail ou empresa..."
className="h-9 pl-9"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Select value={teamRoleFilter} onValueChange={(value) => setTeamRoleFilter(value as "all" | RoleOption)}>
<SelectTrigger className="h-9 w-full sm:w-48">
<SelectValue placeholder="Todos os papéis" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os papéis</SelectItem>
{selectableRoles.filter((option) => option !== "machine").map((option) => (
<SelectItem key={`team-filter-${option}`} value={option}>
{formatRole(option)}
</SelectItem>
))}
</SelectContent>
</Select>
{(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setTeamSearch("")
setTeamRoleFilter("all")
}}
>
Limpar filtros
</Button>
) : null}
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Equipe cadastrada</CardTitle>
@ -568,7 +780,7 @@ async function handleDeleteUser() {
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{teamUsers.map((user) => (
{filteredTeamUsers.map((user) => (
<tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
@ -590,9 +802,9 @@ async function handleDeleteUser() {
Editar
</Button>
<Button
variant="ghost"
variant="outline"
size="sm"
className="text-red-600 hover:bg-red-500/10"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={!canManageUser(user.role)}
onClick={() => {
if (!canManageUser(user.role)) return
@ -605,10 +817,12 @@ async function handleDeleteUser() {
</td>
</tr>
))}
{teamUsers.length === 0 ? (
{filteredTeamUsers.length === 0 ? (
<tr>
<td colSpan={7} className="py-6 text-center text-neutral-500">
Nenhum usuário cadastrado até o momento.
{teamUsers.length === 0
? "Nenhum usuário cadastrado até o momento."
: "Nenhum usuário corresponde aos filtros atuais."}
</td>
</tr>
) : null}
@ -661,6 +875,25 @@ async function handleDeleteUser() {
</TabsContent>
<TabsContent value="machines" className="mt-6 space-y-6">
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold text-neutral-900">Agentes de máquina</p>
<p className="text-xs text-neutral-500">{filteredMachineUsers.length} {filteredMachineUsers.length === 1 ? "agente" : "agentes"} vinculados via desktop client.</p>
</div>
</div>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="relative w-full sm:max-w-xs">
<IconSearch className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
<Input
value={machineSearch}
onChange={(event) => setMachineSearch(event.target.value)}
placeholder="Buscar por hostname ou e-mail técnico"
className="h-9 pl-9"
/>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Agentes de máquina</CardTitle>
@ -678,33 +911,41 @@ async function handleDeleteUser() {
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{machineUsers.map((user) => (
<tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "Máquina"}</td>
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
<td className="py-3 pr-4 text-neutral-600">{user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"}</td>
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="py-3">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/admin/machines">Gerenciar</Link>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-500/10"
onClick={() => setDeleteUserId(user.id)}
>
Remover acesso
</Button>
</div>
</td>
</tr>
))}
{machineUsers.length === 0 ? (
{filteredMachineUsers.map((user) => {
const machineId = extractMachineId(user.email)
return (
<tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "Máquina"}</td>
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
<td className="py-3 pr-4 text-neutral-600">{user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"}</td>
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="py-3">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => setEditUserId(user.id)}>
Editar
</Button>
<Button variant="ghost" size="sm" asChild>
<Link href={machineId ? `/admin/machines/${machineId}` : "/admin/machines"}>Detalhes da máquina</Link>
</Button>
<Button
variant="outline"
size="sm"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
onClick={() => setDeleteUserId(user.id)}
>
Remover acesso
</Button>
</div>
</td>
</tr>
)
})}
{filteredMachineUsers.length === 0 ? (
<tr>
<td colSpan={5} className="py-6 text-center text-neutral-500">
Nenhuma máquina provisionada ainda.
{machineUsers.length === 0
? "Nenhuma máquina provisionada ainda."
: "Nenhum agente encontrado para a busca atual."}
</td>
</tr>
) : null}
@ -751,7 +992,7 @@ async function handleDeleteUser() {
<div className="grid gap-2">
<Label>Papel</Label>
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
<SelectTrigger id="invite-role">
<SelectTrigger id="invite-role" className="h-9">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
@ -888,6 +1129,119 @@ async function handleDeleteUser() {
</Card>
</TabsContent>
</Tabs>
<Dialog
open={createDialogOpen}
onOpenChange={(open) => {
if (open) {
setCreateDialogOpen(true)
} else {
handleCloseCreateDialog()
}
}}
>
<DialogContent className="max-w-lg space-y-4">
<DialogHeader>
<DialogTitle>Novo usuário</DialogTitle>
<DialogDescription>Crie uma conta para membros da equipe com acesso imediato ao sistema.</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateUser} className="space-y-5">
<div className="grid gap-2">
<Label htmlFor="create-name">Nome</Label>
<Input
id="create-name"
placeholder="Nome completo"
value={createForm.name}
onChange={(event) => setCreateForm((prev) => ({ ...prev, name: event.target.value }))}
required
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="create-email">E-mail</Label>
<Input
id="create-email"
type="email"
inputMode="email"
placeholder="nome@suaempresa.com"
value={createForm.email}
onChange={(event) => setCreateForm((prev) => ({ ...prev, email: event.target.value }))}
required
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="create-role">Papel</Label>
<Select
value={createForm.role}
onValueChange={(value) => setCreateForm((prev) => ({ ...prev, role: value as RoleOption }))}
>
<SelectTrigger id="create-role" className="h-9">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{selectableRoles.filter((option) => option !== "machine").map((option) => (
<SelectItem key={`create-role-${option}`} value={option}>
{formatRole(option)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="create-tenant">Espaço (tenant)</Label>
<Input
id="create-tenant"
placeholder="tenant-atlas"
value={createForm.tenantId}
onChange={(event) => setCreateForm((prev) => ({ ...prev, tenantId: event.target.value }))}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="create-company">Empresa vinculada</Label>
<Select
value={createForm.companyId}
onValueChange={(value) => setCreateForm((prev) => ({ ...prev, companyId: value }))}
>
<SelectTrigger id="create-company" className="h-9">
<SelectValue placeholder="Sem vínculo" />
</SelectTrigger>
<SelectContent>
{companyOptions.map((company) => (
<SelectItem key={`create-company-${company.id}`} value={company.id}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{createPassword ? (
<div className="rounded-lg border border-slate-300 bg-slate-50 p-3">
<p className="text-xs text-neutral-600">Senha temporária gerada:</p>
<div className="mt-2 flex flex-wrap items-center gap-3">
<code className="text-sm font-semibold text-neutral-900">{createPassword}</code>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => navigator.clipboard.writeText(createPassword).then(() => toast.success("Senha copiada"))}
>
Copiar
</Button>
</div>
</div>
) : null}
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={handleCloseCreateDialog} disabled={isCreatingUser}>
Cancelar
</Button>
<Button type="submit" disabled={isCreatingUser}>
{isCreatingUser ? "Criando..." : "Criar usuário"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Sheet open={Boolean(editUser)} onOpenChange={(open) => (!open ? setEditUserId(null) : null)}>
<SheetContent side="right" className="space-y-6 overflow-y-auto px-6 pb-10 sm:max-w-2xl">

View file

@ -235,9 +235,9 @@ export function AdminClientsManager({ initialClients }: { initialClients: AdminC
enableSorting: false,
cell: ({ row }) => (
<Button
variant="ghost"
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={isPending}
onClick={() => handleDelete([row.original.id])}
>
@ -313,9 +313,9 @@ export function AdminClientsManager({ initialClients }: { initialClients: AdminC
</SelectContent>
</Select>
<Button
variant="destructive"
variant="outline"
size="sm"
className="gap-2"
className="gap-2 border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700 disabled:text-rose-300 disabled:border-rose-100"
disabled={selectedRows.length === 0 || isPending}
onClick={() => handleDelete(selectedRows.map((row) => row.id))}
>

View file

@ -82,6 +82,18 @@ type MachineSummary = {
architecture?: string | null
}
function formatCnpjInput(value: string): string {
const digits = value.replace(/\D/g, "").slice(0, 14)
if (digits.length <= 2) return digits
if (digits.length <= 5) return `${digits.slice(0, 2)}.${digits.slice(2)}`
if (digits.length <= 8) return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5)}`
if (digits.length <= 12) {
return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5, 8)}/${digits.slice(8)}`
}
return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5, 8)}/${digits.slice(8, 12)}-${digits.slice(12)}`
}
export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) {
const [companies, setCompanies] = useState<Company[]>(() => initialCompanies ?? [])
const [isPending, startTransition] = useTransition()
@ -139,6 +151,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
setForm({
...c,
contractedHoursPerMonth: c.contractedHoursPerMonth ?? undefined,
cnpj: c.cnpj ? formatCnpjInput(c.cnpj) : null,
})
}
@ -163,11 +176,13 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
? form.contractedHoursPerMonth
: null
const cnpjDigits = form.cnpj ? form.cnpj.replace(/\D/g, "") : ""
const payload = {
name: form.name?.trim(),
slug: form.slug?.trim(),
isAvulso: Boolean(form.isAvulso ?? false),
cnpj: form.cnpj?.trim() || null,
cnpj: cnpjDigits ? cnpjDigits : null,
domain: form.domain?.trim() || null,
phone: form.phone?.trim() || null,
description: form.description?.trim() || null,
@ -331,7 +346,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
name="companyName"
value={form.name ?? ""}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
placeholder="Nome da empresa ou apelido interno"
placeholder="Nome da empresa"
/>
</div>
<div className="grid gap-2">
@ -360,7 +375,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
id={cnpjId}
name="companyCnpj"
value={form.cnpj ?? ""}
onChange={(e) => setForm((p) => ({ ...p, cnpj: e.target.value }))}
onChange={(e) => setForm((p) => ({ ...p, cnpj: formatCnpjInput(e.target.value) }))}
placeholder="00.000.000/0000-00"
/>
</div>
@ -472,7 +487,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
) : null}
<Card className="border-slate-200/80 shadow-none">
<CardHeader className="gap-4 border-b border-slate-100 py-6 dark:border-slate-800/60">
<CardHeader className="gap-4 bg-white px-6 py-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<CardTitle className="text-xl font-semibold tracking-tight">Empresas cadastradas</CardTitle>
@ -489,14 +504,14 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Buscar por nome, slug ou domínio..."
className="pl-9"
className="h-9 pl-9"
/>
</div>
<Button
type="button"
variant="ghost"
variant="outline"
size="sm"
className="sm:self-start"
className="gap-2 self-start sm:self-auto"
disabled={isPending}
onClick={() => startTransition(() => { void refresh() })}
>
@ -505,9 +520,9 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<CardContent className="bg-white p-4">
{isMobile ? (
<div className="space-y-4 p-4">
<div className="space-y-4 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
{hasCompanies ? (
filteredCompanies.map((company) => {
const companyMachines = machinesByCompanyId.get(company.id) ?? []
@ -554,6 +569,10 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
<IconPencil className="size-4" />
Editar empresa
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setMachinesDialog({ companyId: company.id, name: company.name })}>
<IconDeviceDesktop className="size-4" />
Ver máquinas
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void toggleAvulso(company)}>
<IconSwitchHorizontal className="size-4" />
{company.isAvulso ? "Marcar como recorrente" : "Marcar como avulso"}
@ -673,9 +692,9 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
)}
</div>
) : (
<div className="overflow-hidden rounded-lg border border-slate-200">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<Table className="min-w-full table-fixed text-sm">
<TableHeader className="sticky top-0 z-10 bg-muted/60 backdrop-blur supports-[backdrop-filter]:bg-muted/40">
<TableHeader className="bg-slate-50">
<TableRow className="border-b border-slate-200 dark:border-slate-800/60 [&_th]:h-10 [&_th]:text-xs [&_th]:font-medium [&_th]:uppercase [&_th]:tracking-wide [&_th]:text-muted-foreground [&_th:first-child]:rounded-tl-lg [&_th:last-child]:rounded-tr-lg">
<TableHead className="w-[30%] min-w-[220px] pl-6">Empresa</TableHead>
<TableHead className="w-[22%] min-w-[180px] pl-4">Provisionamento</TableHead>
@ -935,23 +954,26 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
}
}}
>
<DialogContent>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Excluir empresa</DialogTitle>
<DialogDescription>
<div className="flex items-center gap-2 text-amber-600">
<IconAlertTriangle className="size-5" />
<DialogTitle className="text-base font-semibold text-neutral-900">Confirmar exclusão</DialogTitle>
</div>
<DialogDescription className="text-sm text-neutral-600">
Esta operação remove o cadastro do cliente e impede novos vínculos automáticos.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 text-sm text-neutral-600">
<p>
Confirme a exclusão de{" "}
<span className="font-semibold text-neutral-900">{deleteTarget?.name ?? "empresa selecionada"}</span>.
Deseja remover definitivamente{" "}
<span className="font-semibold text-neutral-900">{deleteTarget?.name ?? "a empresa selecionada"}</span>?
</p>
<p className="text-xs text-neutral-500">
Registros históricos que apontem para a empresa poderão impedir a exclusão.
Registros históricos podem impedir a exclusão. Usuários e tickets ainda vinculados serão desvinculados automaticamente.
</p>
</div>
<DialogFooter>
<DialogFooter className="gap-2 sm:justify-end">
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
Cancelar
</Button>

View file

@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import {
Table,
@ -1310,6 +1310,23 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
<CardHeader>
<CardTitle>Detalhes</CardTitle>
<CardDescription>Resumo da máquina selecionada.</CardDescription>
{machine ? (
<CardAction>
<div className="flex flex-col items-end gap-2 text-xs sm:text-sm">
{companyName ? (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-1 font-semibold text-neutral-600 shadow-sm">
{companyName}
</div>
) : null}
<MachineStatusBadge status={effectiveStatus} />
{!isActive ? (
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 font-semibold uppercase text-rose-700">
Máquina desativada
</Badge>
) : null}
</div>
</CardAction>
) : null}
</CardHeader>
<CardContent className="space-y-6">
{!machine ? (
@ -1317,7 +1334,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
) : (
<div className="space-y-6">
<section className="space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-wrap items-start gap-2">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h1 className="break-words text-2xl font-semibold text-neutral-900">{machine.hostname}</h1>
@ -1330,19 +1347,6 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
{machine.authEmail ?? "E-mail não definido"}
</p>
</div>
<div className="flex flex-col items-end gap-2 text-sm">
{companyName ? (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-600 shadow-sm">
{companyName}
</div>
) : null}
<MachineStatusBadge status={effectiveStatus} />
{!isActive ? (
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 text-xs font-semibold uppercase text-rose-700">
Máquina desativada
</Badge>
) : null}
</div>
</div>
{/* ping integrado na badge de status */}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
@ -1375,7 +1379,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
{isActive ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
</Button>
{machine.registeredBy ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
<Badge variant="outline" className="inline-flex h-9 items-center rounded-full border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
Registrada via {machine.registeredBy}
</Badge>
) : null}
@ -2667,14 +2671,14 @@ function MetricsGrid({ metrics, hardware, disks }: { metrics: MachineMetrics; ha
const percentLabel = card.percent !== null ? `${Math.round(card.percent)}%` : "—"
return (
<div key={card.key} className="flex items-center gap-4 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div className="relative h-20 w-20">
<div className="relative h-24 w-24">
<ChartContainer
config={{ usage: { label: card.label, color: card.color } }}
className="h-20 w-20 aspect-square"
className="h-24 w-24 aspect-square"
>
<RadialBarChart
data={[{ name: card.label, value: percentValue }]}
innerRadius="55%"
innerRadius="68%"
outerRadius="100%"
startAngle={90}
endAngle={-270}
@ -2683,7 +2687,7 @@ function MetricsGrid({ metrics, hardware, disks }: { metrics: MachineMetrics; ha
<RadialBar dataKey="value" cornerRadius={10} fill="var(--color-usage)" background />
</RadialBarChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-sm font-semibold text-neutral-800">
<div className="pointer-events-none absolute inset-[18%] flex items-center justify-center rounded-full bg-white/70 text-sm font-semibold text-neutral-900">
{percentLabel}
</div>
</div>

View file

@ -494,11 +494,11 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
</div>
<Dialog open={!!previewAttachment} onOpenChange={(open) => { if (!open) setPreviewAttachment(null) }}>
<DialogContent className="max-w-3xl border border-slate-200 p-0">
<DialogHeader className="flex items-center justify-between gap-3 px-4 py-3">
<DialogTitle className="text-base font-semibold text-neutral-800">
<DialogHeader className="relative px-4 py-3">
<DialogTitle className="pr-10 text-base font-semibold text-neutral-800">
{previewAttachment?.name ?? "Visualização do anexo"}
</DialogTitle>
<DialogClose className="inline-flex size-7 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400/30">
<DialogClose className="absolute right-4 top-3 inline-flex size-7 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400/30">
<X className="size-4" />
</DialogClose>
</DialogHeader>
@ -611,13 +611,22 @@ function PortalCommentAttachmentCard({
const target = await ensureUrl()
if (!target) return
try {
const response = await fetch(target, { credentials: "include" })
if (!response.ok) {
throw new Error(`Unexpected status ${response.status}`)
}
const blob = await response.blob()
const blobUrl = window.URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = target
link.href = blobUrl
link.download = attachment.name ?? "anexo"
link.rel = "noopener noreferrer"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.setTimeout(() => {
window.URL.revokeObjectURL(blobUrl)
}, 1000)
} catch (error) {
console.error("Falha ao iniciar download do anexo", error)
window.open(target, "_blank", "noopener,noreferrer")

View file

@ -118,7 +118,10 @@ export function SectionCards() {
<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">
<CardDescription>Tempo médio da 1ª resposta</CardDescription>
<CardDescription className="leading-snug">
<span className="block">Tempo médio da</span>
<span className="block">1ª resposta</span>
</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">
{dashboard ? formatMinutes(dashboard.firstResponse.averageMinutes) : <Skeleton className="h-8 w-24" />}
</CardTitle>
@ -169,7 +172,10 @@ export function SectionCards() {
<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">
<CardDescription>Tickets resolvidos (7 dias)</CardDescription>
<CardDescription className="leading-snug">
<span className="block">Tickets resolvidos</span>
<span className="block">(7 dias)</span>
</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">
{dashboard ? dashboard.resolution.resolvedLast7d : <Skeleton className="h-8 w-12" />}
</CardTitle>

View file

@ -116,7 +116,7 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
<h2 className="text-xl font-semibold text-neutral-900">{ticket.subject}</h2>
<p className="text-sm text-neutral-600">{ticket.summary}</p>
</div>
<div className="flex flex-wrap gap-2 text-xs text-neutral-600">
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-600">
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
<TicketStatusBadge status={ticket.status} />
<span className="font-medium text-neutral-900">Solicitante: {ticket.requester.name}</span>
@ -163,4 +163,4 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
</CardContent>
</Card>
)
}
}

View file

@ -514,9 +514,9 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</Dialog>
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
<DialogContent className="max-w-3xl border border-slate-200 p-0">
<DialogHeader className="flex items-center justify-between gap-3 px-4 py-3">
<DialogTitle className="text-base font-semibold text-neutral-800">Visualização do anexo</DialogTitle>
<DialogClose className="inline-flex size-7 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400/30">
<DialogHeader className="relative px-4 py-3">
<DialogTitle className="pr-10 text-base font-semibold text-neutral-800">Visualização do anexo</DialogTitle>
<DialogClose className="absolute right-4 top-3 inline-flex size-7 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400/30">
<X className="size-4" />
</DialogClose>
</DialogHeader>
@ -614,13 +614,22 @@ function CommentAttachmentCard({
const target = url ?? (await ensureUrl())
if (!target) return
try {
const response = await fetch(target, { credentials: "include" })
if (!response.ok) {
throw new Error(`Unexpected status ${response.status}`)
}
const blob = await response.blob()
const blobUrl = window.URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = target
link.href = blobUrl
link.download = attachment.name ?? "anexo"
link.rel = "noopener noreferrer"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.setTimeout(() => {
window.URL.revokeObjectURL(blobUrl)
}, 1000)
} catch (error) {
console.error("Failed to download attachment", error)
window.open(target, "_blank", "noopener,noreferrer")

View file

@ -1,21 +0,0 @@
"use client";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
import type { TicketWithDetails } from "@/lib/schemas/ticket";
export function TicketDetailStatic({ ticket }: { ticket: TicketWithDetails }) {
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<TicketTimeline ticket={ticket} />
</div>
<TicketDetailsPanel ticket={ticket} />
</div>
</div>
);
}

View file

@ -6,7 +6,6 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
import type { Id } from "@/convex/_generated/dataModel";
import type { TicketWithDetails } from "@/lib/schemas/ticket";
import { getTicketById } from "@/lib/mocks/tickets";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { TicketComments } from "@/components/tickets/ticket-comments.rich";
@ -16,9 +15,8 @@ import { TicketTimeline } from "@/components/tickets/ticket-timeline";
import { useAuth } from "@/lib/auth-client";
export function TicketDetailView({ id }: { id: string }) {
const isMockId = id.startsWith("ticket-");
const { convexUserId } = useAuth();
const shouldSkip = isMockId || !convexUserId;
const shouldSkip = !convexUserId;
const t = useQuery(
api.tickets.getById,
shouldSkip
@ -29,40 +27,66 @@ export function TicketDetailView({ id }: { id: string }) {
viewerId: convexUserId as Id<"users">,
}
);
let ticket: TicketWithDetails | null = null;
if (t) {
ticket = mapTicketWithDetailsFromServer(t as unknown);
} else if (isMockId) {
ticket = getTicketById(id) ?? null;
}
if (!ticket) return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-3 p-6">
<div className="flex items-center gap-2"><Skeleton className="h-5 w-24" /><Skeleton className="h-5 w-20" /></div>
<Skeleton className="h-7 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-4 p-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="flex items-center gap-2"><Skeleton className="h-4 w-28" /><Skeleton className="h-3 w-24" /></div>
<Skeleton className="h-16 w-full" />
</div>
))}
</CardContent>
</Card>
const isLoading = shouldSkip || t === undefined;
if (isLoading) {
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-3 p-6">
{Array.from({ length: 5 }).map((_, i) => (<Skeleton key={i} className="h-3 w-full" />))}
<div className="flex items-center gap-2"><Skeleton className="h-5 w-24" /><Skeleton className="h-5 w-20" /></div>
<Skeleton className="h-7 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-4 p-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="flex items-center gap-2"><Skeleton className="h-4 w-28" /><Skeleton className="h-3 w-24" /></div>
<Skeleton className="h-16 w-full" />
</div>
))}
</CardContent>
</Card>
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-3 p-6">
{Array.from({ length: 5 }).map((_, i) => (<Skeleton key={i} className="h-3 w-full" />))}
</CardContent>
</Card>
</div>
</div>
);
}
if (!t) {
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-3 p-6">
<p className="text-base font-semibold text-neutral-900">Ticket não encontrado</p>
<p className="text-sm text-neutral-600">O ticket solicitado não existe ou você não tem permissão para visualizá-lo.</p>
</CardContent>
</Card>
</div>
</div>
);
);
}
const ticket = mapTicketWithDetailsFromServer(t as unknown) as TicketWithDetails | null;
if (!ticket) {
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-3 p-6">
<p className="text-base font-semibold text-neutral-900">Ticket não encontrado</p>
<p className="text-sm text-neutral-600">O ticket solicitado não existe ou você não tem permissão para visualizá-lo.</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket} />

View file

@ -6,7 +6,6 @@ import { format, formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { type LucideIcon, Code, FileText, Mail, MessageCircle, MessageSquare, Phone } from "lucide-react"
import { tickets as ticketsMock } from "@/lib/mocks/tickets"
import type { Ticket, TicketChannel, TicketStatus } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
@ -110,7 +109,8 @@ export type TicketsTableProps = {
tickets?: Ticket[]
}
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
export function TicketsTable({ tickets }: TicketsTableProps) {
const safeTickets = tickets ?? []
const [now, setNow] = useState(() => Date.now())
const router = useRouter()
@ -152,7 +152,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<TableHead className="hidden w-[150px] pl-6 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 xl:w-[180px]">
Empresa
</TableHead>
<TableHead className="hidden w-[90px] 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 lg:w-[100px]">
<TableHead className="hidden w-[90px] px-4 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell lg:w-[100px]">
Prioridade
</TableHead>
<TableHead className="w-[160px] pl-6 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 sm:w-[200px] lg:pl-10 xl:w-[230px] xl:pl-14">
@ -170,7 +170,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</TableRow>
</TableHeader>
<TableBody>
{tickets.map((ticket) => {
{safeTickets.map((ticket) => {
const ChannelIcon = channelIcon[ticket.channel] ?? MessageCircle
return (
@ -240,9 +240,9 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
{((ticket.company ?? null) as { name?: string } | null)?.name ?? "—"}
</span>
</TableCell>
<TableCell className={`${cellClass} hidden md:table-cell pl-1 pr-6 lg:pr-8`}>
<TableCell className={`${cellClass} hidden md:table-cell px-4`}>
<div
className="inline-flex"
className="flex justify-center"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
@ -288,7 +288,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</TableBody>
</Table>
</div>
{tickets.length === 0 && (
{safeTickets.length === 0 && (
<Empty className="my-6">
<EmptyHeader>
<EmptyMedia variant="icon">

View file

@ -5,6 +5,7 @@ import { ChangeEvent, useEffect, useMemo, useState } from "react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { COUNTRY_DIAL_CODES, type CountryDialCode } from "@/lib/country-codes"
export type CountryOption = {
code: string
@ -15,26 +16,7 @@ export type CountryOption = {
formatLocal: (digits: string) => string
}
const COUNTRY_OPTIONS: CountryOption[] = [
{
code: "BR",
label: "Brasil",
dialCode: "+55",
flag: "🇧🇷",
maxDigits: 11,
formatLocal: formatBrazilPhone,
},
{
code: "US",
label: "Estados Unidos",
dialCode: "+1",
flag: "🇺🇸",
maxDigits: 10,
formatLocal: formatUsPhone,
},
]
const DEFAULT_COUNTRY = COUNTRY_OPTIONS[0]
const DEFAULT_COUNTRY_CODE = "BR"
function formatBrazilPhone(digits: string): string {
const cleaned = digits.slice(0, 11)
@ -63,12 +45,18 @@ function extractDigits(value?: string | null): string {
function findCountryByValue(value?: string | null): CountryOption {
const digits = extractDigits(value)
return (
COUNTRY_OPTIONS.find((option) => {
const dialDigits = extractDigits(option.dialCode)
return digits.startsWith(dialDigits) && digits.length > dialDigits.length
}) ?? DEFAULT_COUNTRY
)
const dialDigits = extractDigits(DEFAULT_COUNTRY.dialCode)
let matched: CountryOption = DEFAULT_COUNTRY
let matchedLength = digits.startsWith(dialDigits) ? dialDigits.length : 0
for (const option of COUNTRY_OPTIONS) {
const optionDialDigits = extractDigits(option.dialCode)
if (optionDialDigits.length === 0) continue
if (digits.startsWith(optionDialDigits) && optionDialDigits.length > matchedLength) {
matched = option
matchedLength = optionDialDigits.length
}
}
return matched
}
function toLocalDigits(value: string | null | undefined, country: CountryOption): string {
@ -88,9 +76,49 @@ function formatStoredValue(country: CountryOption, localDigits: string): string
function placeholderFor(country: CountryOption): string {
if (country.code === "BR") return "(11) 91234-5678"
if (country.code === "US") return "(555) 123-4567"
if (country.code === "CA") return "(416) 123-4567"
return "Número de telefone"
}
function formatGenericPhone(digits: string): string {
const cleaned = digits.slice(0, 15)
if (!cleaned) return ""
return cleaned.replace(/(\d{3,4})(?=\d)/g, "$1 ").trim()
}
function flagEmojiFor(code: string): string {
if (!code || code.length !== 2) return "🏳️"
const base = 127397
const chars = Array.from(code.toUpperCase()).map((char) => base + char.charCodeAt(0))
return String.fromCodePoint(...chars)
}
const regionDisplayNames =
typeof Intl !== "undefined" && "DisplayNames" in Intl
? new Intl.DisplayNames(["pt-BR", "pt", "en"], { type: "region" })
: null
const SPECIAL_FORMATS: Record<string, { maxDigits: number; formatLocal: (digits: string) => string }> = {
BR: { maxDigits: 11, formatLocal: formatBrazilPhone },
US: { maxDigits: 10, formatLocal: formatUsPhone },
CA: { maxDigits: 10, formatLocal: formatUsPhone },
}
const COUNTRY_OPTIONS: CountryOption[] = COUNTRY_DIAL_CODES.map((entry: CountryDialCode) => {
const special = SPECIAL_FORMATS[entry.code]
return {
code: entry.code,
label: regionDisplayNames?.of(entry.code) ?? entry.name ?? entry.code,
dialCode: entry.dialCode,
flag: flagEmojiFor(entry.code),
maxDigits: special?.maxDigits ?? 15,
formatLocal: special?.formatLocal ?? formatGenericPhone,
}
}).sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
const DEFAULT_COUNTRY =
COUNTRY_OPTIONS.find((option) => option.code === DEFAULT_COUNTRY_CODE) ?? COUNTRY_OPTIONS[0]
export function PhoneInput({ value, onChange, className, id, name }: PhoneInputProps) {
const [selectedCountry, setSelectedCountry] = useState<CountryOption>(DEFAULT_COUNTRY)
const [localDigits, setLocalDigits] = useState<string>("")
@ -131,7 +159,7 @@ export function PhoneInput({ value, onChange, className, id, name }: PhoneInputP
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectContent className="max-h-72 overflow-y-auto">
{COUNTRY_OPTIONS.map((option) => (
<SelectItem key={option.code} value={option.code}>
<span className="inline-flex items-center gap-2">

1234
src/lib/country-codes.ts Normal file

File diff suppressed because it is too large Load diff

View file

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