733 lines
30 KiB
TypeScript
733 lines
30 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useState } from "react"
|
|
import { useMutation, useQuery } from "convex/react"
|
|
import { toast } from "sonner"
|
|
import { IconAdjustments, IconForms, IconListDetails, IconTypography } from "@tabler/icons-react"
|
|
import { ArrowDown, ArrowUp } from "lucide-react"
|
|
import { api } from "@/convex/_generated/api"
|
|
import type { Id } from "@/convex/_generated/dataModel"
|
|
import { useAuth } from "@/lib/auth-client"
|
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
|
|
|
type FieldOption = { value: string; label: string }
|
|
|
|
type Field = {
|
|
id: string
|
|
key: string
|
|
label: string
|
|
description: string
|
|
type: "text" | "number" | "select" | "date" | "boolean"
|
|
required: boolean
|
|
options: FieldOption[]
|
|
order: number
|
|
scope: string
|
|
companyId: string | null
|
|
}
|
|
|
|
const TYPE_LABELS: Record<Field["type"], string> = {
|
|
text: "Texto",
|
|
number: "Número",
|
|
select: "Seleção",
|
|
date: "Data",
|
|
boolean: "Verdadeiro/Falso",
|
|
}
|
|
|
|
export function FieldsManager() {
|
|
const { session, convexUserId } = useAuth()
|
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
|
|
|
const fields = useQuery(
|
|
api.fields.list,
|
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
|
) as Field[] | undefined
|
|
|
|
const templates = useQuery(
|
|
api.ticketFormTemplates.listActive,
|
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
|
) as Array<{ id: string; key: string; label: string }> | undefined
|
|
|
|
const companies = useQuery(
|
|
api.companies.list,
|
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
|
) as Array<{ id: string; name: string; slug?: string }> | undefined
|
|
|
|
const scopeOptions = useMemo(
|
|
() => [
|
|
{ value: "all", label: "Todos os formulários" },
|
|
...((templates ?? []).map((tpl) => ({ value: tpl.key, label: tpl.label })) ?? []),
|
|
],
|
|
[templates]
|
|
)
|
|
|
|
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
|
|
if (!companies) return []
|
|
return companies
|
|
.map((company) => ({
|
|
value: company.id,
|
|
label: company.name,
|
|
description: company.slug ?? undefined,
|
|
keywords: company.slug ? [company.slug] : [],
|
|
}))
|
|
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
|
|
}, [companies])
|
|
|
|
const companyLabelById = useMemo(() => {
|
|
const map = new Map<string, string>()
|
|
companyOptions.forEach((option) => map.set(option.value, option.label))
|
|
return map
|
|
}, [companyOptions])
|
|
|
|
const companyComboboxOptions = useMemo<SearchableComboboxOption[]>(() => {
|
|
return [{ value: "all", label: "Todas as empresas" }, ...companyOptions]
|
|
}, [companyOptions])
|
|
|
|
const templateLabelByKey = useMemo(() => {
|
|
const map = new Map<string, string>()
|
|
templates?.forEach((tpl) => map.set(tpl.key, tpl.label))
|
|
return map
|
|
}, [templates])
|
|
|
|
const createField = useMutation(api.fields.create)
|
|
const updateField = useMutation(api.fields.update)
|
|
const removeField = useMutation(api.fields.remove)
|
|
const reorderFields = useMutation(api.fields.reorder)
|
|
|
|
const [label, setLabel] = useState("")
|
|
const [description, setDescription] = useState("")
|
|
const [type, setType] = useState<Field["type"]>("text")
|
|
const [required, setRequired] = useState(false)
|
|
const [options, setOptions] = useState<FieldOption[]>([])
|
|
const [scopeSelection, setScopeSelection] = useState<string>("all")
|
|
const [companySelection, setCompanySelection] = useState<string>("all")
|
|
const [saving, setSaving] = useState(false)
|
|
const [editingField, setEditingField] = useState<Field | null>(null)
|
|
const [editingScope, setEditingScope] = useState<string>("all")
|
|
const [editingCompanySelection, setEditingCompanySelection] = useState<string>("all")
|
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
|
|
|
const totals = useMemo(() => {
|
|
if (!fields) return { total: 0, required: 0, select: 0 }
|
|
return {
|
|
total: fields.length,
|
|
required: fields.filter((field) => field.required).length,
|
|
select: fields.filter((field) => field.type === "select").length,
|
|
}
|
|
}, [fields])
|
|
|
|
const resetForm = () => {
|
|
setLabel("")
|
|
setDescription("")
|
|
setType("text")
|
|
setRequired(false)
|
|
setOptions([])
|
|
setScopeSelection("all")
|
|
setCompanySelection("all")
|
|
setEditingCompanySelection("all")
|
|
}
|
|
|
|
const closeCreateDialog = () => {
|
|
setIsCreateDialogOpen(false)
|
|
resetForm()
|
|
}
|
|
|
|
const normalizeOptions = (source: FieldOption[]) =>
|
|
source
|
|
.map((option) => ({
|
|
label: option.label.trim(),
|
|
value: option.value.trim() || option.label.trim().toLowerCase().replace(/\s+/g, "_"),
|
|
}))
|
|
.filter((option) => option.label.length > 0)
|
|
|
|
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault()
|
|
if (!label.trim()) {
|
|
toast.error("Informe o rótulo do campo")
|
|
return
|
|
}
|
|
if (!convexUserId) {
|
|
toast.error("Sessão não sincronizada com o Convex")
|
|
return
|
|
}
|
|
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
|
|
const scopeValue = scopeSelection === "all" ? undefined : scopeSelection
|
|
const companyIdValue = companySelection === "all" ? undefined : (companySelection as Id<"companies">)
|
|
setSaving(true)
|
|
toast.loading("Criando campo...", { id: "field" })
|
|
try {
|
|
await createField({
|
|
tenantId,
|
|
actorId: convexUserId as Id<"users">,
|
|
label: label.trim(),
|
|
description: description.trim() || undefined,
|
|
type,
|
|
required,
|
|
options: preparedOptions,
|
|
scope: scopeValue,
|
|
companyId: companyIdValue,
|
|
})
|
|
toast.success("Campo criado", { id: "field" })
|
|
closeCreateDialog()
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível criar o campo", { id: "field" })
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleRemove = async (field: Field) => {
|
|
const confirmed = window.confirm(`Excluir o campo ${field.label}?`)
|
|
if (!confirmed) return
|
|
if (!convexUserId) {
|
|
toast.error("Sessão não sincronizada com o Convex")
|
|
return
|
|
}
|
|
toast.loading("Removendo campo...", { id: `field-remove-${field.id}` })
|
|
try {
|
|
await removeField({
|
|
tenantId,
|
|
fieldId: field.id as Id<"ticketFields">,
|
|
actorId: convexUserId as Id<"users">,
|
|
})
|
|
toast.success("Campo removido", { id: `field-remove-${field.id}` })
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível remover o campo", { id: `field-remove-${field.id}` })
|
|
}
|
|
}
|
|
|
|
const openEdit = (field: Field) => {
|
|
setEditingField(field)
|
|
setLabel(field.label)
|
|
setDescription(field.description)
|
|
setType(field.type)
|
|
setRequired(field.required)
|
|
setOptions(field.options)
|
|
setEditingScope(field.scope ?? "all")
|
|
setEditingCompanySelection(field.companyId ?? "all")
|
|
}
|
|
|
|
const handleUpdate = async () => {
|
|
if (!editingField) return
|
|
if (!label.trim()) {
|
|
toast.error("Informe o rótulo do campo")
|
|
return
|
|
}
|
|
if (!convexUserId) {
|
|
toast.error("Sessão não sincronizada com o Convex")
|
|
return
|
|
}
|
|
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
|
|
const scopeValue = editingScope === "all" ? undefined : editingScope
|
|
const companyIdValue = editingCompanySelection === "all" ? undefined : (editingCompanySelection as Id<"companies">)
|
|
setSaving(true)
|
|
toast.loading("Atualizando campo...", { id: "field-edit" })
|
|
try {
|
|
await updateField({
|
|
tenantId,
|
|
fieldId: editingField.id as Id<"ticketFields">,
|
|
actorId: convexUserId as Id<"users">,
|
|
label: label.trim(),
|
|
description: description.trim() || undefined,
|
|
type,
|
|
required,
|
|
options: preparedOptions,
|
|
scope: scopeValue,
|
|
companyId: companyIdValue,
|
|
})
|
|
toast.success("Campo atualizado", { id: "field-edit" })
|
|
setEditingField(null)
|
|
resetForm()
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível atualizar o campo", { id: "field-edit" })
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const moveField = async (field: Field, direction: "up" | "down") => {
|
|
if (!fields) return
|
|
if (!convexUserId) {
|
|
toast.error("Sessão não sincronizada com o Convex")
|
|
return
|
|
}
|
|
const index = fields.findIndex((item) => item.id === field.id)
|
|
const targetIndex = direction === "up" ? index - 1 : index + 1
|
|
if (targetIndex < 0 || targetIndex >= fields.length) return
|
|
const reordered = [...fields]
|
|
const [removed] = reordered.splice(index, 1)
|
|
reordered.splice(targetIndex, 0, removed)
|
|
try {
|
|
await reorderFields({
|
|
tenantId,
|
|
actorId: convexUserId as Id<"users">,
|
|
orderedIds: reordered.map((item) => item.id as Id<"ticketFields">),
|
|
})
|
|
toast.success("Ordem atualizada")
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível reordenar os campos")
|
|
}
|
|
}
|
|
|
|
const addOption = () => {
|
|
setOptions((current) => [...current, { label: "", value: "" }])
|
|
}
|
|
|
|
const updateOption = (index: number, key: keyof FieldOption, value: string) => {
|
|
setOptions((current) => {
|
|
const copy = [...current]
|
|
copy[index] = { ...copy[index], [key]: value }
|
|
return copy
|
|
})
|
|
}
|
|
|
|
const removeOption = (index: number) => {
|
|
setOptions((current) => current.filter((_, optIndex) => optIndex !== index))
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
|
|
<IconForms className="size-4" /> Campos personalizados
|
|
</CardTitle>
|
|
<CardDescription>Metadados adicionais disponíveis nos tickets.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="text-3xl font-semibold text-neutral-900">
|
|
{fields ? totals.total : <Skeleton className="h-8 w-16" />}
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
|
|
<IconTypography className="size-4" /> Campos obrigatórios
|
|
</CardTitle>
|
|
<CardDescription>Informações exigidas na abertura.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="text-3xl font-semibold text-neutral-900">
|
|
{fields ? totals.required : <Skeleton className="h-8 w-16" />}
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
|
|
<IconListDetails className="size-4" /> Campos de seleção
|
|
</CardTitle>
|
|
<CardDescription>Usados para listas e múltipla escolha.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="text-3xl font-semibold text-neutral-900">
|
|
{fields ? totals.select : <Skeleton className="h-8 w-16" />}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
|
|
<IconAdjustments className="size-5 text-neutral-500" /> Novo campo
|
|
</CardTitle>
|
|
<CardDescription>Capture informações específicas do seu fluxo de atendimento.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex justify-between gap-4">
|
|
<p className="text-sm text-neutral-600">Abra o formulário avançado para definir escopo, empresa e opções.</p>
|
|
<Button
|
|
onClick={() => {
|
|
resetForm()
|
|
setIsCreateDialogOpen(true)
|
|
}}
|
|
>
|
|
Configurar campo
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="space-y-4">
|
|
{fields === undefined ? (
|
|
<div className="space-y-4">
|
|
{Array.from({ length: 4 }).map((_, index) => (
|
|
<Skeleton key={index} className="h-28 rounded-2xl" />
|
|
))}
|
|
</div>
|
|
) : fields.length === 0 ? (
|
|
<Card className="border-dashed border-slate-300 bg-slate-50/80">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhum campo cadastrado</CardTitle>
|
|
<CardDescription className="text-neutral-600">
|
|
Crie campos personalizados para enriquecer os tickets com informações importantes.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
</Card>
|
|
) : (
|
|
fields.map((field, index) => {
|
|
const scopeLabel =
|
|
field.scope === "all"
|
|
? "Todos os formulários"
|
|
: templateLabelByKey.get(field.scope) ?? `Formulário: ${field.scope}`
|
|
return (
|
|
<Card key={field.id} className="border-slate-200">
|
|
<CardHeader>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-3">
|
|
<CardTitle className="text-xl font-semibold text-neutral-900">{field.label}</CardTitle>
|
|
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
|
{TYPE_LABELS[field.type]}
|
|
</Badge>
|
|
{field.required ? (
|
|
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
|
obrigatório
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
<CardDescription className="text-neutral-600">Identificador: {field.key}</CardDescription>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge variant="secondary" className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-neutral-700">
|
|
{scopeLabel}
|
|
</Badge>
|
|
<Badge variant="secondary" className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-neutral-700">
|
|
{field.companyId ? `Empresa: ${companyLabelById.get(field.companyId) ?? "Específica"}` : "Todas as empresas"}
|
|
</Badge>
|
|
</div>
|
|
{field.description ? (
|
|
<p className="text-sm text-neutral-600">{field.description}</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2">
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => openEdit(field)}>
|
|
Editar
|
|
</Button>
|
|
<Button variant="destructive" size="sm" onClick={() => handleRemove(field)}>
|
|
Excluir
|
|
</Button>
|
|
</div>
|
|
<div className="flex gap-1 pt-4">
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
className="size-8"
|
|
disabled={index === 0}
|
|
onClick={() => moveField(field, "up")}
|
|
>
|
|
<ArrowUp className="size-4" />
|
|
<span className="sr-only">Subir</span>
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
className="size-8"
|
|
disabled={index === fields.length - 1}
|
|
onClick={() => moveField(field, "down")}
|
|
>
|
|
<ArrowDown className="size-4" />
|
|
<span className="sr-only">Descer</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
{field.type === "select" && field.options.length > 0 ? (
|
|
<CardContent>
|
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Opções cadastradas</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{field.options.map((option) => (
|
|
<Badge key={option.value} variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
|
{option.label} ({option.value})
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
) : null}
|
|
</Card>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
<Dialog open={isCreateDialogOpen} onOpenChange={(open) => {
|
|
if (!open) {
|
|
closeCreateDialog()
|
|
} else {
|
|
setIsCreateDialogOpen(true)
|
|
}
|
|
}}>
|
|
<DialogContent className="max-w-4xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Novo campo personalizado</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleCreate} className="grid gap-4 py-2 lg:grid-cols-[minmax(0,280px)_minmax(0,1fr)]">
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="field-label">Rótulo</Label>
|
|
<Input
|
|
id="field-label"
|
|
placeholder="Ex.: Número do contrato"
|
|
value={label}
|
|
onChange={(event) => setLabel(event.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Tipo de dado</Label>
|
|
<Select value={type} onValueChange={(value) => setType(value as Field["type"])}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="text">Texto curto</SelectItem>
|
|
<SelectItem value="number">Número</SelectItem>
|
|
<SelectItem value="select">Seleção</SelectItem>
|
|
<SelectItem value="date">Data</SelectItem>
|
|
<SelectItem value="boolean">Verdadeiro/Falso</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox id="field-required" checked={required} onCheckedChange={(value) => setRequired(Boolean(value))} />
|
|
<Label htmlFor="field-required" className="text-sm font-normal text-neutral-600">
|
|
Campo obrigatório na abertura
|
|
</Label>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Aplicar em</Label>
|
|
<Select value={scopeSelection} onValueChange={setScopeSelection}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Todos os formulários" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{scopeOptions.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Empresa (opcional)</Label>
|
|
<SearchableCombobox
|
|
value={companySelection}
|
|
onValueChange={(value) => setCompanySelection(value ?? "all")}
|
|
options={companyComboboxOptions}
|
|
placeholder="Todas as empresas"
|
|
renderValue={(option) =>
|
|
option ? (
|
|
<span className="truncate">{option.label}</span>
|
|
) : (
|
|
<span className="text-muted-foreground">Todas as empresas</span>
|
|
)
|
|
}
|
|
/>
|
|
<p className="text-xs text-neutral-500">
|
|
Sem seleção o campo aparecerá para todos os tickets.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="field-description">Descrição</Label>
|
|
<textarea
|
|
id="field-description"
|
|
className="min-h-[96px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
|
|
placeholder="Como este campo será utilizado"
|
|
value={description}
|
|
onChange={(event) => setDescription(event.target.value)}
|
|
/>
|
|
</div>
|
|
{type === "select" ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label>Opções</Label>
|
|
<Button type="button" variant="outline" size="sm" onClick={addOption}>
|
|
Adicionar opção
|
|
</Button>
|
|
</div>
|
|
{options.length === 0 ? (
|
|
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
|
|
Adicione pelo menos uma opção para este campo de seleção.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{options.map((option, index) => (
|
|
<div key={index} className="grid gap-3 rounded-lg border border-slate-200 p-3 md:grid-cols-[minmax(0,1fr)_minmax(0,200px)_auto]">
|
|
<Input
|
|
placeholder="Rótulo"
|
|
value={option.label}
|
|
onChange={(event) => updateOption(index, "label", event.target.value)}
|
|
/>
|
|
<Input
|
|
placeholder="Valor"
|
|
value={option.value}
|
|
onChange={(event) => updateOption(index, "value", event.target.value)}
|
|
/>
|
|
<Button variant="ghost" type="button" onClick={() => removeOption(index)}>
|
|
Remover
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" variant="outline" onClick={closeCreateDialog} disabled={saving}>
|
|
Cancelar
|
|
</Button>
|
|
<Button type="submit" disabled={saving}>
|
|
{saving ? "Salvando..." : "Criar campo"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={Boolean(editingField)} onOpenChange={(value) => (!value ? setEditingField(null) : null)}>
|
|
<DialogContent className="max-w-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Editar campo</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-2 lg:grid-cols-[minmax(0,260px)_minmax(0,1fr)]">
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="edit-field-label">Rótulo</Label>
|
|
<Input
|
|
id="edit-field-label"
|
|
value={label}
|
|
onChange={(event) => setLabel(event.target.value)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Tipo de dado</Label>
|
|
<Select value={type} onValueChange={(value) => setType(value as Field["type"])}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="text">Texto curto</SelectItem>
|
|
<SelectItem value="number">Número</SelectItem>
|
|
<SelectItem value="select">Seleção</SelectItem>
|
|
<SelectItem value="date">Data</SelectItem>
|
|
<SelectItem value="boolean">Verdadeiro/Falso</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox id="edit-field-required" checked={required} onCheckedChange={(value) => setRequired(Boolean(value))} />
|
|
<Label htmlFor="edit-field-required" className="text-sm font-normal text-neutral-600">
|
|
Campo obrigatório na abertura
|
|
</Label>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Aplicar em</Label>
|
|
<Select value={editingScope} onValueChange={setEditingScope}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Todos os formulários" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{scopeOptions.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Empresa (opcional)</Label>
|
|
<SearchableCombobox
|
|
value={editingCompanySelection}
|
|
onValueChange={(value) => setEditingCompanySelection(value ?? "all")}
|
|
options={companyComboboxOptions}
|
|
placeholder="Todas as empresas"
|
|
renderValue={(option) =>
|
|
option ? (
|
|
<span className="truncate">{option.label}</span>
|
|
) : (
|
|
<span className="text-muted-foreground">Todas as empresas</span>
|
|
)
|
|
}
|
|
/>
|
|
<p className="text-xs text-neutral-500">
|
|
Defina uma empresa para restringir este campo apenas aos tickets dela.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="edit-field-description">Descrição</Label>
|
|
<textarea
|
|
id="edit-field-description"
|
|
className="min-h-[96px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
|
|
value={description}
|
|
onChange={(event) => setDescription(event.target.value)}
|
|
/>
|
|
</div>
|
|
{type === "select" ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label>Opções</Label>
|
|
<Button type="button" variant="outline" size="sm" onClick={addOption}>
|
|
Adicionar opção
|
|
</Button>
|
|
</div>
|
|
{options.length === 0 ? (
|
|
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
|
|
Inclua ao menos uma opção para salvar este campo.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{options.map((option, index) => (
|
|
<div key={index} className="grid gap-3 rounded-lg border border-slate-200 p-3 md:grid-cols-[minmax(0,1fr)_minmax(0,200px)_auto]">
|
|
<Input
|
|
placeholder="Rótulo"
|
|
value={option.label}
|
|
onChange={(event) => updateOption(index, "label", event.target.value)}
|
|
/>
|
|
<Input
|
|
placeholder="Valor"
|
|
value={option.value}
|
|
onChange={(event) => updateOption(index, "value", event.target.value)}
|
|
/>
|
|
<Button variant="ghost" type="button" onClick={() => removeOption(index)}>
|
|
Remover
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="gap-2">
|
|
<Button variant="outline" onClick={() => setEditingField(null)}>
|
|
Cancelar
|
|
</Button>
|
|
<Button onClick={handleUpdate} disabled={saving}>
|
|
Salvar alterações
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|