feat: improve custom fields admin and date filters
This commit is contained in:
parent
11a4b903c4
commit
b721348e19
14 changed files with 491 additions and 205 deletions
|
|
@ -541,11 +541,22 @@ async function fetchTicketFieldsByScopes(
|
|||
scopes: string[],
|
||||
companyId: Id<"companies"> | null
|
||||
): Promise<TicketFieldScopeMap> {
|
||||
const uniqueScopes = Array.from(new Set(scopes.filter((scope) => Boolean(scope))));
|
||||
if (uniqueScopes.length === 0) {
|
||||
const scopeLookup = new Map<string, string>();
|
||||
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();
|
||||
}
|
||||
const scopeSet = new Set(uniqueScopes);
|
||||
|
||||
const companyIdStr = companyId ? String(companyId) : null;
|
||||
const result: TicketFieldScopeMap = new Map();
|
||||
const allFields = await ctx.db
|
||||
|
|
@ -553,21 +564,45 @@ async function fetchTicketFieldsByScopes(
|
|||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
|
||||
for (const field of allFields) {
|
||||
const scope = field.scope ?? "";
|
||||
if (!scopeSet.has(scope)) {
|
||||
continue;
|
||||
const addFieldToScope = (scopeKey: string, field: Doc<"ticketFields">) => {
|
||||
const originalKey = scopeLookup.get(scopeKey);
|
||||
if (!originalKey) {
|
||||
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;
|
||||
if (fieldCompanyId && (!companyIdStr || companyIdStr !== fieldCompanyId)) {
|
||||
continue;
|
||||
}
|
||||
const current = result.get(scope);
|
||||
if (current) {
|
||||
current.push(field);
|
||||
} else {
|
||||
result.set(scope, [field]);
|
||||
|
||||
if (normalizedFieldScope === "all") {
|
||||
normalizedScopeSet.forEach((scopeKey) => addFieldToScope(scopeKey, field));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizedFieldScope === "default") {
|
||||
if (normalizedScopeSet.has("default")) {
|
||||
addFieldToScope("default", field);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!normalizedScopeSet.has(normalizedFieldScope)) {
|
||||
continue;
|
||||
}
|
||||
addFieldToScope(normalizedFieldScope, field);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -2915,7 +2950,16 @@ export const listTicketForms = query({
|
|||
const viewerRole = (viewer.role ?? "").toUpperCase()
|
||||
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 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) ?? []
|
||||
let enabled = staffOverride
|
||||
? true
|
||||
|
|
|
|||
134
docs/migracao-sistema-2025-11-15.md
Normal file
134
docs/migracao-sistema-2025-11-15.md
Normal 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 / 8 GB | 8 vCPU / 24 GB |
|
||||
| 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` (5 GB) | `/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 300 s após propagação).
|
||||
- Observação: durante ~4 h alguns clientes ainda enxergaram o IP antigo porque o TTL prévio era 14.400 s; 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 300 s pelo menos 4 h 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 24–48 h 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 ≥ 12 GB ou ajuste conforme uso real.
|
||||
|
||||
Com este fluxo documentado, repetir ou reverter a migração passa a ser um processo guiado e audível.
|
||||
25
src/app/admin/custom-fields/page.tsx
Normal file
25
src/app/admin/custom-fields/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
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 { AppShell } from "@/components/app-shell"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
|
|
@ -11,15 +10,14 @@ export default function AdminFieldsPage() {
|
|||
<AppShell
|
||||
header={
|
||||
<SiteHeader
|
||||
title="Categorias e campos personalizados"
|
||||
lead="Administre as categorias primárias/secundárias e os campos adicionais aplicados aos tickets."
|
||||
title="Categorias e formulários"
|
||||
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">
|
||||
<CategoriesManager />
|
||||
<TicketFormTemplatesManager />
|
||||
<FieldsManager />
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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"
|
||||
|
|
@ -113,6 +114,7 @@ export function FieldsManager() {
|
|||
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 }
|
||||
|
|
@ -134,6 +136,11 @@ export function FieldsManager() {
|
|||
setEditingCompanySelection("all")
|
||||
}
|
||||
|
||||
const closeCreateDialog = () => {
|
||||
setIsCreateDialogOpen(false)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const normalizeOptions = (source: FieldOption[]) =>
|
||||
source
|
||||
.map((option) => ({
|
||||
|
|
@ -170,7 +177,7 @@ export function FieldsManager() {
|
|||
companyId: companyIdValue,
|
||||
})
|
||||
toast.success("Campo criado", { id: "field" })
|
||||
resetForm()
|
||||
closeCreateDialog()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível criar o campo", { id: "field" })
|
||||
|
|
@ -336,128 +343,16 @@ export function FieldsManager() {
|
|||
</CardTitle>
|
||||
<CardDescription>Capture informações específicas do seu fluxo de atendimento.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCreate} className="grid gap-4 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">
|
||||
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 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>
|
||||
|
||||
|
|
@ -521,26 +416,28 @@ export function FieldsManager() {
|
|||
Excluir
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-neutral-500">
|
||||
<div className="flex gap-1 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="px-2"
|
||||
className="size-8"
|
||||
disabled={index === 0}
|
||||
onClick={() => moveField(field, "up")}
|
||||
>
|
||||
Subir
|
||||
<ArrowUp className="size-4" />
|
||||
<span className="sr-only">Subir</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="px-2"
|
||||
className="size-8"
|
||||
disabled={index === fields.length - 1}
|
||||
onClick={() => moveField(field, "down")}
|
||||
>
|
||||
Descer
|
||||
<ArrowDown className="size-4" />
|
||||
<span className="sr-only">Descer</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -564,6 +461,144 @@ export function FieldsManager() {
|
|||
)}
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
AlertTriangle,
|
||||
Building,
|
||||
Building2,
|
||||
ClipboardList,
|
||||
CalendarDays,
|
||||
ChevronDown,
|
||||
Clock4,
|
||||
|
|
@ -115,6 +116,7 @@ const navigation: NavigationGroup[] = [
|
|||
{ title: "Equipe", url: "/admin", icon: LayoutDashboard, requiredRole: "admin", exact: true },
|
||||
{ title: "Empresas", url: "/admin/companies", icon: Building, 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 relatórios", url: "/admin/report-templates", icon: LayoutTemplate, requiredRole: "admin" },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { DateRange } from "react-day-picker"
|
||||
import { IconEraser } from "@tabler/icons-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
|
|
@ -18,6 +19,7 @@ type DateRangeButtonProps = {
|
|||
to: string | null
|
||||
onChange: (next: DateRangeValue) => void
|
||||
className?: string
|
||||
clearLabel?: string
|
||||
}
|
||||
|
||||
function strToDate(value?: string | null): Date | undefined {
|
||||
|
|
@ -39,7 +41,8 @@ function formatPtBR(value?: Date): string {
|
|||
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(
|
||||
() => ({
|
||||
from: strToDate(from),
|
||||
|
|
@ -47,35 +50,54 @@ export function DateRangeButton({ from, to, onChange, className }: DateRangeButt
|
|||
}),
|
||||
[from, to],
|
||||
)
|
||||
const [draftRange, setDraftRange] = useState<DateRange | undefined>(range)
|
||||
|
||||
const label =
|
||||
range?.from && range?.to
|
||||
? `${formatPtBR(range.from)} - ${formatPtBR(range.to)}`
|
||||
: "Período"
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDraftRange(range)
|
||||
}
|
||||
}, [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) => {
|
||||
if (!next?.from && !next?.to) {
|
||||
onChange({ from: null, to: null })
|
||||
setDraftRange(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (next?.from && !next?.to) {
|
||||
const single = dateToStr(next.from)
|
||||
if (from && to && from === to && single === from) {
|
||||
onChange({ from: null, to: null })
|
||||
return
|
||||
}
|
||||
onChange({ from: single, to: single })
|
||||
return
|
||||
}
|
||||
setDraftRange(next)
|
||||
|
||||
const nextFrom = dateToStr(next?.from) ?? null
|
||||
const nextTo = dateToStr(next?.to) ?? nextFrom
|
||||
onChange({ from: nextFrom, to: nextTo })
|
||||
if (next?.from && next?.to) {
|
||||
const nextFrom = dateToStr(next.from)
|
||||
const nextTo = dateToStr(next.to) ?? nextFrom
|
||||
onChange({ from: nextFrom, to: nextTo })
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next)
|
||||
if (!next) {
|
||||
setDraftRange(range)
|
||||
} else {
|
||||
setDraftRange(range)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -95,8 +117,25 @@ export function DateRangeButton({ from, to, onChange, className }: DateRangeButt
|
|||
fixedWeeks
|
||||
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>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,16 +55,20 @@ export function PortalTicketForm() {
|
|||
) as TicketFormDefinition[] | undefined
|
||||
|
||||
const forms = useMemo<TicketFormDefinition[]>(() => {
|
||||
const base: TicketFormDefinition = {
|
||||
const fallback: TicketFormDefinition = {
|
||||
key: "default",
|
||||
label: "Chamado",
|
||||
description: "Formulário básico para solicitações gerais.",
|
||||
fields: [],
|
||||
}
|
||||
if (Array.isArray(formsRemote) && formsRemote.length > 0) {
|
||||
return [base, ...formsRemote]
|
||||
if (!formsRemote || formsRemote.length === 0) {
|
||||
return [fallback]
|
||||
}
|
||||
return [base]
|
||||
const hasDefault = formsRemote.some((form) => form.key === fallback.key)
|
||||
if (hasDefault) {
|
||||
return formsRemote
|
||||
}
|
||||
return [fallback, ...formsRemote]
|
||||
}, [formsRemote])
|
||||
|
||||
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
|
||||
|
|
@ -100,10 +104,10 @@ export function PortalTicketForm() {
|
|||
|
||||
const customFieldsInvalid = useMemo(
|
||||
() =>
|
||||
selectedFormKey !== "default" && selectedForm?.fields?.length
|
||||
selectedForm?.fields?.length
|
||||
? hasMissingRequiredCustomFields(selectedForm.fields, customFieldValues)
|
||||
: false,
|
||||
[selectedFormKey, selectedForm, customFieldValues]
|
||||
[selectedForm, customFieldValues]
|
||||
)
|
||||
|
||||
const handleFormSelection = (value: string) => {
|
||||
|
|
@ -171,7 +175,7 @@ export function PortalTicketForm() {
|
|||
}
|
||||
|
||||
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
|
||||
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
|
||||
if (selectedForm?.fields?.length) {
|
||||
const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues)
|
||||
if (!normalized.ok) {
|
||||
toast.error(normalized.message, { id: "portal-new-ticket" })
|
||||
|
|
@ -322,7 +326,7 @@ export function PortalTicketForm() {
|
|||
subcategoryLabel="Subcategoria *"
|
||||
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">
|
||||
<p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p>
|
||||
{selectedForm.fields.map((field) => {
|
||||
|
|
|
|||
|
|
@ -283,8 +283,8 @@ export function MachineCategoryReport() {
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Máquina
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Usuário (solicitante)
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export function ReportsFilterToolbar({
|
|||
<ToggleGroupItem value="365d" className="hidden min-w-[96px] justify-center px-4 lg:inline-flex">
|
||||
12 meses
|
||||
</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
|
||||
</ToggleGroupItem>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useMemo, useState } from "react"
|
|||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
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 { Button } from "@/components/ui/button"
|
||||
|
|
@ -12,7 +12,6 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
|||
import { Separator } from "@/components/ui/separator"
|
||||
import { useAuth, signOut } from "@/lib/auth-client"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { FieldsManager } from "@/components/admin/fields/fields-manager"
|
||||
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
|
|
@ -36,6 +35,14 @@ const ROLE_LABELS: Record<string, string> = {
|
|||
}
|
||||
|
||||
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",
|
||||
description: "Controle quem pode atuar nas filas e atribua permissões refinadas por equipe.",
|
||||
|
|
@ -53,10 +60,10 @@ const SETTINGS_ACTIONS: SettingsAction[] = [
|
|||
icon: Share2,
|
||||
},
|
||||
{
|
||||
title: "Campos e categorias",
|
||||
description: "Ajuste categorias, subcategorias e campos personalizados para qualificar tickets.",
|
||||
title: "Categorias e formulários",
|
||||
description: "Mantenha categorias padronizadas e templates de formulário alinhados à operação.",
|
||||
href: "/admin/fields",
|
||||
cta: "Editar estrutura",
|
||||
cta: "Gerenciar categorias",
|
||||
requiredRole: "admin",
|
||||
icon: Layers3,
|
||||
},
|
||||
|
|
@ -271,17 +278,7 @@ export function SettingsContent() {
|
|||
})}
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,16 +216,20 @@ export function NewTicketDialog({
|
|||
) as TicketFormDefinition[] | undefined
|
||||
|
||||
const forms = useMemo<TicketFormDefinition[]>(() => {
|
||||
const base: TicketFormDefinition = {
|
||||
const fallback: TicketFormDefinition = {
|
||||
key: "default",
|
||||
label: "Chamado",
|
||||
description: "Formulário básico para abertura de chamados gerais.",
|
||||
fields: [],
|
||||
}
|
||||
if (Array.isArray(formsRemote) && formsRemote.length > 0) {
|
||||
return [base, ...formsRemote]
|
||||
if (!formsRemote || formsRemote.length === 0) {
|
||||
return [fallback]
|
||||
}
|
||||
return [base]
|
||||
const hasDefault = formsRemote.some((form) => form.key === fallback.key)
|
||||
if (hasDefault) {
|
||||
return formsRemote
|
||||
}
|
||||
return [fallback, ...formsRemote]
|
||||
}, [formsRemote])
|
||||
|
||||
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
|
||||
|
|
@ -531,7 +535,7 @@ export function NewTicketDialog({
|
|||
}
|
||||
|
||||
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
|
||||
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
|
||||
if (selectedForm?.fields?.length) {
|
||||
const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues)
|
||||
if (!normalized.ok) {
|
||||
toast.error(normalized.message, { id: "new-ticket" })
|
||||
|
|
@ -1036,7 +1040,7 @@ export function NewTicketDialog({
|
|||
</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">
|
||||
<p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p>
|
||||
{selectedForm.fields.map((field) => {
|
||||
|
|
|
|||
|
|
@ -256,7 +256,11 @@ export function TicketCustomFieldsSection({ ticket, variant = "card", className
|
|||
if (!formsRemote || formsRemote.length === 0) {
|
||||
return [DEFAULT_FORM]
|
||||
}
|
||||
return formsRemote
|
||||
const hasDefault = formsRemote.some((form) => form.key === DEFAULT_FORM.key)
|
||||
if (hasDefault) {
|
||||
return formsRemote
|
||||
}
|
||||
return [DEFAULT_FORM, ...formsRemote]
|
||||
}, [formsRemote])
|
||||
|
||||
const selectedForm = useMemo<TicketFormDefinition>(() => {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export function SearchableCombobox({
|
|||
aria-controls={listId}
|
||||
disabled={disabled}
|
||||
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,
|
||||
triggerClassName,
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue