feat: improve quick actions and remote access

This commit is contained in:
Esdras Renan 2025-11-18 21:16:00 -03:00
parent aeb6d50377
commit 4f8dad2255
10 changed files with 906 additions and 154 deletions

View file

@ -0,0 +1,300 @@
"use client"
import { useMemo, useState } from "react"
import { useQuery } from "convex/react"
import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
import { toast } from "sonner"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
type QuickCreateUserDialogProps = {
open: boolean
onOpenChange(open: boolean): void
tenantId: string
viewerId: string | null
onSuccess?(payload: { email: string }): void
}
type PortalRole = "MANAGER" | "COLLABORATOR"
const ROLE_OPTIONS: ReadonlyArray<{ value: PortalRole; label: string }> = [
{ value: "MANAGER", label: "Gestor" },
{ value: "COLLABORATOR", label: "Colaborador" },
]
const ROLE_TO_PAYLOAD: Record<PortalRole, "manager" | "collaborator"> = {
MANAGER: "manager",
COLLABORATOR: "collaborator",
}
const NO_COMPANY_VALUE = "__none__"
const NO_MANAGER_VALUE = "__no_manager__"
type QuickUserFormState = {
name: string
email: string
jobTitle: string
role: PortalRole
companyId: string
managerId: string
}
function defaultForm(): QuickUserFormState {
return {
name: "",
email: "",
jobTitle: "",
role: "COLLABORATOR",
companyId: NO_COMPANY_VALUE,
managerId: NO_MANAGER_VALUE,
}
}
export function QuickCreateUserDialog({ open, onOpenChange, tenantId, viewerId, onSuccess }: QuickCreateUserDialogProps) {
const [form, setForm] = useState<QuickUserFormState>(defaultForm)
const [isSubmitting, setIsSubmitting] = useState(false)
const canQuery = open && Boolean(viewerId)
const companies = useQuery(
api.companies.list,
canQuery ? { tenantId, viewerId: viewerId as Id<"users"> } : "skip"
) as Array<{ id: string; name: string; slug?: string }> | undefined
const customers = useQuery(
api.users.listCustomers,
canQuery ? { tenantId, viewerId: viewerId as Id<"users"> } : "skip"
) as Array<{ id: string; name: string; email: string; role: string }> | undefined
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: NO_COMPANY_VALUE, label: "Sem empresa vinculada" }]
if (!companies) return base
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
...base,
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
const managerOptions = useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: NO_MANAGER_VALUE, label: "Sem gestor" }]
if (!customers) return base
const managers = customers.filter((user) => user.role === "MANAGER")
return [
...base,
...managers.map((manager) => ({
value: manager.id,
label: manager.name,
description: manager.email,
})),
]
}, [customers])
const resetForm = () => {
setForm(defaultForm)
}
const handleClose = () => {
if (isSubmitting) return
resetForm()
onOpenChange(false)
}
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!viewerId) {
toast.error("Não foi possível identificar o usuário atual.")
return
}
const name = form.name.trim()
const email = form.email.trim().toLowerCase()
if (!name) {
toast.error("Informe o nome do usuário.")
return
}
if (!email || !email.includes("@")) {
toast.error("Informe um e-mail válido.")
return
}
const jobTitle = form.jobTitle.trim()
const managerId = form.managerId !== NO_MANAGER_VALUE ? form.managerId : null
const companyId = form.companyId !== NO_COMPANY_VALUE ? form.companyId : null
const payload = {
name,
email,
role: ROLE_TO_PAYLOAD[form.role],
tenantId,
jobTitle: jobTitle || null,
managerId,
}
setIsSubmitting(true)
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(() => null)
throw new Error(data?.error ?? "Não foi possível criar o usuário.")
}
const data = (await response.json()) as { user: { email: string }; temporaryPassword?: string }
if (companyId) {
const assignResponse = await fetch("/api/admin/users/assign-company", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, companyId }),
})
if (!assignResponse.ok) {
const assignData = await assignResponse.json().catch(() => null)
toast.error(assignData?.error ?? "Usuário criado, mas não foi possível vinculá-lo à empresa.")
}
}
resetForm()
onOpenChange(false)
onSuccess?.({ email })
toast.success("Usuário criado com sucesso.", {
description: data.temporaryPassword ? `Senha temporária: ${data.temporaryPassword}` : undefined,
action: data.temporaryPassword
? {
label: "Copiar",
onClick: async () => {
try {
await navigator.clipboard?.writeText?.(data.temporaryPassword ?? "")
toast.success("Senha copiada para a área de transferência.")
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível copiar a senha."
toast.error(message)
}
},
}
: undefined,
})
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao criar usuário."
toast.error(message)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(next) : handleClose())}>
<DialogContent className="max-w-lg space-y-6">
<DialogHeader>
<DialogTitle>Novo usuário</DialogTitle>
<DialogDescription>Crie acessos para gestores ou colaboradores sem sair da página atual.</DialogDescription>
</DialogHeader>
<form className="space-y-5" onSubmit={handleSubmit}>
<div className="grid gap-2">
<Label htmlFor="quick-user-name">Nome</Label>
<Input
id="quick-user-name"
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Nome completo"
disabled={isSubmitting}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="quick-user-email">E-mail</Label>
<Input
id="quick-user-email"
type="email"
value={form.email}
onChange={(event) => setForm((prev) => ({ ...prev, email: event.target.value }))}
placeholder="usuario@empresa.com"
disabled={isSubmitting}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="quick-user-job-title">Cargo</Label>
<Input
id="quick-user-job-title"
value={form.jobTitle}
onChange={(event) => setForm((prev) => ({ ...prev, jobTitle: event.target.value }))}
placeholder="Ex.: Analista de Suporte"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label>Papel</Label>
<Select
value={form.role}
onValueChange={(value) => setForm((prev) => ({ ...prev, role: value as PortalRole }))}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{ROLE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Gestor direto</Label>
<SearchableCombobox
value={form.managerId}
onValueChange={(value) =>
setForm((prev) => ({
...prev,
managerId: value === null ? NO_MANAGER_VALUE : value,
}))
}
options={managerOptions}
placeholder="Sem gestor definido"
searchPlaceholder="Buscar gestor..."
disabled={isSubmitting || !customers}
allowClear
clearLabel="Remover gestor"
/>
</div>
<div className="grid gap-2">
<Label>Empresa vinculada</Label>
<SearchableCombobox
value={form.companyId}
onValueChange={(value) =>
setForm((prev) => ({
...prev,
companyId: value === null ? NO_COMPANY_VALUE : value,
}))
}
options={companyOptions}
placeholder="Sem empresa vinculada"
searchPlaceholder="Buscar empresa..."
disabled={isSubmitting || !companies}
/>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="ghost" onClick={handleClose} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Criando..." : "Criar usuário"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}