feat: improve quick actions and remote access
This commit is contained in:
parent
aeb6d50377
commit
4f8dad2255
10 changed files with 906 additions and 154 deletions
300
src/components/quick-actions/quick-create-user-dialog.tsx
Normal file
300
src/components/quick-actions/quick-create-user-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue