feat: improve custom fields admin and date filters

This commit is contained in:
Esdras Renan 2025-11-15 01:51:55 -03:00
parent 11a4b903c4
commit b721348e19
14 changed files with 491 additions and 205 deletions

View file

@ -541,11 +541,22 @@ async function fetchTicketFieldsByScopes(
scopes: string[], scopes: string[],
companyId: Id<"companies"> | null companyId: Id<"companies"> | null
): Promise<TicketFieldScopeMap> { ): Promise<TicketFieldScopeMap> {
const uniqueScopes = Array.from(new Set(scopes.filter((scope) => Boolean(scope)))); const scopeLookup = new Map<string, string>();
if (uniqueScopes.length === 0) { scopes.forEach((scope) => {
const trimmed = scope?.trim();
if (!trimmed) {
return;
}
const normalized = trimmed.toLowerCase();
if (!scopeLookup.has(normalized)) {
scopeLookup.set(normalized, trimmed);
}
});
if (!scopeLookup.size) {
return new Map(); return new Map();
} }
const scopeSet = new Set(uniqueScopes);
const companyIdStr = companyId ? String(companyId) : null; const companyIdStr = companyId ? String(companyId) : null;
const result: TicketFieldScopeMap = new Map(); const result: TicketFieldScopeMap = new Map();
const allFields = await ctx.db const allFields = await ctx.db
@ -553,21 +564,45 @@ async function fetchTicketFieldsByScopes(
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect(); .collect();
for (const field of allFields) { const addFieldToScope = (scopeKey: string, field: Doc<"ticketFields">) => {
const scope = field.scope ?? ""; const originalKey = scopeLookup.get(scopeKey);
if (!scopeSet.has(scope)) { if (!originalKey) {
continue; return;
} }
const current = result.get(originalKey);
if (current) {
current.push(field);
} else {
result.set(originalKey, [field]);
}
};
const normalizedScopeSet = new Set(scopeLookup.keys());
for (const field of allFields) {
const rawScope = field.scope?.trim();
const normalizedFieldScope = rawScope && rawScope.length > 0 ? rawScope.toLowerCase() : "all";
const fieldCompanyId = field.companyId ? String(field.companyId) : null; const fieldCompanyId = field.companyId ? String(field.companyId) : null;
if (fieldCompanyId && (!companyIdStr || companyIdStr !== fieldCompanyId)) { if (fieldCompanyId && (!companyIdStr || companyIdStr !== fieldCompanyId)) {
continue; continue;
} }
const current = result.get(scope);
if (current) { if (normalizedFieldScope === "all") {
current.push(field); normalizedScopeSet.forEach((scopeKey) => addFieldToScope(scopeKey, field));
} else { continue;
result.set(scope, [field]);
} }
if (normalizedFieldScope === "default") {
if (normalizedScopeSet.has("default")) {
addFieldToScope("default", field);
}
continue;
}
if (!normalizedScopeSet.has(normalizedFieldScope)) {
continue;
}
addFieldToScope(normalizedFieldScope, field);
} }
return result; return result;
} }
@ -2915,7 +2950,16 @@ export const listTicketForms = query({
const viewerRole = (viewer.role ?? "").toUpperCase() const viewerRole = (viewer.role ?? "").toUpperCase()
const templates = await fetchTemplateSummaries(ctx, tenantId) const templates = await fetchTemplateSummaries(ctx, tenantId)
const scopes = templates.map((template) => template.key) const defaultTemplate: TemplateSummary = {
key: "default",
label: "Chamado",
description: "Campos adicionais exibidos em chamados gerais ou sem template específico.",
defaultEnabled: true,
}
const templatesWithDefault = [defaultTemplate, ...templates.filter((template) => template.key !== "default")]
const scopes = templatesWithDefault.map((template) => template.key)
const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes, viewerCompanyId) const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes, viewerCompanyId)
const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT" const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT"
@ -2938,7 +2982,7 @@ export const listTicketForms = query({
}> }>
}> }>
for (const template of templates) { for (const template of templatesWithDefault) {
const templateSettings = settingsByTemplate.get(template.key) ?? [] const templateSettings = settingsByTemplate.get(template.key) ?? []
let enabled = staffOverride let enabled = staffOverride
? true ? true

View file

@ -0,0 +1,134 @@
# Migração do stack `sistema` para a VPS 154.12.253.40 (15/11/2025)
Este documento registra o procedimento completo usado para clonar e mover o stack `sistema` (Convex self-hosted + dashboard + web/Next.js) da VPS antiga `217.216.64.168` para a VPS nova `154.12.253.40` (ambas Ubuntu 24.04 com Docker Swarm). Use-o como referência para futuros blue/green ou rollbacks.
## 1. Componentes inventariados
| Item | VPS antiga | VPS nova |
| --- | --- | --- |
| Hostname | `vmi2889318` | `vmi2872619` |
| CPU/RAM | 4 vCPU / 8GB | 8 vCPU / 24GB |
| Stack | `sistema_convex_backend`, `sistema_convex_dashboard`, `sistema_web` | os mesmos + outros stacks existentes |
| Redes relevantes | `traefik_public`, `digital_network` | idem |
| Volumes do stack | `sistema_convex_data`, `sistema_sistema_db`, `sistema_db` | copiados 1:1 |
| Bind mount | `/apps/sistema` (5GB) | `/home/renan/apps/sistema` + symlink `/apps/sistema` |
Arquivos auxiliares recuperados da VPS antiga e copiados para `/root` na nova:
- `/root/services.json` inventário completo de serviços, útil para recriar stacks.
- `/root/replay_services_from_json.sh` script que recria serviços usando `services.json`.
## 2. Pré-requisitos e boas práticas
1. **Autenticação**: senha root na VPS antiga (`sshpass`) e chave `codex_ed25519` para a nova (chmod 600).
2. **Congelamento de escrita**: antes do delta final, escalar a 0 os serviços que escrevem (`docker service scale sistema_* =0`).
3. **Snapshots/volumes**: cópia via contêiner Alpine + tar em streaming evita salvar arquivos temporários.
4. **DNS**: prepare um alias (ex.: `vps2.esdrasrenan.com.br`) e reduza o TTL dos CNAMEs *antes* do corte para minimizar caches antigos.
5. **TLS**: copie `acme.json` entre as VPS e recarregue o Traefik para manter os certificados válidos após o corte.
## 3. Procedimento realizado
### 3.1. Inventário e auditoria
```
# CPU/memória, Swarm, stacks e volumes na VPS antiga
sshpass -p '...' ssh root@217.216.64.168 'lscpu; free -h; docker info; docker stack ls; docker stack services sistema; docker volume ls'
# Idem na VPS nova (confirmar recursos extras)
ssh -i codex_ed25519 root@154.12.253.40 'lscpu; free -h; docker stack ls; docker service ls'
```
### 3.2. Cópia inicial do código e volumes
```
# Código Next.js (bind mount)
sshpass -p '...' ssh root@217.216.64.168 'tar czf - -C /apps sistema' | ssh -i codex_ed25519 root@154.12.253.40 'tar xzf - -C /apps'
# Volumes Docker em streaming (repetir para cada volume)
for vol in sistema_convex_data sistema_sistema_db sistema_db; do
sshpass -p '...' ssh root@217.216.64.168 "docker run --rm -v $vol:/data alpine tar cz -C /data ." | ssh -i codex_ed25519 root@154.12.253.40 "docker volume create $vol >/dev/null; docker run --rm -i -v $vol:/data alpine tar xz -C /data"
done
# Ajustar caminho esperado pelo serviço web
ssh root@154.12.253.40 'mkdir -p /home/renan/apps && mv /apps/sistema /home/renan/apps/ && ln -sfn /home/renan/apps/sistema /apps/sistema'
```
### 3.3. Recriação dos serviços no Swarm da VPS nova
Recriação manual (mais controle que o script) mantendo labels do Traefik:
```
# Convex backend
NET=traefik_public
STACK=sistema
**docker service create** --name ${STACK}_convex_backend --replicas 1 --constraint 'node.role == manager' --network $NET --mount type=volume,src=sistema_convex_data,dst=/convex/data --limit-memory 12g --env CONVEX_CLOUD_ORIGIN=https://convex.esdrasrenan.com.br --env CONVEX_SITE_ORIGIN=https://convex.esdrasrenan.com.br --env FLEET_SYNC_SECRET=... --env MACHINE_PROVISIONING_SECRET=... --env MACHINE_TOKEN_TTL_MS=2592000000 --env RUST_LOG=info --label traefik.http.routers.sistema_convex.rule='Host(`convex.esdrasrenan.com.br`)' ghcr.io/get-convex/convex-backend:latest
# Dashboard (mantido 0/0 até ser necessário)
docker service create ... --replicas 0 ghcr.io/get-convex/convex-dashboard:latest
# Web (Next.js/Bun) notar bind mount + volume SQLite
docker service create --name ${STACK}_web --replicas 1 --constraint 'node.role == manager' --network $NET --mount type=bind,src=/home/renan/apps/sistema,dst=/app --mount type=volume,src=sistema_sistema_db,dst=/app/data --limit-memory 2g --user 1000:1000 --workdir /app --env NEXT_PUBLIC_APP_URL=https://tickets.esdrasrenan.com.br --env NEXT_PUBLIC_CONVEX_URL=https://convex.esdrasrenan.com.br --env CONVEX_INTERNAL_URL=http://sistema_convex_backend:3210 --env BETTER_AUTH_SECRET=... --label traefik.http.routers.sistema_web.rule='Host(`tickets.esdrasrenan.com.br`)' oven/bun:1.3.1 bash -lc 'bash /app/scripts/start-web.sh'
```
### 3.4. Certificados TLS
1. Copiar `acme.json` das duas VPS.
2. Mesclar certificados (para não perder a conta/chain nova) e reenviar para o volume `certificados`.
3. Escalar `traefik_traefik` para 0, gravar o arquivo e retornar a 1.
```
sshpass -p '...' ssh root@217.216.64.168 'docker run --rm -v certificados:/data alpine cat /data/acme.json' > acme-old.json
ssh root@154.12.253.40 'docker run --rm -v certificados:/data alpine cat /data/acme.json' > acme-new.json
python3 merge_acme.py # script que mescla Certificates
cat acme-merged.json | ssh root@154.12.253.40 'docker run --rm -i -v certificados:/data alpine sh -c "cat > /data/acme.json && chmod 600 /data/acme.json"'
```
### 3.5. Delta final antes do corte
1. `docker service scale sistema_* =0` na VPS antiga.
2. Repetir o loop de volumes + `tar` do código para pegar qualquer alteração mais recente.
3. Reaplicar `chown -R 1000:1000 /home/renan/apps/sistema` e re-symlink `/apps/sistema`.
4. Forçar rolling update nos serviços da VPS nova:
```
ssh root@154.12.253.40 'docker service update --force sistema_convex_backend sistema_web'
```
### 3.6. DNS
- Criado `vps2.esdrasrenan.com.br` (A → 154.12.253.40).
- Atualizados os CNAMEs `tickets.esdrasrenan.com.br` e `convex.esdrasrenan.com.br` para apontar ao alias novo (TTL efetivo 300s após propagação).
- Observação: durante ~4h alguns clientes ainda enxergaram o IP antigo porque o TTL prévio era 14.400s; na próxima migração, reduzir o TTL horas antes do corte para evitar 404 temporário.
### 3.7. Imagem Bun com OpenSSL
Para eliminar o warning do Prisma, foi criada uma imagem local (`sistema-web:openssl-20251115`) adicionando `apt-get install openssl`. Atualização do serviço:
```
ssh root@154.12.253.40 'docker build -t sistema-web:openssl-20251115 - <<"EOF"
FROM oven/bun:1.3.1
RUN apt-get update && apt-get install -y --no-install-recommends openssl \n && rm -rf /var/lib/apt/lists/*
EOF'
docker service update --image sistema-web:openssl-20251115 sistema_web
```
**Atenção**: essa imagem só existe localmente; se outro nó entrar no Swarm, publique-a em um registry ou reconstrua no novo nó antes de escalar o serviço.
## 4. Verificações pós-migração
- `docker service ls | grep sistema``convex_backend 1/1`, `web 1/1`, `dashboard 0/0`.
- `curl -k -I --resolve tickets.esdrasrenan.com.br:443:154.12.253.40 https://tickets.esdrasrenan.com.br` → 307 `/login`.
- `curl -k -I --resolve convex.esdrasrenan.com.br:443:154.12.253.40 https://convex.esdrasrenan.com.br` → 200.
- `docker logs <container web>` sem warnings de OpenSSL.
- `dig @1.1.1.1 +short tickets.esdrasrenan.com.br``vps2.esdrasrenan.com.br``154.12.253.40`.
## 5. Recomendações para futuras trocas
1. **TTL primeiro, migração depois**: reduza CNAMEs para 300s pelo menos 4h antes de alterar IPs.
2. **Dashboard opcional**: mantenha `sistema_convex_dashboard` em `0/0` até precisar, escalando com `docker service scale sistema_convex_dashboard=1`.
3. **Imagem Bun custom**: mantenha o Dockerfile acima em repositório (ex.: `containers/sistema-web/Dockerfile`) para reconstruções reproduzíveis.
4. **Rollback**: para voltar à VPS antiga, restaure o DNS (CNAME → `vps.esdrasrenan.com.br`) e escale os serviços antigos para `1`. Guarde a VPS por 2448h antes de desativá-la definitivamente.
5. **Logs**: monitore `docker service logs sistema_convex_backend` para garantir que não haja `exit 137`. Se reaparecer, mantenha o limite de memória ≥ 12GB ou ajuste conforme uso real.
Com este fluxo documentado, repetir ou reverter a migração passa a ser um processo guiado e audível.

View file

@ -0,0 +1,25 @@
import { FieldsManager } from "@/components/admin/fields/fields-manager"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { requireAuthenticatedSession } from "@/lib/auth-server"
export const dynamic = "force-dynamic"
export default async function AdminCustomFieldsPage() {
await requireAuthenticatedSession()
return (
<AppShell
header={
<SiteHeader
title="Campos personalizados"
lead="Crie campos adicionais para admissão, desligamento e chamados gerais mantendo a consistência visual do painel."
/>
}
>
<div className="mx-auto w-full max-w-6xl space-y-8 px-6 pb-12 lg:px-8">
<FieldsManager />
</div>
</AppShell>
)
}

View file

@ -1,5 +1,4 @@
import { CategoriesManager } from "@/components/admin/categories/categories-manager" import { CategoriesManager } from "@/components/admin/categories/categories-manager"
import { FieldsManager } from "@/components/admin/fields/fields-manager"
import { TicketFormTemplatesManager } from "@/components/admin/fields/ticket-form-templates-manager" import { TicketFormTemplatesManager } from "@/components/admin/fields/ticket-form-templates-manager"
import { AppShell } from "@/components/app-shell" import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header" import { SiteHeader } from "@/components/site-header"
@ -11,15 +10,14 @@ export default function AdminFieldsPage() {
<AppShell <AppShell
header={ header={
<SiteHeader <SiteHeader
title="Categorias e campos personalizados" title="Categorias e formulários"
lead="Administre as categorias primárias/secundárias e os campos adicionais aplicados aos tickets." lead="Administre categorias, subcategorias e templates utilizados para classificar tickets."
/> />
} }
> >
<div className="mx-auto w-full max-w-6xl space-y-8 px-6 lg:px-8"> <div className="mx-auto w-full max-w-6xl space-y-8 px-6 lg:px-8">
<CategoriesManager /> <CategoriesManager />
<TicketFormTemplatesManager /> <TicketFormTemplatesManager />
<FieldsManager />
</div> </div>
</AppShell> </AppShell>
) )

View file

@ -4,6 +4,7 @@ import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner" import { toast } from "sonner"
import { IconAdjustments, IconForms, IconListDetails, IconTypography } from "@tabler/icons-react" import { IconAdjustments, IconForms, IconListDetails, IconTypography } from "@tabler/icons-react"
import { ArrowDown, ArrowUp } from "lucide-react"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
@ -113,6 +114,7 @@ export function FieldsManager() {
const [editingField, setEditingField] = useState<Field | null>(null) const [editingField, setEditingField] = useState<Field | null>(null)
const [editingScope, setEditingScope] = useState<string>("all") const [editingScope, setEditingScope] = useState<string>("all")
const [editingCompanySelection, setEditingCompanySelection] = useState<string>("all") const [editingCompanySelection, setEditingCompanySelection] = useState<string>("all")
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const totals = useMemo(() => { const totals = useMemo(() => {
if (!fields) return { total: 0, required: 0, select: 0 } if (!fields) return { total: 0, required: 0, select: 0 }
@ -134,6 +136,11 @@ export function FieldsManager() {
setEditingCompanySelection("all") setEditingCompanySelection("all")
} }
const closeCreateDialog = () => {
setIsCreateDialogOpen(false)
resetForm()
}
const normalizeOptions = (source: FieldOption[]) => const normalizeOptions = (source: FieldOption[]) =>
source source
.map((option) => ({ .map((option) => ({
@ -170,7 +177,7 @@ export function FieldsManager() {
companyId: companyIdValue, companyId: companyIdValue,
}) })
toast.success("Campo criado", { id: "field" }) toast.success("Campo criado", { id: "field" })
resetForm() closeCreateDialog()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
toast.error("Não foi possível criar o campo", { id: "field" }) toast.error("Não foi possível criar o campo", { id: "field" })
@ -336,128 +343,16 @@ export function FieldsManager() {
</CardTitle> </CardTitle>
<CardDescription>Capture informações específicas do seu fluxo de atendimento.</CardDescription> <CardDescription>Capture informações específicas do seu fluxo de atendimento.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex justify-between gap-4">
<form onSubmit={handleCreate} className="grid gap-4 lg:grid-cols-[minmax(0,280px)_minmax(0,1fr)]"> <p className="text-sm text-neutral-600">Abra o formulário avançado para definir escopo, empresa e opções.</p>
<div className="space-y-3"> <Button
<div className="space-y-2"> onClick={() => {
<Label htmlFor="field-label">Rótulo</Label> resetForm()
<Input setIsCreateDialogOpen(true)
id="field-label" }}
placeholder="Ex.: Número do contrato" >
value={label} Configurar campo
onChange={(event) => setLabel(event.target.value)} </Button>
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">
Selecione uma empresa para tornar este campo exclusivo dela. Sem seleção, o campo aparecerá em 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">
<Button type="submit" disabled={saving}>
Criar campo
</Button>
</div>
</div>
</form>
</CardContent> </CardContent>
</Card> </Card>
@ -521,26 +416,28 @@ export function FieldsManager() {
Excluir Excluir
</Button> </Button>
</div> </div>
<div className="flex gap-2 text-xs text-neutral-500"> <div className="flex gap-1 pt-4">
<Button <Button
type="button" type="button"
size="sm" size="icon"
variant="ghost" variant="ghost"
className="px-2" className="size-8"
disabled={index === 0} disabled={index === 0}
onClick={() => moveField(field, "up")} onClick={() => moveField(field, "up")}
> >
Subir <ArrowUp className="size-4" />
<span className="sr-only">Subir</span>
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm" size="icon"
variant="ghost" variant="ghost"
className="px-2" className="size-8"
disabled={index === fields.length - 1} disabled={index === fields.length - 1}
onClick={() => moveField(field, "down")} onClick={() => moveField(field, "down")}
> >
Descer <ArrowDown className="size-4" />
<span className="sr-only">Descer</span>
</Button> </Button>
</div> </div>
</div> </div>
@ -564,6 +461,144 @@ export function FieldsManager() {
)} )}
</div> </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)}> <Dialog open={Boolean(editingField)} onOpenChange={(value) => (!value ? setEditingField(null) : null)}>
<DialogContent className="max-w-3xl"> <DialogContent className="max-w-3xl">
<DialogHeader> <DialogHeader>

View file

@ -5,6 +5,7 @@ import {
AlertTriangle, AlertTriangle,
Building, Building,
Building2, Building2,
ClipboardList,
CalendarDays, CalendarDays,
ChevronDown, ChevronDown,
Clock4, Clock4,
@ -115,6 +116,7 @@ const navigation: NavigationGroup[] = [
{ title: "Equipe", url: "/admin", icon: LayoutDashboard, requiredRole: "admin", exact: true }, { title: "Equipe", url: "/admin", icon: LayoutDashboard, requiredRole: "admin", exact: true },
{ title: "Empresas", url: "/admin/companies", icon: Building, requiredRole: "admin" }, { title: "Empresas", url: "/admin/companies", icon: Building, requiredRole: "admin" },
{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" }, { title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" },
{ title: "Campos personalizados", url: "/admin/custom-fields", icon: ClipboardList, requiredRole: "admin" },
{ title: "Templates de comentários", url: "/settings/templates", icon: LayoutTemplate, requiredRole: "admin" }, { title: "Templates de comentários", url: "/settings/templates", icon: LayoutTemplate, requiredRole: "admin" },
{ title: "Templates de relatórios", url: "/admin/report-templates", icon: LayoutTemplate, requiredRole: "admin" }, { title: "Templates de relatórios", url: "/admin/report-templates", icon: LayoutTemplate, requiredRole: "admin" },
], ],

View file

@ -1,7 +1,8 @@
"use client" "use client"
import { useMemo } from "react" import { useEffect, useMemo, useState } from "react"
import type { DateRange } from "react-day-picker" import type { DateRange } from "react-day-picker"
import { IconEraser } from "@tabler/icons-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar" import { Calendar } from "@/components/ui/calendar"
@ -18,6 +19,7 @@ type DateRangeButtonProps = {
to: string | null to: string | null
onChange: (next: DateRangeValue) => void onChange: (next: DateRangeValue) => void
className?: string className?: string
clearLabel?: string
} }
function strToDate(value?: string | null): Date | undefined { function strToDate(value?: string | null): Date | undefined {
@ -39,7 +41,8 @@ function formatPtBR(value?: Date): string {
return value ? value.toLocaleDateString("pt-BR") : "" return value ? value.toLocaleDateString("pt-BR") : ""
} }
export function DateRangeButton({ from, to, onChange, className }: DateRangeButtonProps) { export function DateRangeButton({ from, to, onChange, className, clearLabel = "Limpar período" }: DateRangeButtonProps) {
const [open, setOpen] = useState(false)
const range: DateRange | undefined = useMemo( const range: DateRange | undefined = useMemo(
() => ({ () => ({
from: strToDate(from), from: strToDate(from),
@ -47,35 +50,54 @@ export function DateRangeButton({ from, to, onChange, className }: DateRangeButt
}), }),
[from, to], [from, to],
) )
const [draftRange, setDraftRange] = useState<DateRange | undefined>(range)
const label = useEffect(() => {
range?.from && range?.to if (!open) {
? `${formatPtBR(range.from)} - ${formatPtBR(range.to)}` setDraftRange(range)
: "Período" }
}, [open, range])
const displayRange = open ? draftRange ?? range : range
const label = (() => {
if (displayRange?.from && displayRange?.to) {
return `${formatPtBR(displayRange.from)} - ${formatPtBR(displayRange.to)}`
}
if (displayRange?.from && !displayRange?.to) {
return `${formatPtBR(displayRange.from)} - …`
}
return "Período"
})()
const handleSelect = (next?: DateRange) => { const handleSelect = (next?: DateRange) => {
if (!next?.from && !next?.to) { if (!next?.from && !next?.to) {
onChange({ from: null, to: null }) setDraftRange(undefined)
return return
} }
if (next?.from && !next?.to) { setDraftRange(next)
const single = dateToStr(next.from)
if (from && to && from === to && single === from) {
onChange({ from: null, to: null })
return
}
onChange({ from: single, to: single })
return
}
const nextFrom = dateToStr(next?.from) ?? null if (next?.from && next?.to) {
const nextTo = dateToStr(next?.to) ?? nextFrom const nextFrom = dateToStr(next.from)
onChange({ from: nextFrom, to: nextTo }) const nextTo = dateToStr(next.to) ?? nextFrom
onChange({ from: nextFrom, to: nextTo })
setOpen(false)
}
} }
return ( return (
<Popover> <Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) {
setDraftRange(range)
} else {
setDraftRange(range)
}
}}
>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
@ -95,8 +117,25 @@ export function DateRangeButton({ from, to, onChange, className }: DateRangeButt
fixedWeeks fixedWeeks
showOutsideDays showOutsideDays
/> />
<div className="flex items-center justify-center gap-3 border-t border-border/70 bg-slate-50/80 px-3 py-2">
<button
type="button"
onClick={() => {
onChange({ from: null, to: null })
setDraftRange(undefined)
setOpen(false)
}}
className="inline-flex items-center gap-2 rounded-full px-3 py-1 text-sm font-medium text-neutral-600 transition hover:text-neutral-900 disabled:opacity-40"
disabled={!from && !to}
>
<IconEraser className="size-4" />
<span>{clearLabel}</span>
</button>
{!draftRange?.to && draftRange?.from ? (
<span className="text-xs text-neutral-500">Selecione a data final</span>
) : null}
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
} }

View file

@ -55,16 +55,20 @@ export function PortalTicketForm() {
) as TicketFormDefinition[] | undefined ) as TicketFormDefinition[] | undefined
const forms = useMemo<TicketFormDefinition[]>(() => { const forms = useMemo<TicketFormDefinition[]>(() => {
const base: TicketFormDefinition = { const fallback: TicketFormDefinition = {
key: "default", key: "default",
label: "Chamado", label: "Chamado",
description: "Formulário básico para solicitações gerais.", description: "Formulário básico para solicitações gerais.",
fields: [], fields: [],
} }
if (Array.isArray(formsRemote) && formsRemote.length > 0) { if (!formsRemote || formsRemote.length === 0) {
return [base, ...formsRemote] return [fallback]
} }
return [base] const hasDefault = formsRemote.some((form) => form.key === fallback.key)
if (hasDefault) {
return formsRemote
}
return [fallback, ...formsRemote]
}, [formsRemote]) }, [formsRemote])
const [selectedFormKey, setSelectedFormKey] = useState<string>("default") const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
@ -100,10 +104,10 @@ export function PortalTicketForm() {
const customFieldsInvalid = useMemo( const customFieldsInvalid = useMemo(
() => () =>
selectedFormKey !== "default" && selectedForm?.fields?.length selectedForm?.fields?.length
? hasMissingRequiredCustomFields(selectedForm.fields, customFieldValues) ? hasMissingRequiredCustomFields(selectedForm.fields, customFieldValues)
: false, : false,
[selectedFormKey, selectedForm, customFieldValues] [selectedForm, customFieldValues]
) )
const handleFormSelection = (value: string) => { const handleFormSelection = (value: string) => {
@ -171,7 +175,7 @@ export function PortalTicketForm() {
} }
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = [] let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
if (selectedFormKey !== "default" && selectedForm?.fields?.length) { if (selectedForm?.fields?.length) {
const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues) const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues)
if (!normalized.ok) { if (!normalized.ok) {
toast.error(normalized.message, { id: "portal-new-ticket" }) toast.error(normalized.message, { id: "portal-new-ticket" })
@ -322,7 +326,7 @@ export function PortalTicketForm() {
subcategoryLabel="Subcategoria *" subcategoryLabel="Subcategoria *"
secondaryEmptyLabel="Selecione uma categoria" secondaryEmptyLabel="Selecione uma categoria"
/> />
{selectedFormKey !== "default" && selectedForm.fields.length > 0 ? ( {selectedForm.fields.length > 0 ? (
<div className="grid gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:grid-cols-2"> <div className="grid gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:grid-cols-2">
<p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p> <p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p>
{selectedForm.fields.map((field) => { {selectedForm.fields.map((field) => {

View file

@ -283,8 +283,8 @@ export function MachineCategoryReport() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<div className="space-y-1"> <div className="space-y-3">
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500"> <span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Máquina Máquina
</span> </span>
@ -296,7 +296,7 @@ export function MachineCategoryReport() {
triggerClassName="h-10 w-full rounded-2xl border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800" triggerClassName="h-10 w-full rounded-2xl border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-3">
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500"> <span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Usuário (solicitante) Usuário (solicitante)
</span> </span>

View file

@ -119,7 +119,7 @@ export function ReportsFilterToolbar({
<ToggleGroupItem value="365d" className="hidden min-w-[96px] justify-center px-4 lg:inline-flex"> <ToggleGroupItem value="365d" className="hidden min-w-[96px] justify-center px-4 lg:inline-flex">
12 meses 12 meses
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem value="all" className="hidden min-w-[96px] justify-center px-4 lg:inline-flex"> <ToggleGroupItem value="all" className="hidden min-w-[120px] justify-center px-5 lg:inline-flex">
Todo histórico Todo histórico
</ToggleGroupItem> </ToggleGroupItem>
</> </>

View file

@ -4,7 +4,7 @@ import { useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { toast } from "sonner"
import { Settings2, Share2, ShieldCheck, UserCog, UserPlus, Users2, Layers3, MessageSquareText, BellRing } from "lucide-react" import { Settings2, Share2, ShieldCheck, UserCog, UserPlus, Users2, Layers3, MessageSquareText, BellRing, ClipboardList } from "lucide-react"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -12,7 +12,6 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { useAuth, signOut } from "@/lib/auth-client" import { useAuth, signOut } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { FieldsManager } from "@/components/admin/fields/fields-manager"
import type { LucideIcon } from "lucide-react" import type { LucideIcon } from "lucide-react"
@ -36,6 +35,14 @@ const ROLE_LABELS: Record<string, string> = {
} }
const SETTINGS_ACTIONS: SettingsAction[] = [ const SETTINGS_ACTIONS: SettingsAction[] = [
{
title: "Campos personalizados",
description: "Configure campos extras por formulário e empresa para enriquecer os tickets.",
href: "/admin/custom-fields",
cta: "Abrir campos",
requiredRole: "admin",
icon: ClipboardList,
},
{ {
title: "Times & papéis", title: "Times & papéis",
description: "Controle quem pode atuar nas filas e atribua permissões refinadas por equipe.", description: "Controle quem pode atuar nas filas e atribua permissões refinadas por equipe.",
@ -53,10 +60,10 @@ const SETTINGS_ACTIONS: SettingsAction[] = [
icon: Share2, icon: Share2,
}, },
{ {
title: "Campos e categorias", title: "Categorias e formulários",
description: "Ajuste categorias, subcategorias e campos personalizados para qualificar tickets.", description: "Mantenha categorias padronizadas e templates de formulário alinhados à operação.",
href: "/admin/fields", href: "/admin/fields",
cta: "Editar estrutura", cta: "Gerenciar categorias",
requiredRole: "admin", requiredRole: "admin",
icon: Layers3, icon: Layers3,
}, },
@ -271,17 +278,7 @@ export function SettingsContent() {
})} })}
</div> </div>
</section> </section>
{isStaff ? (
<section id="custom-fields" className="space-y-4">
<div>
<h2 className="text-base font-semibold text-neutral-900">Campos personalizados</h2>
<p className="text-sm text-neutral-600">
Ajuste os campos de admissão, desligamento e demais metadados diretamente pelo painel administrativo.
</p>
</div>
<FieldsManager />
</section>
) : null}
</div> </div>
) )
} }

View file

@ -216,16 +216,20 @@ export function NewTicketDialog({
) as TicketFormDefinition[] | undefined ) as TicketFormDefinition[] | undefined
const forms = useMemo<TicketFormDefinition[]>(() => { const forms = useMemo<TicketFormDefinition[]>(() => {
const base: TicketFormDefinition = { const fallback: TicketFormDefinition = {
key: "default", key: "default",
label: "Chamado", label: "Chamado",
description: "Formulário básico para abertura de chamados gerais.", description: "Formulário básico para abertura de chamados gerais.",
fields: [], fields: [],
} }
if (Array.isArray(formsRemote) && formsRemote.length > 0) { if (!formsRemote || formsRemote.length === 0) {
return [base, ...formsRemote] return [fallback]
} }
return [base] const hasDefault = formsRemote.some((form) => form.key === fallback.key)
if (hasDefault) {
return formsRemote
}
return [fallback, ...formsRemote]
}, [formsRemote]) }, [formsRemote])
const [selectedFormKey, setSelectedFormKey] = useState<string>("default") const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
@ -531,7 +535,7 @@ export function NewTicketDialog({
} }
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = [] let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
if (selectedFormKey !== "default" && selectedForm?.fields?.length) { if (selectedForm?.fields?.length) {
const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues) const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues)
if (!normalized.ok) { if (!normalized.ok) {
toast.error(normalized.message, { id: "new-ticket" }) toast.error(normalized.message, { id: "new-ticket" })
@ -1036,7 +1040,7 @@ export function NewTicketDialog({
</div> </div>
</div> </div>
{selectedFormKey !== "default" && selectedForm.fields.length > 0 ? ( {selectedForm.fields.length > 0 ? (
<div className="grid gap-4 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:grid-cols-2 lg:col-span-2"> <div className="grid gap-4 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:grid-cols-2 lg:col-span-2">
<p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p> <p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p>
{selectedForm.fields.map((field) => { {selectedForm.fields.map((field) => {

View file

@ -256,7 +256,11 @@ export function TicketCustomFieldsSection({ ticket, variant = "card", className
if (!formsRemote || formsRemote.length === 0) { if (!formsRemote || formsRemote.length === 0) {
return [DEFAULT_FORM] return [DEFAULT_FORM]
} }
return formsRemote const hasDefault = formsRemote.some((form) => form.key === DEFAULT_FORM.key)
if (hasDefault) {
return formsRemote
}
return [DEFAULT_FORM, ...formsRemote]
}, [formsRemote]) }, [formsRemote])
const selectedForm = useMemo<TicketFormDefinition>(() => { const selectedForm = useMemo<TicketFormDefinition>(() => {

View file

@ -102,7 +102,7 @@ export function SearchableCombobox({
aria-controls={listId} aria-controls={listId}
disabled={disabled} disabled={disabled}
className={cn( className={cn(
"flex h-full w-full items-center justify-between gap-2 rounded-full border border-input bg-background px-3 text-sm font-semibold text-foreground shadow-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60", "flex h-11 w-full items-center justify-between gap-2 rounded-full border border-input bg-background px-3 text-sm font-semibold text-foreground shadow-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
className, className,
triggerClassName, triggerClassName,
)} )}