From b00e52475f0606e9fc5bf94eca52d9119714557a Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Thu, 13 Nov 2025 15:06:00 -0300 Subject: [PATCH 001/495] =?UTF-8?q?Melhora=20filtros=20no=20desktop=20e=20?= =?UTF-8?q?adiciona=20=C3=ADcone=20em=20Todos=20os=20tickets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/app-sidebar.tsx | 2 +- .../portal/portal-ticket-filters.tsx | 99 ++++++++++++++++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index bc2e22d..53fd4b8 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -79,7 +79,7 @@ const navigation: NavigationGroup[] = [ icon: Ticket, requiredRole: "staff", children: [ - { title: "Todos os tickets", url: "/tickets", requiredRole: "staff" }, + { title: "Todos os tickets", url: "/tickets", icon: Ticket, requiredRole: "staff" }, { title: "Resolvidos", url: "/tickets/resolved", icon: ShieldCheck, requiredRole: "staff" }, ], }, diff --git a/src/components/portal/portal-ticket-filters.tsx b/src/components/portal/portal-ticket-filters.tsx index 97a2630..bb21d50 100644 --- a/src/components/portal/portal-ticket-filters.tsx +++ b/src/components/portal/portal-ticket-filters.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { DatePicker } from "@/components/ui/date-picker" export type PortalTicketFiltersState = { queue: string | null @@ -62,6 +63,102 @@ export function PortalTicketFilters({ onFiltersChange(partial) } + const hasAnyFilterApplied = + Boolean(filters.queue) || + Boolean(filters.company) || + Boolean(filters.categoryId) || + Boolean(filters.assigneeId) || + Boolean(filters.dateFrom) || + Boolean(filters.dateTo) || + filters.status !== "active" || + filters.sort !== "recent" + + if (hideAdvancedFilters) { + return ( +
+
+
+ + + +
+
+

Período

+
+
+

A partir de

+ handleChange({ dateFrom: value })} + placeholder="Selecionar data" + className="rounded-xl border-slate-300 bg-white" + /> +
+
+

Até

+ handleChange({ dateTo: value })} + placeholder="Selecionar data" + className="rounded-xl border-slate-300 bg-white" + /> +
+
+
+
+ {hasAnyFilterApplied && ( +
+ +
+ )} +
+ ) + } + return (
@@ -184,7 +281,7 @@ export function PortalTicketFilters({
- {(filters.queue || filters.company || filters.categoryId || filters.assigneeId || filters.dateFrom || filters.dateTo || filters.status !== "active" || filters.sort !== "recent") && ( + {hasAnyFilterApplied && ( + + + + + + ) +} + export function TicketsFilters({ onChange, queues = [], @@ -136,227 +224,215 @@ export function TicketsFilters({ return (
-
-
- setPartial({ search: event.target.value })} - className="md:max-w-sm" - /> - {canUseAdvancedFilters ? ( - - ) : null} - {canUseAdvancedFilters ? ( - - ) : null} - -
-
- {canUseAdvancedFilters ? ( - - ) : null} - - - - +
+
+
+
+ setPartial({ search: event.target.value })} + className="w-full rounded-xl border-slate-300 bg-white/90" + /> +
+
+ + + + + +
+

Status

+ +
+
+

Prioridade

+ +
+
+

Canal

+ +
+
+
- - -
-

- Status -

- -
-
-

- Prioridade -

- -
-
-

- Canal -

- -
-
- - -
-
-
-
-

Período

-
-
-
- - setPartial({ dateFrom: value })} - placeholder="Selecionar data" - /> +
-
- - setPartial({ dateTo: value })} - placeholder="Selecionar data" +
+ {canUseAdvancedFilters && ( + + )} + {canUseAdvancedFilters && ( + + )} + + {canUseAdvancedFilters && ( + + )} +
+
+ + + setPartial({ dateFrom: from, dateTo: to })} + className="w-full" />
-
+
{activeFilters.length > 0 && (
{activeFilters.map((chip) => ( - + {chip} ))} diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx index 8ab6bb4..91604aa 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -103,18 +103,32 @@ function CalendarDropdown(props: CalendarDropdownProps) { export function Calendar({ className, classNames, + styles, + modifiersClassNames, showOutsideDays = true, captionLayout = "dropdown", startMonth, endMonth, ...props }: CalendarProps) { + const mergedStyles = React.useMemo( + () => ({ + day_selected: { backgroundColor: "transparent" }, + day_range_start: { backgroundColor: "transparent" }, + day_range_end: { backgroundColor: "transparent" }, + day_range_middle: { backgroundColor: "transparent" }, + ...(styles ?? {}), + }), + [styles] + ) + return ( Date: Thu, 13 Nov 2025 18:43:41 -0300 Subject: [PATCH 003/495] Improve today highlighting in calendar --- src/components/ui/calendar.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx index 91604aa..8e2642f 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -20,6 +20,14 @@ type OptionElement = React.ReactElement<{ type CalendarDropdownProps = DropdownProps & { children?: React.ReactNode } +const TODAY_HIGHLIGHT_CLASS = [ + // Subtle cyan background + ring when today isn't selected + "[&_button]:ring-1 [&_button]:ring-[#00d6eb] [&_button]:ring-offset-1 [&_button]:ring-offset-white", + "[&_button:not([aria-selected='true'])]:bg-[#00e8ff]/20 [&_button:not([aria-selected='true'])]:text-neutral-900", + // Preserve selection colors but keep a cyan outline when today is part of the range + "[&_button[aria-selected='true']]:ring-[#00f5ff] [&_button[aria-selected='true']]:ring-offset-neutral-900 [&_button[aria-selected='true']]:shadow-[0_0_0_1.5px_rgba(0,214,235,0.45)]", +].join(" ") + const buildOptions = (options?: DropdownProps["options"], children?: React.ReactNode) => { if (options && options.length > 0) { return options.map((option) => ({ @@ -169,8 +177,7 @@ export function Calendar({ range_middle: "bg-transparent text-neutral-900 [&_button]:rounded-full [&_button]:bg-neutral-900/10 [&_button]:text-neutral-900", range_start: "bg-transparent [&_button]:rounded-full [&_button]:bg-neutral-900 [&_button]:text-neutral-50 [&_button]:focus-visible:ring-0", range_end: "bg-transparent [&_button]:rounded-full [&_button]:bg-neutral-900 [&_button]:text-neutral-50 [&_button]:focus-visible:ring-0", - today: - "[&_button]:bg-[#00e8ff]/20 [&_button]:text-neutral-900 [&_button]:ring-1 [&_button]:ring-[#00d6eb] [&_button]:ring-offset-1 [&_button]:ring-offset-white", + today: TODAY_HIGHLIGHT_CLASS, ...modifiersClassNames, }} // Setas ao lado do caption From a08545fd40d43979c55eaefbb3277840516e1833 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Thu, 13 Nov 2025 19:50:06 -0300 Subject: [PATCH 004/495] Refine tickets filter layout --- src/components/tickets/tickets-filters.tsx | 245 ++++++++++++--------- 1 file changed, 138 insertions(+), 107 deletions(-) diff --git a/src/components/tickets/tickets-filters.tsx b/src/components/tickets/tickets-filters.tsx index 37da284..616749f 100644 --- a/src/components/tickets/tickets-filters.tsx +++ b/src/components/tickets/tickets-filters.tsx @@ -1,7 +1,7 @@ "use client" import { useEffect, useMemo, useState } from "react" -import { IconCalendar, IconFilter, IconRefresh } from "@tabler/icons-react" +import { IconCalendar, IconFilter, IconRefresh, IconSearch } from "@tabler/icons-react" import type { DateRange } from "react-day-picker" import { @@ -31,6 +31,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" type QueueOption = string @@ -146,10 +147,10 @@ function DateRangeButton({ from, to, onChange, className }: DateRangeButtonProps @@ -222,32 +223,47 @@ export function TicketsFilters({ return chips }, [filters, assignees, categories, canUseAdvancedFilters]) + const advancedFiltersCount = Number(Boolean(filters.status)) + Number(Boolean(filters.priority)) + Number(Boolean(filters.channel)) + return (
-
-
-
-
- setPartial({ search: event.target.value })} - className="w-full rounded-xl border-slate-300 bg-white/90" - /> +
+
+
+
+
+ + setPartial({ search: event.target.value })} + className="w-full rounded-2xl border-slate-300 bg-white/95 pl-9" + /> +
-
+
+ setPartial({ dateFrom: from, dateTo: to })} + className="w-full min-w-[200px] rounded-2xl border-slate-300 bg-white/95 text-left text-sm font-semibold text-neutral-700 lg:w-auto" + /> - +

Status

setPartial({ queue: value === ALL_VALUE ? null : value })} - > - - - - - Todas as filas - {queues.map((queue) => ( - - {queue} - - ))} - - +
+ +
)} {canUseAdvancedFilters && ( - +
+ +
)} - - {canUseAdvancedFilters && ( +
+
+ {canUseAdvancedFilters && ( +
+ +
)}
-
- - - setPartial({ dateFrom: from, dateTo: to })} - className="w-full" - /> + + Mais recentes + + + Mais antigos + +
From feca5dd4a7be66f5942de599e0222c9acab7218c Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Thu, 13 Nov 2025 20:19:19 -0300 Subject: [PATCH 005/495] Enhance tickets filters UI --- src/components/tickets/tickets-filters.tsx | 147 +++++++++------------ 1 file changed, 59 insertions(+), 88 deletions(-) diff --git a/src/components/tickets/tickets-filters.tsx b/src/components/tickets/tickets-filters.tsx index 616749f..32a6efd 100644 --- a/src/components/tickets/tickets-filters.tsx +++ b/src/components/tickets/tickets-filters.tsx @@ -1,14 +1,20 @@ "use client" import { useEffect, useMemo, useState } from "react" -import { IconCalendar, IconFilter, IconRefresh, IconSearch } from "@tabler/icons-react" +import { + IconCalendar, + IconFilter, + IconRefresh, + IconSearch, + IconList, + IconBuilding, + IconTags, + IconUser, +} from "@tabler/icons-react" import type { DateRange } from "react-day-picker" -import { - ticketChannelSchema, - ticketPrioritySchema, - type TicketStatus, -} from "@/lib/schemas/ticket" +import { ticketPrioritySchema, type TicketStatus } from "@/lib/schemas/ticket" +import { PriorityIcon } from "@/components/tickets/priority-select" import type { TicketFiltersState } from "@/lib/ticket-filters" import { defaultTicketFilters } from "@/lib/ticket-filters" @@ -19,11 +25,7 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Calendar } from "@/components/ui/calendar" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Select, SelectContent, @@ -32,6 +34,7 @@ import { SelectValue, } from "@/components/ui/select" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" type QueueOption = string @@ -69,18 +72,6 @@ const priorityOptions = ticketPrioritySchema.options.map((priority) => ({ }[priority], })) -const channelOptions = ticketChannelSchema.options.map((channel) => ({ - value: channel, - label: { - EMAIL: "E-mail", - WHATSAPP: "WhatsApp", - CHAT: "Chat", - PHONE: "Telefone", - API: "API", - MANUAL: "Manual", - }[channel], -})) - function strToDate(value?: string | null): Date | undefined { if (!value) return undefined const [y, m, d] = value.split("-").map(Number) @@ -201,6 +192,13 @@ export function TicketsFilters({ const normalizedRole = viewerRole?.toLowerCase() ?? null const canUseAdvancedFilters = normalizedRole === "admin" || normalizedRole === "agent" + const companyOptionsMemo = useMemo(() => { + return Array.from(new Set(companies.filter((company): company is string => Boolean(company)))).map((company) => ({ + value: company, + label: company, + })) + }, [companies]) + const activeFilters = useMemo(() => { const chips: string[] = [] if (filters.status) chips.push(`Status: ${statusLabelMap[filters.status] ?? filters.status}`) @@ -223,7 +221,7 @@ export function TicketsFilters({ return chips }, [filters, assignees, categories, canUseAdvancedFilters]) - const advancedFiltersCount = Number(Boolean(filters.status)) + Number(Boolean(filters.priority)) + Number(Boolean(filters.channel)) + const advancedFiltersCount = Number(Boolean(filters.status)) + Number(Boolean(filters.priority)) return (
@@ -251,8 +249,8 @@ export function TicketsFilters({
-
-

Canal

- setPartial({ queue: value === ALL_VALUE ? null : value })} > - + @@ -357,31 +340,26 @@ export function TicketsFilters({
)} {canUseAdvancedFilters && ( -
- +
+ + setPartial({ company: value })} + options={companyOptionsMemo} + placeholder="Empresa" + allowClear + clearLabel="Todas as empresas" + className="min-h-[40px] w-full rounded-2xl border border-slate-300 bg-slate-50/80 pl-9 text-left text-sm font-semibold text-neutral-700" + />
)} -
+
+
{canUseAdvancedFilters && ( -
+
+ setPartial({ queue: value === ALL_VALUE ? null : value })} > - - + + + Todas as filas @@ -340,8 +340,7 @@ export function TicketsFilters({
)} {canUseAdvancedFilters && ( -
- +
setPartial({ company: value })} @@ -349,18 +348,19 @@ export function TicketsFilters({ placeholder="Empresa" allowClear clearLabel="Todas as empresas" - className="min-h-[40px] w-full rounded-2xl border border-slate-300 bg-slate-50/80 pl-9 text-left text-sm font-semibold text-neutral-700" + triggerClassName={fieldTrigger} + prefix={} />
)} -
- +
{canUseAdvancedFilters && ( -
- +
Date: Thu, 13 Nov 2025 21:43:36 -0300 Subject: [PATCH 007/495] Auto-open modals from global quick actions --- src/app/admin/companies/page.tsx | 12 +++++++++-- src/app/admin/devices/page.tsx | 7 ++++++- src/app/admin/users/page.tsx | 13 ++++++++++-- .../companies/admin-companies-manager.tsx | 9 ++++++++- .../admin/devices/admin-devices-overview.tsx | 16 ++++++++++++++- .../admin/users/admin-users-workspace.tsx | 20 +++++++++++++++++-- src/components/global-quick-actions.tsx | 4 ++-- 7 files changed, 70 insertions(+), 11 deletions(-) diff --git a/src/app/admin/companies/page.tsx b/src/app/admin/companies/page.tsx index d8cca43..2debf1c 100644 --- a/src/app/admin/companies/page.tsx +++ b/src/app/admin/companies/page.tsx @@ -8,9 +8,13 @@ import { fetchCompaniesByTenant, normalizeCompany } from "@/server/company-servi export const runtime = "nodejs" export const dynamic = "force-dynamic" -export default async function AdminCompaniesPage() { +export default async function AdminCompaniesPage({ + searchParams, +}: { searchParams: Promise> }) { const session = await requireStaffSession() const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + const params = await searchParams + const autoOpenCreateCompany = params.quick === "new-company" const companies = (await fetchCompaniesByTenant(tenantId)).map(normalizeCompany) return (
- +
) diff --git a/src/app/admin/devices/page.tsx b/src/app/admin/devices/page.tsx index 403e6fc..2ada0f7 100644 --- a/src/app/admin/devices/page.tsx +++ b/src/app/admin/devices/page.tsx @@ -12,6 +12,7 @@ export default async function AdminDevicesPage({ const params = await searchParams const companyParam = params.company const company = typeof companyParam === "string" ? companyParam : undefined + const autoOpenCreateDevice = params.quick === "new-device" return (
- +
) diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 5065ca4..ef46043 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -9,9 +9,13 @@ import { fetchCompaniesByTenant, normalizeCompany } from "@/server/company-servi export const runtime = "nodejs" export const dynamic = "force-dynamic" -export default async function AdminUsersPage() { +export default async function AdminUsersPage({ + searchParams, +}: { searchParams: Promise> }) { const session = await requireStaffSession() const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + const params = await searchParams + const autoOpenCreateUser = params.quick === "new-user" const users = await prisma.user.findMany({ where: { @@ -103,7 +107,12 @@ export default async function AdminUsersPage() { } >
- +
) diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx index 9a8fff0..a78fb4f 100644 --- a/src/components/admin/companies/admin-companies-manager.tsx +++ b/src/components/admin/companies/admin-companies-manager.tsx @@ -95,6 +95,7 @@ type LastAlertInfo = { createdAt: number; usagePct: number; threshold: number } type Props = { initialCompanies: NormalizedCompany[] tenantId?: string | null + autoOpenCreate?: boolean } type ViewMode = "table" | "board" @@ -291,7 +292,7 @@ function FieldError({ error }: { error?: string }) { return

{error}

} -export function AdminCompaniesManager({ initialCompanies, tenantId }: Props) { +export function AdminCompaniesManager({ initialCompanies, tenantId, autoOpenCreate = false }: Props) { const [companies, setCompanies] = useState(() => initialCompanies) const [view, setView] = useState("table") const [search, setSearch] = useState("") @@ -429,6 +430,12 @@ export function AdminCompaniesManager({ initialCompanies, tenantId }: Props) { const cancelDelete = useCallback(() => setIsDeleting(null), []) + useEffect(() => { + if (autoOpenCreate) { + openCreate() + } + }, [autoOpenCreate, openCreate]) + const handleDelete = useCallback(async () => { if (!isDeleting) return try { diff --git a/src/components/admin/devices/admin-devices-overview.tsx b/src/components/admin/devices/admin-devices-overview.tsx index 7dae465..3630598 100644 --- a/src/components/admin/devices/admin-devices-overview.tsx +++ b/src/components/admin/devices/admin-devices-overview.tsx @@ -1268,7 +1268,15 @@ function OsIcon({ osName }: { osName?: string | null }) { return } -export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all" }: { tenantId: string; initialCompanyFilterSlug?: string }) { +export function AdminDevicesOverview({ + tenantId, + initialCompanyFilterSlug = "all", + autoOpenCreateDevice = false, +}: { + tenantId: string + initialCompanyFilterSlug?: string + autoOpenCreateDevice?: boolean +}) { const { devices, isLoading } = useDevicesQuery(tenantId) const [q, setQ] = useState("") const [statusFilter, setStatusFilter] = useState("all") @@ -1555,6 +1563,12 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all setIsCreateDeviceOpen(true) }, [selectedCompany, companyFilterSlug]) + useEffect(() => { + if (autoOpenCreateDevice) { + handleOpenCreateDevice() + } + }, [autoOpenCreateDevice, handleOpenCreateDevice]) + const handleCreateDevice = useCallback(async () => { if (!convexUserId) { toast.error("Sincronize a sessão antes de criar dispositivos.") diff --git a/src/components/admin/users/admin-users-workspace.tsx b/src/components/admin/users/admin-users-workspace.tsx index 0648107..b5b24a8 100644 --- a/src/components/admin/users/admin-users-workspace.tsx +++ b/src/components/admin/users/admin-users-workspace.tsx @@ -95,6 +95,7 @@ type Props = { initialAccounts: AdminAccount[] companies: NormalizedCompany[] tenantId: string + autoOpenCreate?: boolean } type SectionEditorState = @@ -164,7 +165,7 @@ function FieldError({ message }: { message?: string }) { return

{message}

} -export function AdminUsersWorkspace({ initialAccounts, companies, tenantId }: Props) { +export function AdminUsersWorkspace({ initialAccounts, companies, tenantId, autoOpenCreate = false }: Props) { const [tab, setTab] = useState<"accounts" | "structure">("accounts") return ( setTab(value as typeof tab)}> @@ -173,7 +174,12 @@ export function AdminUsersWorkspace({ initialAccounts, companies, tenantId }: Pr Estrutura das empresas - + @@ -186,10 +192,12 @@ function AccountsTable({ initialAccounts, companies, tenantId, + autoOpenCreate, }: { initialAccounts: AdminAccount[] companies: NormalizedCompany[] tenantId: string + autoOpenCreate?: boolean }) { const [accounts, setAccounts] = useState(initialAccounts) const [search, setSearch] = useState("") @@ -212,6 +220,7 @@ function AccountsTable({ const [isResettingPassword, setIsResettingPassword] = useState(false) const [passwordPreview, setPasswordPreview] = useState(null) const [createDialogOpen, setCreateDialogOpen] = useState(false) + const autoOpenHandledRef = useRef(false) const [isCreatingAccount, setIsCreatingAccount] = useState(false) const [createForm, setCreateForm] = useState(() => createDefaultAccountForm()) @@ -400,6 +409,13 @@ function AccountsTable({ setCreateForm(createDefaultAccountForm()) }, []) + useEffect(() => { + if (!autoOpenCreate || autoOpenHandledRef.current) return + autoOpenHandledRef.current = true + setCreateForm(createDefaultAccountForm()) + setCreateDialogOpen(true) + }, [autoOpenCreate]) + useEffect(() => { if (editAccount) { setEditForm({ diff --git a/src/components/global-quick-actions.tsx b/src/components/global-quick-actions.tsx index a0329f7..5164ce5 100644 --- a/src/components/global-quick-actions.tsx +++ b/src/components/global-quick-actions.tsx @@ -39,7 +39,7 @@ export function GlobalQuickActions() { label: "Adicionar empresa", description: "Cadastrar novo cliente", icon: Building, - href: "/admin/companies", + href: "/admin/companies?quick=new-company", visible: Boolean(isAdmin), }, { @@ -47,7 +47,7 @@ export function GlobalQuickActions() { label: "Novo usuário", description: "Gestores / colaboradores", icon: UserPlus, - href: "/admin/users", + href: "/admin/users?quick=new-user", visible: Boolean(isAdmin), }, ] From 8db3b20a408f9d48b96c07147a85383556c752cf Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Thu, 13 Nov 2025 21:45:46 -0300 Subject: [PATCH 008/495] Add tests for quick modal auto-open --- src/app/admin/quick-modals.test.tsx | 149 ++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/app/admin/quick-modals.test.tsx diff --git a/src/app/admin/quick-modals.test.tsx b/src/app/admin/quick-modals.test.tsx new file mode 100644 index 0000000..59a91c4 --- /dev/null +++ b/src/app/admin/quick-modals.test.tsx @@ -0,0 +1,149 @@ +import { describe, expect, it, vi, beforeEach } from "vitest" +import type { ReactElement } from "react" + +import { AdminDevicesOverview } from "@/components/admin/devices/admin-devices-overview" +import { AdminCompaniesManager } from "@/components/admin/companies/admin-companies-manager" +import { AdminUsersWorkspace } from "@/components/admin/users/admin-users-workspace" + +// Shared mocks for server-side helpers +const requireStaffSession = vi.fn() +const fetchCompaniesByTenant = vi.fn() +const normalizeCompany = vi.fn((c) => c) +const prismaUserFindMany = vi.fn() +const prismaAuthUserFindMany = vi.fn() +const prismaAuthSessionFindMany = vi.fn() + +vi.mock("@/lib/auth-server", () => ({ + requireStaffSession, +})) + +vi.mock("@/server/company-service", () => ({ + fetchCompaniesByTenant, + normalizeCompany, +})) + +vi.mock("@/lib/prisma", () => ({ + prisma: { + user: { findMany: prismaUserFindMany }, + authUser: { findMany: prismaAuthUserFindMany }, + authSession: { findMany: prismaAuthSessionFindMany }, + }, +})) + +// Helpers +function getSingleChild(element: ReactElement): ReactElement { + const child = (element.props as { children?: ReactElement | ReactElement[] }).children + if (!child) throw new Error("Expected element to have children") + return Array.isArray(child) ? child[0] : child +} + +describe("admin quick actions -> auto-open flags", () => { + describe("devices page", () => { + it("passes autoOpenCreateDevice=true when quick=new-device", async () => { + const { default: AdminDevicesPage } = await import("./devices/page") + + const element = (await AdminDevicesPage({ + searchParams: Promise.resolve({ quick: "new-device" }), + } as unknown as { searchParams: Promise> })) as ReactElement + + const outerDiv = getSingleChild(element) + const overview = getSingleChild(outerDiv) + + expect(overview.type).toBe(AdminDevicesOverview) + expect(overview.props.autoOpenCreateDevice).toBe(true) + }) + + it("passes autoOpenCreateDevice=false when quick is missing", async () => { + const { default: AdminDevicesPage } = await import("./devices/page") + + const element = (await AdminDevicesPage({ + searchParams: Promise.resolve({}), + } as unknown as { searchParams: Promise> })) as ReactElement + + const outerDiv = getSingleChild(element) + const overview = getSingleChild(outerDiv) + + expect(overview.type).toBe(AdminDevicesOverview) + expect(overview.props.autoOpenCreateDevice).toBe(false) + }) + }) + + describe("companies page", () => { + beforeEach(() => { + requireStaffSession.mockResolvedValue({ + user: { tenantId: "tenant-1" }, + }) + fetchCompaniesByTenant.mockResolvedValue([]) + normalizeCompany.mockImplementation((c) => c) + }) + + it("passes autoOpenCreate=true when quick=new-company", async () => { + const { default: AdminCompaniesPage } = await import("./companies/page") + + const element = (await AdminCompaniesPage({ + searchParams: Promise.resolve({ quick: "new-company" }), + } as unknown as { searchParams: Promise> })) as ReactElement + + const outerDiv = getSingleChild(element) + const manager = getSingleChild(outerDiv) + + expect(manager.type).toBe(AdminCompaniesManager) + expect(manager.props.autoOpenCreate).toBe(true) + }) + + it("passes autoOpenCreate=false when quick is missing", async () => { + const { default: AdminCompaniesPage } = await import("./companies/page") + + const element = (await AdminCompaniesPage({ + searchParams: Promise.resolve({}), + } as unknown as { searchParams: Promise> })) as ReactElement + + const outerDiv = getSingleChild(element) + const manager = getSingleChild(outerDiv) + + expect(manager.type).toBe(AdminCompaniesManager) + expect(manager.props.autoOpenCreate).toBe(false) + }) + }) + + describe("users page", () => { + beforeEach(() => { + requireStaffSession.mockResolvedValue({ + user: { tenantId: "tenant-1" }, + }) + prismaUserFindMany.mockResolvedValue([]) + prismaAuthUserFindMany.mockResolvedValue([]) + prismaAuthSessionFindMany.mockResolvedValue([]) + fetchCompaniesByTenant.mockResolvedValue([]) + normalizeCompany.mockImplementation((c) => c) + }) + + it("passes autoOpenCreate=true when quick=new-user", async () => { + const { default: AdminUsersPage } = await import("./users/page") + + const element = (await AdminUsersPage({ + searchParams: Promise.resolve({ quick: "new-user" }), + } as unknown as { searchParams: Promise> })) as ReactElement + + const outerDiv = getSingleChild(element) + const workspace = getSingleChild(outerDiv) + + expect(workspace.type).toBe(AdminUsersWorkspace) + expect(workspace.props.autoOpenCreate).toBe(true) + }) + + it("passes autoOpenCreate=false when quick is missing", async () => { + const { default: AdminUsersPage } = await import("./users/page") + + const element = (await AdminUsersPage({ + searchParams: Promise.resolve({}), + } as unknown as { searchParams: Promise> })) as ReactElement + + const outerDiv = getSingleChild(element) + const workspace = getSingleChild(outerDiv) + + expect(workspace.type).toBe(AdminUsersWorkspace) + expect(workspace.props.autoOpenCreate).toBe(false) + }) + }) +}) From 6a75a0a9edf3aa668ba1a91590879f9aa070950c Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Thu, 13 Nov 2025 21:55:56 -0300 Subject: [PATCH 009/495] Wire quick actions to reopen admin modals in-place --- .../companies/admin-companies-manager.tsx | 9 ++++++++ .../admin/devices/admin-devices-overview.tsx | 9 ++++++++ .../admin/users/admin-users-workspace.tsx | 9 ++++++++ src/components/global-quick-actions.tsx | 22 +++++++++++++++++-- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx index a78fb4f..4906bf2 100644 --- a/src/components/admin/companies/admin-companies-manager.tsx +++ b/src/components/admin/companies/admin-companies-manager.tsx @@ -436,6 +436,15 @@ export function AdminCompaniesManager({ initialCompanies, tenantId, autoOpenCrea } }, [autoOpenCreate, openCreate]) + useEffect(() => { + if (typeof window === "undefined") return + const handler = () => { + openCreate() + } + window.addEventListener("quick-open-company", handler) + return () => window.removeEventListener("quick-open-company", handler) + }, [openCreate]) + const handleDelete = useCallback(async () => { if (!isDeleting) return try { diff --git a/src/components/admin/devices/admin-devices-overview.tsx b/src/components/admin/devices/admin-devices-overview.tsx index 3630598..60ea135 100644 --- a/src/components/admin/devices/admin-devices-overview.tsx +++ b/src/components/admin/devices/admin-devices-overview.tsx @@ -1569,6 +1569,15 @@ export function AdminDevicesOverview({ } }, [autoOpenCreateDevice, handleOpenCreateDevice]) + useEffect(() => { + if (typeof window === "undefined") return + const handler = () => { + handleOpenCreateDevice() + } + window.addEventListener("quick-open-device", handler) + return () => window.removeEventListener("quick-open-device", handler) + }, [handleOpenCreateDevice]) + const handleCreateDevice = useCallback(async () => { if (!convexUserId) { toast.error("Sincronize a sessão antes de criar dispositivos.") diff --git a/src/components/admin/users/admin-users-workspace.tsx b/src/components/admin/users/admin-users-workspace.tsx index b5b24a8..8f3a1d7 100644 --- a/src/components/admin/users/admin-users-workspace.tsx +++ b/src/components/admin/users/admin-users-workspace.tsx @@ -416,6 +416,15 @@ function AccountsTable({ setCreateDialogOpen(true) }, [autoOpenCreate]) + useEffect(() => { + if (typeof window === "undefined") return + const handler = () => { + handleOpenCreateDialog() + } + window.addEventListener("quick-open-user", handler) + return () => window.removeEventListener("quick-open-user", handler) + }, [handleOpenCreateDialog]) + useEffect(() => { if (editAccount) { setEditForm({ diff --git a/src/components/global-quick-actions.tsx b/src/components/global-quick-actions.tsx index 5164ce5..0b1769a 100644 --- a/src/components/global-quick-actions.tsx +++ b/src/components/global-quick-actions.tsx @@ -1,7 +1,7 @@ "use client" import { useId, useMemo } from "react" -import { useRouter } from "next/navigation" +import { usePathname, useRouter } from "next/navigation" import { MonitorSmartphone, Building, UserPlus, ChevronRight } from "lucide-react" import { Button } from "@/components/ui/button" @@ -22,6 +22,7 @@ type QuickLink = { export function GlobalQuickActions() { const { convexUserId, isAdmin, isStaff, isLoading } = useAuth() const router = useRouter() + const pathname = usePathname() const actionId = useId() const links = useMemo(() => { @@ -81,7 +82,24 @@ export function GlobalQuickActions() { +
+ {templateOptions.length === 0 ? ( +

+ Nenhum template cadastrado ainda. Crie um template para padronizar as colunas das exportações de inventário. +

+ ) : ( +
+ {templateOptions.map((tpl) => ( + + ))} +
+ )} + + + + + + + {selectedTemplateId === "new" ? "Novo template" : "Editar template"} + + + Defina o conjunto de colunas e, opcionalmente, vincule o template a uma empresa específica. + + + +
+ + setName(event.target.value)} + placeholder="Ex.: Inventário padrão, Inventário reduzido..." + className="h-9 rounded-lg border-slate-300 text-sm" + /> +
+
+ +