From a8658539a71ba88502ff7af01962eb22c39f32ad Mon Sep 17 00:00:00 2001
From: Ruan Bueno
Date: Wed, 17 Jun 2026 11:20:19 -0300
Subject: [PATCH] =?UTF-8?q?feat(forms):=20drag/menu=20no=20Quadro=20V2,=20?=
=?UTF-8?q?redesign=20subp=C3=A1ginas=20do=20cliente=20e=20exporta=C3=A7?=
=?UTF-8?q?=C3=A3o=20XLSX?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Central V2 (Quadro): arrastar entre níveis (portal no body + z-index p/ ficar
por cima e seguir o cursor), menu de contexto no botão direito e toast ao mover
- Menu de contexto: corrige label para "Mover para {próxima etapa}"
- Minhas solicitações: reescrita V2 (KPIs, Lista|Quadro, chips, linhas ricas e
drawer de detalhe reaproveitando ResponseDetails + ResponseChat)
- Raiz do formulário: faixa de info + preview, e barra de ações reposicionada
(gestão à esquerda, solicitante à direita, criar chamado manual abaixo)
- Responder: cabeçalho do painel + estado de sucesso V2
- Catálogo: botão "Ver" leva direto à raiz do formulário
- Fundo de identidade (v2-scope) passa a cobrir o layout inteiro (fixed)
- Exportação de respostas (geral e por usuário) agora gera .xlsx de verdade
Bump 1.43.1 -> 1.46.0
Co-Authored-By: Claude Opus 4.8
---
package.json | 2 +-
.../HANDOFF-subpaginas.md | 89 +++
.../design_handoff_solicitacoes_2/README.md | 113 +++
.../Solicitacoes.html | 300 ++++++++
.../design_handoff_solicitacoes_2/classic.jsx | 140 ++++
.../design_handoff_solicitacoes_2/client.jsx | 245 +++++++
.../design_handoff_solicitacoes_2/data.js | 366 ++++++++++
.../design_handoff_solicitacoes_2/icons.jsx | 67 ++
.../design_handoff_solicitacoes_2/pages.jsx | 662 ++++++++++++++++++
.../design_handoff_solicitacoes_2/shell.jsx | 156 +++++
.../design_handoff_solicitacoes_2/tech.jsx | 376 ++++++++++
src/app/(authenticated)/forms/[id]/page.tsx | 138 +++-
.../forms/[id]/respond/page.tsx | 14 +-
.../kanban/_components/tags-context-menu.tsx | 4 +-
.../forms/my-responses/page.tsx | 11 +
.../create-manual-response-button-wrapper.tsx | 5 +-
.../forms/create-manual-response-button.tsx | 6 +-
src/components/forms/form-response.tsx | 41 +-
.../form-responses-export-by-user-dialog.tsx | 17 +-
.../forms/form-responses-export-dialog.tsx | 17 +-
src/components/forms/user-responses-list.tsx | 630 +++++++++--------
src/components/forms/v2/central-view.tsx | 77 +-
src/components/forms/v2/forms-list-v2.tsx | 23 +-
src/components/forms/v2/virtualized-board.tsx | 281 ++++++--
src/lib/download-file.ts | 19 +
src/lib/form-xlsx-export.ts | 23 +
src/server/api/routers/form-response.ts | 27 +-
src/styles/globals.css | 14 +-
28 files changed, 3405 insertions(+), 458 deletions(-)
create mode 100644 public/handoffs/design_handoff_solicitacoes_2/HANDOFF-subpaginas.md
create mode 100644 public/handoffs/design_handoff_solicitacoes_2/README.md
create mode 100644 public/handoffs/design_handoff_solicitacoes_2/Solicitacoes.html
create mode 100644 public/handoffs/design_handoff_solicitacoes_2/classic.jsx
create mode 100644 public/handoffs/design_handoff_solicitacoes_2/client.jsx
create mode 100644 public/handoffs/design_handoff_solicitacoes_2/data.js
create mode 100644 public/handoffs/design_handoff_solicitacoes_2/icons.jsx
create mode 100644 public/handoffs/design_handoff_solicitacoes_2/pages.jsx
create mode 100644 public/handoffs/design_handoff_solicitacoes_2/shell.jsx
create mode 100644 public/handoffs/design_handoff_solicitacoes_2/tech.jsx
create mode 100644 src/lib/download-file.ts
create mode 100644 src/lib/form-xlsx-export.ts
diff --git a/package.json b/package.json
index d884a405..e62254d4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "elo",
- "version": "1.43.1",
+ "version": "1.46.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/public/handoffs/design_handoff_solicitacoes_2/HANDOFF-subpaginas.md b/public/handoffs/design_handoff_solicitacoes_2/HANDOFF-subpaginas.md
new file mode 100644
index 00000000..be911260
--- /dev/null
+++ b/public/handoffs/design_handoff_solicitacoes_2/HANDOFF-subpaginas.md
@@ -0,0 +1,89 @@
+# Handoff: Sub-páginas do Módulo de Solicitações (elo)
+
+> Complemento ao `README.md` deste pacote. Cobre as **três telas internas** que sucedem o catálogo da Visão do Cliente, todas no mesmo tema (teal da marca, gradiente ambiente vermelho/verde, claro+escuro, Geist):
+>
+> 1. **Raiz do formulário** — `/forms/{slug}`
+> 2. **Responder** — `/forms/{slug}/respond`
+> 3. **Minhas solicitações** — `/forms/my-responses`
+>
+> Estes designs **já têm scaffolding no codebase** (`src/components/forms/v2/`). O objetivo do handoff é fechar o visual/comportamento dessas páginas usando os componentes que já existem.
+
+## Sobre os arquivos de design
+Referências em **HTML/React (Babel inline)** — protótipo, **não** código de produção. Recriar no codebase real (Next.js App Router + shadcn/ui + Tailwind + tRPC + Prisma) reaproveitando componentes existentes. Para rodar: abrir `Solicitacoes.html` no navegador, ligar **"Novo layout"**, **Visão do cliente**, e navegar pelo catálogo.
+
+**Novos arquivos do protótipo nesta entrega:**
+- `pages.jsx` — as três sub-páginas + `SubPageShell` (breadcrumb), `FieldInput` (todos os tipos de campo), drawer de detalhe do cliente.
+- `data.js` — agora inclui `FORM_FIELDS` (definição de campos por formulário, espelhando `form-types.ts`), `CURRENT_USER` e `MY_REQUESTS` enriquecido (status, comentário, dados enviados, conversa).
+- `client.jsx` / `shell.jsx` — navegação cliente (catálogo → raiz / responder / minhas solicitações).
+
+---
+
+## Mapeamento para o codebase
+
+| Protótipo (`pages.jsx`) | Rota real | Componente real |
+|---|---|---|
+| `FormRootPage` | `src/app/(authenticated)/forms/[id]/page.tsx` | `FormsSubPageShell` + `FormsPanel` + `FormPreview` (readOnly) |
+| `RespondPage` / `RespondForm` | `src/app/(authenticated)/forms/[id]/respond/page.tsx` | `FormsSubPageShell` + `FormsPanel` + `FormResponseComponent` |
+| `MyResponsesPage` | `src/app/(authenticated)/forms/my-responses/page.tsx` | `FormsSubPageShell` + `UserResponsesList` |
+| `SubPageShell` + `Breadcrumb` | — | `src/components/forms/v2/forms-sub-page-shell.tsx` (`FormsSubPageShell`, `FormsPanel`) + `useSetBreadcrumbs` (`breadcrumb-context`) |
+| `FieldInput` / `FieldRow` | — | já existe em `form-response.tsx` / `form-preview.tsx` (render por `field.type`) |
+| `RequestDrawer` (detalhe do cliente) | — | reaproveitar `response-details.tsx` + `response-chat.tsx` dentro de um `Sheet` |
+| `MultiCombo` | — | `src/components/forms/multi-select.tsx` |
+| `FORM_FIELDS` (mock) | — | `form.fields` (vem de `api.form.getById`), tipado por `src/lib/form-types.ts` |
+
+> A casca (`FormsSubPageShell`) já publica a trilha de navegação no header global via `useSetBreadcrumbs`. As três páginas só precisam passar o array `breadcrumbs`, `title`, `description` e (quando houver) `actions`.
+
+---
+
+## Tokens / vocabulário (reaproveitar de `globals.css`)
+Mesmos do README principal. Específicos destas telas:
+
+- **Casca:** breadcrumb 12.5px (último item `font-weight:600`, cor `--foreground`); título 28px/700/-0.025em; `description` muted, `max-width: 70ch`; `actions` no topo direito.
+- **Painel (`FormsPanel`):** `rounded-[var(--v2-radius-card)]`, `border hsl(var(--v2-border-soft))`, `bg hsl(var(--card)/.75)`, `shadow-[var(--v2-shadow)]`, `backdrop-blur`, padding 26–28px.
+- **Campos do formulário:** label 13.5px/600 + `*` obrigatório em vermelho (`hsl(0 72% 58%)`); inputs altura 42px, radius 9px, `bg hsl(var(--v2-card-2)/.7)`, foco com borda accent + `box-shadow 0 0 0 3px accent/.12`; erro com borda `hsl(0 72% 55%/.7)` + mensagem 12.5px.
+- **Faixa de info (raiz):** grid de 4 colunas, células `hsl(var(--card)/.9)` separadas por `border-soft`, label uppercase 11px + valor 14px com ícone Lucide.
+- **Pílulas de status (minimalista):** Não iniciado → neutro (cinza); Em andamento → âmbar `hsl(38 92% 55%)`; Concluído → verde `hsl(158 64% 45%)`. (Os mesmos `dot`/tons do README.)
+
+---
+
+## Telas
+
+### 1. Raiz do formulário — `/forms/{slug}`
+**Propósito:** página inicial de um tipo de solicitação — entender do que se trata e abrir uma nova.
+
+- **Casca:** breadcrumb `Home / Solicitações / {título}`; título = `form.title`; descrição = `Criado há {tempo} · {setor}` + `form.description` (via `FormDescription`); ações à direita.
+- **Ações (perfil colaborador):** `Minhas solicitações` (ghost → `/forms/my-responses`) + **`Abrir nova solicitação`** (accent teal → `/forms/{id}/respond`).
+ - *No `page.tsx` real* há também `Respostas` e `Editar` (admin, gated por `canEditForm`) e `CreateManualResponseButtonWrapper`. Mantê-los condicionados à permissão; para o colaborador, exibir só as duas ações acima.
+- **Faixa de info:** Setor responsável · Prazo de resposta · Campos (`form.fields.length`) · Em aberto agora.
+- **Painel:** `FormPreview` (`readOnly`) — título do formulário + **todos os campos desabilitados**; campos `dynamic` já mostram nome/setor do usuário com cadeado "Preenchido automaticamente". Rodapé com `Abrir nova solicitação`.
+
+### 2. Responder — `/forms/{slug}/respond`
+**Propósito:** preencher e enviar a solicitação. Equivale a `FormResponseComponent`.
+
+- **Casca:** breadcrumb `Home / Solicitações / {título} / Responder`; título = `form.title`; descrição = `form.description`.
+- **Painel:** formulário real, um `FieldRow` por campo. Tipos suportados (de `form-types.ts`): `text`, `number`, `textarea`, `combobox` (single = `Select`; multiple = `MultiSelect`/`MultiCombo` com chips), `formatted` (`InputMask` — cpf/cnpj/phone/email), `file` (dropzone com tipos aceitos), `checkbox`, `dynamic` (read-only, preenchido por `api.user.me`).
+- **Validação:** schema Zod dinâmico (já implementado em `form-response.tsx`); obrigatórios com `*` e mensagem "Este campo é obrigatório". Cabeçalho do painel informa nº de campos obrigatórios.
+- **Rodapé:** `Cancelar` (→ raiz do formulário) + **`Enviar solicitação`** (accent, `Send`).
+- **Sucesso (pós-`mutate`):** alerta verde "Solicitação enviada com sucesso!" + `Ver minhas solicitações` (ghost) e `Abrir nova solicitação` (accent → `reset` do form). Espelha o estado `isSubmitted` do componente real.
+
+### 3. Minhas solicitações — `/forms/my-responses`
+**Propósito:** acompanhar todas as próprias solicitações. Evolução visual de `UserResponsesList` para o vocabulário v2.
+
+- **Casca:** breadcrumb `Home / Solicitações / Minhas solicitações`; título "Minhas solicitações"; descrição "Visualize e acompanhe o status…"; ação `Nova solicitação` (→ catálogo).
+- **KPIs discretos:** Total · Em aberto · Aguardando você (âmbar) · Concluídas (accent).
+- **Toolbar:** busca (nº / formulário) + segmented **`Lista | Quadro`** (substitui o `Tabs` "Lista com Filtros / Kanban" do componente atual).
+- **Lista:** chips de filtro por status (`Todas / Não iniciadas / Em andamento / Concluídas`) + linhas ricas: `#id` (mono), pílula de status, badge "Aguardando você", título do formulário, **comentário de status** (`statusComment`), meta (`setor · Enviada {idade} · Responsável`). Linha inteira clicável.
+- **Quadro:** 3 colunas (Não iniciado / Em progresso / Concluído) com contagem; cards compactos. (No real, manter o `DragDropContext`/`KanbanColumn`, **bloqueando** o arraste da própria solicitação — regra já existente.)
+- **Drawer de detalhe (clique numa linha/card):** `#id` + status, título, nota de status, **Dados enviados** (grid de campos), **Conversa** (bolhas: você à direita em accent/.12, setor/técnico à esquerda) e caixa de resposta. Recriar com `Sheet` + `response-details.tsx` + `response-chat.tsx`.
+
+---
+
+## Navegação / estado (cliente)
+- **Catálogo → telas:** "Ver" (linha) → raiz; "Abrir solicitação" (linha) e "Iniciar solicitação" (drawer rápido `FormQuickOpenDrawer`) → responder; "Minhas solicitações" / "Ver todas" / "Abrir o Kanban" → my-responses.
+- **Estado (my-responses):** `view` (lista/quadro), `query`, `filter` (status), `selected` (drawer). Dados de `api.formResponse.listUserResponses`; resposta na conversa via `response-chat`; mudança de status no quadro via `formResponse.updateStatus`.
+- **Campos dinâmicos:** preenchidos por `api.user.me` (`user_name`, `user_sector`) — read-only com cadeado.
+- **Transições:** `fade-in` 0.3s nas trocas de página; drawer desliza da direita 0.26s `cubic-bezier(.2,.8,.2,1)`. *Garantir estado de repouso visível (opacity 1)* para não depender da animação completar.
+- **Responsivo:** faixa de info colapsa para 2 colunas <860px; quadro vira 1 coluna <1000px; toolbar/ações quebram para coluna no mobile.
+
+## Definição de campos (referência)
+`data.js → FORM_FIELDS` traz um conjunto realista por formulário (categoria, urgência, local, descrição, anexo, etc.), apenas para o protótipo. **Em produção os campos vêm de `form.fields`** (montados no Form Builder e persistidos no Prisma); o renderer deve ser agnóstico ao conteúdo e dirigido por `field.type`.
diff --git a/public/handoffs/design_handoff_solicitacoes_2/README.md b/public/handoffs/design_handoff_solicitacoes_2/README.md
new file mode 100644
index 00000000..632dae02
--- /dev/null
+++ b/public/handoffs/design_handoff_solicitacoes_2/README.md
@@ -0,0 +1,113 @@
+# Handoff: Redesign do Módulo de Solicitações (elo)
+
+## Visão geral
+Redesign do módulo **Solicitações** da intranet *elo* (Grupo R Henz), com **duas visões distintas** e um **toggle "Novo layout"** que troca o visual da página por usuário (preferência individual, sem afetar os demais):
+
+- **Visão do Cliente** — catálogo limpo para o colaborador escolher um tipo de solicitação e abrir um chamado, além de acompanhar as próprias solicitações. Inspiração: Zendesk (limpo, corporativo).
+- **Visão do Técnico** — workspace resolutivo para atender chamados: **Fila + Detalhe** lado a lado e um **Quadro** (kanban) refinado como visão alternativa.
+
+Mantém: marca **elo**, fonte **Geist**, gradiente ambiente vermelho/verde como identidade, tema **claro e escuro**, iconografia minimalista (Lucide).
+
+## Sobre os arquivos de design
+Os arquivos deste pacote são **referências de design feitas em HTML/React (Babel inline)** — protótipos que mostram aparência e comportamento pretendidos, **não código de produção para copiar**. A tarefa é **recriar estes designs no codebase real** (Next.js App Router + React + shadcn/ui + Tailwind + tRPC + Prisma), reutilizando os componentes e padrões já existentes.
+
+## Fidelidade
+**Alta fidelidade (hi-fi).** Cores, tipografia, espaçamento e interações são finais. Recriar pixel a pixel usando a biblioteca de UI existente (shadcn/ui) e os tokens do `globals.css`.
+
+---
+
+## Mapeamento para o codebase atual
+
+| Protótipo | Componente real a alterar/criar |
+|---|---|
+| Shell (sidebar + topbar + toggle de tema) | já existe — apenas adicionar o botão **"Novo layout"** na topbar e o contexto de preferência |
+| **Visão do Cliente** (catálogo) | `src/components/forms/forms-list.tsx` |
+| Drawer "abrir solicitação" | novo: `src/components/forms/form-quick-open-drawer.tsx` (usar `Sheet` do shadcn) |
+| **Visão do Técnico — Fila+Detalhe** | nova página/visão sobre `src/components/forms/responses-list.tsx` + `response-details.tsx` + `response-chat.tsx` + `status-update-button.tsx` |
+| **Visão do Técnico — Quadro** | evolução do kanban atual de `/forms` (Kanban de Solicitações) |
+| Filtros | reaproveitar `responses-filters.tsx`, `setor-search.tsx`, `user-search.tsx` |
+
+### Toggle "Novo layout" (preferência por usuário)
+- Persistir uma flag por usuário: campo `uiLayoutVersion` (`"classic" | "v2"`) no model `User` (Prisma) **ou** preferência leve em `localStorage` para um rollout gradual.
+- Expor um `LayoutPreferenceContext` (client) que lê/escreve a flag; a topbar recebe o botão de toggle (componente ` `).
+- Enquanto `v2` estiver ativo, renderizar os novos componentes; caso contrário, os atuais. Recomendado começar via `localStorage` + feature flag por role para testar com poucos usuários.
+
+---
+
+## Design Tokens
+
+Reaproveitar os tokens HSL já existentes em `src/styles/globals.css` (shadcn). Adições/uso específico do redesign:
+
+```css
+/* Accent da marca (cristal) — usar com parcimônia */
+--accent: 184 100% 33%; /* #00a5a7 teal */
+--accent-foreground: 0 0% 100%;
+
+/* Gradiente ambiente de identidade (camada fixa, pointer-events:none, z-index 0) */
+/* dark: glow-a 0 72% 45% (vermelho), glow-b 158 64% 40% (verde), opacidade .22 */
+/* light: glow-a 0 72% 60%, glow-b 158 54% 50%, opacidade .10 */
+background:
+ radial-gradient(60% 55% at 92% 8%, hsl(var(--glow-a) / var(--glow-op)), transparent 60%),
+ radial-gradient(55% 60% at 6% 96%, hsl(var(--glow-b) / var(--glow-op)), transparent 62%);
+
+/* radius padrão de card no redesign */ --radius-card: 12px;
+```
+
+**Status (tom minimalista):**
+- Não iniciado → neutro (`dot` cinza `hsl(0 0% 55%)`)
+- Em progresso → âmbar `hsl(38 92% 55%)` (texto), fundo `/.1`, borda `/.25`
+- Concluído → verde `hsl(158 64% 45%)` (texto), fundo `/.1`, borda `/.25`
+
+**Prioridade (dots):** alta `hsl(0 72% 55%)` · média `hsl(38 92% 55%)` · baixa `hsl(0 0% 50%)`.
+
+**Tipografia:** Geist (já configurada via `--font-geist-sans`). Geist Mono para números/IDs (`#1159`), SLA e métricas. Título de página 28px/700/-0.025em; títulos de card 15px/600.
+
+**Espaçamento:** container `max-width: 1380px`, padding de conteúdo `30px 34px`. Cards `padding 18px`, `gap 10–18px`, `border 1px hsl(var(--border-soft))`.
+
+---
+
+## Telas / Visões
+
+### 1. Visão do Cliente — Catálogo de Solicitações
+- **Propósito:** colaborador encontra o tipo certo de solicitação, abre um chamado e acompanha os seus.
+- **Layout:** header (título + subtítulo à esquerda; ações `Minhas solicitações` ghost + `Criar formulário` primary à direita). Abaixo, **faixa de KPIs discretos** (números grandes em mono, label pequena): *Em aberto, Aguardando você, Concluídas no mês, Tempo médio de resposta*. Depois, grid `1fr / 320px`:
+ - **Coluna principal:** busca (input com ícone) + **chips** de filtro por setor (Todos, TI, Compras, Marketing, Infraestrutura) + **lista de linhas de catálogo**.
+ - **Trilho direito (sticky):** card "Minhas solicitações" com itens compactos (#id, status pill, título, idade, responsável / "Aguardando você") + botão "Abrir o Kanban".
+- **Linha de catálogo (`.cat-row`):** ícone do setor em quadrado teal `42px` (fundo `accent/.1`, borda `accent/.2`) · título + badge opcional "Mais usado" · descrição (muted, máx ~62ch) · meta (`setor · N campos · �clock Resposta em {SLA}`) · ações à direita (`Ver` ghost + `Abrir solicitação` accent). Hover: borda accent + leve `translateY(-1px)` + sombra.
+- **Drawer (Sheet à direita, 460px):** ao clicar numa linha — ícone+título, descrição, grid de info (Setor responsável / Prazo / Em aberto agora), placeholders dos campos do formulário, footer com `Cancelar` + `Iniciar solicitação`.
+
+### 2. Visão do Técnico — Fila + Detalhe (`view = "fila"`, padrão)
+- **Propósito:** atender e resolver chamados rápido, sem trocar de tela.
+- **Layout:** header (título "Central de chamados" + `Gerenciar Tags` ghost + segmented `Fila | Quadro`). Faixa de KPIs: *Não iniciados, Em progresso, Sem responsável, SLA em risco, Resolvidos hoje*. Abaixo, grid `372px / 1fr`, altura `calc(100vh - 290px)`.
+ - **Fila (esquerda):** busca + tabs (`Todos / Novos / Andamento / Concluídos`) + lista de `.q-row` (dot de prioridade, `#id`, badge "Novo", status à direita, resumo, avatar+solicitante, SLA — vermelho se em risco). Item ativo: borda+fundo accent.
+ - **Detalhe (direita):** header (`#id`, prioridade, formulário, título do chamado; ações: **status changer** com menu, **Assumir chamado** / pílula de responsável, menu `...`). Corpo: 2 cards (Solicitante / SLA), **Dados da solicitação** (grid de campos enviados), **Conversa** (bolhas: solicitante à esquerda neutro, técnico à direita em accent/.12). Rodapé fixo: anexar + input "Responder ao solicitante…" + `Enviar`.
+- **Comportamentos resolutivos:** "Assumir" define responsável = usuário atual e move `NOT_STARTED → IN_PROGRESS`; responder também promove para `IN_PROGRESS`; trocar status via menu (Não iniciado / Em progresso / Concluído).
+
+### 3. Visão do Técnico — Quadro (`view = "quadro"`)
+- Kanban refinado de 3 colunas (Não iniciado / Em progresso / Concluído) com contadores reais (18 / 21 / 746). Cards compactos: dot de prioridade, `#id`, badge "Novo", resumo, solicitante, rodapé com SLA + responsável (ou "Sem responsável" em vermelho). Clicar num card abre o **Detalhe** (volta para a Fila com o item selecionado). Arrastar entre colunas para mudar status é o próximo passo natural.
+
+---
+
+## Interações & Estado
+- **Estado (técnico):** lista de tickets em estado client-side derivada da query tRPC `formResponse.listByForm` (ou uma nova `formResponse.listQueue`); `selectedId`, `view` (fila/quadro), `tab` (status), `query` (busca). Mutations: `status` (já existe `status-update-button`), `assign`, `reply` (usar `response-chat`).
+- **Estado (cliente):** `query`, `categoria` (setor), `drawerForm`.
+- **Tema:** classe `data-theme` / `dark` no `` (já há toggle de tema). Todos os tokens respeitam claro/escuro.
+- **Transições:** fade-in 0.3s nas trocas de visão; drawer desliza da direita 0.26s `cubic-bezier(.2,.8,.2,1)`.
+- **Responsivo:** abaixo de 1100px a fila/detalhe e o grid do cliente colapsam para 1 coluna; sidebar recolhe.
+
+## Assets
+- Logo **elo**: glyph de corrente (ver `public/favicon.svg`) + wordmark "elo".
+- Accent teal `#00a5a7` derivado de `public/icon_cristal.svg`.
+- Ícones: Lucide (já usado no projeto). Sem SVGs desenhados à mão.
+
+## Arquivos neste pacote (referência de design)
+- `Solicitacoes.html` — entrypoint (tokens CSS de claro/escuro, gradiente, fontes, mount React)
+- `shell.jsx` — sidebar + topbar + toggle "Novo layout" + segmented de visão + navegação das sub-páginas
+- `client.jsx` — Visão do Cliente (catálogo + drawer + minhas solicitações)
+- `pages.jsx` — sub-páginas do cliente (raiz do formulário, responder, minhas solicitações) — ver `HANDOFF-subpaginas.md`
+- `tech.jsx` — Visão do Técnico (fila + detalhe + quadro)
+- `classic.jsx` — recriação do visual atual (o "antes" do toggle)
+- `icons.jsx` — ícones Lucide e o glyph da marca
+- `data.js` — mock derivado das telas reais (formulários e chamados)
+
+> Para rodar o protótipo: abrir `Solicitacoes.html` num navegador (sem build). É só referência visual/comportamental.
diff --git a/public/handoffs/design_handoff_solicitacoes_2/Solicitacoes.html b/public/handoffs/design_handoff_solicitacoes_2/Solicitacoes.html
new file mode 100644
index 00000000..97171848
--- /dev/null
+++ b/public/handoffs/design_handoff_solicitacoes_2/Solicitacoes.html
@@ -0,0 +1,300 @@
+
+
+
+
+
+Módulo de Solicitações — elo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/handoffs/design_handoff_solicitacoes_2/classic.jsx b/public/handoffs/design_handoff_solicitacoes_2/classic.jsx
new file mode 100644
index 00000000..93443cc2
--- /dev/null
+++ b/public/handoffs/design_handoff_solicitacoes_2/classic.jsx
@@ -0,0 +1,140 @@
+/* ============================================================
+ VISÃO CLÁSSICA (o "antes") — recriação aproximada das telas atuais
+ ============================================================ */
+
+function ClassicCard({ form }) {
+ return (
+
+
{form.title.toUpperCase()}
+
Criado há cerca de 1 ano
+
+ Editar
+
+
{form.desc}
+
{form.fields} campos
+
+ Ver
+ Abrir nova solicitação
+
+
+ );
+}
+
+function ClassicClient() {
+ return (
+
+
+
+
Solicitações
+
Crie, gerencie e responda solicitações personalizadas.
+
+
+ Minhas solicitações
+ Criar um novo formulário
+
+
+
+ Tutorial
+ Kanban
+
+
+ {window.FORMS.map(f => )}
+
+
+ );
+}
+
+function ClassicKanbanCard({ t }) {
+ return (
+
+
+ #{t.id}
+ {t.form.toUpperCase()}
+ {t.isNew && Novo }
+
+ {t.tag &&
{t.tag} }
+
+ {t.requester}
+
+
+ {t.age}
+
+
Detalhes
+
+ );
+}
+
+function ClassicTech() {
+ const cols = [
+ { key: "NOT_STARTED", label: "Não Iniciado", n: 18, accent: "0 72% 50%" },
+ { key: "IN_PROGRESS", label: "Em Progresso", n: 21, accent: "45 90% 50%" },
+ { key: "COMPLETED", label: "Concluído", n: 746, accent: "158 64% 42%" },
+ ];
+ return (
+
+
+
+
Kanban de Solicitações
+
Visualize e organize as respostas recebidas nos seus formulários.
+
+
Gerenciar Tags
+
+
+
+
+ Filtros Avançados
+
+
+ {["Número", "Solicitante", "Setor", "Formulário", "Status", "Prioridade", "Tags", "Período"].map(f => (
+
+ ))}
+
+
+
+
+ {cols.map(c => (
+
+
{c.label} ({c.n})
+
+ {window.TICKETS.filter(t => t.status === c.key).map(t => )}
+
+
+ ))}
+
+
+ );
+}
+
+function ClassicView({ role, setRole }) {
+ return (
+
+
+
+
+
+ {role === "client" ?
:
}
+
+ );
+}
+
+const classicCss = `
+.cl-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
+@media (max-width: 1000px) { .cl-grid { grid-template-columns: 1fr 1fr; } }
+@media (max-width: 700px) { .cl-grid { grid-template-columns: 1fr; } }
+.cl-card { display: flex; flex-direction: column; padding: 20px; border-radius: var(--radius); background: hsl(var(--card) / .85); border: 1px solid hsl(var(--border-soft)); min-height: 290px; }
+.cl-card-title { font-size: 15px; font-weight: 700; line-height: 1.3; letter-spacing: -.01em; }
+.cl-filters { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
+@media (max-width: 900px) { .cl-filters { grid-template-columns: 1fr 1fr; } }
+.cl-input { height: 38px; border-radius: 9px; background: hsl(var(--card-2)); border: 1px solid hsl(var(--border-soft)); }
+.clk-board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; align-items: start; }
+@media (max-width: 1000px) { .clk-board { grid-template-columns: 1fr; } }
+.clk-col { background: hsl(var(--card) / .5); border: 1px solid hsl(var(--border-soft)); border-radius: var(--radius); padding: 14px; }
+.clk-col-head { font-size: 16px; font-weight: 700; margin-bottom: 14px; }
+.clk-card { padding: 13px; border-radius: 10px; background: hsl(var(--card)); border: 1px solid hsl(var(--border-soft)); }
+.clk-tag { display: inline-block; margin-top: 8px; font-size: 11px; font-weight: 600; color: #fff; background: linear-gradient(90deg, hsl(330 80% 55%), hsl(280 70% 55%)); padding: 2px 10px; border-radius: 99px; }
+`;
+
+window.ClassicView = ClassicView;
diff --git a/public/handoffs/design_handoff_solicitacoes_2/client.jsx b/public/handoffs/design_handoff_solicitacoes_2/client.jsx
new file mode 100644
index 00000000..b54144d8
--- /dev/null
+++ b/public/handoffs/design_handoff_solicitacoes_2/client.jsx
@@ -0,0 +1,245 @@
+/* ============================================================
+ VISÃO DO CLIENTE — catálogo de solicitações (estilo limpo)
+ ============================================================ */
+
+const SECTOR_ICON = {
+ "TI": I.admin, "Compras": I.cart, "Marketing": I.mega,
+ "Infraestrutura": I.settings, "Marketing ": I.mega,
+};
+
+function statusPill(status, small) {
+ const m = window.STATUS_META[status];
+ return (
+
+ {m.short}
+
+ );
+}
+
+function CatalogRow({ form, onOpen, nav }) {
+ const Icon = SECTOR_ICON[form.sector] || I.form;
+ return (
+ onOpen(form)}>
+
+
+
+ {form.title}
+ {form.popular && Mais usado }
+
+
{form.desc}
+
+ {form.sector}
+
+ {form.fields} campos
+
+ Resposta em {form.sla}
+
+
+
+ { e.stopPropagation(); nav.form(form.id); }}>
+ Ver
+
+ { e.stopPropagation(); nav.respond(form.id); }}>
+ Abrir solicitação
+
+
+
+ );
+}
+
+function MyRequestCard({ r }) {
+ return (
+
+
+ #{r.id}
+ {statusPill(r.status, true)}
+
+
{r.form}
+
+ {r.age}
+ {r.waiting
+ ? Aguardando você
+ : {r.assignee ?? "Sem responsável"} }
+
+
+ );
+}
+
+function Drawer({ form, onClose, nav }) {
+ if (!form) return null;
+ const Icon = SECTOR_ICON[form.sector] || I.form;
+ return (
+
+
e.stopPropagation()}>
+
+
+
+
{form.title}
+
+ {form.sector} {form.fields} campos
+
+
+
+
+
{form.desc}
+
+
+
Setor responsável {form.sector}
+
Prazo de resposta {form.sla}
+
Em aberto agora {form.open} solicitações
+
+
+
+
Campos do formulário
+
+ {Array.from({ length: Math.min(form.fields, 5) }).map((_, i) => (
+
+ ))}
+ {form.fields > 5 &&
+ {form.fields - 5} outros campos }
+
+
+
+ Cancelar
+ { onClose(); nav.respond(form.id); }}>
+ Iniciar solicitação
+
+
+
+
+ );
+}
+
+function ClientView({ nav }) {
+ const [q, setQ] = React.useState("");
+ const [cat, setCat] = React.useState("Todos");
+ const [drawer, setDrawer] = React.useState(null);
+ const cats = ["Todos", "TI", "Compras", "Marketing", "Infraestrutura"];
+
+ const forms = window.FORMS.filter(f =>
+ (cat === "Todos" || f.sector === cat) &&
+ (q === "" || f.title.toLowerCase().includes(q.toLowerCase()) || f.desc.toLowerCase().includes(q.toLowerCase()))
+ );
+
+ return (
+
+
+
+ {/* header */}
+
+
+
Solicitações
+
Escolha um tipo de solicitação e abra um chamado para o setor responsável.
+
+
+ Minhas solicitações
+ Criar formulário
+
+
+
+ {/* KPIs discretos */}
+
+
1 Em aberto
+
1 Aguardando você
+
2 Concluídas no mês
+
~6h Tempo médio de resposta
+
+
+
+ {/* main: catalog */}
+
+
+
+
+ setQ(e.target.value)} />
+
+
+
+ {cats.map(c => (
+ setCat(c)}>{c}
+ ))}
+
+
+
+ {forms.map(f =>
)}
+ {forms.length === 0 && (
+
+
+
Nenhum tipo encontrado para “{q}”.
+
+ )}
+
+
+
+ {/* rail: my requests */}
+
+
+
+
setDrawer(null)} nav={nav} />
+
+ );
+}
+
+const clientCss = `
+.cv-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
+.cv-grid { display: grid; grid-template-columns: 1fr 320px; gap: 24px; align-items: start; }
+@media (max-width: 1100px) { .cv-grid { grid-template-columns: 1fr; } }
+.chips { display: flex; gap: 8px; flex-wrap: wrap; }
+.cat-list { display: flex; flex-direction: column; gap: 10px; }
+.cat-row {
+ display: flex; align-items: center; gap: 16px; padding: 18px;
+ background: hsl(var(--card) / .9); border: 1px solid hsl(var(--border-soft));
+ border-radius: var(--radius); cursor: pointer; transition: all .16s; backdrop-filter: blur(6px);
+}
+.cat-row:hover { border-color: hsl(var(--accent) / .4); transform: translateY(-1px); box-shadow: var(--shadow); }
+.cat-ic {
+ width: 42px; height: 42px; border-radius: 11px; flex-shrink: 0;
+ display: grid; place-items: center; color: hsl(var(--accent));
+ background: hsl(var(--accent) / .1); border: 1px solid hsl(var(--accent) / .2);
+}
+.cat-title { font-size: 15px; font-weight: 600; letter-spacing: -.01em; }
+.cat-desc { color: hsl(var(--muted)); font-size: 13px; margin: 4px 0 8px; line-height: 1.5; max-width: 62ch; }
+.cat-meta { display: flex; align-items: center; gap: 9px; font-size: 12px; color: hsl(var(--faint)); }
+.dotsep { width: 3px; height: 3px; border-radius: 50%; background: hsl(var(--faint)); }
+.cat-actions { display: flex; gap: 8px; flex-shrink: 0; }
+@media (max-width: 720px) { .cat-actions { display: none; } }
+
+.myreq {
+ padding: 12px 13px; border-radius: 10px; border: 1px solid hsl(var(--border-soft));
+ background: hsl(var(--card-2) / .6); display: flex; flex-direction: column; gap: 7px; cursor: pointer; transition: border-color .15s;
+}
+.myreq:hover { border-color: hsl(var(--border)); }
+.myreq-title { font-size: 13px; font-weight: 500; line-height: 1.35; }
+.link-btn { font-size: 12.5px; color: hsl(var(--accent)); font-weight: 600; }
+
+/* drawer */
+.drawer-wrap { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.45); display: flex; justify-content: flex-end; }
+.drawer { width: 460px; max-width: 92vw; height: 100%; overflow-y: auto; background: hsl(var(--panel)); border-left: 1px solid hsl(var(--border)); padding: 24px; }
+.fade-in-r { animation: slideR .26s cubic-bezier(.2,.8,.2,1); }
+@keyframes slideR { from { transform: translateX(40px); opacity: .4; } to { transform: none; opacity: 1; } }
+.drawer-info { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; padding: 16px; border-radius: 11px; background: hsl(var(--card-2) / .7); border: 1px solid hsl(var(--border-soft)); }
+.drawer-info > div { display: flex; flex-direction: column; gap: 3px; }
+.drawer-info span.faint { font-size: 11px; }
+.drawer-info strong { font-size: 13.5px; }
+.field-skel { display: flex; flex-direction: column; gap: 6px; }
+.fs-label { height: 9px; border-radius: 4px; background: hsl(var(--border)); }
+.fs-input { height: 36px; border-radius: 8px; background: hsl(var(--card-2)); border: 1px solid hsl(var(--border-soft)); }
+.drawer-foot { display: flex; gap: 10px; margin-top: 24px; padding-top: 18px; border-top: 1px solid hsl(var(--border-soft)); }
+`;
+
+window.ClientView = ClientView;
+window.statusPill = statusPill;
diff --git a/public/handoffs/design_handoff_solicitacoes_2/data.js b/public/handoffs/design_handoff_solicitacoes_2/data.js
new file mode 100644
index 00000000..977b4114
--- /dev/null
+++ b/public/handoffs/design_handoff_solicitacoes_2/data.js
@@ -0,0 +1,366 @@
+/* ============================================================
+ Mock data — Módulo de Solicitações (Grupo R Henz / elo)
+ Derivado das telas reais. Não é dado de produção.
+ ============================================================ */
+
+// Catálogo de tipos de solicitação (visão cliente)
+window.FORMS = [
+ {
+ id: "mkt",
+ title: "Solicitação ao Marketing",
+ sector: "Marketing",
+ fields: 7,
+ sla: "2 dias úteis",
+ open: 4,
+ desc: "Solicitações diversas ao MKT: adesivo, placa, organização de eventos, etc.",
+ },
+ {
+ id: "material",
+ title: "Material de uso interno",
+ sector: "Compras",
+ fields: 9,
+ sla: "3 dias úteis",
+ open: 9,
+ desc: "Compra de materiais para uso e consumo interno: escritório, limpeza, EPIs, eletrônicos, embalagens.",
+ },
+ {
+ id: "tec",
+ title: "Implementação de tecnologia",
+ sector: "TI",
+ fields: 7,
+ sla: "5 dias úteis",
+ open: 2,
+ desc: "Solicitações para implementação de aplicativos de desenvolvimento interno ou de fornecedores externos.",
+ },
+ {
+ id: "infra",
+ title: "Manutenção e/ou infraestrutura",
+ sector: "Infraestrutura",
+ fields: 9,
+ sla: "2 dias úteis",
+ open: 6,
+ desc: "Manutenção predial, elétrica, hidráulica e conserto de equipamentos.",
+ },
+ {
+ id: "chamado-ti",
+ title: "Chamado de atendimento de TI",
+ sector: "TI",
+ fields: 7,
+ sla: "4 horas",
+ open: 18,
+ desc: "Atendimento de TI: internet, cabeamento, depósito, recebimento e setor de garantia.",
+ popular: true,
+ },
+ {
+ id: "usuarios",
+ title: "Usuários SIGIN / Windows / E-mails",
+ sector: "TI",
+ fields: 7,
+ sla: "1 dia útil",
+ open: 3,
+ desc: "Criação e ajuste de contas de usuário: SIGIN, Windows e e-mail corporativo.",
+ },
+];
+
+// Usuário logado (visão cliente)
+window.CURRENT_USER = { name: "Rafael Müller", sector: "Comercial", initials: "RM" };
+
+// Solicitações do próprio usuário (visão cliente)
+window.MY_REQUESTS = [
+ {
+ id: 1162, formId: "chamado-ti", form: "Chamado de atendimento de TI", sector: "TI",
+ status: "IN_PROGRESS", assignee: "Equipe Dev", age: "há 3 horas", updated: "há 40 min", waiting: false,
+ statusComment: "Técnico a caminho do galpão para verificar o ponto de rede.",
+ fieldsData: [
+ ["Categoria do atendimento", "Internet / Rede"],
+ ["Local / estação", "Galpão 2 — baia 4"],
+ ["Urgência", "Alta — operação parada"],
+ ["Descrição", "O computador do recebimento parou de conectar na rede via cabo hoje cedo."],
+ ],
+ thread: [
+ { who: "Rafael Müller", role: "Você", time: "08:12", msg: "Bom dia, o computador do recebimento parou de conectar na rede hoje cedo." },
+ { who: "Equipe Dev", role: "Técnico", time: "08:40", msg: "Recebido, Rafael. Já estamos indo até o galpão verificar o cabeamento." },
+ ],
+ },
+ {
+ id: 1149, formId: "material", form: "Material de uso interno", sector: "Compras",
+ status: "NOT_STARTED", assignee: null, age: "há 1 dia", updated: "há 1 dia", waiting: true,
+ statusComment: "Aguardando aprovação do centro de custo informado.",
+ fieldsData: [
+ ["Categoria", "Escritório"],
+ ["Item", "Papel sulfite A4 75g"],
+ ["Quantidade", "5 resmas"],
+ ["Centro de custo", "4.01 — Administrativo"],
+ ],
+ thread: [
+ { who: "Rafael Müller", role: "Você", time: "Ontem 09:20", msg: "Acabou o papel A4 no financeiro, preciso de reposição." },
+ { who: "Compras", role: "Setor", time: "Ontem 10:05", msg: "Para liberar precisamos confirmar o centro de custo. Pode validar com a gestão?" },
+ ],
+ },
+ {
+ id: 1131, formId: "mkt", form: "Solicitação ao Marketing", sector: "Marketing",
+ status: "COMPLETED", assignee: "Caroline Doege", age: "há 4 dias", updated: "há 2 dias", waiting: false,
+ statusComment: "Arte final entregue na pasta compartilhada do setor.",
+ fieldsData: [
+ ["Tipo de material", "Placa / sinalização"],
+ ["Título da peça", "Sinalização de doca — Expedição"],
+ ["Prazo desejado", "30/05/2026"],
+ ],
+ thread: [
+ { who: "Rafael Müller", role: "Você", time: "12/05 14:02", msg: "Preciso de uma placa de sinalização nova para a doca de expedição." },
+ { who: "Caroline Doege", role: "Marketing", time: "13/05 16:30", msg: "Arte aprovada e enviada para impressão. Arquivo final na pasta do setor." },
+ ],
+ },
+ {
+ id: 1175, formId: "infra", form: "Manutenção e/ou infraestrutura", sector: "Infraestrutura",
+ status: "IN_PROGRESS", assignee: "Equipe Manutenção", age: "há 5 horas", updated: "há 2 horas", waiting: false,
+ statusComment: "Peça solicitada ao fornecedor — prazo de 2 dias úteis.",
+ fieldsData: [
+ ["Tipo de serviço", "Elétrica"],
+ ["Local", "Escritório central — sala 3"],
+ ["Urgência", "Média"],
+ ["Descrição", "Tomada da sala 3 sem energia, disjuntor desarmando."],
+ ],
+ thread: [
+ { who: "Rafael Müller", role: "Você", time: "Hoje 09:10", msg: "A tomada da sala 3 está sem energia e o disjuntor desarma sozinho." },
+ { who: "Equipe Manutenção", role: "Técnico", time: "Hoje 11:30", msg: "Identificamos o curto. Peça de reposição já solicitada ao fornecedor." },
+ ],
+ },
+ {
+ id: 1168, formId: "tec", form: "Implementação de tecnologia", sector: "TI",
+ status: "NOT_STARTED", assignee: null, age: "há 2 dias", updated: "há 2 dias", waiting: false,
+ statusComment: "Em análise de viabilidade pela equipe de tecnologia.",
+ fieldsData: [
+ ["Nome do sistema", "Painel de indicadores comerciais"],
+ ["Origem", "Desenvolvimento interno"],
+ ["Setores impactados", "Comercial, Financeiro"],
+ ],
+ thread: [
+ { who: "Rafael Müller", role: "Você", time: "2 dias atrás", msg: "Gostaria de um painel para acompanhar indicadores do comercial em tempo real." },
+ ],
+ },
+ {
+ id: 1098, formId: "usuarios", form: "Usuários SIGIN / Windows / E-mails", sector: "TI",
+ status: "COMPLETED", assignee: "Gilnei Wolf", age: "há 12 dias", updated: "há 11 dias", waiting: false,
+ statusComment: "Usuário criado e credenciais enviadas por e-mail.",
+ fieldsData: [
+ ["Tipo de solicitação", "Criar usuário"],
+ ["Colaborador", "Maria Eduarda Lopes"],
+ ["Acessos", "Windows, E-mail, SIGIN"],
+ ["Data de início", "26/05/2026"],
+ ],
+ thread: [
+ { who: "Rafael Müller", role: "Você", time: "04/06 10:00", msg: "Nova colaboradora começa segunda, precisa dos acessos básicos." },
+ { who: "Gilnei Wolf", role: "Técnico", time: "05/06 16:30", msg: "Usuário criado e credenciais enviadas. Qualquer coisa estou à disposição." },
+ ],
+ },
+];
+
+// Fila de chamados (visão técnico)
+window.TICKETS = [
+ {
+ id: 1159, form: "Chamado de atendimento de TI", sector: "TI", status: "NOT_STARTED",
+ priority: "high", requester: "Jozimar Daniel Haag", initials: "JH", assignee: null,
+ age: "há 17 horas", sla: "Vence em 2h", slaRisk: true, isNew: true,
+ summary: "Computador do recebimento não conecta na rede via cabo.",
+ fieldsData: [
+ ["Setor", "Recebimento — Depósito"],
+ ["Equipamento", "Desktop Dell OptiPlex 3070"],
+ ["Tipo de problema", "Sem internet via cabo"],
+ ["Urgência", "Alta — operação parada"],
+ ["Local", "Galpão 2, baia 4"],
+ ],
+ thread: [
+ { who: "Jozimar Daniel Haag", role: "Solicitante", time: "08:12", msg: "Bom dia, o computador do recebimento parou de conectar na rede hoje cedo." },
+ ],
+ },
+ {
+ id: 1158, form: "Chamado de atendimento de TI", sector: "TI", status: "IN_PROGRESS",
+ priority: "medium", requester: "Luana De Mello H", initials: "LM", assignee: "Equipe Dev",
+ age: "há 19 horas", sla: "Dentro do prazo", slaRisk: false, isNew: true, tag: "Equipe Dev",
+ summary: "Sistema de etiquetas travando ao gerar lote acima de 200 itens.",
+ fieldsData: [
+ ["Setor", "Expedição"],
+ ["Sistema", "Gerador de etiquetas"],
+ ["Comportamento", "Trava ao gerar lote > 200"],
+ ["Frequência", "Diária"],
+ ],
+ thread: [
+ { who: "Luana De Mello H", role: "Solicitante", time: "Ontem 14:02", msg: "O gerador trava quando o lote passa de 200 etiquetas." },
+ { who: "Gilnei Wolf", role: "Equipe Dev", time: "Ontem 15:20", msg: "Conseguimos reproduzir aqui. Investigando o limite de memória do processo." },
+ ],
+ },
+ {
+ id: 1155, form: "Chamado de atendimento de TI", sector: "TI", status: "IN_PROGRESS",
+ priority: "low", requester: "Gilnei Wolf", initials: "GW", assignee: "Gilnei Wolf",
+ age: "há 1 dia", sla: "Dentro do prazo", slaRisk: false, tag: "Equipe Dev",
+ summary: "Solicitação de acesso ao painel de relatórios de vendas.",
+ fieldsData: [
+ ["Setor", "Comercial"],
+ ["Acesso", "Painel de relatórios de vendas"],
+ ["Justificativa", "Acompanhamento de metas mensais"],
+ ],
+ thread: [
+ { who: "Gilnei Wolf", role: "Solicitante", time: "Ontem 09:40", msg: "Preciso de acesso ao painel de relatórios para acompanhar metas." },
+ ],
+ },
+ {
+ id: 1156, form: "Usuários SIGIN / Windows / E-mails", sector: "TI", status: "COMPLETED",
+ priority: "medium", requester: "Caroline Doege", initials: "CD", assignee: "Caroline Doege",
+ age: "há 22 horas", sla: "Concluído", slaRisk: false, isNew: true,
+ summary: "Criação de usuário Windows e e-mail para nova colaboradora.",
+ fieldsData: [
+ ["Colaborador", "Maria Eduarda Lopes"],
+ ["Setor", "Financeiro"],
+ ["Acessos", "Windows, E-mail, SIGIN"],
+ ["Data de início", "26/05/2026"],
+ ],
+ thread: [
+ { who: "Caroline Doege", role: "Solicitante", time: "Ontem 10:00", msg: "Nova colaboradora começa segunda, precisa dos acessos." },
+ { who: "Equipe Dev", role: "Técnico", time: "Ontem 16:30", msg: "Usuário criado e credenciais enviadas. Chamado concluído." },
+ ],
+ },
+ {
+ id: 1154, form: "Chamado de atendimento de TI", sector: "TI", status: "COMPLETED",
+ priority: "low", requester: "Caroline Doege", initials: "CD", assignee: "Caroline Doege",
+ age: "há 1 dia", sla: "Concluído", slaRisk: false,
+ summary: "Impressora do RH sem comunicação com o servidor de impressão.",
+ fieldsData: [
+ ["Setor", "RH"],
+ ["Equipamento", "Impressora HP LaserJet"],
+ ["Problema", "Sem comunicação com servidor"],
+ ],
+ thread: [
+ { who: "Caroline Doege", role: "Solicitante", time: "2 dias atrás", msg: "A impressora do RH parou de imprimir." },
+ { who: "Gilnei Wolf", role: "Técnico", time: "Ontem 11:15", msg: "Reinstalado driver e fila de impressão. Funcionando." },
+ ],
+ },
+ {
+ id: 1152, form: "Chamado de atendimento de TI", sector: "TI", status: "NOT_STARTED",
+ priority: "medium", requester: "Lenilson Fonseca", initials: "LF", assignee: null,
+ age: "há 1 dia", sla: "Vence em 6h", slaRisk: false,
+ summary: "Telefone IP do setor de garantia sem linha.",
+ fieldsData: [
+ ["Setor", "Garantia"],
+ ["Equipamento", "Telefone IP Grandstream"],
+ ["Problema", "Sem linha / sem tom"],
+ ],
+ thread: [
+ { who: "Lenilson Fonseca", role: "Solicitante", time: "Ontem 13:50", msg: "O telefone da garantia está sem linha desde ontem." },
+ ],
+ },
+ {
+ id: 1145, form: "Material de uso interno", sector: "Compras", status: "NOT_STARTED",
+ priority: "low", requester: "Ana Beatriz Kunz", initials: "AK", assignee: null,
+ age: "há 2 dias", sla: "Vence em 1 dia", slaRisk: false,
+ summary: "Reposição de material de escritório para o setor financeiro.",
+ fieldsData: [
+ ["Setor", "Financeiro"],
+ ["Itens", "Papel A4, canetas, grampeador"],
+ ["Quantidade", "5 resmas, 2 cx canetas"],
+ ],
+ thread: [
+ { who: "Ana Beatriz Kunz", role: "Solicitante", time: "2 dias atrás", msg: "Acabou o papel A4 no financeiro." },
+ ],
+ },
+];
+
+// Contadores de coluna (do kanban real)
+window.BOARD_COUNTS = { NOT_STARTED: 18, IN_PROGRESS: 21, COMPLETED: 746 };
+
+window.STATUS_META = {
+ NOT_STARTED: { label: "Não iniciado", short: "Não iniciado", tone: "neutral" },
+ IN_PROGRESS: { label: "Em progresso", short: "Em progresso", tone: "active" },
+ COMPLETED: { label: "Concluído", short: "Concluído", tone: "done" },
+};
+
+window.PRIORITY_META = {
+ high: { label: "Alta", tone: "high" },
+ medium: { label: "Média", tone: "medium" },
+ low: { label: "Baixa", tone: "low" },
+};
+
+/* ============================================================
+ Definição dos campos de cada formulário (visão responder)
+ Espelha src/lib/form-types.ts:
+ text | number | checkbox | formatted | combobox | file | textarea | dynamic
+ ============================================================ */
+const opt = (arr) => arr.map((v) => (typeof v === "string" ? { label: v, value: v } : v));
+
+window.FORM_FIELDS = {
+ "chamado-ti": [
+ { name: "solicitante", type: "dynamic", dynamicType: "user_name", label: "Solicitante" },
+ { name: "setor", type: "dynamic", dynamicType: "user_sector", label: "Setor" },
+ { name: "categoria", type: "combobox", label: "Categoria do atendimento", required: true,
+ placeholder: "Selecione a categoria",
+ options: opt(["Internet / Rede", "Cabeamento", "Depósito", "Recebimento", "Setor de garantia", "Outro"]) },
+ { name: "local", type: "text", label: "Local / estação de trabalho", required: true, placeholder: "Ex.: Galpão 2 — baia 4" },
+ { name: "urgencia", type: "combobox", label: "Urgência", required: true, placeholder: "Selecione a urgência",
+ options: opt(["Baixa", "Média", "Alta — operação parada"]) },
+ { name: "descricao", type: "textarea", label: "Descreva o problema", required: true, rows: 4,
+ placeholder: "Conte o que está acontecendo, desde quando, e o que já tentou.",
+ helpText: "Quanto mais detalhes, mais rápido conseguimos resolver." },
+ { name: "anexo", type: "file", label: "Anexar foto ou print", acceptedFileTypes: ".pdf,.jpg,.png", multipleFiles: true },
+ ],
+ mkt: [
+ { name: "solicitante", type: "dynamic", dynamicType: "user_name", label: "Solicitante" },
+ { name: "setor", type: "dynamic", dynamicType: "user_sector", label: "Setor" },
+ { name: "tipo", type: "combobox", label: "Tipo de material", required: true, placeholder: "Selecione o tipo",
+ options: opt(["Adesivo", "Placa / sinalização", "Organização de evento", "Brinde", "Arte para redes", "Outro"]) },
+ { name: "titulo", type: "text", label: "Título da peça / evento", required: true, placeholder: "Ex.: Sinalização da nova doca" },
+ { name: "briefing", type: "textarea", label: "Briefing", required: true, rows: 4,
+ placeholder: "Objetivo, público, mensagem principal, formato e medidas." },
+ { name: "prazo", type: "text", label: "Prazo desejado", required: true, placeholder: "dd/mm/aaaa" },
+ { name: "referencias", type: "file", label: "Referências (opcional)", acceptedFileTypes: ".pdf,.jpg,.png", multipleFiles: true },
+ ],
+ material: [
+ { name: "solicitante", type: "dynamic", dynamicType: "user_name", label: "Solicitante" },
+ { name: "setor", type: "dynamic", dynamicType: "user_sector", label: "Setor" },
+ { name: "categoria", type: "combobox", label: "Categoria", required: true, placeholder: "Selecione a categoria",
+ options: opt(["Escritório", "Limpeza", "EPIs", "Eletrônicos", "Embalagens"]) },
+ { name: "item", type: "text", label: "Item", required: true, placeholder: "Ex.: Papel sulfite A4 75g" },
+ { name: "quantidade", type: "number", label: "Quantidade", required: true, min: 1, placeholder: "0" },
+ { name: "unidade", type: "combobox", label: "Unidade", required: true, placeholder: "Selecione a unidade",
+ options: opt(["Unidade", "Caixa", "Pacote", "Resma", "Litro", "Par"]) },
+ { name: "centro", type: "text", label: "Centro de custo", placeholder: "Ex.: 4.01 — Administrativo" },
+ { name: "justificativa", type: "textarea", label: "Justificativa", rows: 3, placeholder: "Para que será usado este material?" },
+ { name: "urgente", type: "checkbox", label: "Compra urgente", placeholder: "Marcar como urgente (ruptura de estoque)" },
+ ],
+ tec: [
+ { name: "solicitante", type: "dynamic", dynamicType: "user_name", label: "Solicitante" },
+ { name: "setor", type: "dynamic", dynamicType: "user_sector", label: "Setor" },
+ { name: "nome", type: "text", label: "Nome do app / sistema", required: true, placeholder: "Como o sistema deve se chamar?" },
+ { name: "origem", type: "combobox", label: "Origem", required: true, placeholder: "Selecione a origem",
+ options: opt(["Desenvolvimento interno", "Fornecedor externo", "Ainda não definido"]) },
+ { name: "objetivo", type: "textarea", label: "Objetivo da implementação", required: true, rows: 4,
+ placeholder: "Qual problema esse sistema resolve? Quem vai usar?" },
+ { name: "setores", type: "combobox", label: "Setores impactados", multiple: true, placeholder: "Selecione os setores",
+ options: opt(["TI", "Comercial", "Financeiro", "Expedição", "RH", "Compras", "Marketing"]) },
+ { name: "meta", type: "text", label: "Prazo / meta", placeholder: "dd/mm/aaaa" },
+ ],
+ infra: [
+ { name: "solicitante", type: "dynamic", dynamicType: "user_name", label: "Solicitante" },
+ { name: "setor", type: "dynamic", dynamicType: "user_sector", label: "Setor" },
+ { name: "tipo", type: "combobox", label: "Tipo de serviço", required: true, placeholder: "Selecione o tipo",
+ options: opt(["Predial", "Elétrica", "Hidráulica", "Conserto de equipamento", "Outro"]) },
+ { name: "local", type: "text", label: "Local", required: true, placeholder: "Unidade / setor / sala" },
+ { name: "urgencia", type: "combobox", label: "Urgência", required: true, placeholder: "Selecione a urgência",
+ options: opt(["Baixa", "Média", "Alta"]) },
+ { name: "descricao", type: "textarea", label: "Descrição do problema", required: true, rows: 4,
+ placeholder: "Descreva o que precisa de manutenção." },
+ { name: "ramal", type: "formatted", formattedType: "phone", label: "Ramal / telefone para contato", placeholder: "(00) 00000-0000" },
+ { name: "risco", type: "checkbox", label: "Risco à segurança", placeholder: "Há risco à segurança das pessoas" },
+ { name: "foto", type: "file", label: "Foto do local (opcional)", acceptedFileTypes: ".jpg,.png", multipleFiles: true },
+ ],
+ usuarios: [
+ { name: "solicitante", type: "dynamic", dynamicType: "user_name", label: "Solicitante" },
+ { name: "tipo", type: "combobox", label: "Tipo de solicitação", required: true, placeholder: "Selecione o tipo",
+ options: opt(["Criar usuário", "Ajustar acessos", "Desativar usuário", "Redefinir senha"]) },
+ { name: "colaborador", type: "text", label: "Nome do colaborador", required: true, placeholder: "Nome completo" },
+ { name: "cargo", type: "text", label: "Cargo / função", required: true, placeholder: "Ex.: Assistente financeiro" },
+ { name: "acessos", type: "combobox", label: "Acessos necessários", multiple: true, placeholder: "Selecione os acessos",
+ options: opt(["SIGIN", "Windows", "E-mail corporativo", "VPN"]) },
+ { name: "setorColab", type: "text", label: "Setor do colaborador", required: true, placeholder: "Ex.: Financeiro" },
+ { name: "inicio", type: "text", label: "Data de início", placeholder: "dd/mm/aaaa" },
+ ],
+};
diff --git a/public/handoffs/design_handoff_solicitacoes_2/icons.jsx b/public/handoffs/design_handoff_solicitacoes_2/icons.jsx
new file mode 100644
index 00000000..8f55235e
--- /dev/null
+++ b/public/handoffs/design_handoff_solicitacoes_2/icons.jsx
@@ -0,0 +1,67 @@
+/* Ícones minimalistas (estilo Lucide), stroke currentColor */
+const Ic = ({ d, size = 18, sw = 2, fill = "none", children, style }) => (
+
+ {d ? : children}
+
+);
+
+// elo brand glyph (chain link, rotated like favicon)
+const EloGlyph = ({ size = 28 }) => (
+
+
+
+
+);
+
+const I = {
+ dash: (p) => >} />,
+ reserve: (p) => >} />,
+ mega: (p) => ,
+ file: (p) => >} />,
+ bulb: (p) => >} />,
+ form: (p) => >} />,
+ cart: (p) => >} />,
+ phone: (p) => ,
+ admin: (p) => >} />,
+ spark: (p) => ,
+ sun: (p) => >} />,
+ moon: (p) => ,
+ menu: (p) => >} />,
+ chev: (p) => ,
+ chevDown: (p) => ,
+ eye: (p) => >} />,
+ edit: (p) => >} />,
+ plus: (p) => >} />,
+ search: (p) => >} />,
+ msg: (p) => ,
+ tag: (p) => >} />,
+ filter: (p) => ,
+ clock: (p) => >} />,
+ user: (p) => >} />,
+ users: (p) => >} />,
+ check: (p) => ,
+ checkCircle: (p) => >} />,
+ arrowRight: (p) => ,
+ layout: (p) => >} />,
+ board: (p) => >} />,
+ list: (p) => >} />,
+ send: (p) => ,
+ alert: (p) => >} />,
+ inbox: (p) => >} />,
+ bolt: (p) => ,
+ paperclip: (p) => ,
+ more: (p) => >} />,
+ settings: (p) => >} />,
+ hash: (p) => >} />,
+ calendar: (p) => >} />,
+ trend: (p) => >} />,
+ arrowLeft: (p) => ,
+ lock: (p) => >} />,
+ building: (p) => >} />,
+ home: (p) => >} />,
+ refresh: (p) => >} />,
+};
+
+Object.assign(window, { Ic, I, EloGlyph });
diff --git a/public/handoffs/design_handoff_solicitacoes_2/pages.jsx b/public/handoffs/design_handoff_solicitacoes_2/pages.jsx
new file mode 100644
index 00000000..fbd0ffdd
--- /dev/null
+++ b/public/handoffs/design_handoff_solicitacoes_2/pages.jsx
@@ -0,0 +1,662 @@
+/* ============================================================
+ SUB-PÁGINAS DO MÓDULO (visão cliente)
+ - FormRootPage → /forms/{slug}
+ - RespondPage → /forms/{slug}/responder
+ - MyResponsesPage→ /forms/minhas-solicitacoes
+ Espelha FormsSubPageShell + FormResponseComponent + UserResponsesList
+ ============================================================ */
+
+/* ---------- trilha de navegação (breadcrumb) ---------- */
+function Breadcrumb({ items }) {
+ return (
+
+ {items.map((it, i) => {
+ const last = i === items.length - 1;
+ return (
+
+ {it.onClick && !last
+ ? {it.label}
+ : {it.label} }
+ {!last && }
+
+ );
+ })}
+
+ );
+}
+
+/* ---------- casca padrão das sub-páginas ---------- */
+function SubPageShell({ breadcrumbs, backFn, title, titlePrefix, description, actions, children }) {
+ return (
+
+
+ {breadcrumbs &&
}
+
+
+ {backFn && (
+
Voltar
+ )}
+
+ {titlePrefix}
+
{title}
+
+ {description &&
{description}
}
+
+ {actions &&
{actions}
}
+
+ {children}
+
+ );
+}
+
+function Panel({ children, style }) {
+ return {children}
;
+}
+
+/* ============================================================
+ CONTROLES DE CAMPO (renderiza por tipo, como no codebase real)
+ ============================================================ */
+function MultiCombo({ field, value, onChange, disabled }) {
+ const [open, setOpen] = React.useState(false);
+ const ref = React.useRef(null);
+ React.useEffect(() => {
+ const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
+ document.addEventListener("mousedown", h);
+ return () => document.removeEventListener("mousedown", h);
+ }, []);
+ const toggle = (v) => onChange(value.includes(v) ? value.filter(x => x !== v) : [...value, v]);
+ return (
+
+
setOpen(o => !o)}>
+ {value.length === 0
+ ? {field.placeholder || "Selecione opções"}
+ : {value.map(v => {v} )} }
+
+
+ {open && !disabled && (
+
+ {field.options.map(o => (
+ toggle(o.value)}>
+ {value.includes(o.value) && } {o.label}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function FileInput({ field, value, onChange, disabled }) {
+ const id = "file-" + field.name;
+ return (
+
+
+ {value ? value : "Selecionar arquivo" + (field.multipleFiles ? "(s)" : "")}
+ {field.acceptedFileTypes}
+ {
+ const fs = Array.from(e.target.files || []);
+ onChange(fs.length ? fs.map(f => f.name).join(", ") : "");
+ }} />
+
+ );
+}
+
+function FieldInput({ field, value, onChange, disabled, error }) {
+ const f = field;
+ const errC = error ? " fr-err" : "";
+ if (f.type === "text" || f.type === "formatted")
+ return onChange(e.target.value)} />;
+ if (f.type === "number")
+ return onChange(e.target.value)} />;
+ if (f.type === "textarea")
+ return
}
+ actions={ Nova solicitação }
+ >
+
+
{counts.all} Total
+
{counts.open} Em aberto
+
{counts.waiting} Aguardando você
+
{counts.done} Concluídas
+
+
+
+
+
+ setQ(e.target.value)} />
+
+
+
+ setView("lista")}> Lista
+ setView("quadro")}> Quadro
+
+
+
+ {view === "lista" && (
+
+ {[["ALL", "Todas"], ["NOT_STARTED", "Não iniciadas"], ["IN_PROGRESS", "Em andamento"], ["COMPLETED", "Concluídas"]].map(([k, l]) => (
+ setFilter(k)}>{l}
+ ))}
+
+ )}
+
+ {view === "lista" ? (
+
+ {filtered.map(r =>
)}
+ {filtered.length === 0 && (
+
+
+
Nenhuma solicitação encontrada.
+
+ )}
+
+ ) : (
+
+ {STATUS_ORDER.map(s => {
+ const items = reqs.filter(r => r.status === s);
+ return (
+
+
+
+ {cols[s].label}
+ {items.length}
+
+
+ {items.map(r =>
)}
+ {items.length === 0 &&
Nenhuma solicitação aqui
}
+
+
+ );
+ })}
+
+ )}
+
+ setOpen(null)} onReply={onReply} />
+
+ );
+}
+
+const pagesCss = `
+/* breadcrumb */
+.bc { display: flex; align-items: center; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
+.bc-link { font-size: 12.5px; color: hsl(var(--muted)); font-weight: 500; transition: color .14s; }
+.bc-link:hover { color: hsl(var(--text)); }
+button.bc-link { cursor: pointer; }
+.bc-cur { font-size: 12.5px; color: hsl(var(--text)); font-weight: 600; }
+.bc-sep { color: hsl(var(--faint)); opacity: .6; }
+
+/* shell head */
+.sp-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
+.sp-back { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 10px; padding: 5px 10px; border-radius: 8px;
+ font-size: 12px; font-weight: 500; color: hsl(var(--muted)); border: 1px solid hsl(var(--border-soft)); background: hsl(var(--card)); }
+.sp-back:hover { color: hsl(var(--text)); border-color: hsl(var(--border)); }
+.sp-desc { margin-top: 8px; max-width: 70ch; font-size: 14px; color: hsl(var(--muted)); line-height: 1.55; }
+.sp-actions { display: flex; gap: 10px; flex-wrap: wrap; }
+
+/* info grid (form root) */
+.info-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin: 24px 0; border-radius: var(--radius);
+ overflow: hidden; border: 1px solid hsl(var(--border-soft)); background: hsl(var(--border-soft)); }
+.info-grid > div { background: hsl(var(--card) / .9); padding: 15px 18px; display: flex; flex-direction: column; gap: 6px; }
+.info-grid .faint { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; }
+.info-grid strong { font-size: 14px; display: inline-flex; align-items: center; gap: 7px; }
+.info-grid strong svg { color: hsl(var(--faint)); }
+@media (max-width: 860px) { .info-grid { grid-template-columns: 1fr 1fr; } }
+
+/* panel */
+.sp-panel { background: hsl(var(--card) / .8); border: 1px solid hsl(var(--border-soft)); border-radius: var(--radius);
+ padding: 26px 28px; backdrop-filter: blur(6px); box-shadow: var(--shadow-sm); }
+.prev-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
+.prev-title { font-size: 19px; font-weight: 650; letter-spacing: -.02em; }
+.prev-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 26px; padding-top: 22px; border-top: 1px solid hsl(var(--border-soft)); }
+
+/* form */
+.fr-form { display: flex; flex-direction: column; gap: 20px; max-width: 680px; }
+.fr-row { display: flex; flex-direction: column; gap: 8px; }
+.fr-label { font-size: 13.5px; font-weight: 600; }
+.fr-req { color: hsl(0 72% 58%); margin-left: 3px; }
+.fr-help { font-size: 12px; color: hsl(var(--muted)); }
+.fr-errmsg { font-size: 12.5px; font-weight: 500; color: hsl(0 72% 60%); }
+.fr-input, .fr-textarea, .fr-select {
+ width: 100%; font-family: inherit; font-size: 14px; color: hsl(var(--text));
+ background: hsl(var(--card-2) / .7); border: 1px solid hsl(var(--border)); border-radius: 9px; outline: none; transition: border-color .15s, box-shadow .15s;
+}
+.fr-input { height: 42px; padding: 0 14px; }
+.fr-textarea { padding: 11px 14px; resize: vertical; line-height: 1.5; }
+.fr-input:focus, .fr-textarea:focus, .fr-select:focus { border-color: hsl(var(--accent) / .6); box-shadow: 0 0 0 3px hsl(var(--accent) / .12); }
+.fr-input::placeholder, .fr-textarea::placeholder { color: hsl(var(--faint)); }
+.fr-input:disabled, .fr-textarea:disabled, .fr-select:disabled { opacity: .7; cursor: default; }
+.fr-err { border-color: hsl(0 72% 55% / .7) !important; }
+.fr-select-wrap { position: relative; display: block; }
+.fr-select { height: 42px; padding: 0 38px 0 14px; appearance: none; cursor: pointer; text-align: left; }
+.fr-select-ic { position: absolute; right: 13px; top: 50%; transform: translateY(-50%); color: hsl(var(--muted)); pointer-events: none; }
+.fr-select option { color: initial; }
+
+/* checkbox */
+.fr-check { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; font-size: 14px; user-select: none; }
+.fr-check input { position: absolute; opacity: 0; width: 0; height: 0; }
+.fr-check-box { width: 20px; height: 20px; border-radius: 6px; border: 1px solid hsl(var(--border)); background: hsl(var(--card-2));
+ display: grid; place-items: center; color: #fff; transition: all .15s; flex-shrink: 0; }
+.fr-check input:checked + .fr-check-box { background: hsl(var(--accent)); border-color: hsl(var(--accent)); }
+.fr-check.disabled { opacity: .7; cursor: default; }
+
+/* multiselect */
+.fr-ms { position: relative; }
+.fr-ms-box { width: 100%; min-height: 42px; height: auto; padding: 6px 38px 6px 10px; cursor: pointer; display: flex; align-items: center; }
+.fr-ms-ph { color: hsl(var(--faint)); font-size: 14px; padding-left: 4px; }
+.fr-ms-chips { display: flex; flex-wrap: wrap; gap: 6px; }
+.fr-chip { display: inline-flex; align-items: center; padding: 3px 9px; border-radius: 7px; font-size: 12.5px; font-weight: 500;
+ background: hsl(var(--accent) / .14); color: hsl(var(--accent)); border: 1px solid hsl(var(--accent) / .25); }
+:root[data-theme="light"] .fr-chip { color: hsl(184 100% 26%); }
+.fr-ms-menu { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 30; padding: 5px;
+ background: hsl(var(--panel)); border: 1px solid hsl(var(--border)); border-radius: 10px; box-shadow: var(--shadow); max-height: 240px; overflow-y: auto; }
+.fr-ms-opt { display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 10px; border-radius: 7px; font-size: 13.5px; color: hsl(var(--text)); text-align: left; }
+.fr-ms-opt:hover { background: hsl(var(--card-2)); }
+.fr-ms-check { width: 18px; height: 18px; border-radius: 5px; border: 1px solid hsl(var(--border)); display: grid; place-items: center; color: #fff; flex-shrink: 0; }
+.fr-ms-opt.on .fr-ms-check { background: hsl(var(--accent)); border-color: hsl(var(--accent)); }
+
+/* file */
+.fr-file { display: flex; align-items: center; gap: 10px; min-height: 46px; padding: 0 16px; border-radius: 9px; cursor: pointer;
+ border: 1px dashed hsl(var(--border)); background: hsl(var(--card-2) / .5); color: hsl(var(--muted)); font-size: 13.5px; transition: all .15s; }
+.fr-file:hover { border-color: hsl(var(--accent) / .5); color: hsl(var(--text)); }
+.fr-file input { display: none; }
+.fr-file-hint { margin-left: auto; font-size: 11.5px; color: hsl(var(--faint)); font-family: var(--mono); }
+.fr-file.disabled { opacity: .7; cursor: default; }
+
+/* dynamic */
+.fr-dynamic { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 42px;
+ padding: 0 14px; border-radius: 9px; background: hsl(var(--card-2) / .5); border: 1px solid hsl(var(--border-soft)); }
+.fr-dyn-val { font-size: 14px; font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.fr-dyn-lock { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; color: hsl(var(--faint)); flex-shrink: 0; }
+
+/* success */
+.fr-success { display: flex; gap: 16px; align-items: flex-start; padding: 8px 0 4px; }
+.fr-success-ic { width: 48px; height: 48px; border-radius: 12px; flex-shrink: 0; display: grid; place-items: center;
+ color: hsl(158 64% 45%); background: hsl(158 64% 45% / .12); border: 1px solid hsl(158 64% 45% / .25); }
+.fr-success h3 { font-size: 17px; font-weight: 650; margin-bottom: 6px; }
+.fr-success p { font-size: 13.5px; line-height: 1.55; max-width: 60ch; }
+.fr-success-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; padding-top: 20px; border-top: 1px solid hsl(var(--border-soft)); }
+
+/* my responses */
+.mr-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
+.mr-list { display: flex; flex-direction: column; gap: 10px; }
+.mr-row { display: flex; align-items: center; gap: 16px; width: 100%; text-align: left; padding: 16px 18px;
+ background: hsl(var(--card) / .9); border: 1px solid hsl(var(--border-soft)); border-radius: var(--radius); cursor: pointer; transition: all .15s; backdrop-filter: blur(6px); }
+.mr-row:hover { border-color: hsl(var(--accent) / .4); transform: translateY(-1px); box-shadow: var(--shadow); }
+.mr-row-main { flex: 1; min-width: 0; }
+.mr-row-top { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
+.mr-waiting { font-size: 11px; font-weight: 600; color: hsl(38 92% 55%); background: hsl(38 92% 50% / .12); padding: 2px 8px; border-radius: 99px; }
+:root[data-theme="light"] .mr-waiting { color: hsl(32 90% 40%); }
+.mr-row-title { font-size: 15px; font-weight: 600; letter-spacing: -.01em; margin: 7px 0 6px; }
+.mr-row-comment { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: hsl(var(--muted)); margin-bottom: 8px; }
+.mr-row-comment svg { flex-shrink: 0; opacity: .7; }
+.mr-row-meta { display: flex; align-items: center; gap: 9px; font-size: 12px; color: hsl(var(--faint)); flex-wrap: wrap; }
+.mr-row-go { color: hsl(var(--faint)); flex-shrink: 0; transition: transform .15s, color .15s; }
+.mr-row:hover .mr-row-go { color: hsl(var(--accent)); transform: translateX(2px); }
+
+/* board cards (cliente) */
+.b-card2 { text-align: left; width: 100%; padding: 14px; border-radius: 11px; background: hsl(var(--card)); border: 1px solid hsl(var(--border-soft));
+ display: flex; flex-direction: column; gap: 8px; transition: all .14s; cursor: pointer; }
+.b-card2:hover { border-color: hsl(var(--accent) / .45); transform: translateY(-1px); box-shadow: var(--shadow); }
+.b-card2-title { font-size: 13.5px; font-weight: 600; line-height: 1.4; }
+.b-card2-foot { display: flex; align-items: center; justify-content: space-between; padding-top: 8px; border-top: 1px solid hsl(var(--border-soft)); }
+
+/* drawer (cliente) */
+.pg-drawer-wrap { position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.45); display: flex; justify-content: flex-end; }
+.pg-drawer { width: 480px; max-width: 94vw; height: 100%; display: flex; flex-direction: column; background: hsl(var(--panel)); border-left: 1px solid hsl(var(--border)); }
+.pg-drawer-head { display: flex; align-items: flex-start; gap: 12px; padding: 22px 22px 16px; border-bottom: 1px solid hsl(var(--border-soft)); }
+.pg-drawer-title { font-size: 16px; font-weight: 650; letter-spacing: -.01em; margin: 8px 0 4px; }
+.pg-drawer-body { flex: 1; overflow-y: auto; padding: 20px 22px; }
+.pg-status-note { display: flex; align-items: flex-start; gap: 9px; padding: 12px 14px; border-radius: 11px;
+ background: hsl(var(--card-2) / .7); border: 1px solid hsl(var(--border-soft)); font-size: 13px; margin-bottom: 22px; }
+.pg-status-note .dot { margin-top: 6px; }
+.pg-section-label { font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: hsl(var(--muted)); margin: 4px 0 12px; }
+.pg-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; border-radius: 11px; overflow: hidden; border: 1px solid hsl(var(--border-soft)); background: hsl(var(--border-soft)); margin-bottom: 24px; }
+.pg-field { background: hsl(var(--card)); padding: 11px 14px; display: flex; flex-direction: column; gap: 3px; }
+.pg-field .faint { font-size: 11px; }
+.pg-field span:last-child { font-size: 13px; font-weight: 500; }
+.pg-field:nth-last-child(1):nth-child(odd) { grid-column: 1 / -1; }
+.pg-thread { display: flex; flex-direction: column; gap: 12px; }
+.pg-bubble-row { display: flex; }
+.pg-bubble-row.mine { justify-content: flex-end; }
+.pg-bubble { max-width: 82%; padding: 10px 13px; border-radius: 13px; border-top-left-radius: 4px; background: hsl(var(--card-2)); border: 1px solid hsl(var(--border-soft)); }
+.pg-bubble.mine { background: hsl(var(--accent) / .12); border-color: hsl(var(--accent) / .25); border-top-left-radius: 13px; border-top-right-radius: 4px; }
+.pg-bubble-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
+.pg-bubble-head strong { font-size: 12.5px; }
+.pg-bubble-head .faint { font-size: 10.5px; }
+.pg-bubble p { font-size: 13px; line-height: 1.5; }
+.pg-reply { display: flex; align-items: center; gap: 8px; padding: 12px 16px; border-top: 1px solid hsl(var(--border-soft)); background: hsl(var(--panel) / .6); }
+.pg-reply input { flex: 1; height: 38px; padding: 0 13px; border-radius: 9px; font-size: 13.5px; color: hsl(var(--text)); outline: none;
+ background: hsl(var(--card-2)); border: 1px solid hsl(var(--border-soft)); }
+.pg-reply input:focus { border-color: hsl(var(--accent) / .5); }
+`;
+
+Object.assign(window, { SubPageShell, FormRootPage, RespondPage, MyResponsesPage });
diff --git a/public/handoffs/design_handoff_solicitacoes_2/shell.jsx b/public/handoffs/design_handoff_solicitacoes_2/shell.jsx
new file mode 100644
index 00000000..8db87c41
--- /dev/null
+++ b/public/handoffs/design_handoff_solicitacoes_2/shell.jsx
@@ -0,0 +1,156 @@
+/* ============================================================
+ Shell do elo: sidebar + topbar + orquestração de visões
+ ============================================================ */
+
+function NavItem({ icon: Icon, label, active, hasChildren, open, onClick, sub }) {
+ return (
+
+
+ {label}
+ {hasChildren && }
+
+ );
+}
+
+function Sidebar() {
+ return (
+
+
+
+ elo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Topbar({ theme, setTheme, collapsed, setCollapsed, newLayout, setNewLayout, role }) {
+ return (
+
+ );
+}
+
+function App() {
+ const [theme, setThemeState] = React.useState("dark");
+ const [collapsed, setCollapsed] = React.useState(false);
+ const [newLayout, setNewLayout] = React.useState(true);
+ const [role, setRole] = React.useState("client"); // client | tech
+ const [route, setRoute] = React.useState({ name: "catalog" }); // catalog | form | respond | mine
+
+ const go = (r) => { setRoute(r); document.querySelector(".content")?.scrollTo({ top: 0 }); };
+ const nav = {
+ catalog: () => go({ name: "catalog" }),
+ mine: () => go({ name: "mine" }),
+ form: (id) => go({ name: "form", id }),
+ respond: (id) => go({ name: "respond", id }),
+ };
+ // volta ao catálogo ao trocar de papel ou desligar o novo layout
+ React.useEffect(() => { setRoute({ name: "catalog" }); }, [role, newLayout]);
+
+ const setTheme = (t) => {
+ setThemeState(t);
+ document.documentElement.setAttribute("data-theme", t);
+ };
+ React.useEffect(() => { document.documentElement.setAttribute("data-theme", theme); }, []);
+
+ const findForm = (id) => window.FORMS.find(f => f.id === id) || window.FORMS[0];
+
+ const renderClient = () => {
+ if (route.name === "form") return ;
+ if (route.name === "respond") return ;
+ if (route.name === "mine") return ;
+ return ;
+ };
+
+ return (
+
+
+
+
+
+
+
+ {/* Banner explicando o toggle quando ligado */}
+ {newLayout && (
+
+
+
+ Novo layout do módulo de Solicitações
+ — ativado só para você. Use o botão no topo para voltar ao visual atual.
+
+
+
+
+ )}
+
+ {newLayout
+ ? (role === "client" ? renderClient() :
)
+ :
}
+
+
+
+
+ );
+}
+
+function RoleSwitch({ role, setRole }) {
+ return (
+
+ setRole("client")}>
+ Visão do cliente
+
+ setRole("tech")}>
+ Visão do técnico
+
+
+ );
+}
+
+window.App = App;
+window.RoleSwitch = RoleSwitch;
diff --git a/public/handoffs/design_handoff_solicitacoes_2/tech.jsx b/public/handoffs/design_handoff_solicitacoes_2/tech.jsx
new file mode 100644
index 00000000..b653e06b
--- /dev/null
+++ b/public/handoffs/design_handoff_solicitacoes_2/tech.jsx
@@ -0,0 +1,376 @@
+/* ============================================================
+ VISÃO DO TÉCNICO — workspace resolutivo (fila + detalhe / quadro)
+ ============================================================ */
+
+const TECH_USER = "Gilnei Wolf";
+const STATUS_ORDER = ["NOT_STARTED", "IN_PROGRESS", "COMPLETED"];
+
+function priorityDot(p) {
+ return ;
+}
+
+function Avatar({ initials, size = 26 }) {
+ return (
+ {initials}
+ );
+}
+
+/* ---------- linha da fila ---------- */
+function QueueRow({ t, active, onClick }) {
+ const sm = window.STATUS_META[t.status];
+ return (
+
+
+
+
+ #{t.id}
+ {t.isNew && Novo }
+ {sm.short}
+
+
{t.summary}
+
+
+
{t.requester}
+
+
+ {t.sla}
+
+
+
+
+ );
+}
+
+/* ---------- painel de detalhe ---------- */
+function Detail({ t, onStatus, onReply, onAssign }) {
+ const [msg, setMsg] = React.useState("");
+ const [menu, setMenu] = React.useState(false);
+ if (!t) {
+ return (
+
+
+
Selecione um chamado na fila para começar a atender.
+
+ );
+ }
+ const sm = window.STATUS_META[t.status];
+ const send = () => { if (msg.trim()) { onReply(t.id, msg.trim()); setMsg(""); } };
+
+ return (
+
+ {/* header */}
+
+
+ #{t.id}
+ {priorityDot(t.priority)}
+ {window.PRIORITY_META[t.priority].label} prioridade
+
+ {t.form}
+
+
{t.summary}
+
+
+
+
setMenu(!menu)}>
+ {sm.label}
+
+ {menu && (
+
setMenu(false)}>
+ {STATUS_ORDER.map(s => (
+ { onStatus(t.id, s); setMenu(false); }}
+ className={s === t.status ? "sel" : ""}>
+ {window.STATUS_META[s].label}
+ {s === t.status && }
+
+ ))}
+
+ )}
+
+ {!t.assignee
+ ?
onAssign(t.id)}> Assumir chamado
+ :
w[0]).slice(0,2).join("")).toUpperCase()} size={20} /> {t.assignee} }
+
+
+
+
+
+ {/* requester + sla cards */}
+
+
+
+
SLA
+
+
+ {t.sla}
+
+
Aberto {t.age}
+
+
+
+ {/* submitted fields */}
+
Dados da solicitação
+
+ {t.fieldsData.map(([k, v], i) => (
+
+ {k}
+ {v}
+
+ ))}
+
+
+ {/* conversation */}
+
+ Conversa
+
+
+ {t.thread.map((m, i) => {
+ const mine = m.role !== "Solicitante";
+ return (
+
+ {!mine &&
}
+
+
{m.who} {m.role} · {m.time}
+
{m.msg}
+
+ {mine &&
}
+
+ );
+ })}
+
+
+
+ {/* reply */}
+
+
+ setMsg(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Enter") send(); }} />
+
+ Enviar
+
+
+
+ );
+}
+
+/* ---------- quadro (kanban refinado) ---------- */
+function Board({ tickets, onPick }) {
+ return (
+
+ {STATUS_ORDER.map(s => {
+ const sm = window.STATUS_META[s];
+ const items = tickets.filter(t => t.status === s);
+ const total = window.BOARD_COUNTS[s];
+ return (
+
+
+
+ {sm.label}
+ {total}
+
+
+ {items.map(t => (
+
onPick(t.id)}>
+
+
+ #{t.id}
+ {t.isNew && Novo }
+
+ {t.summary}
+
+
+
+ {t.sla}
+
+ {t.assignee
+ ? {t.assignee.split(" ")[0]}
+ : Sem responsável }
+
+
+ ))}
+ {items.length === 0 &&
Sem itens nesta amostra
}
+
+
+ );
+ })}
+
+ );
+}
+
+function TechView() {
+ const [tickets, setTickets] = React.useState(() => JSON.parse(JSON.stringify(window.TICKETS)));
+ const [view, setView] = React.useState("fila"); // fila | quadro
+ const [sel, setSel] = React.useState(tickets[0].id);
+ const [tab, setTab] = React.useState("ALL");
+ const [q, setQ] = React.useState("");
+
+ const update = (id, patch) => setTickets(ts => ts.map(t => t.id === id ? { ...t, ...patch } : t));
+ const onStatus = (id, status) => update(id, { status });
+ const onAssign = (id) => update(id, { assignee: TECH_USER, status: tickets.find(t=>t.id===id).status === "NOT_STARTED" ? "IN_PROGRESS" : tickets.find(t=>t.id===id).status });
+ const onReply = (id, message) => {
+ setTickets(ts => ts.map(t => t.id === id ? {
+ ...t, thread: [...t.thread, { who: TECH_USER, role: "Técnico", time: "agora", msg: message }],
+ status: t.status === "NOT_STARTED" ? "IN_PROGRESS" : t.status,
+ } : t));
+ };
+
+ const filtered = tickets.filter(t =>
+ (tab === "ALL" || t.status === tab) &&
+ (q === "" || (t.summary + t.requester + t.id).toLowerCase().includes(q.toLowerCase()))
+ );
+ const current = tickets.find(t => t.id === sel);
+
+ const counts = {
+ open: tickets.filter(t => t.status === "NOT_STARTED").length,
+ prog: tickets.filter(t => t.status === "IN_PROGRESS").length,
+ unassigned: tickets.filter(t => !t.assignee).length,
+ risk: tickets.filter(t => t.slaRisk).length,
+ };
+
+ return (
+
+
+
+ {/* header */}
+
+
+
Central de chamados
+
Atenda, acompanhe e resolva as solicitações recebidas nos formulários.
+
+
+
Gerenciar Tags
+
+ setView("fila")}> Fila
+ setView("quadro")}> Quadro
+
+
+
+
+ {/* KPIs discretos */}
+
+
{window.BOARD_COUNTS.NOT_STARTED} Não iniciados
+
{window.BOARD_COUNTS.IN_PROGRESS} Em progresso
+
{counts.unassigned} Sem responsável
+
{counts.risk} SLA em risco
+
12 Resolvidos hoje
+
+
+ {view === "fila" ? (
+
+ {/* queue */}
+
+
+
+ setQ(e.target.value)} />
+
+
+ {[["ALL", "Todos"], ["NOT_STARTED", "Novos"], ["IN_PROGRESS", "Andamento"], ["COMPLETED", "Concluídos"]].map(([k, l]) => (
+ setTab(k)}>{l}
+ ))}
+
+
+ {filtered.map(t =>
setSel(t.id)} />)}
+ {filtered.length === 0 && Nenhum chamado neste filtro
}
+
+
+ {/* detail */}
+
+
+ ) : (
+
{ setSel(id); setView("fila"); }} />
+ )}
+
+ );
+}
+
+const techCss = `
+.t-avatar { border-radius: 50%; display: inline-grid; place-items: center; flex-shrink: 0;
+ background: linear-gradient(135deg, hsl(var(--accent)), hsl(var(--accent) / .55)); color: #fff; font-weight: 600; }
+.sla-risk { color: hsl(0 72% 58%) !important; font-weight: 600; }
+.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.work { display: grid; grid-template-columns: 372px 1fr; gap: 18px; height: calc(100vh - 290px); min-height: 520px; }
+@media (max-width: 1100px) { .work { grid-template-columns: 1fr; height: auto; } }
+
+/* queue */
+.queue { display: flex; flex-direction: column; min-height: 0; }
+.q-tabs { display: flex; gap: 4px; margin-bottom: 10px; }
+.q-tab { padding: 6px 11px; border-radius: 8px; font-size: 12.5px; font-weight: 600; color: hsl(var(--muted)); border: 1px solid transparent; }
+.q-tab:hover { color: hsl(var(--text)); }
+.q-tab.active { background: hsl(var(--card-2)); color: hsl(var(--text)); border-color: hsl(var(--border-soft)); }
+.q-list { display: flex; flex-direction: column; gap: 8px; overflow-y: auto; padding-right: 4px; flex: 1; }
+.q-row { display: flex; gap: 11px; padding: 13px; border-radius: 11px; text-align: left; width: 100%;
+ background: hsl(var(--card) / .8); border: 1px solid hsl(var(--border-soft)); transition: all .14s; }
+.q-row:hover { border-color: hsl(var(--border)); }
+.q-row.active { border-color: hsl(var(--accent) / .55); background: hsl(var(--accent) / .07); box-shadow: inset 0 0 0 1px hsl(var(--accent) / .25); }
+.q-prio { margin-top: 6px; }
+.q-top { display: flex; align-items: center; gap: 7px; }
+.q-new { font-size: 9.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: hsl(0 72% 60%); background: hsl(0 72% 55% / .14); padding: 1px 6px; border-radius: 5px; }
+.q-status { margin-left: auto; font-size: 11px; font-weight: 600; }
+.q-title { font-size: 13.5px; font-weight: 500; line-height: 1.4; margin: 5px 0 7px; }
+.q-meta { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: hsl(var(--faint)); }
+
+/* detail */
+.detail { display: flex; flex-direction: column; min-height: 0; background: hsl(var(--card) / .75); border: 1px solid hsl(var(--border-soft)); border-radius: var(--radius); overflow: hidden; backdrop-filter: blur(6px); }
+.detail.empty { align-items: center; justify-content: center; gap: 12px; text-align: center; }
+.d-head { padding: 18px 20px 14px; border-bottom: 1px solid hsl(var(--border-soft)); }
+.d-title { font-size: 18px; font-weight: 650; letter-spacing: -.02em; margin: 8px 0 14px; line-height: 1.3; }
+.d-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.status-wrap { position: relative; }
+.status-btn { border: 1px solid currentColor; background: transparent; }
+.status-menu { position: absolute; top: calc(100% + 6px); left: 0; z-index: 20; min-width: 190px;
+ background: hsl(var(--panel)); border: 1px solid hsl(var(--border)); border-radius: 10px; padding: 5px; box-shadow: var(--shadow); }
+.status-menu button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 10px; border-radius: 7px; font-size: 13px; color: hsl(var(--text)); }
+.status-menu button:hover { background: hsl(var(--card-2)); }
+.status-menu button.sel { font-weight: 600; }
+.assignee-pill { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 500; padding: 4px 10px 4px 4px; border-radius: 99px; background: hsl(var(--card-2)); border: 1px solid hsl(var(--border-soft)); }
+
+.d-body { flex: 1; overflow-y: auto; padding: 18px 20px; }
+.d-info { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px; }
+.d-info-card { padding: 13px 14px; border-radius: 11px; background: hsl(var(--card-2) / .6); border: 1px solid hsl(var(--border-soft)); }
+.d-info-card .faint { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; }
+.d-section-label { font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: hsl(var(--muted)); margin: 4px 0 12px; }
+.d-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; border-radius: 11px; overflow: hidden; border: 1px solid hsl(var(--border-soft)); margin-bottom: 24px; background: hsl(var(--border-soft)); }
+.d-field { background: hsl(var(--card)); padding: 11px 14px; display: flex; flex-direction: column; gap: 3px; }
+.d-field .faint { font-size: 11px; }
+.d-field span:last-child { font-size: 13px; font-weight: 500; }
+.thread { display: flex; flex-direction: column; gap: 14px; }
+.bubble-row { display: flex; gap: 9px; align-items: flex-start; }
+.bubble-row.mine { flex-direction: row-reverse; }
+.bubble { max-width: 78%; padding: 10px 13px; border-radius: 13px; background: hsl(var(--card-2)); border: 1px solid hsl(var(--border-soft)); border-top-left-radius: 4px; }
+.bubble.mine { background: hsl(var(--accent) / .12); border-color: hsl(var(--accent) / .25); border-top-left-radius: 13px; border-top-right-radius: 4px; }
+.bubble-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
+.bubble-head strong { font-size: 12.5px; }
+.bubble-head .faint { font-size: 10.5px; }
+.bubble p { font-size: 13px; line-height: 1.5; }
+.d-reply { display: flex; align-items: center; gap: 8px; padding: 12px 16px; border-top: 1px solid hsl(var(--border-soft)); background: hsl(var(--panel) / .5); }
+.d-reply input { flex: 1; background: hsl(var(--card-2)); border: 1px solid hsl(var(--border-soft)); border-radius: 9px; height: 38px; padding: 0 13px; color: hsl(var(--text)); outline: none; font-size: 13.5px; }
+.d-reply input:focus { border-color: hsl(var(--accent) / .5); }
+.btn:disabled { opacity: .45; cursor: default; }
+
+/* board */
+.board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; align-items: start; }
+@media (max-width: 1000px) { .board { grid-template-columns: 1fr; } }
+.b-col { background: hsl(var(--card) / .55); border: 1px solid hsl(var(--border-soft)); border-radius: var(--radius); padding: 12px; }
+.b-col-head { display: flex; align-items: center; gap: 8px; padding: 4px 6px 12px; }
+.b-col-title { font-weight: 600; font-size: 14px; }
+.b-col-count { margin-left: auto; font-family: var(--mono); font-size: 12px; color: hsl(var(--muted)); background: hsl(var(--card-2)); padding: 2px 9px; border-radius: 99px; }
+.b-col-body { display: flex; flex-direction: column; gap: 9px; }
+.b-card { text-align: left; width: 100%; padding: 13px; border-radius: 11px; background: hsl(var(--card)); border: 1px solid hsl(var(--border-soft)); display: flex; flex-direction: column; gap: 8px; transition: all .14s; }
+.b-card:hover { border-color: hsl(var(--accent) / .45); transform: translateY(-1px); box-shadow: var(--shadow); }
+.b-card-title { font-size: 13px; font-weight: 500; line-height: 1.4; }
+.b-card-foot { display: flex; align-items: center; justify-content: space-between; padding-top: 8px; border-top: 1px solid hsl(var(--border-soft)); }
+.unassigned { font-size: 10.5px; color: hsl(0 72% 60%); font-weight: 600; }
+.b-empty { font-size: 12px; color: hsl(var(--faint)); text-align: center; padding: 16px; }
+`;
+
+window.TechView = TechView;
diff --git a/src/app/(authenticated)/forms/[id]/page.tsx b/src/app/(authenticated)/forms/[id]/page.tsx
index c973571a..8a913ec6 100644
--- a/src/app/(authenticated)/forms/[id]/page.tsx
+++ b/src/app/(authenticated)/forms/[id]/page.tsx
@@ -1,6 +1,7 @@
+import type { ReactNode } from "react"
import { api } from "@/trpc/server"
import { Button } from "@/components/ui/button"
-import { Pencil, FileText, MessageSquare } from "lucide-react"
+import { Pencil, FileText, MessageSquare, Calendar, Eye, Lock, Globe } from "lucide-react"
import Link from "next/link"
import { notFound, redirect } from "next/navigation"
import { FormPreview } from "@/components/forms/form-preview"
@@ -86,45 +87,122 @@ export default async function FormPage({ params }: FormPageProps) {
{ label: form.title },
]}
title={form.title}
- description={
- <>
-
- Criado {formatDistanceToNow(new Date(form.createdAt), { addSuffix: true, locale: ptBR })}
-
-
- >
- }
- actions={
- <>
-
-
-
- Respostas
-
-
+ description={ }
+ >
+ {/* Barra de ações */}
+
+
+ {/* Gestão (somente quem pode editar) — à esquerda */}
{canEdit && (
-
-
-
- Editar
+
+
+
+
+ Respostas
+
+
+
+
+
+ Editar
+
+
+
+ )}
+
+ {/* Ações do solicitante — à direita */}
+
+
+
+
+ Minhas solicitações
- )}
-
-
+
+
+
+ Abrir nova solicitação
+
+
+
+
+
+ {/* Criar chamado manual — abaixo e com largura reduzida */}
+ {canEdit && (
+
+ )}
+
+
+ {/* Faixa de info */}
+
+ }
+ value={formatDistanceToNow(new Date(form.createdAt), { addSuffix: true, locale: ptBR })}
+ />
+ }
+ value={`${(form.fields as unknown[]).length} ao todo`}
+ className="border-t border-[hsl(var(--v2-border-soft))] sm:border-l sm:border-t-0"
+ />
+ : }
+ value={form.isPrivate ? "Restrito" : "Público"}
+ className="border-t border-[hsl(var(--v2-border-soft))] sm:border-l sm:border-t-0"
+ />
+
+
+
+
+
+ Estes são os campos que você vai preencher. Clique em{" "}
+ Abrir nova solicitação para começar.
+
+
+ Pré-visualização
+
+
+
+
+
+
+
+
Abrir nova solicitação
-
- >
- }
- >
-
-
+
)
}
+interface InfoCellProps {
+ label: string
+ icon: ReactNode
+ value: string
+ className?: string
+}
+
+function InfoCell({ label, icon, value, className }: InfoCellProps) {
+ return (
+
+
+ {label}
+
+
+ {icon}
+ {value}
+
+
+ )
+}
+
diff --git a/src/app/(authenticated)/forms/[id]/respond/page.tsx b/src/app/(authenticated)/forms/[id]/respond/page.tsx
index 34531f80..25a0e939 100644
--- a/src/app/(authenticated)/forms/[id]/respond/page.tsx
+++ b/src/app/(authenticated)/forms/[id]/respond/page.tsx
@@ -27,6 +27,9 @@ export default async function RespondFormPage({ params }: RespondFormPageProps)
notFound()
}
+ const fields = form.fields as unknown as Field[]
+ const requiredCount = fields.filter((f) => f.required).length
+
// Verificar se o usuário tem permissão para acessar este formulário
if (!canAccessForm(
userData.role_config,
@@ -56,7 +59,16 @@ export default async function RespondFormPage({ params }: RespondFormPageProps)
description={ }
>
-
+
+
Preencha sua solicitação
+ {requiredCount > 0 && (
+
+ {requiredCount} {requiredCount === 1 ? "campo obrigatório" : "campos obrigatórios"} · marcados com{" "}
+ *
+
+ )}
+
+
diff --git a/src/app/(authenticated)/forms/kanban/_components/tags-context-menu.tsx b/src/app/(authenticated)/forms/kanban/_components/tags-context-menu.tsx
index 370cc2e9..f3a6ae31 100644
--- a/src/app/(authenticated)/forms/kanban/_components/tags-context-menu.tsx
+++ b/src/app/(authenticated)/forms/kanban/_components/tags-context-menu.tsx
@@ -123,8 +123,8 @@ export function ResponseContextMenu({
if (currentIndex < statusOrder.length - 1) {
const nextStatus = statusOrder[currentIndex + 1]!
const labels: Record = {
- NOT_STARTED: "Em Progresso",
- IN_PROGRESS: "Concluído",
+ NOT_STARTED: "Não iniciado",
+ IN_PROGRESS: "Em progresso",
COMPLETED: "Concluído",
}
return labels[nextStatus] ?? ""
diff --git a/src/app/(authenticated)/forms/my-responses/page.tsx b/src/app/(authenticated)/forms/my-responses/page.tsx
index 935daf16..fb518fd5 100644
--- a/src/app/(authenticated)/forms/my-responses/page.tsx
+++ b/src/app/(authenticated)/forms/my-responses/page.tsx
@@ -1,8 +1,11 @@
import { UserResponsesList } from "@/components/forms/user-responses-list"
import { Suspense } from "react"
+import Link from "next/link"
+import { Plus } from "lucide-react"
import { ResponsesSkeleton } from "@/components/forms/responses-skeleton"
import { DashboardShell } from "@/components/ui/dashboard-shell"
import { FormsSubPageShell } from "@/components/forms/v2/forms-sub-page-shell"
+import { Button } from "@/components/ui/button"
export const metadata = {
title: "Minhas solicitações",
@@ -20,6 +23,14 @@ export default async function MyResponsesPage() {
]}
title="Minhas solicitações"
description="Visualize e acompanhe o status de todas as suas respostas a solicitações."
+ actions={
+
+
+
+ Nova solicitação
+
+
+ }
>
}>
diff --git a/src/components/forms/create-manual-response-button-wrapper.tsx b/src/components/forms/create-manual-response-button-wrapper.tsx
index 589bde25..434fd1e8 100644
--- a/src/components/forms/create-manual-response-button-wrapper.tsx
+++ b/src/components/forms/create-manual-response-button-wrapper.tsx
@@ -8,15 +8,16 @@ import type { Field } from "@/lib/form-types"
interface CreateManualResponseButtonWrapperProps {
formId: string
formFields: Field[]
+ className?: string
}
-export function CreateManualResponseButtonWrapper({ formId, formFields }: CreateManualResponseButtonWrapperProps) {
+export function CreateManualResponseButtonWrapper({ formId, formFields, className }: CreateManualResponseButtonWrapperProps) {
const { data: userData } = api.user.me.useQuery()
if (!canCreateSolicitacoes(userData?.role_config ?? null)) {
return null
}
- return
+ return
}
diff --git a/src/components/forms/create-manual-response-button.tsx b/src/components/forms/create-manual-response-button.tsx
index 97e95f03..b74df930 100644
--- a/src/components/forms/create-manual-response-button.tsx
+++ b/src/components/forms/create-manual-response-button.tsx
@@ -4,21 +4,23 @@ import { useState } from "react"
import { Button } from "@/components/ui/button"
import { UserPlus } from "lucide-react"
import { CreateManualResponseDialog } from "./create-manual-response-dialog"
+import { cn } from "@/lib/utils"
import type { Field } from "@/lib/form-types"
interface CreateManualResponseButtonProps {
formId: string
formFields: Field[]
+ className?: string
}
-export function CreateManualResponseButton({ formId, formFields }: CreateManualResponseButtonProps) {
+export function CreateManualResponseButton({ formId, formFields, className }: CreateManualResponseButtonProps) {
const [open, setOpen] = useState(false)
return (
<>
setOpen(true)}
>
diff --git a/src/components/forms/form-response.tsx b/src/components/forms/form-response.tsx
index 02a4d85c..092e192f 100644
--- a/src/components/forms/form-response.tsx
+++ b/src/components/forms/form-response.tsx
@@ -14,9 +14,9 @@ import { useForm } from "react-hook-form"
import { z } from "zod"
import { api } from "@/trpc/react"
import { useRouter } from "next/navigation"
+import Link from "next/link"
import { toast } from "sonner"
-import { CheckCircle2, Send, Lock } from "lucide-react"
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import { CheckCircle2, Send, Lock, RefreshCw, FileText } from "lucide-react"
// Email de criação de solicitação agora é enviado no router (form-response.ts)
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
@@ -221,19 +221,29 @@ export function FormResponseComponent({
if (isSubmitted && !isEditing) {
return (
-
-
-
- Resposta enviada com sucesso!
- Sua resposta foi registrada. Obrigado por participar.
-
-
-
-
router.push("/forms")}>
- Voltar para formulários
-
+
+
+
+
+
+
+
Solicitação enviada com sucesso!
+
+ Os dados foram registrados e os responsáveis serão notificados. Acompanhe o
+ andamento em “Minhas solicitações”.
+
+
+
+
+
+
+
+
+ Ver minhas solicitações
+
+
{
const empty = buildEmptyFormValues(fields)
reset(empty)
@@ -243,6 +253,7 @@ export function FormResponseComponent({
router.refresh()
}}
>
+
Abrir nova solicitação
@@ -392,7 +403,7 @@ export function FormResponseComponent({
)}
{customIsSubmitting !== undefined
? (customIsSubmitting ? "Salvando..." : "Salvar Alterações")
- : (isSubmitting ? "Enviando..." : (isEditing ? "Salvar Alterações" : "Enviar Resposta"))
+ : (isSubmitting ? "Enviando..." : (isEditing ? "Salvar Alterações" : "Enviar solicitação"))
}
diff --git a/src/components/forms/form-responses-export-by-user-dialog.tsx b/src/components/forms/form-responses-export-by-user-dialog.tsx
index f1d3f509..73f12b61 100644
--- a/src/components/forms/form-responses-export-by-user-dialog.tsx
+++ b/src/components/forms/form-responses-export-by-user-dialog.tsx
@@ -16,6 +16,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { ScrollArea } from "@/components/ui/scroll-area"
import { DateRangePicker } from "@/components/forms/date-range-picker"
import { api } from "@/trpc/react"
+import { downloadBase64File, XLSX_MIME } from "@/lib/download-file"
import type { Field } from "@/lib/form-types"
import { Users, Loader2 } from "lucide-react"
import { toast } from "sonner"
@@ -44,15 +45,9 @@ export function FormResponsesExportByUserDialog({
}
}, [open, exportableFields])
- const exportMutation = api.formResponse.exportByUserCsv.useMutation({
+ const exportMutation = api.formResponse.exportByUserXlsx.useMutation({
onSuccess: (data) => {
- const blob = new Blob([data.csv], { type: "text/csv;charset=utf-8" })
- const url = URL.createObjectURL(blob)
- const a = document.createElement("a")
- a.href = url
- a.download = data.filename
- a.click()
- URL.revokeObjectURL(url)
+ downloadBase64File(data.xlsxBase64, data.filename, XLSX_MIME)
if (data.truncated) {
toast.warning("Exportação limitada", {
description: `Foram incluídas no máximo ${data.rowCount} linhas. Refine o período para outro recorte.`,
@@ -102,7 +97,7 @@ export function FormResponsesExportByUserDialog({
- Exportar relação por usuário (CSV)
+ Exportar relação por usuário (XLSX)
Gera uma planilha com as respostas de "{formTitle}" organizadas por usuário, incluindo o
total de envios de cada respondente.
@@ -143,7 +138,7 @@ export function FormResponsesExportByUserDialog({
{fields.some((f) => f.type === "file") && (
- Campos de arquivo não entram no CSV; abra o detalhe de cada resposta para ver anexos.
+ Campos de arquivo não entram na planilha; abra o detalhe de cada resposta para ver anexos.
)}
@@ -186,7 +181,7 @@ export function FormResponsesExportByUserDialog({
) : (
<>
- Baixar CSV
+ Baixar XLSX
>
)}
diff --git a/src/components/forms/form-responses-export-dialog.tsx b/src/components/forms/form-responses-export-dialog.tsx
index a2512f0e..6fb0e1fc 100644
--- a/src/components/forms/form-responses-export-dialog.tsx
+++ b/src/components/forms/form-responses-export-dialog.tsx
@@ -16,6 +16,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { ScrollArea } from "@/components/ui/scroll-area"
import { DateRangePicker } from "@/components/forms/date-range-picker"
import { api } from "@/trpc/react"
+import { downloadBase64File, XLSX_MIME } from "@/lib/download-file"
import type { Field } from "@/lib/form-types"
import { Download, Loader2 } from "lucide-react"
import { toast } from "sonner"
@@ -43,15 +44,9 @@ export function FormResponsesExportDialog({ formId, formTitle, fields }: FormRes
}
}, [open, exportableFields])
- const exportMutation = api.formResponse.exportSpreadsheetCsv.useMutation({
+ const exportMutation = api.formResponse.exportSpreadsheetXlsx.useMutation({
onSuccess: (data) => {
- const blob = new Blob([data.csv], { type: "text/csv;charset=utf-8" })
- const url = URL.createObjectURL(blob)
- const a = document.createElement("a")
- a.href = url
- a.download = data.filename
- a.click()
- URL.revokeObjectURL(url)
+ downloadBase64File(data.xlsxBase64, data.filename, XLSX_MIME)
if (data.truncated) {
toast.warning("Exportação limitada", {
description: `Foram incluídas no máximo ${data.rowCount} linhas. Refine o período se precisar de outro recorte.`,
@@ -109,7 +104,7 @@ export function FormResponsesExportDialog({ formId, formTitle, fields }: FormRes
- Exportar respostas (CSV)
+ Exportar respostas (XLSX)
Escolha o período (opcional) e os campos do formulário "{formTitle}". Sempre incluímos número,
data, status e dados do respondente.
@@ -138,7 +133,7 @@ export function FormResponsesExportDialog({ formId, formTitle, fields }: FormRes
{fields.some((f) => f.type === "file") && (
- Campos de arquivo não entram no CSV; abra o detalhe de cada resposta para ver anexos.
+ Campos de arquivo não entram na planilha; abra o detalhe de cada resposta para ver anexos.
)}
@@ -174,7 +169,7 @@ export function FormResponsesExportDialog({ formId, formTitle, fields }: FormRes
) : (
<>
- Baixar CSV
+ Baixar XLSX
>
)}
diff --git a/src/components/forms/user-responses-list.tsx b/src/components/forms/user-responses-list.tsx
index c67dc05e..ea198452 100644
--- a/src/components/forms/user-responses-list.tsx
+++ b/src/components/forms/user-responses-list.tsx
@@ -1,330 +1,380 @@
"use client"
-import { useState } from "react"
-import { api } from "@/trpc/react"
-import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import { Eye, FileText, Loader2, Filter } from "lucide-react"
+import * as React from "react"
import Link from "next/link"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
-import { Badge } from "@/components/ui/badge"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
-import { ResponseDialog } from "@/app/(authenticated)/forms/kanban/_components/response-dialog"
-import { DragDropContext, type OnDragEndResponder } from "@hello-pangea/dnd"
-import { KanbanColumn } from "@/app/(authenticated)/forms/kanban/_components/kanban-column"
-import type { ResponseStatus, FormResponse } from "@/types/form-responses"
-import { ResponseChat } from "./response-chat"
-import React from "react"
-
-export function UserResponsesList() {
- const [selectedResponseId, setSelectedResponseId] = useState(null)
- const [isDialogOpen, setIsDialogOpen] = useState(false)
- const [selectedStatuses, setSelectedStatuses] = useState>(new Set())
- const [localResponses, setLocalResponses] = useState([])
- const [selectedKanbanResponse, setSelectedKanbanResponse] = useState(null)
- const [isKanbanDialogOpen, setIsKanbanDialogOpen] = useState(false)
-
- // Fetch responses for both views
- const { data: responses, isLoading } = api.formResponse.listUserResponses.useQuery()
- // Para o kanban, usamos a mesma query das respostas do usuário
- const { data: kanbanResponses, isLoading: isKanbanLoading, refetch } = api.formResponse.listUserResponses.useQuery()
+import { Inbox, KanbanSquare, List as ListIcon, MessageSquare, Search } from "lucide-react"
- // Update local state when server data changes
- React.useEffect(() => {
- if (kanbanResponses) {
- setLocalResponses(kanbanResponses as FormResponse[])
- }
- }, [kanbanResponses])
-
- const updateStatusMutation = api.formResponse.updateStatus.useMutation({
- onSuccess: () => {
- void refetch()
- },
- })
-
- const handleOpenDetails = (responseId: string) => {
- setSelectedResponseId(responseId)
- setIsDialogOpen(true)
- }
-
- const handleKanbanOpenDetails = (responseId: string) => {
- setSelectedKanbanResponse(responseId)
- setIsKanbanDialogOpen(true)
- }
-
- const toggleStatusFilter = (status: string) => {
- const newStatuses = new Set(selectedStatuses)
- if (newStatuses.has(status)) {
- newStatuses.delete(status)
- } else {
- newStatuses.add(status)
- }
- setSelectedStatuses(newStatuses)
- }
-
- const clearFilters = () => {
- setSelectedStatuses(new Set())
- }
+import { api } from "@/trpc/react"
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Card } from "@/components/ui/card"
+import { Sheet, SheetContent } from "@/components/ui/sheet"
+import { RequestStatusPill, STATUS_META } from "@/components/forms/v2/request-status-pill"
+import { ResponseDetails } from "@/components/forms/response-details"
+import { ResponseChat } from "@/components/forms/response-chat"
+import { formatFormResponseNumber } from "@/lib/utils/form-response-number"
+import type { Field } from "@/lib/form-types"
+import type { FormResponse, ResponseStatus } from "@/types/form-responses"
- // Filter responses based on selected statuses
- const filteredResponses = responses?.filter(response =>
- selectedStatuses.size === 0 || selectedStatuses.has(response.status)
- ) ?? []
+type View = "lista" | "quadro"
+type StatusFilter = "ALL" | ResponseStatus
+const STATUS_ORDER: ResponseStatus[] = ["NOT_STARTED", "IN_PROGRESS", "COMPLETED"]
- // Group responses by status for Kanban
- const columns = {
- NOT_STARTED: localResponses.filter((response) => response.status === "NOT_STARTED"),
- IN_PROGRESS: localResponses.filter((response) => response.status === "IN_PROGRESS"),
- COMPLETED: localResponses.filter((response) => response.status === "COMPLETED"),
- }
+const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
+ { key: "ALL", label: "Todas" },
+ { key: "NOT_STARTED", label: "Não iniciadas" },
+ { key: "IN_PROGRESS", label: "Em andamento" },
+ { key: "COMPLETED", label: "Concluídas" },
+]
- // Buscar usuário atual para verificar se pode mover o card
- const { data: currentUser } = api.user.me.useQuery()
+function shortId(r: Pick) {
+ return r.number != null ? formatFormResponseNumber(r.number) : `#${r.id.slice(0, 6)}`
+}
- const onDragEnd: OnDragEndResponder = (result) => {
- const { destination, source, draggableId } = result
+function age(date: Date | string) {
+ return formatDistanceToNow(new Date(date), { addSuffix: true, locale: ptBR })
+}
- if (!destination) return
+export function UserResponsesList() {
+ const [view, setView] = React.useState("lista")
+ const [query, setQuery] = React.useState("")
+ const [filter, setFilter] = React.useState("ALL")
+ const [selectedId, setSelectedId] = React.useState(null)
- if (destination.droppableId === source.droppableId && destination.index === source.index) {
- return
- }
+ const { data, isLoading } = api.formResponse.listUserResponses.useQuery()
+ const responses = React.useMemo(
+ () => (data ?? []) as unknown as FormResponse[],
+ [data],
+ )
- // Verificar se o usuário está tentando mover sua própria solicitação
- const response = localResponses.find(r => r.id === draggableId)
- // eslint-disable-next-line @typescript-eslint/prefer-optional-chain
- if (response && currentUser && response.userId === currentUser.id) {
- // Não permitir que o usuário mova sua própria solicitação
- return
+ const counts = React.useMemo(() => {
+ let notStarted = 0
+ let inProgress = 0
+ let completed = 0
+ for (const r of responses) {
+ if (r.status === "NOT_STARTED") notStarted += 1
+ else if (r.status === "IN_PROGRESS") inProgress += 1
+ else if (r.status === "COMPLETED") completed += 1
}
+ return { total: responses.length, notStarted, inProgress, completed }
+ }, [responses])
- setLocalResponses((prev) =>
- prev.map((res) => {
- if (res.id === draggableId) {
- return {
- ...res,
- status: destination.droppableId as ResponseStatus,
- }
- }
- return res
- }),
- )
-
- updateStatusMutation.mutate({
- responseId: draggableId,
- status: destination.droppableId as ResponseStatus,
+ const filtered = React.useMemo(() => {
+ const q = query.trim().toLowerCase()
+ return responses.filter((r) => {
+ if (filter !== "ALL" && r.status !== filter) return false
+ if (!q) return true
+ const title = (r.form?.title ?? "").toLowerCase()
+ const idStr = `${r.number ?? ""}${r.id}`.toLowerCase()
+ return title.includes(q) || idStr.includes(q)
})
- }
+ }, [responses, filter, query])
- function getStatusBadge(status: string) {
- switch (status) {
- case "NOT_STARTED":
- return (
-
- Não iniciado
-
- )
- case "IN_PROGRESS":
- return (
-
- Em andamento
-
- )
- case "COMPLETED":
- return (
-
- Concluído
-
- )
- default:
- return Desconhecido
+ const grouped = React.useMemo(() => {
+ const map: Record = {
+ NOT_STARTED: [],
+ IN_PROGRESS: [],
+ COMPLETED: [],
}
- }
+ for (const r of responses) map[r.status].push(r)
+ return map
+ }, [responses])
+
+ const selected = React.useMemo(
+ () => responses.find((r) => r.id === selectedId) ?? null,
+ [responses, selectedId],
+ )
- if (isLoading || isKanbanLoading) {
+ if (isLoading) {
return (
-
-
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
)
}
- if (!responses || responses.length === 0) {
+ if (responses.length === 0) {
return (
-
-
-
Nenhuma resposta encontrada
-
Você ainda não respondeu nenhum formulário.
+
+
+
+
Nenhuma solicitação ainda
+
Você ainda não abriu nenhuma solicitação.
+
- Ver Formulários Disponíveis
+
+ Ver solicitações disponíveis
+
-
+
)
}
return (
-
-
-
- Lista com Filtros
- Kanban
-
-
-
- {/* Filtros Colapsáveis */}
-
-
-
-
- Filtros por Status
- {selectedStatuses.size > 0 && (
-
- {selectedStatuses.size} selecionado{selectedStatuses.size > 1 ? 's' : ''}
-
- )}
-
-
-
-
- {[
- { value: "NOT_STARTED", label: "Não iniciado", color: "bg-muted" },
- { value: "IN_PROGRESS", label: "Em andamento", color: "bg-blue-100 text-blue-800 border-blue-300" },
- { value: "COMPLETED", label: "Concluído", color: "bg-green-100 text-green-800 border-green-300" }
- ].map((status) => (
-
toggleStatusFilter(status.value)}
- className="flex items-center gap-2 w-full md:w-1/4"
- >
-
- {status.label}
-
- ))}
-
- {selectedStatuses.size > 0 && (
-
- Limpar Filtros
-
- )}
-
-
-
-
+
+ {/* KPIs */}
+
+
+
+
+
+
- {/* Lista Filtrada */}
-
- {filteredResponses.length === 0 ? (
-
-
-
Nenhuma resposta encontrada
-
- Tente ajustar os filtros para ver mais resultados.
-
- {selectedStatuses.size > 0 && (
-
- Limpar Filtros
-
- )}
-
- ) : (
- filteredResponses.map((response) => (
-
-
-
-
- {response.form.title}
-
- Enviado {formatDistanceToNow(new Date(response.createdAt), { addSuffix: true, locale: ptBR })}
-
-
-
- {getStatusBadge(response.status)}
- {response.statusComment && (
-
{response.statusComment}
- )}
-
-
-
-
- {response.form.description ? (
-
- {response.form.description.replace(/\\n/g, "\n")}
-
- ) : (
- Sem descrição
- )}
-
-
-
-
-
-
- Ver Formulário
-
-
- handleOpenDetails(response.id)}>
-
- Ver Detalhes
-
-
-
- ))
+ {/* Toolbar */}
+
+
+
+ setQuery(e.target.value)}
+ className="pl-9"
+ />
+
+
+ setView("lista")}
+ className={cn(
+ "inline-flex items-center gap-1.5 rounded-[5px] px-2.5 py-1.5 text-xs font-medium transition-colors",
+ view === "lista"
+ ? "bg-[hsl(var(--v2-card-2))] text-foreground"
+ : "text-muted-foreground hover:text-foreground",
)}
+ >
+ Lista
+
+ setView("quadro")}
+ className={cn(
+ "inline-flex items-center gap-1.5 rounded-[5px] px-2.5 py-1.5 text-xs font-medium transition-colors",
+ view === "quadro"
+ ? "bg-[hsl(var(--v2-card-2))] text-foreground"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ Quadro
+
+
+
+
+ {view === "lista" ? (
+ <>
+ {/* Chips de status */}
+
+ {STATUS_FILTERS.map((f) => {
+ const active = filter === f.key
+ return (
+ setFilter(f.key)}
+ className={cn(
+ "rounded-full border px-3 py-1 text-xs font-medium transition-colors",
+ active
+ ? "border-[hsl(var(--brand-accent)/.5)] bg-[hsl(var(--brand-accent)/.1)] text-[hsl(var(--brand-accent))]"
+ : "border-[hsl(var(--v2-border-soft))] text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {f.label}
+
+ )
+ })}
-
-
-
-
-
{
- // Não permitir que o usuário arraste sua própria solicitação
- // eslint-disable-next-line @typescript-eslint/prefer-optional-chain
- return !(currentUser && response.userId === currentUser.id)
- }}
- />
- {
- // Não permitir que o usuário arraste sua própria solicitação
- return !(currentUser?.id === response.userId)
- }}
- />
- {
- // Não permitir que o usuário arraste sua própria solicitação
- return !(currentUser?.id === response.userId)
- }}
- />
+ {filtered.length === 0 ? (
+
+
+ Nenhuma solicitação encontrada.
+
+ ) : (
+
+ {filtered.map((r) => (
+ setSelectedId(r.id)} />
+ ))}
-
-
-
-
- {selectedResponseId && (
-
+ )}
+ >
+ ) : (
+
+ {STATUS_ORDER.map((status) => {
+ const meta = STATUS_META[status]
+ const items = grouped[status]
+ return (
+
+
+
+ {meta.label}
+
+ {items.length}
+
+
+
+ {items.length === 0 ? (
+
+ Nenhuma solicitação aqui
+
+ ) : (
+ items.map((r) => (
+
setSelectedId(r.id)} />
+ ))
+ )}
+
+
+ )
+ })}
+
)}
- {selectedKanbanResponse && (
-
- )}
+ !o && setSelectedId(null)}>
+
+ {selected && }
+
+
+
+ )
+}
+
+interface KpiProps {
+ label: string
+ value: number
+ tone?: "warn" | "accent"
+}
+
+function Kpi({ label, value, tone }: KpiProps) {
+ return (
+
+
+ {value}
+
+
+ {label}
+
)
-}
\ No newline at end of file
+}
+
+function RequestRow({ response: r, onOpen }: { response: FormResponse; onOpen: () => void }) {
+ return (
+
+
+
+ {shortId(r)}
+
+
+
+ {r.form?.title ?? "Sem título"}
+
+ {r.statusComment && (
+
+
+ {r.statusComment}
+
+ )}
+
Enviada {age(r.createdAt)}
+
+
+ )
+}
+
+function BoardCard({ response: r, onOpen }: { response: FormResponse; onOpen: () => void }) {
+ return (
+
+ {shortId(r)}
+
+ {r.form?.title ?? "Sem título"}
+
+ {r.statusComment && (
+ {r.statusComment}
+ )}
+
+ {age(r.createdAt)}
+
+
+ )
+}
+
+function RequestDetail({ response: r }: { response: FormResponse }) {
+ const { data: form } = api.form.getById.useQuery({ id: r.formId })
+ const fields = ((form?.fields as unknown as Field[]) ?? []).filter(Boolean)
+ const responseObjects = Array.isArray(r.responses) ? r.responses : []
+ const meta = STATUS_META[r.status]
+
+ return (
+ <>
+
+
+ {shortId(r)}
+
+
+
+ {r.form?.title ?? "Sem título"}
+
+
Enviada {age(r.createdAt)}
+
+
+
+ {r.statusComment && (
+
+
+ {r.statusComment}
+
+ )}
+
+
+ Dados enviados
+
+
+ {fields.length === 0 ? (
+
Carregando campos…
+ ) : (
+
f.showInList !== false)}
+ />
+ )}
+
+
+
+ Conversa
+
+
+
+ >
+ )
+}
diff --git a/src/components/forms/v2/central-view.tsx b/src/components/forms/v2/central-view.tsx
index 2eccade7..893fff6f 100644
--- a/src/components/forms/v2/central-view.tsx
+++ b/src/components/forms/v2/central-view.tsx
@@ -45,6 +45,8 @@ import { Checkbox } from "@/components/ui/checkbox"
import { VirtualizedQueue } from "./virtualized-queue"
import { VirtualizedBoard } from "./virtualized-board"
import { TagsManagerModal } from "@/app/(authenticated)/forms/kanban/_components/tags-manager-modal"
+import { EditResponseModal } from "@/components/forms/edit-response-modal"
+import type { OnDragEndResponder } from "@hello-pangea/dnd"
type View = "fila" | "quadro"
type Tab = "ALL" | ResponseStatus
@@ -77,6 +79,9 @@ export function CentralView() {
const [selectedId, setSelectedId] = React.useState(null)
const [selectedTagIds, setSelectedTagIds] = React.useState([])
const [tagsModalOpen, setTagsModalOpen] = React.useState(false)
+ const [editResponseId, setEditResponseId] = React.useState(null)
+ const [editFormId, setEditFormId] = React.useState(null)
+ const [editModalOpen, setEditModalOpen] = React.useState(false)
const { data, isLoading, refetch } = api.formResponse.listKanBan.useQuery({
tagIds: selectedTagIds.length > 0 ? selectedTagIds : undefined,
@@ -84,10 +89,11 @@ export function CentralView() {
const { data: availableTags = [] } = api.formResponse.getTags.useQuery()
const utils = api.useUtils()
- const responses = React.useMemo(
- () => (data ?? []) as unknown as FormResponse[],
- [data],
- )
+ // Estado local para feedback otimista do arrastar (drag-and-drop)
+ const [responses, setResponses] = React.useState([])
+ React.useEffect(() => {
+ setResponses((data ?? []) as unknown as FormResponse[])
+ }, [data])
React.useEffect(() => {
if (responses.length > 0 && !selectedId) {
@@ -157,6 +163,44 @@ export function CentralView() {
}
}
+ // Arrastar um card para outro nível (coluna de status)
+ const onDragEnd: OnDragEndResponder = (result) => {
+ const { destination, source, draggableId } = result
+ if (!destination) return
+ if (destination.droppableId === source.droppableId) return
+
+ const newStatus = destination.droppableId as ResponseStatus
+ // Feedback imediato na UI
+ setResponses((prev) =>
+ prev.map((r) => (r.id === draggableId ? { ...r, status: newStatus } : r)),
+ )
+ updateStatus.mutate({ responseId: draggableId, status: newStatus })
+ toast.success(`Chamado movido para ${STATUS_META[newStatus].label}`)
+ }
+
+ // Abrir o chamado no painel de detalhes (visão Fila)
+ function handleOpenDetails(id: string) {
+ setSelectedId(id)
+ setView("fila")
+ }
+
+ function handleEdit(id: string, formId: string) {
+ setEditResponseId(id)
+ setEditFormId(formId)
+ setEditModalOpen(true)
+ }
+
+ function handleMoveToNextStatus(id: string, currentStatus: ResponseStatus) {
+ const currentIndex = STATUS_ORDER.indexOf(currentStatus)
+ if (currentIndex < STATUS_ORDER.length - 1) {
+ const nextStatus = STATUS_ORDER[currentIndex + 1]!
+ setResponses((prev) =>
+ prev.map((r) => (r.id === id ? { ...r, status: nextStatus } : r)),
+ )
+ updateStatus.mutate({ responseId: id, status: nextStatus })
+ }
+ }
+
return (
{/* Header */}
@@ -413,14 +457,31 @@ export function CentralView() {
) : (
{
- setSelectedId(id)
- setView("fila")
- }}
+ availableTags={availableTags}
+ onSelect={handleOpenDetails}
+ onDragEnd={onDragEnd}
+ onOpenDetails={handleOpenDetails}
+ onEdit={handleEdit}
+ onOpenChat={handleOpenDetails}
+ onMoveToNextStatus={handleMoveToNextStatus}
+ onOpenTagsManager={() => setTagsModalOpen(true)}
/>
)}
+
+ {editResponseId && editFormId && (
+ {
+ setEditModalOpen(false)
+ setEditResponseId(null)
+ setEditFormId(null)
+ }}
+ />
+ )}
)
}
diff --git a/src/components/forms/v2/forms-list-v2.tsx b/src/components/forms/v2/forms-list-v2.tsx
index 079cdd71..0f717316 100644
--- a/src/components/forms/v2/forms-list-v2.tsx
+++ b/src/components/forms/v2/forms-list-v2.tsx
@@ -305,11 +305,18 @@ function CatalogRow({ form, onOpen }: CatalogRowProps) {
const sectorLabel = getSector(form)
const fieldsArr = getFieldsArray(form)
return (
- {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault()
+ onOpen()
+ }
+ }}
className={cn(
- "group flex w-full items-center gap-4 rounded-[var(--v2-radius-card)] border border-[hsl(var(--v2-border-soft))] bg-[hsl(var(--card)/.9)] p-4 text-left transition-all",
+ "group flex w-full cursor-pointer items-center gap-4 rounded-[var(--v2-radius-card)] border border-[hsl(var(--v2-border-soft))] bg-[hsl(var(--card)/.9)] p-4 text-left transition-all",
"hover:-translate-y-0.5 hover:border-[hsl(var(--brand-accent)/.4)] hover:shadow-[var(--v2-shadow)]",
"backdrop-blur-sm",
)}
@@ -337,18 +344,20 @@ function CatalogRow({ form, onOpen }: CatalogRowProps) {
-
e.stopPropagation()}
+ className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[hsl(var(--v2-border-soft))] px-3 text-xs font-medium text-muted-foreground transition-colors hover:border-[hsl(var(--brand-accent)/.4)] hover:text-foreground"
>
Ver
-
+
Abrir solicitação
-
+
)
}
diff --git a/src/components/forms/v2/virtualized-board.tsx b/src/components/forms/v2/virtualized-board.tsx
index c729982b..b3eeb708 100644
--- a/src/components/forms/v2/virtualized-board.tsx
+++ b/src/components/forms/v2/virtualized-board.tsx
@@ -1,23 +1,34 @@
"use client"
import * as React from "react"
-import { List, type RowComponentProps } from "react-window"
+import { createPortal } from "react-dom"
+import {
+ DragDropContext,
+ Droppable,
+ Draggable,
+ type OnDragEndResponder,
+} from "@hello-pangea/dnd"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
-import { Clock } from "lucide-react"
+import { Clock, X } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card } from "@/components/ui/card"
import { cn } from "@/lib/utils"
+import { api } from "@/trpc/react"
+import { toast } from "sonner"
import type { FormResponse, ResponseStatus } from "@/types/form-responses"
import { STATUS_META } from "./request-status-pill"
-
-const CARD_HEIGHT = 168
-const CARD_GAP = 8
-const MAX_VISIBLE_CARDS = 6
+import { ResponseContextMenu } from "@/app/(authenticated)/forms/kanban/_components/tags-context-menu"
const STATUS_ORDER: ResponseStatus[] = ["NOT_STARTED", "IN_PROGRESS", "COMPLETED"]
+interface AvailableTag {
+ id: string
+ nome: string
+ cor?: string | null
+}
+
function fullName(user?: { firstName?: string | null; lastName?: string | null; email?: string | null }) {
if (!user) return "Sem solicitante"
const a = user.firstName ?? ""
@@ -38,21 +49,70 @@ function shortId(r: FormResponse) {
return r.number != null ? `#${r.number}` : `#${r.id.slice(0, 6)}`
}
-interface BoardCardRowProps {
- items: FormResponse[]
+interface BoardCardProps {
+ response: FormResponse
+ availableTags: AvailableTag[]
+ isDragging: boolean
onSelect: (id: string) => void
+ onOpenDetails: (id: string) => void
+ onEdit?: (id: string, formId: string) => void
+ onOpenChat?: (id: string) => void
+ onMoveToNextStatus?: (id: string, status: ResponseStatus) => void
+ onOpenTagsManager?: () => void
}
-function BoardCardRow({ index, style, items, onSelect }: RowComponentProps
) {
- const r = items[index]
- if (!r) return null
+function BoardCard({
+ response: r,
+ availableTags,
+ isDragging,
+ onSelect,
+ onOpenDetails,
+ onEdit,
+ onOpenChat,
+ onMoveToNextStatus,
+ onOpenTagsManager,
+}: BoardCardProps) {
+ const [contextMenu, setContextMenu] = React.useState<{ x: number; y: number } | null>(null)
+ const utils = api.useUtils()
+
+ const removeTag = api.formResponse.removeTag.useMutation({
+ onSuccess: () => {
+ toast.success("Tag removida")
+ void utils.formResponse.listKanBan.invalidate()
+ },
+ onError: (error) => toast.error(error.message || "Erro ao remover tag"),
+ })
+
+ const handleContextMenu = (e: React.MouseEvent) => {
+ // Apenas em desktop (não mobile)
+ if (window.innerWidth >= 768) {
+ e.preventDefault()
+ e.stopPropagation()
+ setContextMenu({ x: e.clientX, y: e.clientY })
+ }
+ }
+
+ const appliedTags = r.tags
+ ? availableTags.filter((tag) => r.tags?.includes(tag.id))
+ : []
+
return (
-
-
+ onSelect(r.id)}
- style={{ height: CARD_HEIGHT - CARD_GAP }}
- className="flex w-full flex-col gap-2 rounded-xl border border-[hsl(var(--v2-border-soft))] bg-[hsl(var(--card))] p-3 text-left transition-all hover:-translate-y-0.5 hover:border-[hsl(var(--brand-accent)/.45)] hover:shadow-[var(--v2-shadow)]"
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault()
+ onSelect(r.id)
+ }
+ }}
+ onContextMenu={handleContextMenu}
+ className={cn(
+ "flex w-full cursor-pointer flex-col gap-2 rounded-xl border border-[hsl(var(--v2-border-soft))] bg-[hsl(var(--card))] p-3 text-left transition-all hover:-translate-y-0.5 hover:border-[hsl(var(--brand-accent)/.45)] hover:shadow-[var(--v2-shadow)]",
+ isDragging && "border-[hsl(var(--brand-accent)/.6)] shadow-[var(--v2-shadow)]",
+ )}
>
{shortId(r)}
@@ -61,7 +121,39 @@ function BoardCardRow({ index, style, items, onSelect }: RowComponentProps
)}
+ {r.hasNewMessages && (
+
+ Mensagem
+
+ )}
+
+ {appliedTags.length > 0 && (
+
+ {appliedTags.map((tag) => (
+
+ {tag.nome}
+ {
+ e.stopPropagation()
+ e.preventDefault()
+ removeTag.mutate({ responseId: r.id, tagId: tag.id })
+ }}
+ aria-label={`Remover tag ${tag.nome}`}
+ >
+
+
+
+ ))}
+
+ )}
+
{r.form?.title ?? "Sem título"}
@@ -80,17 +172,51 @@ function BoardCardRow({ index, style, items, onSelect }: RowComponentProps
-
-
+
+
+ {contextMenu && (
+ setContextMenu(null)}
+ onTagChange={() => void utils.formResponse.listKanBan.invalidate()}
+ onOpenDetails={onOpenDetails}
+ onEdit={onEdit}
+ onOpenChat={onOpenChat}
+ onMoveToNextStatus={onMoveToNextStatus}
+ onOpenTagsManager={onOpenTagsManager}
+ />
+ )}
+ >
)
}
interface VirtualizedBoardProps {
responses: FormResponse[]
+ availableTags?: AvailableTag[]
onSelect: (id: string) => void
+ onDragEnd: OnDragEndResponder
+ onOpenDetails: (id: string) => void
+ onEdit?: (id: string, formId: string) => void
+ onOpenChat?: (id: string) => void
+ onMoveToNextStatus?: (id: string, status: ResponseStatus) => void
+ onOpenTagsManager?: () => void
}
-export function VirtualizedBoard({ responses, onSelect }: VirtualizedBoardProps) {
+export function VirtualizedBoard({
+ responses,
+ availableTags = [],
+ onSelect,
+ onDragEnd,
+ onOpenDetails,
+ onEdit,
+ onOpenChat,
+ onMoveToNextStatus,
+ onOpenTagsManager,
+}: VirtualizedBoardProps) {
const grouped = React.useMemo(() => {
const map: Record = {
NOT_STARTED: [],
@@ -102,44 +228,85 @@ export function VirtualizedBoard({ responses, onSelect }: VirtualizedBoardProps)
}, [responses])
return (
-
- {STATUS_ORDER.map((status) => {
- const meta = STATUS_META[status]
- const items = grouped[status]
- const visibleCards = Math.min(MAX_VISIBLE_CARDS, items.length)
- const listHeight = visibleCards * CARD_HEIGHT
- return (
-
-
-
- {meta.label}
-
- {items.length}
-
-
- {items.length === 0 ? (
-
- Sem itens
-
- ) : (
-
-
+
+
+ {STATUS_ORDER.map((status) => {
+ const meta = STATUS_META[status]
+ const items = grouped[status]
+ return (
+
+
+
+ {meta.label}
+
+ {items.length}
+
- )}
-
- )
- })}
-
+
+
+ {(provided, dropSnapshot) => (
+
+ {items.length === 0 && !dropSnapshot.isDraggingOver ? (
+
+ Sem itens
+
+ ) : (
+ items.map((r, index) => (
+
+ {(dragProvided, dragSnapshot) => {
+ const node = (
+
+
+
+ )
+ // Enquanto arrasta, renderiza num portal no para escapar de
+ // ancestrais com transform/backdrop-filter/overflow (que quebravam a
+ // posição do cursor e jogavam o card para trás das demais camadas).
+ if (dragSnapshot.isDragging && typeof document !== "undefined") {
+ return createPortal(node, document.body)
+ }
+ return node
+ }}
+
+ ))
+ )}
+ {provided.placeholder}
+
+ )}
+
+
+ )
+ })}
+
+
)
}
diff --git a/src/lib/download-file.ts b/src/lib/download-file.ts
new file mode 100644
index 00000000..462b04bd
--- /dev/null
+++ b/src/lib/download-file.ts
@@ -0,0 +1,19 @@
+/** MIME de planilha Excel (.xlsx). */
+export const XLSX_MIME =
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+
+/** Decodifica um base64 e dispara o download como arquivo (somente no browser). */
+export function downloadBase64File(base64: string, filename: string, mimeType: string): void {
+ const binary = atob(base64)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i)
+ }
+ const blob = new Blob([bytes], { type: mimeType })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = filename
+ a.click()
+ URL.revokeObjectURL(url)
+}
diff --git a/src/lib/form-xlsx-export.ts b/src/lib/form-xlsx-export.ts
new file mode 100644
index 00000000..08873913
--- /dev/null
+++ b/src/lib/form-xlsx-export.ts
@@ -0,0 +1,23 @@
+import * as XLSX from "xlsx"
+
+/**
+ * Gera uma planilha .xlsx (codificada em base64) a partir de cabeçalhos + linhas.
+ * Uso server-side (tRPC): o cliente decodifica o base64 num Blob para download.
+ */
+export function buildXlsxBase64FromRows(
+ headers: string[],
+ rows: string[][],
+ sheetName = "Respostas",
+): string {
+ const worksheet = XLSX.utils.aoa_to_sheet([headers, ...rows])
+ const workbook = XLSX.utils.book_new()
+ // Excel limita o nome da aba a 31 caracteres.
+ XLSX.utils.book_append_sheet(workbook, worksheet, sheetName.slice(0, 31))
+ return XLSX.write(workbook, { type: "base64", bookType: "xlsx" }) as string
+}
+
+/** Nome de arquivo seguro com extensão .xlsx. */
+export function sanitizeXlsxFilename(name: string): string {
+ const base = name.replace(/[^\w.\-() À-ɏ]+/g, "_").trim() || "formulario"
+ return base.endsWith(".xlsx") ? base.slice(0, 120) : `${base.slice(0, 100)}.xlsx`
+}
diff --git a/src/server/api/routers/form-response.ts b/src/server/api/routers/form-response.ts
index ea89b5f8..c710abff 100644
--- a/src/server/api/routers/form-response.ts
+++ b/src/server/api/routers/form-response.ts
@@ -6,11 +6,8 @@ import { sendEmail } from "@/lib/mail/email-utils"
import { mockEmailSituacaoFormulario, mockEmailRespostaFormulario, mockEmailChatMensagemFormulario, mockEmailTagFormulario } from "@/lib/mail/html-mock"
import { formatFormResponseNumber } from "@/lib/utils/form-response-number"
import type { Field } from "@/lib/form-types"
-import {
- buildCsvFromRows,
- formatSpreadsheetCell,
- sanitizeExportFilename,
-} from "@/lib/form-csv-export"
+import { formatSpreadsheetCell } from "@/lib/form-csv-export"
+import { buildXlsxBase64FromRows, sanitizeXlsxFilename } from "@/lib/form-xlsx-export"
const MAX_SPREADSHEET_EXPORT_ROWS = 8_000
@@ -471,10 +468,10 @@ export const formResponseRouter = createTRPCRouter({
}),
/**
- * Exporta respostas em CSV (UTF-8 com BOM). Só responsáveis do formulário;
+ * Exporta respostas em planilha .xlsx (base64). Só responsáveis do formulário;
* o formulário precisa ter `spreadsheetExportEnabled`.
*/
- exportSpreadsheetCsv: protectedProcedure
+ exportSpreadsheetXlsx: protectedProcedure
.input(
z.object({
formId: z.string(),
@@ -588,21 +585,21 @@ export const formResponseRouter = createTRPCRouter({
return [...staticCells, ...fieldCells]
})
- const csv = buildCsvFromRows(headers, bodyRows)
+ const xlsxBase64 = buildXlsxBase64FromRows(headers, bodyRows, "Respostas")
return {
- csv,
- filename: sanitizeExportFilename(`${form.title ?? "formulario"}-export`),
+ xlsxBase64,
+ filename: sanitizeXlsxFilename(`${form.title ?? "formulario"}-export`),
truncated: rows.length >= MAX_SPREADSHEET_EXPORT_ROWS,
rowCount: rows.length,
}
}),
/**
- * Exporta respostas agrupadas por usuário em CSV (UTF-8 com BOM).
+ * Exporta respostas agrupadas por usuário em planilha .xlsx (base64).
* Colunas: Respondente, E-mail, Setor, Total de envios, Número, Data envio, Status + campos selecionados.
*/
- exportByUserCsv: protectedProcedure
+ exportByUserXlsx: protectedProcedure
.input(
z.object({
formId: z.string(),
@@ -721,11 +718,11 @@ export const formResponseRouter = createTRPCRouter({
return [...staticCells, ...fieldCells]
})
- const csv = buildCsvFromRows(headers, bodyRows)
+ const xlsxBase64 = buildXlsxBase64FromRows(headers, bodyRows, "Por usuário")
return {
- csv,
- filename: sanitizeExportFilename(`${form.title ?? "formulario"}-por-usuario`),
+ xlsxBase64,
+ filename: sanitizeXlsxFilename(`${form.title ?? "formulario"}-por-usuario`),
truncated: rows.length >= MAX_SPREADSHEET_EXPORT_ROWS,
rowCount: rows.length,
}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 9cc405e1..aa53142c 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -86,24 +86,26 @@
}
/* === Camada ambiente do redesign Solicitações v2 ===
- Aplicada apenas dentro de containers .v2-scope (não vaza para a app inteira). */
+ O brilho de identidade é renderizado a NÍVEL DE LAYOUT (viewport), não preso ao
+ container centralizado — assim cobre toda a tela junto com o canvas animado, de
+ forma consistente em todas as páginas v2 (catálogo, central, responder, etc.). */
.v2-scope {
position: relative;
}
.v2-scope::before {
content: "";
- position: absolute;
+ position: fixed;
inset: 0;
pointer-events: none;
- z-index: 0;
- /* Glows de tamanho FIXO (rem) ancorados nos cantos: assim o efeito ambiente
+ z-index: -5;
+ /* Glows de tamanho FIXO (rem) ancorados nos cantos da viewport: o efeito ambiente
permanece consistente e não estica/clipa em páginas internas longas
- (ex.: responder/personalizar um formulário). */
+ (ex.: responder/personalizar um formulário). Fica acima do canvas animado
+ (body, z -10) e abaixo do conteúdo da página. */
background:
radial-gradient(42rem 34rem at 100% 0, hsl(var(--v2-glow-a) / var(--v2-glow-op)), transparent 70%),
radial-gradient(42rem 34rem at 0 100%, hsl(var(--v2-glow-b) / var(--v2-glow-op)), transparent 70%);
background-repeat: no-repeat;
- border-radius: inherit;
}
.v2-scope > * {
position: relative;