300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
"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>
|
|
)
|
|
}
|