Ajusta placeholders, formulários e widgets
This commit is contained in:
parent
343f0c8c64
commit
b94cea2f9a
33 changed files with 2122 additions and 462 deletions
|
|
@ -29,6 +29,7 @@ type Field = {
|
|||
required: boolean
|
||||
options: FieldOption[]
|
||||
order: number
|
||||
scope: string
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<Field["type"], string> = {
|
||||
|
|
@ -48,6 +49,25 @@ export function FieldsManager() {
|
|||
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 scopeOptions = useMemo(
|
||||
() => [
|
||||
{ value: "all", label: "Todos os formulários" },
|
||||
...((templates ?? []).map((tpl) => ({ value: tpl.key, label: tpl.label })) ?? []),
|
||||
],
|
||||
[templates]
|
||||
)
|
||||
|
||||
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)
|
||||
|
|
@ -58,8 +78,10 @@ export function FieldsManager() {
|
|||
const [type, setType] = useState<Field["type"]>("text")
|
||||
const [required, setRequired] = useState(false)
|
||||
const [options, setOptions] = useState<FieldOption[]>([])
|
||||
const [scopeSelection, setScopeSelection] = useState<string>("all")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editingField, setEditingField] = useState<Field | null>(null)
|
||||
const [editingScope, setEditingScope] = useState<string>("all")
|
||||
|
||||
const totals = useMemo(() => {
|
||||
if (!fields) return { total: 0, required: 0, select: 0 }
|
||||
|
|
@ -76,6 +98,7 @@ export function FieldsManager() {
|
|||
setType("text")
|
||||
setRequired(false)
|
||||
setOptions([])
|
||||
setScopeSelection("all")
|
||||
}
|
||||
|
||||
const normalizeOptions = (source: FieldOption[]) =>
|
||||
|
|
@ -97,6 +120,7 @@ export function FieldsManager() {
|
|||
return
|
||||
}
|
||||
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
|
||||
const scopeValue = scopeSelection === "all" ? undefined : scopeSelection
|
||||
setSaving(true)
|
||||
toast.loading("Criando campo...", { id: "field" })
|
||||
try {
|
||||
|
|
@ -108,6 +132,7 @@ export function FieldsManager() {
|
|||
type,
|
||||
required,
|
||||
options: preparedOptions,
|
||||
scope: scopeValue,
|
||||
})
|
||||
toast.success("Campo criado", { id: "field" })
|
||||
resetForm()
|
||||
|
|
@ -147,6 +172,7 @@ export function FieldsManager() {
|
|||
setType(field.type)
|
||||
setRequired(field.required)
|
||||
setOptions(field.options)
|
||||
setEditingScope(field.scope ?? "all")
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
|
|
@ -160,6 +186,7 @@ export function FieldsManager() {
|
|||
return
|
||||
}
|
||||
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
|
||||
const scopeValue = editingScope === "all" ? undefined : editingScope
|
||||
setSaving(true)
|
||||
toast.loading("Atualizando campo...", { id: "field-edit" })
|
||||
try {
|
||||
|
|
@ -172,6 +199,7 @@ export function FieldsManager() {
|
|||
type,
|
||||
required,
|
||||
options: preparedOptions,
|
||||
scope: scopeValue,
|
||||
})
|
||||
toast.success("Campo atualizado", { id: "field-edit" })
|
||||
setEditingField(null)
|
||||
|
|
@ -304,6 +332,21 @@ export function FieldsManager() {
|
|||
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>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -378,7 +421,12 @@ export function FieldsManager() {
|
|||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
fields.map((field, index) => (
|
||||
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">
|
||||
|
|
@ -395,6 +443,9 @@ export function FieldsManager() {
|
|||
) : null}
|
||||
</div>
|
||||
<CardDescription className="text-neutral-600">Identificador: {field.key}</CardDescription>
|
||||
<Badge variant="secondary" className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-neutral-700">
|
||||
{scopeLabel}
|
||||
</Badge>
|
||||
{field.description ? (
|
||||
<p className="text-sm text-neutral-600">{field.description}</p>
|
||||
) : null}
|
||||
|
|
@ -446,7 +497,8 @@ export function FieldsManager() {
|
|||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
))
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -487,6 +539,21 @@ export function FieldsManager() {
|
|||
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>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
345
src/components/admin/fields/ticket-form-templates-manager.tsx
Normal file
345
src/components/admin/fields/ticket-form-templates-manager.tsx
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { Plus, MoreHorizontal, Archive, RefreshCcw } from "lucide-react"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
type Template = {
|
||||
id: string
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
defaultEnabled: boolean
|
||||
baseTemplateKey: string | null
|
||||
isSystem: boolean
|
||||
isArchived: boolean
|
||||
order: number
|
||||
}
|
||||
|
||||
export function TicketFormTemplatesManager() {
|
||||
const { session, convexUserId } = useAuth()
|
||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
const viewerId = convexUserId as Id<"users"> | null
|
||||
|
||||
const ensureDefaults = useMutation(api.tickets.ensureTicketFormDefaults)
|
||||
const createTemplate = useMutation(api.ticketFormTemplates.create)
|
||||
const updateTemplate = useMutation(api.ticketFormTemplates.update)
|
||||
const archiveTemplate = useMutation(api.ticketFormTemplates.archive)
|
||||
|
||||
const hasEnsuredRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!viewerId || hasEnsuredRef.current) return
|
||||
hasEnsuredRef.current = true
|
||||
ensureDefaults({ tenantId, actorId: viewerId }).catch((error) => {
|
||||
console.error("[ticket-templates] ensure defaults failed", error)
|
||||
hasEnsuredRef.current = false
|
||||
})
|
||||
}, [ensureDefaults, tenantId, viewerId])
|
||||
|
||||
const templates = useQuery(
|
||||
api.ticketFormTemplates.list,
|
||||
viewerId ? { tenantId, viewerId } : "skip"
|
||||
) as Template[] | undefined
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [newLabel, setNewLabel] = useState("")
|
||||
const [newDescription, setNewDescription] = useState("")
|
||||
const [baseTemplate, setBaseTemplate] = useState<string>("")
|
||||
const [cloneFields, setCloneFields] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const [editingTemplate, setEditingTemplate] = useState<Template | null>(null)
|
||||
const [editLabel, setEditLabel] = useState("")
|
||||
const [editDescription, setEditDescription] = useState("")
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
|
||||
const activeTemplates = useMemo(() => {
|
||||
if (!templates) return []
|
||||
return templates.filter((tpl) => !tpl.isArchived).sort((a, b) => a.order - b.order)
|
||||
}, [templates])
|
||||
|
||||
const archivedTemplates = useMemo(() => {
|
||||
if (!templates) return []
|
||||
return templates.filter((tpl) => tpl.isArchived).sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
|
||||
}, [templates])
|
||||
|
||||
const baseOptions = useMemo(() => {
|
||||
return (templates ?? []).filter((tpl) => !tpl.isArchived)
|
||||
}, [templates])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!viewerId) return
|
||||
const label = newLabel.trim()
|
||||
if (label.length < 3) {
|
||||
toast.error("Informe um nome com pelo menos 3 caracteres")
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
try {
|
||||
await createTemplate({
|
||||
tenantId,
|
||||
actorId: viewerId,
|
||||
label,
|
||||
description: newDescription.trim() || undefined,
|
||||
baseTemplateKey: baseTemplate || undefined,
|
||||
cloneFields,
|
||||
})
|
||||
toast.success("Formulário criado com sucesso.")
|
||||
setCreateDialogOpen(false)
|
||||
setNewLabel("")
|
||||
setNewDescription("")
|
||||
setBaseTemplate("")
|
||||
setCloneFields(true)
|
||||
} catch (error) {
|
||||
console.error("[ticket-templates] create failed", error)
|
||||
toast.error("Não foi possível criar o formulário.")
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!viewerId || !editingTemplate) return
|
||||
const label = editLabel.trim()
|
||||
if (label.length < 3) {
|
||||
toast.error("Informe um nome com pelo menos 3 caracteres")
|
||||
return
|
||||
}
|
||||
setSavingEdit(true)
|
||||
try {
|
||||
await updateTemplate({
|
||||
tenantId,
|
||||
actorId: viewerId,
|
||||
templateId: editingTemplate.id as Id<"ticketFormTemplates">,
|
||||
label,
|
||||
description: editDescription.trim() || undefined,
|
||||
})
|
||||
toast.success("Formulário atualizado.")
|
||||
setEditingTemplate(null)
|
||||
} catch (error) {
|
||||
console.error("[ticket-templates] update failed", error)
|
||||
toast.error("Não foi possível atualizar o formulário.")
|
||||
} finally {
|
||||
setSavingEdit(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleArchive = async (template: Template, archived: boolean) => {
|
||||
if (!viewerId) return
|
||||
try {
|
||||
await archiveTemplate({
|
||||
tenantId,
|
||||
actorId: viewerId,
|
||||
templateId: template.id as Id<"ticketFormTemplates">,
|
||||
archived,
|
||||
})
|
||||
toast.success(archived ? "Formulário arquivado." : "Formulário reativado.")
|
||||
} catch (error) {
|
||||
console.error("[ticket-templates] toggle archive failed", error)
|
||||
toast.error("Não foi possível atualizar o formulário.")
|
||||
}
|
||||
}
|
||||
|
||||
const renderTemplateCard = (template: Template) => (
|
||||
<Card key={template.id} className="border-slate-200">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base font-semibold text-neutral-900">{template.label}</CardTitle>
|
||||
<CardDescription>{template.description || "Sem descrição"}</CardDescription>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Badge variant="outline" className="rounded-full px-2 py-0.5 text-xs font-semibold">
|
||||
{template.isSystem ? "Padrão do sistema" : "Personalizado"}
|
||||
</Badge>
|
||||
{!template.defaultEnabled ? (
|
||||
<Badge variant="secondary" className="rounded-full px-2 py-0.5 text-xs font-semibold">
|
||||
Desabilitado por padrão
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setEditingTemplate(template)
|
||||
setEditLabel(template.label)
|
||||
setEditDescription(template.description)
|
||||
}}
|
||||
>
|
||||
Renomear
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => handleToggleArchive(template, !template.isArchived)}>
|
||||
{template.isArchived ? (
|
||||
<span className="flex items-center gap-2 text-emerald-600">
|
||||
<RefreshCcw className="size-3.5" />
|
||||
Reativar
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2 text-rose-600">
|
||||
<Archive className="size-3.5" />
|
||||
Arquivar
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">Modelos de formulário</CardTitle>
|
||||
<CardDescription>Controle quais formulários especiais ficam disponíveis na abertura de tickets.</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" className="gap-2" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Novo formulário
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!templates ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-28 w-full rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTemplates.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50/70 p-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum formulário personalizado. Clique em "Novo formulário" para começar.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{activeTemplates.map(renderTemplateCard)}
|
||||
</div>
|
||||
)}
|
||||
{archivedTemplates.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Arquivados</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{archivedTemplates.map(renderTemplateCard)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={createDialogOpen} onOpenChange={(open) => !creating && setCreateDialogOpen(open)}>
|
||||
<DialogContent className="max-w-lg space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Novo formulário</DialogTitle>
|
||||
<DialogDescription>Crie formulários específicos para fluxos como admissões, desligamentos ou demandas especiais.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-neutral-700">Nome</label>
|
||||
<Input
|
||||
value={newLabel}
|
||||
onChange={(event) => setNewLabel(event.target.value)}
|
||||
placeholder="Ex.: Troca de equipamento"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-neutral-700">Descrição</label>
|
||||
<Textarea
|
||||
value={newDescription}
|
||||
onChange={(event) => setNewDescription(event.target.value)}
|
||||
placeholder="Explique quando este formulário deve ser usado"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-neutral-700">Basear em</label>
|
||||
<Select value={baseTemplate} onValueChange={setBaseTemplate}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Em branco" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Começar do zero</SelectItem>
|
||||
{baseOptions.map((tpl) => (
|
||||
<SelectItem key={tpl.key} value={tpl.key}>
|
||||
{tpl.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{baseTemplate ? (
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
||||
<Checkbox
|
||||
checked={cloneFields}
|
||||
onCheckedChange={(value) => setCloneFields(Boolean(value))}
|
||||
/>
|
||||
<span>Copiar campos do formulário base</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => !creating && setCreateDialogOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
{creating ? "Criando..." : "Criar formulário"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(editingTemplate)} onOpenChange={(open) => !savingEdit && !open && setEditingTemplate(null)}>
|
||||
<DialogContent className="max-w-lg space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar formulário</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-neutral-700">Nome</label>
|
||||
<Input value={editLabel} onChange={(event) => setEditLabel(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-neutral-700">Descrição</label>
|
||||
<Textarea
|
||||
value={editDescription}
|
||||
onChange={(event) => setEditDescription(event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => !savingEdit && setEditingTemplate(null)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSaveEdit} disabled={savingEdit}>
|
||||
{savingEdit ? "Salvando..." : "Salvar alterações"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue