Initial commit DBMaker - Oficiais e Funcionando
This commit is contained in:
116
src/App.tsx
Normal file
116
src/App.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useAuthStore } from './lib/store'
|
||||
import { useEffect, Suspense, lazy } from 'react'
|
||||
import { supabase } from './lib/supabase'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import LoadingSpinner from './components/common/LoadingSpinner'
|
||||
|
||||
// Layout
|
||||
import Layout from './components/layout/Layout'
|
||||
|
||||
// Lazy load pages
|
||||
const Login = lazy(() => import('./pages/Login'))
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const Templates = lazy(() => import('./pages/Templates'))
|
||||
const TemplateCreate = lazy(() => import('./pages/TemplateCreate'))
|
||||
const TemplateEdit = lazy(() => import('./pages/TemplateEdit'))
|
||||
const TopicosGestao = lazy(() => import('./pages/TopicosGestao'))
|
||||
const Databooks = lazy(() => import('./pages/Databooks'))
|
||||
const DatabookNew = lazy(() => import('./pages/DatabookNew'))
|
||||
const DatabookEdit = lazy(() => import('./pages/DatabookEdit'))
|
||||
const DatabookView = lazy(() => import('./pages/DatabookView'))
|
||||
const Configuracoes = lazy(() => import('./pages/Configuracoes'))
|
||||
const Busca = lazy(() => import('./pages/Busca'))
|
||||
const DesignDatabook = lazy(() => import('./pages/DesignDatabook'))
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutos
|
||||
gcTime: 1000 * 60 * 10, // 10 minutos (antes era cacheTime)
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((state) => state.user)
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function App() {
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const logout = useAuthStore((state) => state.logout)
|
||||
|
||||
// Verificar sessão ao carregar
|
||||
useEffect(() => {
|
||||
const checkSession = async () => {
|
||||
const { data } = await supabase.auth.getSession()
|
||||
if (data.session?.user) {
|
||||
setUser(data.session.user as any)
|
||||
}
|
||||
}
|
||||
|
||||
checkSession()
|
||||
|
||||
// Escutar mudanças de autenticação
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (session?.user) {
|
||||
setUser(session.user as any)
|
||||
} else {
|
||||
logout()
|
||||
}
|
||||
})
|
||||
|
||||
return () => subscription?.unsubscribe()
|
||||
}, [setUser, logout])
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-screen"><LoadingSpinner size="lg" /></div>}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="templates" element={<Templates />} />
|
||||
<Route path="templates/criar" element={<TemplateCreate />} />
|
||||
<Route path="templates/:id/editar" element={<TemplateEdit />} />
|
||||
<Route path="topicos" element={<TopicosGestao />} />
|
||||
<Route path="databooks" element={<Databooks />} />
|
||||
<Route path="databook/novo" element={<DatabookNew />} />
|
||||
<Route path="databook/:id/editar" element={<DatabookEdit />} />
|
||||
<Route path="databook/:id" element={<DatabookView />} />
|
||||
<Route path="design" element={<DesignDatabook />} />
|
||||
<Route path="configuracoes" element={<Configuracoes />} />
|
||||
<Route path="busca" element={<Busca />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
520
src/components/ManualModal.tsx
Normal file
520
src/components/ManualModal.tsx
Normal file
@@ -0,0 +1,520 @@
|
||||
import { useState } from 'react'
|
||||
import { X, ChevronRight, BookOpen } from 'lucide-react'
|
||||
|
||||
interface ManualSection {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const sections: ManualSection[] = [
|
||||
{
|
||||
id: 'inicio',
|
||||
title: 'Primeiros Passos',
|
||||
icon: '🚀',
|
||||
content: `
|
||||
# 🚀 Primeiros Passos
|
||||
|
||||
## Login
|
||||
|
||||
1. Acesse a plataforma SteelBook
|
||||
2. Digite seu **email** e **senha**
|
||||
3. Clique em **"Entrar"**
|
||||
4. Você será redirecionado para o Dashboard
|
||||
|
||||
> **Dica:** Se esqueceu sua senha, entre em contato com o administrador.
|
||||
|
||||
## Sua Primeira Sessão
|
||||
|
||||
Após fazer login, você verá:
|
||||
- **Dashboard** com seus projetos recentes
|
||||
- **Barra de navegação** no topo
|
||||
- **Menu lateral** com opções principais
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'dashboard',
|
||||
title: 'Dashboard',
|
||||
icon: '📊',
|
||||
content: `
|
||||
# 📊 Dashboard
|
||||
|
||||
O Dashboard é sua central de controle. Aqui você vê:
|
||||
|
||||
## Estatísticas Rápidas
|
||||
|
||||
- **Total Projetos:** Número total de databooks
|
||||
- **Em Andamento:** Projetos em desenvolvimento
|
||||
- **Finalizados:** Projetos concluídos
|
||||
- **Templates:** Modelos disponíveis
|
||||
|
||||
## Projetos Recentes
|
||||
|
||||
Uma tabela mostrando seus últimos projetos com:
|
||||
|
||||
| Campo | Descrição |
|
||||
|-------|-----------|
|
||||
| **Projeto** | Nome e número do projeto |
|
||||
| **Cliente** | Empresa cliente |
|
||||
| **Status** | Rascunho, Em Andamento, Revisão, Finalizado |
|
||||
| **Progresso** | Barra visual com percentual |
|
||||
| **Ações** | Ver, Editar, Clonar, Deletar |
|
||||
|
||||
## Entendendo o Progresso
|
||||
|
||||
A barra de progresso mostra quantos tópicos já têm documentos:
|
||||
|
||||
\`\`\`
|
||||
Progresso = (Tópicos com documentos / Total de tópicos) × 100
|
||||
|
||||
Exemplo:
|
||||
├─ Total de tópicos: 28
|
||||
├─ Tópicos com documentos: 7
|
||||
└─ Progresso: 25% ✓
|
||||
\`\`\`
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'databook',
|
||||
title: 'Criando um Databook',
|
||||
icon: '📚',
|
||||
content: `
|
||||
# 📚 Criando um Databook
|
||||
|
||||
## Passo 1: Novo Databook
|
||||
|
||||
1. Clique no botão **"Novo Databook"** (canto superior direito)
|
||||
2. Você será levado à página de criação
|
||||
|
||||
## Passo 2: Informações Básicas
|
||||
|
||||
Preencha os campos:
|
||||
|
||||
- **Número do Projeto:** Ex: \`PROJ-2024-001\`
|
||||
- **Nome do Projeto:** Ex: \`Databook Turbina XYZ\`
|
||||
- **Cliente:** Selecione na lista ou crie novo
|
||||
- **Template:** Escolha o template padrão ou customizado
|
||||
- **Data de Início:** Quando o projeto começa
|
||||
- **Data de Entrega:** Prazo para conclusão
|
||||
|
||||
## Passo 3: Configurações do Databook
|
||||
|
||||
Na aba **"Configurações"**, customize:
|
||||
|
||||
### Informações do Produto
|
||||
- Nome do produto
|
||||
- Tipo (Ex: Turbina, Compressor)
|
||||
- Descrição técnica
|
||||
- Normas aplicáveis
|
||||
|
||||
### Informações do Cliente
|
||||
- Nome da empresa
|
||||
- Contato principal
|
||||
- Email
|
||||
- Telefone
|
||||
|
||||
### Aparência
|
||||
- **Cores:** Primária e secundária
|
||||
- **Logo da Empresa:** Upload da logo
|
||||
- **Logo do Cliente:** Upload da logo
|
||||
- **Marca d'água:** Imagem de fundo
|
||||
|
||||
## Passo 4: Salvar
|
||||
|
||||
Clique em **"Salvar Configurações"** para confirmar.
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'documentos',
|
||||
title: 'Gerenciando Documentos',
|
||||
icon: '📄',
|
||||
content: `
|
||||
# 📄 Gerenciando Documentos
|
||||
|
||||
## Adicionando Documentos
|
||||
|
||||
1. Abra o databook que deseja editar
|
||||
2. Navegue até a seção desejada
|
||||
3. Clique em **"+ Adicionar Documento"**
|
||||
4. Selecione o arquivo (PDF, JPG, PNG)
|
||||
5. Preencha os dados:
|
||||
- **Título:** Nome do documento
|
||||
- **Número:** Código do documento
|
||||
- **Revisão:** Versão (Ex: Rev. 1)
|
||||
- **Data:** Data do documento
|
||||
- **Tags:** Palavras-chave
|
||||
|
||||
## Visualizando Documentos
|
||||
|
||||
Cada documento mostra:
|
||||
- **Thumbnail:** Prévia do arquivo
|
||||
- **Informações:** Título, número, revisão
|
||||
- **Ações:** Visualizar, Editar, Deletar
|
||||
|
||||
## Organizando Documentos
|
||||
|
||||
Você pode:
|
||||
- **Reordenar:** Arrastar e soltar
|
||||
- **Filtrar:** Por categoria ou tag
|
||||
- **Buscar:** Por título ou número
|
||||
|
||||
## Deletando Documentos
|
||||
|
||||
1. Clique no ícone **🗑️ (Lixo)**
|
||||
2. Confirme a exclusão
|
||||
3. Documento será removido permanentemente
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'topicos',
|
||||
title: 'Tópicos e Categorias',
|
||||
icon: '🏷️',
|
||||
content: `
|
||||
# 🏷️ Tópicos e Categorias
|
||||
|
||||
## Entendendo Tópicos
|
||||
|
||||
Tópicos são as seções do seu databook. Exemplo:
|
||||
|
||||
\`\`\`
|
||||
1. Atestado de Conformidade
|
||||
1.1 Certificados
|
||||
1.2 Desenhos
|
||||
2. Procedimentos
|
||||
2.1 Soldagem
|
||||
2.2 Inspeção
|
||||
\`\`\`
|
||||
|
||||
## Gerenciando Tópicos
|
||||
|
||||
Acesse **Menu → Gestão de Tópicos**
|
||||
|
||||
### Criar Novo Tópico
|
||||
|
||||
1. Clique **"Novo Tópico"**
|
||||
2. Preencha:
|
||||
- **Número:** Ex: \`1.1\`
|
||||
- **Título:** Nome do tópico
|
||||
- **Descrição:** Detalhes (opcional)
|
||||
- **Categoria:** Selecione uma categoria
|
||||
- **Obrigatório:** Marque se é obrigatório
|
||||
3. Clique **"Criar"**
|
||||
|
||||
### Reordenar Tópicos
|
||||
|
||||
1. Clique e segure o ícone **⋮⋮ (Arrastar)**
|
||||
2. Arraste para a nova posição
|
||||
3. A ordem é atualizada automaticamente
|
||||
|
||||
## Categorias
|
||||
|
||||
Categorias organizam seus tópicos por tipo:
|
||||
|
||||
| Categoria | Cor | Uso |
|
||||
|-----------|-----|-----|
|
||||
| 🟢 Certificados | Verde | Certificações e conformidade |
|
||||
| 🔵 Desenhos | Azul | Desenhos técnicos |
|
||||
| 🟠 Relatórios | Laranja | Relatórios de inspeção |
|
||||
| 🟣 Procedimentos | Roxo | Procedimentos e instruções |
|
||||
| 🔴 Normas | Vermelho | Normas e especificações |
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'configuracoes',
|
||||
title: 'Configurações',
|
||||
icon: '⚙️',
|
||||
content: `
|
||||
# ⚙️ Configurações
|
||||
|
||||
## Mapeamento de Pastas
|
||||
|
||||
Configure pastas locais ou na nuvem para sincronização automática.
|
||||
|
||||
**Criar Mapeamento:**
|
||||
1. Clique **"Novo Mapeamento"**
|
||||
2. Preencha:
|
||||
- **Tipo de Documento:** Ex: \`Certificados\`
|
||||
- **Categoria:** Selecione
|
||||
- **Caminho:** Local ou URL da nuvem
|
||||
- **Frequência:** Manual, Ao criar, Diário, Semanal
|
||||
3. Clique **"Criar"**
|
||||
|
||||
## Gerenciamento de Categorias
|
||||
|
||||
Crie e customize categorias.
|
||||
|
||||
**Criar Categoria:**
|
||||
1. Clique **"Nova Categoria"**
|
||||
2. Preencha:
|
||||
- **Nome:** Ex: \`Testes\`
|
||||
- **Descrição:** Detalhes
|
||||
- **Cor:** Escolha uma cor
|
||||
3. Clique **"Criar"**
|
||||
|
||||
## Gerenciamento de Usuários
|
||||
|
||||
(Apenas para administradores)
|
||||
|
||||
Adicione e gerencie usuários do sistema.
|
||||
|
||||
**Adicionar Usuário:**
|
||||
1. Clique **"Novo Usuário"**
|
||||
2. Preencha:
|
||||
- **Email:** Email do usuário
|
||||
- **Nome:** Nome completo
|
||||
- **Perfil:** Admin, Gerente, Engenheiro, Cliente
|
||||
3. Clique **"Criar"**
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'pdf',
|
||||
title: 'Gerando PDF',
|
||||
icon: '📑',
|
||||
content: `
|
||||
# 📑 Gerando PDF
|
||||
|
||||
## Visualizar Preview
|
||||
|
||||
1. Abra o databook
|
||||
2. Clique em **"Preview"**
|
||||
3. Veja como ficará o PDF final
|
||||
|
||||
## Gerar PDF
|
||||
|
||||
1. Clique em **"Gerar PDF"**
|
||||
2. Aguarde o processamento
|
||||
3. O arquivo será baixado automaticamente
|
||||
|
||||
## Personalizações no PDF
|
||||
|
||||
O PDF incluirá:
|
||||
- ✅ Logo da empresa
|
||||
- ✅ Logo do cliente
|
||||
- ✅ Marca d'água
|
||||
- ✅ Cores personalizadas
|
||||
- ✅ Numeração de páginas
|
||||
- ✅ Todos os documentos organizados
|
||||
|
||||
## Dicas para Melhor Resultado
|
||||
|
||||
1. **Revise antes:** Sempre visualize o preview
|
||||
2. **Organize bem:** Ordene os documentos corretamente
|
||||
3. **Use cores:** Personalize as cores do databook
|
||||
4. **Adicione logos:** Inclua logos para profissionalismo
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'dicas',
|
||||
title: 'Dicas e Truques',
|
||||
icon: '💡',
|
||||
content: `
|
||||
# 💡 Dicas e Truques
|
||||
|
||||
## 💡 Dica 1: Use Categorias Consistentemente
|
||||
|
||||
Sempre use as mesmas categorias para manter a organização.
|
||||
|
||||
## 💡 Dica 2: Nomeie Documentos Claramente
|
||||
|
||||
Use nomes descritivos:
|
||||
- ✅ Bom: \`Certificado_Soldagem_Rev1_2024\`
|
||||
- ❌ Ruim: \`doc1\`, \`arquivo\`
|
||||
|
||||
## 💡 Dica 3: Revise Antes de Gerar PDF
|
||||
|
||||
Sempre visualize o preview antes de gerar o PDF final.
|
||||
|
||||
## 💡 Dica 4: Use Tags para Busca
|
||||
|
||||
Adicione tags relevantes aos documentos para facilitar busca posterior.
|
||||
|
||||
## 💡 Dica 5: Mantenha Backups
|
||||
|
||||
Exporte seus databooks regularmente como backup.
|
||||
|
||||
## 💡 Dica 6: Organize Hierarquicamente
|
||||
|
||||
Use a numeração hierárquica:
|
||||
- \`1\` - Tópico principal
|
||||
- \`1.1\` - Subtópico
|
||||
- \`1.1.1\` - Sub-subtópico
|
||||
|
||||
## 💡 Dica 7: Aproveite a Sincronização
|
||||
|
||||
Configure mapeamento de pastas para sincronizar automaticamente.
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'faq',
|
||||
title: 'Perguntas Frequentes',
|
||||
icon: '❓',
|
||||
content: `
|
||||
# ❓ Perguntas Frequentes
|
||||
|
||||
## P: Como faço para clonar um databook?
|
||||
|
||||
R: No Dashboard, clique no ícone **📋 (Clonar)** ao lado do projeto. Uma cópia será criada com status "Rascunho".
|
||||
|
||||
## P: Posso editar um databook finalizado?
|
||||
|
||||
R: Sim, mas recomenda-se criar uma nova revisão. Clique em **"Nova Revisão"** nas configurações.
|
||||
|
||||
## P: Qual é o tamanho máximo de arquivo?
|
||||
|
||||
R: Até 50 MB por arquivo. Comprima se necessário.
|
||||
|
||||
## P: Como faço backup dos meus databooks?
|
||||
|
||||
R: Exporte como PDF ou entre em contato com o administrador para backup do banco de dados.
|
||||
|
||||
## P: Posso compartilhar um databook com outro usuário?
|
||||
|
||||
R: Sim, adicione o usuário no painel de configurações e defina as permissões.
|
||||
|
||||
## P: Como faço para deletar um databook?
|
||||
|
||||
R: No Dashboard, clique no ícone **🗑️ (Lixo)**. Confirme a exclusão. Esta ação não pode ser desfeita.
|
||||
|
||||
## P: Qual é o tempo de processamento para gerar PDF?
|
||||
|
||||
R: Geralmente entre 5 a 30 segundos, dependendo do tamanho do databook.
|
||||
|
||||
## P: Posso usar caracteres especiais nos nomes?
|
||||
|
||||
R: Recomenda-se usar apenas letras, números, hífen e underscore para evitar problemas.
|
||||
`
|
||||
}
|
||||
]
|
||||
|
||||
interface ManualModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ManualModal({ isOpen, onClose }: ManualModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<string>('inicio')
|
||||
|
||||
const currentSection = sections.find(s => s.id === activeSection)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-5xl max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<BookOpen size={28} className="text-primary" />
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Manual do Usuário</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 border-r border-gray-200 dark:border-gray-700 overflow-y-auto flex-shrink-0 bg-gray-50 dark:bg-gray-900">
|
||||
<nav className="p-4 space-y-2">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg transition-all flex items-center justify-between group ${
|
||||
activeSection === section.id
|
||||
? 'bg-primary/10 dark:bg-primary/20 text-primary font-semibold'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-lg">{section.icon}</span>
|
||||
<span className="text-sm">{section.title}</span>
|
||||
</span>
|
||||
{activeSection === section.id && (
|
||||
<ChevronRight size={18} className="text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-y-auto p-8 bg-white dark:bg-gray-800">
|
||||
{currentSection && (
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<div className="text-gray-700 dark:text-gray-100 leading-relaxed space-y-4">
|
||||
{currentSection.content.split('\n').map((line, idx) => {
|
||||
if (line.startsWith('# ')) {
|
||||
return (
|
||||
<h1 key={idx} className="text-3xl font-bold text-gray-900 dark:text-white mt-6 mb-4">
|
||||
{line.replace('# ', '')}
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
if (line.startsWith('## ')) {
|
||||
return (
|
||||
<h2 key={idx} className="text-2xl font-semibold text-gray-800 dark:text-gray-50 mt-5 mb-3">
|
||||
{line.replace('## ', '')}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
if (line.startsWith('### ')) {
|
||||
return (
|
||||
<h3 key={idx} className="text-lg font-semibold text-gray-700 dark:text-gray-100 mt-4 mb-2">
|
||||
{line.replace('### ', '')}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
if (line.startsWith('- ')) {
|
||||
return (
|
||||
<li key={idx} className="ml-6 text-gray-700 dark:text-gray-100">
|
||||
{line.replace('- ', '')}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
if (line.startsWith('> ')) {
|
||||
return (
|
||||
<div key={idx} className="bg-primary/5 dark:bg-primary/10 border-l-4 border-primary p-4 my-4">
|
||||
<p className="text-gray-900 dark:text-gray-50 font-medium">{line.replace('> ', '')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (line.startsWith('| ')) {
|
||||
return null
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
return <div key={idx} className="h-2" />
|
||||
}
|
||||
if (line.trim().startsWith('```')) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<p key={idx} className="text-gray-700 dark:text-gray-100">
|
||||
{line}
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900 flex justify-end flex-shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors font-medium"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
src/components/common/Button.tsx
Normal file
61
src/components/common/Button.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
children: ReactNode
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
children,
|
||||
isLoading = false,
|
||||
className,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles = 'font-medium rounded-lg transition-colors inline-flex items-center justify-center'
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-primary hover:bg-primary-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white',
|
||||
secondary: 'bg-secondary hover:bg-secondary-600 dark:bg-gray-700 dark:hover:bg-gray-600 text-white',
|
||||
outline: 'border-2 border-primary dark:border-blue-500 text-primary dark:text-blue-400 hover:bg-primary dark:hover:bg-blue-600 hover:text-white',
|
||||
ghost: 'text-primary dark:text-blue-400 hover:bg-primary-50 dark:hover:bg-blue-900/20',
|
||||
danger: 'bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-base',
|
||||
lg: 'px-6 py-3 text-lg',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
(disabled || isLoading) && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Carregando...
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
46
src/components/common/Input.tsx
Normal file
46
src/components/common/Input.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { InputHTMLAttributes, forwardRef } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, helperText, className, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{label}
|
||||
{props.required && <span className="text-red-500 dark:text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 transition-colors',
|
||||
'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100',
|
||||
'placeholder-gray-400 dark:placeholder-gray-500',
|
||||
error
|
||||
? 'border-red-500 dark:border-red-400 focus:ring-red-500 dark:focus:ring-red-400'
|
||||
: 'border-gray-300 dark:border-gray-600 focus:ring-primary dark:focus:ring-blue-500 focus:border-transparent',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export default Input
|
||||
39
src/components/common/LoadingSpinner.tsx
Normal file
39
src/components/common/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
||||
const sizes = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('flex items-center justify-center', className)}>
|
||||
<svg
|
||||
className={clsx('animate-spin text-primary', sizes[size])}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
src/components/common/Modal.tsx
Normal file
67
src/components/common/Modal.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children: ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const sizes = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className={clsx('relative bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full transition-colors', sizes[size])}>
|
||||
{/* Header */}
|
||||
{title && (
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
src/components/common/ThemeToggle.tsx
Normal file
30
src/components/common/ThemeToggle.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-all duration-200"
|
||||
aria-label={theme === 'light' ? 'Ativar modo escuro' : 'Ativar modo claro'}
|
||||
title={theme === 'light' ? 'Ativar modo escuro' : 'Ativar modo claro'}
|
||||
>
|
||||
<div className="relative w-5 h-5">
|
||||
<Moon
|
||||
size={20}
|
||||
className={`absolute inset-0 text-gray-600 transition-all duration-300 ${
|
||||
theme === 'light' ? 'opacity-100 rotate-0' : 'opacity-0 rotate-90'
|
||||
}`}
|
||||
/>
|
||||
<Sun
|
||||
size={20}
|
||||
className={`absolute inset-0 text-yellow-500 transition-all duration-300 ${
|
||||
theme === 'dark' ? 'opacity-100 rotate-0' : 'opacity-0 -rotate-90'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
46
src/components/common/Toast.tsx
Normal file
46
src/components/common/Toast.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEffect } from 'react'
|
||||
import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface ToastProps {
|
||||
type: ToastType
|
||||
message: string
|
||||
onClose: () => void
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export default function Toast({ type, message, onClose, duration = 5000 }: ToastProps) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onClose()
|
||||
}, duration)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [duration, onClose])
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle className="text-green-500" size={24} />,
|
||||
error: <XCircle className="text-red-500" size={24} />,
|
||||
warning: <AlertCircle className="text-yellow-500" size={24} />,
|
||||
info: <AlertCircle className="text-blue-500" size={24} />,
|
||||
}
|
||||
|
||||
const styles = {
|
||||
success: 'bg-green-50 border-green-200',
|
||||
error: 'bg-red-50 border-red-200',
|
||||
warning: 'bg-yellow-50 border-yellow-200',
|
||||
info: 'bg-blue-50 border-blue-200',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('flex items-center gap-3 p-4 rounded-lg border shadow-lg', styles[type])}>
|
||||
{icons[type]}
|
||||
<p className="flex-1 text-sm font-medium text-gray-900">{message}</p>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
src/components/configuracoes/CategoriasTab.tsx
Normal file
255
src/components/configuracoes/CategoriasTab.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
// @ts-nocheck
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Edit, Trash2, Tag } from 'lucide-react'
|
||||
import Button from '@/components/common/Button'
|
||||
import Input from '@/components/common/Input'
|
||||
import Modal from '@/components/common/Modal'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface Categoria {
|
||||
id: string
|
||||
nome: string
|
||||
descricao: string
|
||||
cor: string
|
||||
ativo: boolean
|
||||
}
|
||||
|
||||
export default function CategoriasTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editingCategoria, setEditingCategoria] = useState<Categoria | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
nome: '',
|
||||
descricao: '',
|
||||
cor: '#3B82F6',
|
||||
})
|
||||
|
||||
const { data: categorias } = useQuery({
|
||||
queryKey: ['categorias'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('categorias')
|
||||
.select('*')
|
||||
.eq('ativo', true)
|
||||
.order('nome', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data as Categoria[]
|
||||
},
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: typeof formData) => {
|
||||
const { error } = await supabase
|
||||
.from('categorias')
|
||||
.insert([{ ...data, ativo: true } as any])
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['categorias'] })
|
||||
toast.success('Categoria criada')
|
||||
setModalOpen(false)
|
||||
resetForm()
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: typeof formData }) => {
|
||||
const { error } = await supabase
|
||||
.from('categorias')
|
||||
.update(data as any)
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['categorias'] })
|
||||
toast.success('Categoria atualizada')
|
||||
setModalOpen(false)
|
||||
setEditingCategoria(null)
|
||||
resetForm()
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { error } = await supabase
|
||||
.from('categorias')
|
||||
.update({ ativo: false } as any)
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['categorias'] })
|
||||
toast.success('Categoria removida')
|
||||
},
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
nome: '',
|
||||
descricao: '',
|
||||
cor: '#3B82F6',
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = (categoria: Categoria) => {
|
||||
setEditingCategoria(categoria)
|
||||
setFormData({
|
||||
nome: categoria.nome,
|
||||
descricao: categoria.descricao || '',
|
||||
cor: categoria.cor,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (editingCategoria) {
|
||||
updateMutation.mutate({ id: editingCategoria.id, data: formData })
|
||||
} else {
|
||||
createMutation.mutate(formData)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Categorias</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Gerencie as categorias para organizar tópicos e documentos
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => {
|
||||
resetForm()
|
||||
setEditingCategoria(null)
|
||||
setModalOpen(true)
|
||||
}}>
|
||||
<Plus size={20} className="mr-2" />
|
||||
Nova Categoria
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{categorias && categorias.length > 0 ? (
|
||||
categorias.map((categoria) => (
|
||||
<div key={categoria.id} className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4" style={{ borderColor: categoria.cor }}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg" style={{ backgroundColor: `${categoria.cor}20` }}>
|
||||
<Tag size={20} style={{ color: categoria.cor }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{categoria.nome}</h3>
|
||||
{categoria.descricao && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{categoria.descricao}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => handleEdit(categoria)}
|
||||
className="flex-1 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900 py-2 rounded transition-colors text-sm"
|
||||
>
|
||||
<Edit size={14} className="inline mr-1" />
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(categoria.id)}
|
||||
className="flex-1 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900 py-2 rounded transition-colors text-sm"
|
||||
>
|
||||
<Trash2 size={14} className="inline mr-1" />
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-3 text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
Nenhuma categoria cadastrada
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => {
|
||||
setModalOpen(false)
|
||||
setEditingCategoria(null)
|
||||
resetForm()
|
||||
}}
|
||||
title={editingCategoria ? 'Editar Categoria' : 'Nova Categoria'}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Nome"
|
||||
value={formData.nome}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, nome: e.target.value }))}
|
||||
placeholder="Ex: Certificados"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Descrição
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.descricao}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, descricao: e.target.value }))}
|
||||
placeholder="Descrição opcional..."
|
||||
className="input-field dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cor
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={formData.cor}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, cor: e.target.value }))}
|
||||
className="h-10 w-20 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.cor}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, cor: e.target.value }))}
|
||||
className="input-field dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100 flex-1"
|
||||
placeholder="#3B82F6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setModalOpen(false)
|
||||
setEditingCategoria(null)
|
||||
resetForm()
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{editingCategoria ? 'Atualizar' : 'Criar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
205
src/components/configuracoes/IntegracaoIATab.tsx
Normal file
205
src/components/configuracoes/IntegracaoIATab.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
// @ts-nocheck
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Edit, Trash2, Cpu, CheckCircle, XCircle } from 'lucide-react'
|
||||
import Button from '@/components/common/Button'
|
||||
import Input from '@/components/common/Input'
|
||||
import Modal from '@/components/common/Modal'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export default function IntegracaoIATab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
provider: 'openai',
|
||||
api_key: '',
|
||||
modelo_padrao: 'gpt-4',
|
||||
ativo: false,
|
||||
})
|
||||
|
||||
const { data: integracoes } = useQuery({
|
||||
queryKey: ['integracoes-ia'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('integracao_ia')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: typeof formData) => {
|
||||
const { error } = await supabase
|
||||
.from('integracao_ia')
|
||||
.insert([{
|
||||
...data,
|
||||
api_key_encriptada: data.api_key, // TODO: Encriptar
|
||||
} as any])
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['integracoes-ia'] })
|
||||
toast.success('Integração configurada')
|
||||
setModalOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { error } = await supabase
|
||||
.from('integracao_ia')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['integracoes-ia'] })
|
||||
toast.success('Integração removida')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Integrações com IA</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Configure APIs de IA para processamento automático de documentos
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setModalOpen(true)}>
|
||||
<Plus size={20} className="mr-2" />
|
||||
Nova Integração
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{integracoes && integracoes.length > 0 ? (
|
||||
integracoes.map((integracao) => (
|
||||
<div key={integracao.id} className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary-100 dark:bg-blue-900 rounded-lg">
|
||||
<Cpu className="text-primary dark:text-blue-400" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 capitalize">
|
||||
{integracao.provider}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{integracao.modelo_padrao}</p>
|
||||
</div>
|
||||
</div>
|
||||
{integracao.ativo ? (
|
||||
<CheckCircle size={20} className="text-green-500 dark:text-green-400" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Status:</span>
|
||||
<span className={integracao.ativo ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'}>
|
||||
{integracao.ativo ? 'Ativo' : 'Inativo'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Tokens máx:</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">{integracao.maximo_tokens}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button className="flex-1 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900 py-2 rounded transition-colors">
|
||||
<Edit size={16} className="inline mr-1" />
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(integracao.id)}
|
||||
className="flex-1 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900 py-2 rounded transition-colors"
|
||||
>
|
||||
<Trash2 size={16} className="inline mr-1" />
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-3 text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
Nenhuma integração configurada
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title="Nova Integração IA"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
value={formData.provider}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, provider: e.target.value }))}
|
||||
className="input-field dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="gemini">Gemini (Google)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="API Key"
|
||||
type="password"
|
||||
value={formData.api_key}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, api_key: e.target.value }))}
|
||||
placeholder="sk-..."
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Modelo Padrão"
|
||||
value={formData.modelo_padrao}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, modelo_padrao: e.target.value }))}
|
||||
placeholder="gpt-4"
|
||||
required
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.ativo}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, ativo: e.target.checked }))}
|
||||
className="text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Ativar integração</span>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => createMutation.mutate(formData)}
|
||||
isLoading={createMutation.isPending}
|
||||
>
|
||||
Criar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
104
src/components/configuracoes/LogsTab.tsx
Normal file
104
src/components/configuracoes/LogsTab.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AlertCircle, CheckCircle, Clock } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
export default function LogsTab() {
|
||||
const { data: logs } = useQuery({
|
||||
queryKey: ['logs'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('logs_indexacao')
|
||||
.select('*, projetos(numero_projeto, nome_projeto)')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
if (error) throw error
|
||||
return data as any[]
|
||||
},
|
||||
})
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'sucesso':
|
||||
return <CheckCircle size={18} className="text-green-500" />
|
||||
case 'erro':
|
||||
return <AlertCircle size={18} className="text-red-500" />
|
||||
default:
|
||||
return <Clock size={18} className="text-yellow-500" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Logs do Sistema</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Histórico de processamentos e operações do sistema
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Data/Hora
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Projeto
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Documentos
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Duração
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{logs && logs.length > 0 ? (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
|
||||
{new Date(log.created_at).toLocaleString('pt-BR')}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{log.projetos?.nome_projeto}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{log.projetos?.numero_projeto}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{log.total_documentos_indexados} / {log.total_documentos_encontrados}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{log.duracao_segundos}s
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(log.status)}
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">{log.status}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
Nenhum log registrado
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
417
src/components/configuracoes/PastasTab.tsx
Normal file
417
src/components/configuracoes/PastasTab.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
// @ts-nocheck
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Edit, Trash2, Folder } from 'lucide-react'
|
||||
import Button from '@/components/common/Button'
|
||||
import Input from '@/components/common/Input'
|
||||
import Modal from '@/components/common/Modal'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface Pasta {
|
||||
id: string
|
||||
tipo_documento: string
|
||||
caminho_local: string
|
||||
categoria_id?: string
|
||||
habilitado: boolean
|
||||
frequencia_atualizacao: string
|
||||
formatos_aceitos: string[]
|
||||
}
|
||||
|
||||
export default function PastasTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editingPasta, setEditingPasta] = useState<Pasta | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
tipo_documento: '',
|
||||
categoria_id: '',
|
||||
caminho_local: '',
|
||||
habilitado: true,
|
||||
frequencia_atualizacao: 'manual',
|
||||
formatos_aceitos: ['pdf', 'jpg', 'png'],
|
||||
})
|
||||
|
||||
const [caminhoTipo, setCaminhoTipo] = useState<'local' | 'nuvem'>('local')
|
||||
|
||||
const { data: pastas } = useQuery({
|
||||
queryKey: ['pastas'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('configuracoes_pastas')
|
||||
.select('*, categoria:categorias(nome, cor)')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data as Pasta[]
|
||||
},
|
||||
})
|
||||
|
||||
const { data: categorias } = useQuery({
|
||||
queryKey: ['categorias'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('categorias')
|
||||
.select('*')
|
||||
.eq('ativo', true)
|
||||
.order('nome', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: typeof formData) => {
|
||||
const { error } = await supabase
|
||||
.from('configuracoes_pastas')
|
||||
.insert([data as any])
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pastas'] })
|
||||
toast.success('Pasta configurada com sucesso')
|
||||
setModalOpen(false)
|
||||
resetForm()
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: typeof formData }) => {
|
||||
const { error } = await supabase
|
||||
.from('configuracoes_pastas')
|
||||
.update(data as any)
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pastas'] })
|
||||
toast.success('Pasta atualizada')
|
||||
setModalOpen(false)
|
||||
setEditingPasta(null)
|
||||
resetForm()
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { error } = await supabase
|
||||
.from('configuracoes_pastas')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pastas'] })
|
||||
toast.success('Pasta removida')
|
||||
},
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
tipo_documento: '',
|
||||
categoria_id: '',
|
||||
caminho_local: '',
|
||||
habilitado: true,
|
||||
frequencia_atualizacao: 'manual',
|
||||
formatos_aceitos: ['pdf', 'jpg', 'png'],
|
||||
})
|
||||
setCaminhoTipo('local')
|
||||
}
|
||||
|
||||
const handleSelectFolder = async () => {
|
||||
try {
|
||||
// @ts-ignore - API do navegador para seleção de pasta
|
||||
const dirHandle = await window.showDirectoryPicker()
|
||||
setFormData(prev => ({ ...prev, caminho_local: dirHandle.name }))
|
||||
toast.success('Pasta selecionada')
|
||||
} catch (error) {
|
||||
// Usuário cancelou ou navegador não suporta
|
||||
toast.info('Use o campo de texto para inserir o caminho manualmente')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleEdit = (pasta: Pasta) => {
|
||||
setEditingPasta(pasta)
|
||||
setFormData({
|
||||
tipo_documento: pasta.tipo_documento,
|
||||
categoria_id: pasta.categoria_id || '',
|
||||
caminho_local: pasta.caminho_local,
|
||||
habilitado: pasta.habilitado,
|
||||
frequencia_atualizacao: pasta.frequencia_atualizacao,
|
||||
formatos_aceitos: pasta.formatos_aceitos,
|
||||
})
|
||||
setCaminhoTipo('local')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (editingPasta) {
|
||||
updateMutation.mutate({ id: editingPasta.id, data: formData })
|
||||
} else {
|
||||
createMutation.mutate(formData)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Mapeamento de Pastas</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Configure as pastas onde seus documentos estão armazenados
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => {
|
||||
resetForm()
|
||||
setEditingPasta(null)
|
||||
setModalOpen(true)
|
||||
}}>
|
||||
<Plus size={20} className="mr-2" />
|
||||
Novo Mapeamento
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Tipo de Documento
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Categoria
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Caminho
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Frequência
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{pastas && pastas.length > 0 ? (
|
||||
pastas.map((pasta) => (
|
||||
<tr key={pasta.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder size={18} className="text-gray-400 dark:text-gray-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{pasta.tipo_documento}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{(pasta as any).categoria ? (
|
||||
<span
|
||||
className="px-2 py-1 text-xs rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${(pasta as any).categoria.cor}20`,
|
||||
color: (pasta as any).categoria.cor
|
||||
}}
|
||||
>
|
||||
{(pasta as any).categoria.nome}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{pasta.caminho_local}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{pasta.frequencia_atualizacao}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
pasta.habilitado
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300'
|
||||
}`}>
|
||||
{pasta.habilitado ? 'Ativo' : 'Inativo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(pasta)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||
>
|
||||
<Edit size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(pasta.id)}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
Nenhum mapeamento configurado
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => {
|
||||
setModalOpen(false)
|
||||
setEditingPasta(null)
|
||||
resetForm()
|
||||
}}
|
||||
title={editingPasta ? 'Editar Mapeamento' : 'Novo Mapeamento'}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Tipo de Documento"
|
||||
value={formData.tipo_documento}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, tipo_documento: e.target.value }))}
|
||||
placeholder="Ex: Certificados de Soldagem"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Categoria
|
||||
</label>
|
||||
<select
|
||||
value={formData.categoria_id}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, categoria_id: e.target.value }))}
|
||||
className="input-field dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
|
||||
>
|
||||
<option value="">Selecione uma categoria...</option>
|
||||
{categorias?.map((cat: any) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tipo de Armazenamento
|
||||
</label>
|
||||
<div className="flex gap-4 mb-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={caminhoTipo === 'local'}
|
||||
onChange={() => setCaminhoTipo('local')}
|
||||
className="text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">Local</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={caminhoTipo === 'nuvem'}
|
||||
onChange={() => setCaminhoTipo('nuvem')}
|
||||
className="text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">Nuvem</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Caminho {caminhoTipo === 'local' ? 'Local' : 'da Nuvem'} *
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.caminho_local}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, caminho_local: e.target.value }))}
|
||||
placeholder={caminhoTipo === 'local' ? 'C:\\Documentos\\Certificados' : 'https://drive.google.com/...'}
|
||||
className="input-field dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100 flex-1"
|
||||
required
|
||||
/>
|
||||
{caminhoTipo === 'local' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleSelectFolder}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<Folder size={16} className="mr-2" />
|
||||
Buscar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{caminhoTipo === 'local'
|
||||
? 'Clique em "Buscar" para selecionar uma pasta ou digite o caminho manualmente'
|
||||
: 'Cole o link da pasta na nuvem (Google Drive, OneDrive, etc)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Frequência de Atualização
|
||||
</label>
|
||||
<select
|
||||
value={formData.frequencia_atualizacao}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, frequencia_atualizacao: e.target.value }))}
|
||||
className="input-field dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
|
||||
>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="ao_criar">Ao Criar Databook</option>
|
||||
<option value="diario">Diário</option>
|
||||
<option value="semanal">Semanal</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.habilitado}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, habilitado: e.target.checked }))}
|
||||
className="text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Habilitado</span>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setModalOpen(false)
|
||||
setEditingPasta(null)
|
||||
resetForm()
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{editingPasta ? 'Atualizar' : 'Criar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
105
src/components/configuracoes/UsuariosTab.tsx
Normal file
105
src/components/configuracoes/UsuariosTab.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Plus, Edit, Trash2, Shield } from 'lucide-react'
|
||||
import Button from '@/components/common/Button'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
export default function UsuariosTab() {
|
||||
const { data: usuarios } = useQuery({
|
||||
queryKey: ['usuarios'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('usuarios')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data as any[]
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Gestão de Usuários</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Gerencie usuários e suas permissões no sistema
|
||||
</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus size={20} className="mr-2" />
|
||||
Novo Usuário
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Nome
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Perfil
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{usuarios && usuarios.length > 0 ? (
|
||||
usuarios.map((usuario) => (
|
||||
<tr key={usuario.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 font-medium text-gray-900 dark:text-gray-100">
|
||||
{usuario.nome_completo}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{usuario.email}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={16} className="text-primary dark:text-blue-400" />
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">{usuario.perfil}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
usuario.ativo
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300'
|
||||
}`}>
|
||||
{usuario.ativo ? 'Ativo' : 'Inativo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||
<Edit size={18} />
|
||||
</button>
|
||||
<button className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
Nenhum usuário cadastrado
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
src/components/databook/DatabookPreview.tsx
Normal file
255
src/components/databook/DatabookPreview.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { X } from 'lucide-react'
|
||||
import Button from '@/components/common/Button'
|
||||
import { useState, useEffect } from 'react'
|
||||
import * as pdfjsLib from 'pdfjs-dist'
|
||||
|
||||
// Configurar worker do PDF.js
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`
|
||||
|
||||
interface DatabookPreviewProps {
|
||||
projeto: any
|
||||
topicos: any[]
|
||||
documentosPorSecao: { [key: string]: any[] }
|
||||
onClose: () => void
|
||||
onGeneratePDF: () => void
|
||||
}
|
||||
|
||||
export default function DatabookPreview({
|
||||
projeto,
|
||||
topicos,
|
||||
documentosPorSecao,
|
||||
onClose,
|
||||
onGeneratePDF
|
||||
}: DatabookPreviewProps) {
|
||||
const [pdfPreviews, setPdfPreviews] = useState<{ [key: string]: string[] }>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Limpa previews anteriores
|
||||
setPdfPreviews({})
|
||||
setLoading(true)
|
||||
|
||||
const loadPdfPreviews = async () => {
|
||||
const previews: { [key: string]: string[] } = {}
|
||||
|
||||
// Cria uma chave única baseada nos documentos para forçar atualização
|
||||
const docKeys = Object.values(documentosPorSecao)
|
||||
.flat()
|
||||
.map(doc => doc.id)
|
||||
.join(',')
|
||||
|
||||
console.log('Carregando previews para documentos:', docKeys)
|
||||
|
||||
for (const topico of topicos) {
|
||||
const docs = documentosPorSecao[topico.numero_topico] || []
|
||||
|
||||
for (const doc of docs) {
|
||||
if (doc.arquivo_tipo.includes('pdf') || doc.arquivo_url.includes('application/pdf')) {
|
||||
try {
|
||||
console.log(`Carregando preview do PDF: ${doc.titulo}`)
|
||||
|
||||
// Carrega o PDF
|
||||
const loadingTask = pdfjsLib.getDocument(doc.arquivo_url)
|
||||
const pdf = await loadingTask.promise
|
||||
const pageCount = pdf.numPages
|
||||
|
||||
console.log(`PDF ${doc.titulo} tem ${pageCount} páginas`)
|
||||
|
||||
// Converte cada página em imagem (apenas primeiras 10 páginas para preview)
|
||||
const pageImages: string[] = []
|
||||
for (let i = 1; i <= Math.min(pageCount, 10); i++) { // Reduzido para 10 páginas
|
||||
const page = await pdf.getPage(i)
|
||||
const viewport = page.getViewport({ scale: 1 }) // Reduzido para escala 1
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
canvas.width = viewport.width
|
||||
canvas.height = viewport.height
|
||||
|
||||
await page.render({
|
||||
canvasContext: context!,
|
||||
viewport: viewport
|
||||
}).promise
|
||||
|
||||
// Usa JPEG com alta compressão para preview
|
||||
const imageUrl = canvas.toDataURL('image/jpeg', 0.7)
|
||||
pageImages.push(imageUrl)
|
||||
}
|
||||
|
||||
if (pageCount > 10) {
|
||||
console.log(`Preview limitado a 10 páginas de ${pageCount} total`)
|
||||
}
|
||||
|
||||
previews[doc.id] = pageImages
|
||||
console.log(`Preview do PDF ${doc.titulo} carregado com ${pageImages.length} páginas`)
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar preview do PDF:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Total de previews carregados:', Object.keys(previews).length)
|
||||
setPdfPreviews(previews)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
loadPdfPreviews()
|
||||
}, [JSON.stringify(documentosPorSecao), topicos])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-6xl h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Pré-visualização do Databook</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onGeneratePDF}>
|
||||
Gerar PDF
|
||||
</Button>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Content */}
|
||||
<div className="flex-1 overflow-y-auto p-8 bg-gray-100 dark:bg-gray-900">
|
||||
<div id="databook-content" className="max-w-4xl mx-auto space-y-8">
|
||||
|
||||
{/* Capa */}
|
||||
<div className="bg-white p-12 shadow-lg" style={{
|
||||
minHeight: '297mm',
|
||||
background: `linear-gradient(135deg, ${projeto?.databooks_mestres?.cor_primaria || '#1E40AF'} 0%, ${projeto?.databooks_mestres?.cor_secundaria || '#64748B'} 100%)`
|
||||
}}>
|
||||
<div className="h-full flex flex-col justify-between text-white">
|
||||
<div>
|
||||
<h1 className="text-5xl font-bold mb-4">
|
||||
{projeto?.databooks_mestres?.titulo_principal || 'DATABOOK'}
|
||||
</h1>
|
||||
{projeto?.databooks_mestres?.subtitulo && (
|
||||
<p className="text-2xl">{projeto.databooks_mestres.subtitulo}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm opacity-80">Projeto</p>
|
||||
<p className="text-xl font-semibold">{projeto?.nome_projeto}</p>
|
||||
<p className="text-lg">{projeto?.numero_projeto}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm opacity-80">Cliente</p>
|
||||
<p className="text-xl">{projeto?.databooks_mestres?.cliente_nome}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm opacity-80">Data</p>
|
||||
<p className="text-lg">{new Date().toLocaleDateString('pt-BR')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Índice */}
|
||||
<div className="bg-white p-12 shadow-lg min-h-[297mm]">
|
||||
<h2 className="text-3xl font-bold mb-8 text-gray-900">Índice</h2>
|
||||
<div className="space-y-2">
|
||||
{topicos?.map((topico, index) => (
|
||||
<div key={topico.id} className="flex justify-between items-center py-2 border-b border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-semibold text-primary">
|
||||
{topico.numero_topico}
|
||||
</span>
|
||||
<span className="text-gray-900">{topico.titulo}</span>
|
||||
{topico.obrigatorio && (
|
||||
<span className="text-red-500 text-sm">*</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-500">{index + 3}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seções */}
|
||||
{topicos?.map((topico) => {
|
||||
const docs = documentosPorSecao[topico.numero_topico] || []
|
||||
|
||||
return (
|
||||
<div key={topico.id}>
|
||||
{/* Página de Separação da Seção */}
|
||||
<div className="bg-white p-12 shadow-lg min-h-[297mm] flex items-center justify-center" style={{
|
||||
background: `linear-gradient(135deg, ${projeto?.databooks_mestres?.cor_primaria || '#1E40AF'}20 0%, ${projeto?.databooks_mestres?.cor_secundaria || '#64748B'}20 100%)`
|
||||
}}>
|
||||
<div className="text-center">
|
||||
<div className="text-6xl font-bold mb-4" style={{
|
||||
color: projeto?.databooks_mestres?.cor_primaria || '#1E40AF'
|
||||
}}>
|
||||
{topico.numero_topico}
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
{topico.titulo}
|
||||
</h2>
|
||||
{topico.descricao && (
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
{topico.descricao}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-8 text-gray-500">
|
||||
{docs.length} documento(s)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documentos ou Não Aplicável */}
|
||||
{docs.length > 0 ? (
|
||||
docs.map((doc) => (
|
||||
<div key={doc.id}>
|
||||
{doc.arquivo_url.startsWith('data:image') ? (
|
||||
<div className="bg-white shadow-lg">
|
||||
<img src={doc.arquivo_url} alt={doc.titulo} className="w-full" />
|
||||
</div>
|
||||
) : (doc.arquivo_tipo.includes('pdf') || doc.arquivo_url.includes('application/pdf')) && pdfPreviews[doc.id] ? (
|
||||
pdfPreviews[doc.id].map((pageImage, pageIdx) => (
|
||||
<div key={`${doc.id}-page-${pageIdx}`} className="bg-white shadow-lg mb-4">
|
||||
<img src={pageImage} alt={`${doc.titulo} - Página ${pageIdx + 1}`} className="w-full" />
|
||||
</div>
|
||||
))
|
||||
) : (doc.arquivo_tipo.includes('pdf') || doc.arquivo_url.includes('application/pdf')) && loading ? (
|
||||
<div className="bg-white p-12 shadow-lg min-h-[297mm] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">📄</div>
|
||||
<p className="text-gray-900 font-semibold text-xl mb-2">{doc.titulo}</p>
|
||||
<p className="text-gray-600">Carregando preview do PDF...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white p-12 shadow-lg min-h-[297mm] flex items-center justify-center">
|
||||
<p className="text-gray-600">Documento: {doc.titulo}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="bg-white p-12 shadow-lg min-h-[297mm] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">📄</div>
|
||||
<h3 className="text-2xl font-semibold text-gray-400 mb-2">
|
||||
Não Aplicável
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
Nenhum documento disponível para esta seção
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
189
src/components/databook/DesignSelector.tsx
Normal file
189
src/components/databook/DesignSelector.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
// @ts-nocheck
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Palette, Check } from 'lucide-react'
|
||||
import Button from '@/components/common/Button'
|
||||
import Modal from '@/components/common/Modal'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { useDesignTemplates } from '@/hooks/useDesignConfig'
|
||||
|
||||
interface DesignSelectorProps {
|
||||
databookId: string
|
||||
currentDesignConfig?: any
|
||||
}
|
||||
|
||||
export default function DesignSelector({ databookId, currentDesignConfig }: DesignSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [selectedTemplates, setSelectedTemplates] = useState<{
|
||||
capa?: string
|
||||
indice?: string
|
||||
divisora?: string
|
||||
cabecalho?: string
|
||||
rodape?: string
|
||||
guia_estilo?: string
|
||||
}>({})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: templatesCapa } = useDesignTemplates('capa')
|
||||
const { data: templatesIndice } = useDesignTemplates('indice')
|
||||
const { data: templatesDivisora } = useDesignTemplates('divisora')
|
||||
const { data: templatesCabecalho } = useDesignTemplates('cabecalho')
|
||||
const { data: templatesRodape } = useDesignTemplates('rodape')
|
||||
|
||||
const applyDesignMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Verificar se já existe uma aplicação
|
||||
const { data: existing } = await supabase
|
||||
.from('databook_design_aplicacoes')
|
||||
.select('id')
|
||||
.eq('databook_id', databookId)
|
||||
.single()
|
||||
|
||||
const aplicacao: any = {
|
||||
databook_id: databookId,
|
||||
template_capa_id: selectedTemplates.capa || null,
|
||||
template_indice_id: selectedTemplates.indice || null,
|
||||
template_divisora_id: selectedTemplates.divisora || null,
|
||||
template_cabecalho_id: selectedTemplates.cabecalho || null,
|
||||
template_rodape_id: selectedTemplates.rodape || null,
|
||||
template_guia_estilo_id: selectedTemplates.guia_estilo || null,
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
// Atualizar
|
||||
const { error } = await supabase
|
||||
.from('databook_design_aplicacoes')
|
||||
.update(aplicacao as any)
|
||||
.eq('id', (existing as any).id)
|
||||
|
||||
if (error) throw error
|
||||
} else {
|
||||
// Inserir
|
||||
const { error } = await supabase
|
||||
.from('databook_design_aplicacoes')
|
||||
.insert(aplicacao)
|
||||
|
||||
if (error) throw error
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['design-config', databookId] })
|
||||
toast.success('Design aplicado com sucesso!')
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Erro ao aplicar design')
|
||||
},
|
||||
})
|
||||
|
||||
const renderTemplateSelector = (
|
||||
title: string,
|
||||
tipo: string,
|
||||
templates: any[] | undefined
|
||||
) => {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">{title}</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{templates?.map((template) => {
|
||||
const isSelected = selectedTemplates[tipo as keyof typeof selectedTemplates] === template.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => setSelectedTemplates(prev => ({ ...prev, [tipo]: template.id }))}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-medium text-gray-900">{template.nome}</h4>
|
||||
{isSelected && <Check size={20} className="text-primary" />}
|
||||
</div>
|
||||
{template.descricao && (
|
||||
<p className="text-xs text-gray-600">{template.descricao}</p>
|
||||
)}
|
||||
|
||||
{/* Preview de cores */}
|
||||
{template.config?.corPrimaria && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-gray-300"
|
||||
style={{ backgroundColor: template.config.corPrimaria }}
|
||||
title="Cor Primária"
|
||||
/>
|
||||
{template.config?.corSecundaria && (
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-gray-300"
|
||||
style={{ backgroundColor: template.config.corSecundaria }}
|
||||
title="Cor Secundária"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{(!templates || templates.length === 0) && (
|
||||
<div className="col-span-2 text-center py-8 text-gray-500">
|
||||
Nenhum template disponível
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Palette size={16} className="mr-2" />
|
||||
{currentDesignConfig ? 'Alterar Design' : 'Aplicar Design'}
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title="Selecionar Templates de Design"
|
||||
>
|
||||
<div className="space-y-6 max-h-[70vh] overflow-y-auto">
|
||||
<p className="text-sm text-gray-600">
|
||||
Escolha os templates de design que serão aplicados ao gerar o PDF deste databook.
|
||||
</p>
|
||||
|
||||
{renderTemplateSelector('Capa Frontal', 'capa', templatesCapa)}
|
||||
{renderTemplateSelector('Índice', 'indice', templatesIndice)}
|
||||
{renderTemplateSelector('Divisoras de Seção', 'divisora', templatesDivisora)}
|
||||
{renderTemplateSelector('Cabeçalho', 'cabecalho', templatesCabecalho)}
|
||||
{renderTemplateSelector('Rodapé', 'rodape', templatesRodape)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => applyDesignMutation.mutate()}
|
||||
isLoading={applyDesignMutation.isPending}
|
||||
disabled={Object.keys(selectedTemplates).length === 0}
|
||||
>
|
||||
Aplicar Design
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
386
src/components/design/TemplateEditor.tsx
Normal file
386
src/components/design/TemplateEditor.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
|
||||
import Input from '@/components/common/Input'
|
||||
|
||||
interface TemplateEditorProps {
|
||||
tipo: string
|
||||
config: Record<string, any>
|
||||
onChange: (config: Record<string, any>) => void
|
||||
}
|
||||
|
||||
export default function TemplateEditor({ tipo, config, onChange }: TemplateEditorProps) {
|
||||
const handleColorChange = (key: string, value: string) => {
|
||||
onChange({ ...config, [key]: value })
|
||||
}
|
||||
|
||||
const handleTextChange = (key: string, value: string) => {
|
||||
onChange({ ...config, [key]: value })
|
||||
}
|
||||
|
||||
const renderEditorByType = () => {
|
||||
switch (tipo) {
|
||||
case 'capa':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cor Primária
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={config.corPrimaria || '#1a365d'}
|
||||
onChange={(e) => handleColorChange('corPrimaria', e.target.value)}
|
||||
className="w-full h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cor Secundária
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={config.corSecundaria || '#2b6cb0'}
|
||||
onChange={(e) => handleColorChange('corSecundaria', e.target.value)}
|
||||
className="w-full h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Título Principal"
|
||||
value={config.titulo || ''}
|
||||
onChange={(e) => handleTextChange('titulo', e.target.value)}
|
||||
placeholder="Ex: BUZIOS 7 PRODUCTION SYSTEM"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Subtítulo"
|
||||
value={config.subtitulo || ''}
|
||||
onChange={(e) => handleTextChange('subtitulo', e.target.value)}
|
||||
placeholder="Ex: AR HEAD FABRICATION"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Cliente"
|
||||
value={config.cliente || ''}
|
||||
onChange={(e) => handleTextChange('cliente', e.target.value)}
|
||||
placeholder="Ex: SAIPEM"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Número do Documento"
|
||||
value={config.numeroDocumento || ''}
|
||||
onChange={(e) => handleTextChange('numeroDocumento', e.target.value)}
|
||||
placeholder="Ex: DB-B97-01_S1_VENDOR_DATABOOK"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Contrato"
|
||||
value={config.contrato || ''}
|
||||
onChange={(e) => handleTextChange('contrato', e.target.value)}
|
||||
placeholder="Ex: OC 1472739"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Fornecedor"
|
||||
value={config.fornecedor || ''}
|
||||
onChange={(e) => handleTextChange('fornecedor', e.target.value)}
|
||||
placeholder="Ex: ENGEMETAL"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'indice':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cor do Título
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={config.corTitulo || '#1a365d'}
|
||||
onChange={(e) => handleColorChange('corTitulo', e.target.value)}
|
||||
className="w-full h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer bg-white dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cor da Linha Divisória
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={config.corLinha || '#2b6cb0'}
|
||||
onChange={(e) => handleColorChange('corLinha', e.target.value)}
|
||||
className="w-full h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer bg-white dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.bilingue || false}
|
||||
onChange={(e) => handleColorChange('bilingue', e.target.checked ? 'true' : 'false')}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Bilíngue (PT/EN)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Título do Índice"
|
||||
value={config.titulo || 'ÍNDICE / TABLE OF CONTENTS'}
|
||||
onChange={(e) => handleTextChange('titulo', e.target.value)}
|
||||
placeholder="Título do índice"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'divisora':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Estilo da Divisora
|
||||
</label>
|
||||
<select
|
||||
value={config.estilo || 'minimalista'}
|
||||
onChange={(e) => handleColorChange('estilo', e.target.value)}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="minimalista">Minimalista</option>
|
||||
<option value="lateral">Lateral</option>
|
||||
<option value="corporativa">Corporativa</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cor Primária
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={config.corPrimaria || '#1a365d'}
|
||||
onChange={(e) => handleColorChange('corPrimaria', e.target.value)}
|
||||
className="w-full h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer bg-white dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cor Secundária
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={config.corSecundaria || '#2b6cb0'}
|
||||
onChange={(e) => handleColorChange('corSecundaria', e.target.value)}
|
||||
className="w-full h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer bg-white dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.bilingue || false}
|
||||
onChange={(e) => handleColorChange('bilingue', e.target.checked ? 'true' : 'false')}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Bilíngue (PT/EN)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Ícone da Seção"
|
||||
value={config.icone || '📑'}
|
||||
onChange={(e) => handleTextChange('icone', e.target.value)}
|
||||
placeholder="Ex: 📑, 🔩, ⚡"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'cabecalho':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cor da Borda
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={config.corBorda || '#2b6cb0'}
|
||||
onChange={(e) => handleColorChange('corBorda', e.target.value)}
|
||||
className="w-full h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer bg-white dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Altura (px)"
|
||||
type="number"
|
||||
value={config.altura || 60}
|
||||
onChange={(e) => handleTextChange('altura', e.target.value)}
|
||||
placeholder="60"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Estilo
|
||||
</label>
|
||||
<select
|
||||
value={config.estilo || 'simples'}
|
||||
onChange={(e) => handleColorChange('estilo', e.target.value)}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="simples">Simples</option>
|
||||
<option value="completo">Completo com Logo</option>
|
||||
<option value="minimalista">Minimalista</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'rodape':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cor da Borda
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={config.corBorda || '#cbd5e0'}
|
||||
onChange={(e) => handleColorChange('corBorda', e.target.value)}
|
||||
className="w-full h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer bg-white dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Altura (px)"
|
||||
type="number"
|
||||
value={config.altura || 40}
|
||||
onChange={(e) => handleTextChange('altura', e.target.value)}
|
||||
placeholder="40"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Estilo
|
||||
</label>
|
||||
<select
|
||||
value={config.estilo || 'simples'}
|
||||
onChange={(e) => handleColorChange('estilo', e.target.value)}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="simples">Simples</option>
|
||||
<option value="completo">Completo</option>
|
||||
<option value="minimalista">Minimalista</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.mostrarPagina || true}
|
||||
onChange={(e) => handleColorChange('mostrarPagina', e.target.checked ? 'true' : 'false')}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Mostrar Número da Página</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'guia_estilo':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cor Primária
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={config.corPrimaria || '#1a365d'}
|
||||
onChange={(e) => handleColorChange('corPrimaria', e.target.value)}
|
||||
className="w-full h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer bg-white dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cor Secundária
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={config.corSecundaria || '#2b6cb0'}
|
||||
onChange={(e) => handleColorChange('corSecundaria', e.target.value)}
|
||||
className="w-full h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer bg-white dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cor de Destaque
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={config.corDestaque || '#4299e1'}
|
||||
onChange={(e) => handleColorChange('corDestaque', e.target.value)}
|
||||
className="w-full h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer bg-white dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Fonte Principal"
|
||||
value={config.fontePrincipal || 'Roboto'}
|
||||
onChange={(e) => handleTextChange('fontePrincipal', e.target.value)}
|
||||
placeholder="Ex: Roboto, Arial"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Fonte Secundária"
|
||||
value={config.fonteSecundaria || 'Open Sans'}
|
||||
onChange={(e) => handleTextChange('fonteSecundaria', e.target.value)}
|
||||
placeholder="Ex: Open Sans, Helvetica"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.incluirPaleta || true}
|
||||
onChange={(e) => handleColorChange('incluirPaleta', e.target.checked ? 'true' : 'false')}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Incluir Paleta de Cores</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.incluirTipografia || true}
|
||||
onChange={(e) => handleColorChange('incluirTipografia', e.target.checked ? 'true' : 'false')}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Incluir Tipografia</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return <p className="text-gray-500 dark:text-gray-400">Selecione um tipo de template</p>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 space-y-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">Configurações do Template</h3>
|
||||
{renderEditorByType()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
394
src/components/design/TemplatePreview.tsx
Normal file
394
src/components/design/TemplatePreview.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'
|
||||
|
||||
interface TemplatePreviewProps {
|
||||
tipo: string
|
||||
config: Record<string, any>
|
||||
}
|
||||
|
||||
export default function TemplatePreview({ tipo, config }: TemplatePreviewProps) {
|
||||
const [zoom, setZoom] = useState(30)
|
||||
const [isPanning, setIsPanning] = useState(false)
|
||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 })
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleZoomIn = () => setZoom(prev => Math.min(prev + 10, 200))
|
||||
const handleZoomOut = () => setZoom(prev => Math.max(prev - 10, 30))
|
||||
const handleResetZoom = () => setZoom(100)
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!scrollContainerRef.current) return
|
||||
setIsPanning(true)
|
||||
setPanStart({
|
||||
x: e.clientX + scrollContainerRef.current.scrollLeft,
|
||||
y: e.clientY + scrollContainerRef.current.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isPanning || !scrollContainerRef.current) return
|
||||
|
||||
scrollContainerRef.current.scrollLeft = panStart.x - e.clientX
|
||||
scrollContainerRef.current.scrollTop = panStart.y - e.clientY
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsPanning(false)
|
||||
}
|
||||
|
||||
const renderPreview = () => {
|
||||
const corPrimaria = config.corPrimaria || '#1a365d'
|
||||
const corSecundaria = config.corSecundaria || '#2b6cb0'
|
||||
|
||||
switch (tipo) {
|
||||
case 'capa':
|
||||
return (
|
||||
<div
|
||||
className="bg-white shadow-2xl flex flex-col justify-between p-8"
|
||||
style={{
|
||||
width: '210mm',
|
||||
height: '297mm',
|
||||
background: `linear-gradient(135deg, rgba(${parseInt(corPrimaria.slice(1, 3), 16)}, ${parseInt(corPrimaria.slice(3, 5), 16)}, ${parseInt(corPrimaria.slice(5, 7), 16)}, 0.05), rgba(${parseInt(corSecundaria.slice(1, 3), 16)}, ${parseInt(corSecundaria.slice(3, 5), 16)}, ${parseInt(corSecundaria.slice(5, 7), 16)}, 0.05))`,
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="w-32 h-16 bg-gray-300 rounded mx-auto mb-8 flex items-center justify-center text-gray-500 text-sm">
|
||||
Logo Cliente
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center flex-1 flex flex-col justify-center">
|
||||
<h1
|
||||
className="text-4xl font-bold mb-4 line-clamp-3"
|
||||
style={{ color: corPrimaria }}
|
||||
>
|
||||
{config.titulo || 'TÍTULO DO PROJETO'}
|
||||
</h1>
|
||||
<h2 className="text-2xl text-gray-700 mb-6">
|
||||
{config.subtitulo || 'Subtítulo do Projeto'}
|
||||
</h2>
|
||||
<div
|
||||
className="w-24 h-1 mx-auto mb-6"
|
||||
style={{ background: corSecundaria }}
|
||||
/>
|
||||
<div className="text-lg text-gray-600">
|
||||
<p className="font-semibold" style={{ color: corSecundaria }}>
|
||||
{config.numeroDocumento || 'DB-XXXX-XX_SX_VENDOR_DATABOOK'}
|
||||
</p>
|
||||
<p>Contrato: {config.contrato || 'OC XXXXXXX'}</p>
|
||||
<p>Cliente: {config.cliente || 'CLIENTE'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="w-24 h-12 bg-gray-300 rounded mx-auto flex items-center justify-center text-gray-500 text-xs">
|
||||
Logo {config.fornecedor || 'Fornecedor'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'indice':
|
||||
return (
|
||||
<div
|
||||
className="bg-white shadow-2xl overflow-y-auto p-8"
|
||||
style={{
|
||||
width: '210mm',
|
||||
height: '297mm',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
className="text-3xl font-bold text-center mb-2"
|
||||
style={{ color: corPrimaria }}
|
||||
>
|
||||
ÍNDICE
|
||||
</h1>
|
||||
{config.bilingue && (
|
||||
<p className="text-center text-gray-600 mb-4">TABLE OF CONTENTS</p>
|
||||
)}
|
||||
<div
|
||||
className="w-full h-1 mb-6"
|
||||
style={{ background: corSecundaria }}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<span className="font-semibold" style={{ color: corSecundaria }}>
|
||||
{i}
|
||||
</span>
|
||||
<span className="flex-1 mx-2 border-b border-dotted border-gray-400" />
|
||||
<span className="font-semibold">{i * 5}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'divisora':
|
||||
const estilo = config.estilo || 'minimalista'
|
||||
|
||||
if (estilo === 'lateral') {
|
||||
return (
|
||||
<div
|
||||
className="bg-white shadow-2xl flex overflow-hidden"
|
||||
style={{
|
||||
width: '210mm',
|
||||
height: '297mm',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-20 flex items-center justify-center text-5xl font-bold text-white"
|
||||
style={{ background: `linear-gradient(180deg, ${corPrimaria}, ${corSecundaria})` }}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div className="flex-1 p-8 flex flex-col justify-center">
|
||||
<h1 className="text-4xl font-bold mb-2" style={{ color: corPrimaria }}>
|
||||
Materiais
|
||||
</h1>
|
||||
{config.bilingue && (
|
||||
<h2 className="text-2xl text-gray-600 italic mb-6">Materials</h2>
|
||||
)}
|
||||
<div className="bg-gray-100 p-4 rounded text-sm text-gray-700">
|
||||
<p>
|
||||
<strong>Projeto:</strong> Projeto Exemplo
|
||||
</p>
|
||||
<p>
|
||||
<strong>Cliente:</strong> Cliente Exemplo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white shadow-2xl flex flex-col items-center justify-center p-8 relative overflow-hidden"
|
||||
style={{
|
||||
width: '210mm',
|
||||
height: '297mm',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute text-9xl font-bold opacity-5"
|
||||
style={{ color: corPrimaria }}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div className="relative z-10 text-center">
|
||||
<p className="text-2xl mb-4" style={{ color: corSecundaria }}>
|
||||
{config.icone || '📑'} Seção 2
|
||||
</p>
|
||||
<h1 className="text-5xl font-bold mb-4" style={{ color: corPrimaria }}>
|
||||
Materiais
|
||||
</h1>
|
||||
{config.bilingue && (
|
||||
<h2 className="text-2xl text-gray-600 italic mb-6">Materials</h2>
|
||||
)}
|
||||
<div
|
||||
className="w-32 h-1 mx-auto"
|
||||
style={{ background: corSecundaria }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'cabecalho':
|
||||
return (
|
||||
<div
|
||||
className="bg-white shadow-2xl overflow-hidden"
|
||||
style={{
|
||||
width: '210mm',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-4"
|
||||
style={{ borderBottom: `2px solid ${corSecundaria}` }}
|
||||
>
|
||||
<div className="w-16 h-8 bg-gray-300 rounded flex items-center justify-center text-xs text-gray-500">
|
||||
Logo
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-700">
|
||||
Projeto Exemplo
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 font-mono">
|
||||
DB-XXXX-XX_SX_VENDOR_DATABOOK
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'rodape':
|
||||
return (
|
||||
<div
|
||||
className="bg-white shadow-2xl overflow-hidden"
|
||||
style={{
|
||||
width: '210mm',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 text-xs text-gray-600"
|
||||
style={{ borderTop: `1px solid ${config.corBorda || '#cbd5e0'}` }}
|
||||
>
|
||||
<span>Rev. 01 | 2024</span>
|
||||
<span className="font-bold text-lg" style={{ color: corPrimaria }}>
|
||||
12
|
||||
</span>
|
||||
<span>Fornecedor</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'guia_estilo':
|
||||
return (
|
||||
<div
|
||||
className="bg-white shadow-2xl p-8 overflow-y-auto"
|
||||
style={{
|
||||
width: '210mm',
|
||||
height: '297mm',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
className="text-3xl font-bold text-center mb-8"
|
||||
style={{ color: corPrimaria }}
|
||||
>
|
||||
Guia de Estilo
|
||||
</h1>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-bold mb-4" style={{ color: corPrimaria }}>
|
||||
Paleta de Cores
|
||||
</h2>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className="w-full h-16 rounded mb-2"
|
||||
style={{ backgroundColor: corPrimaria }}
|
||||
/>
|
||||
<p className="text-xs text-gray-600">Primária</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className="w-full h-16 rounded mb-2"
|
||||
style={{ backgroundColor: corSecundaria }}
|
||||
/>
|
||||
<p className="text-xs text-gray-600">Secundária</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className="w-full h-16 rounded mb-2"
|
||||
style={{ backgroundColor: config.corDestaque || '#4299e1' }}
|
||||
/>
|
||||
<p className="text-xs text-gray-600">Destaque</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-4" style={{ color: corPrimaria }}>
|
||||
Tipografia
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div style={{ fontFamily: config.fontePrincipal || 'Roboto' }}>
|
||||
<p className="text-2xl font-bold">Título Principal</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
{config.fontePrincipal || 'Roboto'}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ fontFamily: config.fonteSecundaria || 'Open Sans' }}>
|
||||
<p className="text-base">Corpo de Texto</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
{config.fonteSecundaria || 'Open Sans'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
className="bg-white shadow-2xl flex items-center justify-center"
|
||||
style={{
|
||||
width: '210mm',
|
||||
height: '297mm',
|
||||
}}
|
||||
>
|
||||
<p className="text-gray-500">Preview não disponível</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-2">
|
||||
{/* Controles de Zoom */}
|
||||
<div className="flex items-center justify-between bg-gray-100 dark:bg-gray-700 rounded-lg p-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors text-gray-700 dark:text-gray-300"
|
||||
title="Diminuir zoom"
|
||||
>
|
||||
<ZoomOut size={16} />
|
||||
</button>
|
||||
<span className="text-xs font-medium w-10 text-center text-gray-700 dark:text-gray-300">{zoom}%</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors text-gray-700 dark:text-gray-300"
|
||||
title="Aumentar zoom"
|
||||
>
|
||||
<ZoomIn size={16} />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
<button
|
||||
onClick={handleResetZoom}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors flex items-center gap-1 text-gray-700 dark:text-gray-300"
|
||||
title="Resetar zoom"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
<span className="text-xs">100%</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
A4 (210mm × 297mm)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Preview Container - Pan para navegar */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 bg-gray-200 rounded-lg overflow-auto cursor-grab active:cursor-grabbing"
|
||||
style={{ minHeight: 0 }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center p-3"
|
||||
style={{
|
||||
minHeight: '100%',
|
||||
minWidth: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${210 * zoom / 100}mm`,
|
||||
height: `${297 * zoom / 100}mm`,
|
||||
transform: `scale(${zoom / 100})`,
|
||||
transformOrigin: 'center',
|
||||
transition: 'transform 0.2s ease-out',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{renderPreview()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
src/components/layout/Header.tsx
Normal file
70
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Search, Bell, User, LogOut } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/lib/store'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import ThemeToggle from '@/components/common/ThemeToggle'
|
||||
|
||||
export default function Header() {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await supabase.auth.signOut()
|
||||
logout()
|
||||
navigate('/login')
|
||||
} catch (error) {
|
||||
console.error('Erro ao fazer logout:', error)
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 h-16 flex items-center justify-between px-6 transition-colors">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<h1 className="text-xl font-bold text-primary dark:text-blue-400">SteelBook</h1>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-md w-full">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar databooks, documentos..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400"
|
||||
onFocus={() => navigate('/busca')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Notifications */}
|
||||
<button className="relative p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg dark:text-gray-400">
|
||||
<Bell size={20} />
|
||||
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{user?.email}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Admin</p>
|
||||
</div>
|
||||
<div className="h-10 w-10 bg-primary dark:bg-blue-600 rounded-full flex items-center justify-center text-white">
|
||||
<User size={20} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
title="Sair"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
17
src/components/layout/Layout.tsx
Normal file
17
src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Header from './Header'
|
||||
import Sidebar from './Sidebar'
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-white dark:bg-gray-900 transition-colors">
|
||||
<Header />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-950 p-6 transition-colors">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
src/components/layout/Sidebar.tsx
Normal file
67
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
Search,
|
||||
List,
|
||||
BookOpen,
|
||||
Palette,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import ManualModal from '@/components/ManualModal'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'Templates', href: '/templates', icon: FileText },
|
||||
{ name: 'Tópicos', href: '/topicos', icon: List },
|
||||
{ name: 'Databooks', href: '/databooks', icon: FolderOpen },
|
||||
{ name: 'Design', href: '/design', icon: Palette },
|
||||
{ name: 'Busca', href: '/busca', icon: Search },
|
||||
{ name: 'Configurações', href: '/configuracoes', icon: Settings },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
const [manualOpen, setManualOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 h-[calc(100vh-4rem)] flex flex-col transition-colors">
|
||||
<nav className="p-4 space-y-2 flex-1">
|
||||
{navigation.map((item) => (
|
||||
<NavLink
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-colors',
|
||||
isActive
|
||||
? 'bg-primary dark:bg-blue-600 text-white'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span className="font-medium">{item.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Manual Button */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-800">
|
||||
<button
|
||||
onClick={() => setManualOpen(true)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors font-medium"
|
||||
>
|
||||
<BookOpen size={20} />
|
||||
<span>Manual do Usuário</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<ManualModal isOpen={manualOpen} onClose={() => setManualOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
191
src/components/ui/beams-background.tsx
Normal file
191
src/components/ui/beams-background.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AnimatedGradientBackgroundProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
intensity?: "subtle" | "medium" | "strong";
|
||||
}
|
||||
|
||||
interface Beam {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
length: number;
|
||||
angle: number;
|
||||
speed: number;
|
||||
opacity: number;
|
||||
hue: number;
|
||||
pulse: number;
|
||||
pulseSpeed: number;
|
||||
}
|
||||
|
||||
function createBeam(width: number, height: number): Beam {
|
||||
const angle = -35 + Math.random() * 10;
|
||||
return {
|
||||
x: Math.random() * width * 1.5 - width * 0.25,
|
||||
y: Math.random() * height * 1.5 - height * 0.25,
|
||||
width: 30 + Math.random() * 60,
|
||||
length: height * 2.5,
|
||||
angle: angle,
|
||||
speed: 0.6 + Math.random() * 1.2,
|
||||
opacity: 0.12 + Math.random() * 0.16,
|
||||
hue: 190 + Math.random() * 70,
|
||||
pulse: Math.random() * Math.PI * 2,
|
||||
pulseSpeed: 0.02 + Math.random() * 0.03,
|
||||
};
|
||||
}
|
||||
|
||||
export function BeamsBackground({
|
||||
className,
|
||||
children,
|
||||
intensity = "strong",
|
||||
}: AnimatedGradientBackgroundProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const beamsRef = useRef<Beam[]>([]);
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
const MINIMUM_BEAMS = 20;
|
||||
|
||||
const opacityMap = {
|
||||
subtle: 0.7,
|
||||
medium: 0.85,
|
||||
strong: 1,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const updateCanvasSize = () => {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = window.innerWidth * dpr;
|
||||
canvas.height = window.innerHeight * dpr;
|
||||
canvas.style.width = `${window.innerWidth}px`;
|
||||
canvas.style.height = `${window.innerHeight}px`;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const totalBeams = MINIMUM_BEAMS * 1.5;
|
||||
beamsRef.current = Array.from({ length: totalBeams }, () =>
|
||||
createBeam(canvas.width, canvas.height)
|
||||
);
|
||||
};
|
||||
|
||||
updateCanvasSize();
|
||||
window.addEventListener("resize", updateCanvasSize);
|
||||
|
||||
function resetBeam(beam: Beam, index: number, totalBeams: number) {
|
||||
if (!canvas) return beam;
|
||||
const column = index % 3;
|
||||
const spacing = canvas.width / 3;
|
||||
beam.y = canvas.height + 100;
|
||||
beam.x =
|
||||
column * spacing +
|
||||
spacing / 2 +
|
||||
(Math.random() - 0.5) * spacing * 0.5;
|
||||
beam.width = 100 + Math.random() * 100;
|
||||
beam.speed = 0.5 + Math.random() * 0.4;
|
||||
beam.hue = 190 + (index * 70) / totalBeams;
|
||||
beam.opacity = 0.2 + Math.random() * 0.1;
|
||||
return beam;
|
||||
}
|
||||
|
||||
function drawBeam(ctx: CanvasRenderingContext2D, beam: Beam) {
|
||||
ctx.save();
|
||||
ctx.translate(beam.x, beam.y);
|
||||
ctx.rotate((beam.angle * Math.PI) / 180);
|
||||
|
||||
const pulsingOpacity =
|
||||
beam.opacity *
|
||||
(0.8 + Math.sin(beam.pulse) * 0.2) *
|
||||
opacityMap[intensity];
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, beam.length);
|
||||
gradient.addColorStop(0, `hsla(${beam.hue}, 85%, 65%, 0)`);
|
||||
gradient.addColorStop(
|
||||
0.1,
|
||||
`hsla(${beam.hue}, 85%, 65%, ${pulsingOpacity * 0.5})`
|
||||
);
|
||||
gradient.addColorStop(
|
||||
0.4,
|
||||
`hsla(${beam.hue}, 85%, 65%, ${pulsingOpacity})`
|
||||
);
|
||||
gradient.addColorStop(
|
||||
0.6,
|
||||
`hsla(${beam.hue}, 85%, 65%, ${pulsingOpacity})`
|
||||
);
|
||||
gradient.addColorStop(
|
||||
0.9,
|
||||
`hsla(${beam.hue}, 85%, 65%, ${pulsingOpacity * 0.5})`
|
||||
);
|
||||
gradient.addColorStop(1, `hsla(${beam.hue}, 85%, 65%, 0)`);
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(-beam.width / 2, 0, beam.width, beam.length);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function animate() {
|
||||
if (!canvas || !ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.filter = "blur(35px)";
|
||||
|
||||
const totalBeams = beamsRef.current.length;
|
||||
beamsRef.current.forEach((beam, index) => {
|
||||
beam.y -= beam.speed;
|
||||
beam.pulse += beam.pulseSpeed;
|
||||
|
||||
if (beam.y + beam.length < -100) {
|
||||
resetBeam(beam, index, totalBeams);
|
||||
}
|
||||
|
||||
drawBeam(ctx, beam);
|
||||
});
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateCanvasSize);
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [intensity]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative min-h-screen w-full overflow-hidden bg-neutral-950", className)}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0"
|
||||
style={{ filter: "blur(15px)" }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-neutral-950/5"
|
||||
animate={{
|
||||
opacity: [0.05, 0.15, 0.05],
|
||||
}}
|
||||
transition={{
|
||||
duration: 10,
|
||||
ease: "easeInOut",
|
||||
repeat: Infinity,
|
||||
}}
|
||||
style={{
|
||||
backdropFilter: "blur(50px)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex h-screen w-full items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/contexts/ThemeContext.tsx
Normal file
56
src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
// Verificar preferência salva ou preferência do sistema
|
||||
const savedTheme = localStorage.getItem('theme') as Theme
|
||||
if (savedTheme) return savedTheme
|
||||
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark'
|
||||
}
|
||||
|
||||
return 'light'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Aplicar tema ao documento
|
||||
const root = document.documentElement
|
||||
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
|
||||
// Salvar preferência
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => prev === 'light' ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
86
src/hooks/useDesignConfig.ts
Normal file
86
src/hooks/useDesignConfig.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
interface DesignTemplate {
|
||||
id: string
|
||||
nome: string
|
||||
tipo: string
|
||||
config: any
|
||||
}
|
||||
|
||||
export function useDesignConfig(databookId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['design-config', databookId],
|
||||
queryFn: async () => {
|
||||
if (!databookId) return null
|
||||
|
||||
// Buscar aplicação de design para este databook
|
||||
const { data: aplicacao, error: aplicacaoError } = await supabase
|
||||
.from('databook_design_aplicacoes')
|
||||
.select('*')
|
||||
.eq('databook_id', databookId)
|
||||
.single()
|
||||
|
||||
if (aplicacaoError && aplicacaoError.code !== 'PGRST116') {
|
||||
throw aplicacaoError
|
||||
}
|
||||
|
||||
if (!aplicacao) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Buscar todos os templates aplicados
|
||||
const templateIds = [
|
||||
(aplicacao as any).template_capa_id,
|
||||
(aplicacao as any).template_indice_id,
|
||||
(aplicacao as any).template_divisora_id,
|
||||
(aplicacao as any).template_cabecalho_id,
|
||||
(aplicacao as any).template_rodape_id,
|
||||
(aplicacao as any).template_guia_estilo_id
|
||||
].filter(Boolean)
|
||||
|
||||
if (templateIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { data: templates, error: templatesError } = await supabase
|
||||
.from('design_templates')
|
||||
.select('*')
|
||||
.in('id', templateIds)
|
||||
|
||||
if (templatesError) throw templatesError
|
||||
|
||||
// Organizar templates por tipo
|
||||
const designConfig: any = {}
|
||||
|
||||
templates?.forEach((template: DesignTemplate) => {
|
||||
designConfig[template.tipo] = template.config
|
||||
})
|
||||
|
||||
return designConfig
|
||||
},
|
||||
enabled: !!databookId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDesignTemplates(tipo?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['design-templates', tipo],
|
||||
queryFn: async () => {
|
||||
let query = supabase
|
||||
.from('design_templates')
|
||||
.select('*')
|
||||
.eq('ativo', true)
|
||||
.order('criado_em', { ascending: false })
|
||||
|
||||
if (tipo) {
|
||||
query = query.eq('tipo', tipo)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) throw error
|
||||
return data as DesignTemplate[]
|
||||
},
|
||||
})
|
||||
}
|
||||
61
src/index.css
Normal file
61
src/index.css
Normal file
@@ -0,0 +1,61 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-primary hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-secondary hover:bg-secondary-600 text-white font-medium py-2 px-4 rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply border-2 border-primary text-primary hover:bg-primary hover:text-white font-medium py-2 px-4 rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white dark:bg-gray-900 rounded-lg shadow-md p-6;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary dark:focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500;
|
||||
}
|
||||
|
||||
/* Select específico */
|
||||
select.input-field {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
/* Textarea específico */
|
||||
textarea.input-field {
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar personalizado para dark mode */
|
||||
@layer utilities {
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 dark:bg-gray-900;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 dark:bg-gray-700 rounded-full;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400 dark:bg-gray-600;
|
||||
}
|
||||
}
|
||||
63
src/lib/constants.ts
Normal file
63
src/lib/constants.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export const PERFIS = {
|
||||
ADMIN: 'admin',
|
||||
GERENTE_QUALIDADE: 'gerente_qualidade',
|
||||
ENGENHEIRO: 'engenheiro',
|
||||
CLIENTE: 'cliente',
|
||||
} as const
|
||||
|
||||
export const STATUS_PROJETO = {
|
||||
RASCUNHO: 'rascunho',
|
||||
EM_ANDAMENTO: 'em_andamento',
|
||||
REVISAO: 'revisao',
|
||||
FINALIZADO: 'finalizado',
|
||||
CANCELADO: 'cancelado',
|
||||
} as const
|
||||
|
||||
export const TAMANHO_PAGINA = {
|
||||
A4: 'A4',
|
||||
LETTER: 'Letter',
|
||||
} as const
|
||||
|
||||
export const ORIENTACAO = {
|
||||
RETRATO: 'retrato',
|
||||
PAISAGEM: 'paisagem',
|
||||
} as const
|
||||
|
||||
export const FREQUENCIA_ATUALIZACAO = {
|
||||
MANUAL: 'manual',
|
||||
AO_CRIAR: 'ao_criar',
|
||||
DIARIO: 'diario',
|
||||
SEMANAL: 'semanal',
|
||||
} as const
|
||||
|
||||
// 28 tópicos padrão do databook
|
||||
export const TOPICOS_PADRAO = [
|
||||
{ numero: '1', titulo: 'Atestado de Conformidade', obrigatorio: true },
|
||||
{ numero: '2.1', titulo: 'Desenhos de Fabricação', obrigatorio: true },
|
||||
{ numero: '2.2', titulo: 'Mapeamento de Soldas', obrigatorio: false },
|
||||
{ numero: '3.1', titulo: 'PIT (Plano de Inspeção e Testes)', obrigatorio: true },
|
||||
{ numero: '3.2', titulo: 'Procedimentos de Inspeção', obrigatorio: false },
|
||||
{ numero: '4.1', titulo: 'Procedimento de Soldagem (EPS/WPS)', obrigatorio: true },
|
||||
{ numero: '4.2', titulo: 'Qualificação de Procedimento (RQPS/PQR)', obrigatorio: true },
|
||||
{ numero: '5.1', titulo: 'Certificados de Metais de Base', obrigatorio: true },
|
||||
{ numero: '5.2.1', titulo: 'Certificados de Consumíveis - Solda', obrigatorio: true },
|
||||
{ numero: '5.2.2', titulo: 'Certificados de Consumíveis - Pintura', obrigatorio: false },
|
||||
{ numero: '5.3', titulo: 'Certificados de Parafusos', obrigatorio: false },
|
||||
{ numero: '5.4', titulo: 'Certificados de Eletrodos de Solda', obrigatorio: false },
|
||||
{ numero: '5.5', titulo: 'Certificados de Gases', obrigatorio: false },
|
||||
{ numero: '5.6', titulo: 'Certificados de Equipamentos', obrigatorio: false },
|
||||
{ numero: '5.7', titulo: 'Certificados de Qualificação de Soldadores', obrigatorio: true },
|
||||
{ numero: '5.8', titulo: 'Certificados de Qualificação de Inspetores', obrigatorio: true },
|
||||
{ numero: '6.1', titulo: 'Relatórios de Inspeção Visual', obrigatorio: true },
|
||||
{ numero: '6.2', titulo: 'Relatórios de Partícula Magnética', obrigatorio: false },
|
||||
{ numero: '6.3', titulo: 'Relatórios de Líquido Penetrante', obrigatorio: false },
|
||||
{ numero: '6.4', titulo: 'Relatórios de Ultrassom', obrigatorio: false },
|
||||
{ numero: '6.5', titulo: 'Relatórios de Radiografia', obrigatorio: false },
|
||||
{ numero: '7.1', titulo: 'Relatórios Dimensionais', obrigatorio: false },
|
||||
{ numero: '7.2', titulo: 'Relatórios de Teste de Carga', obrigatorio: false },
|
||||
{ numero: '8.1', titulo: 'Procedimento de Pintura', obrigatorio: false },
|
||||
{ numero: '8.2', titulo: 'Relatórios de Inspeção de Pintura', obrigatorio: false },
|
||||
{ numero: '8.3', titulo: 'Certificados de Tinta', obrigatorio: false },
|
||||
{ numero: '9.1', titulo: 'Fotos de Fabricação', obrigatorio: false },
|
||||
{ numero: '9.2', titulo: 'Registros de Rastreabilidade', obrigatorio: false },
|
||||
]
|
||||
152
src/lib/mockData.ts
Normal file
152
src/lib/mockData.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// Mock data para desenvolvimento sem Supabase
|
||||
|
||||
export const mockUsuario = {
|
||||
id: '1',
|
||||
email: 'demo@example.com',
|
||||
nome_completo: 'Usuário Demo',
|
||||
perfil: 'admin',
|
||||
ativo: true,
|
||||
}
|
||||
|
||||
export const mockClientes = [
|
||||
{
|
||||
id: '1',
|
||||
nome: 'Equinor Brasil',
|
||||
contato: 'João Silva',
|
||||
email: 'joao@equinor.com',
|
||||
telefone: '+55 21 99999-9999',
|
||||
ativo: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
nome: 'Petrobras',
|
||||
contato: 'Maria Santos',
|
||||
email: 'maria@petrobras.com.br',
|
||||
telefone: '+55 21 88888-8888',
|
||||
ativo: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const mockTemplates = [
|
||||
{
|
||||
id: '1',
|
||||
nome: 'Completo',
|
||||
tipo: 'novo',
|
||||
template_pai_id: null,
|
||||
topicos_selecionados: Array.from({ length: 28 }, (_, i) => `topico-${i + 1}`),
|
||||
total_topicos: 28,
|
||||
total_obrigatorios: 9,
|
||||
descricao: 'Template completo com todas as 28 seções',
|
||||
ativo: true,
|
||||
criado_por: '1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
nome: 'Mínimo',
|
||||
tipo: 'novo',
|
||||
template_pai_id: null,
|
||||
topicos_selecionados: Array.from({ length: 9 }, (_, i) => `topico-obrigatorio-${i + 1}`),
|
||||
total_topicos: 9,
|
||||
total_obrigatorios: 9,
|
||||
descricao: 'Template mínimo com apenas seções obrigatórias',
|
||||
ativo: true,
|
||||
criado_por: '1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
nome: 'Padrão Galpão Civil',
|
||||
tipo: 'novo',
|
||||
template_pai_id: null,
|
||||
topicos_selecionados: Array.from({ length: 18 }, (_, i) => `topico-civil-${i + 1}`),
|
||||
total_topicos: 18,
|
||||
total_obrigatorios: 9,
|
||||
descricao: 'Template para estruturas civis',
|
||||
ativo: true,
|
||||
criado_por: '1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
|
||||
export const mockProjetos = [
|
||||
{
|
||||
id: '1',
|
||||
numero_projeto: 'PRJ-2025-00001',
|
||||
nome_projeto: 'Bacalhau WA0056',
|
||||
cliente_id: '1',
|
||||
template_id: '1',
|
||||
status: 'em_andamento',
|
||||
progresso_percentual: 75,
|
||||
data_inicio: '2025-11-01',
|
||||
data_entrega_prevista: '2025-12-15',
|
||||
responsavel_id: '1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
clientes: mockClientes[0],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
numero_projeto: 'PRJ-2025-00002',
|
||||
nome_projeto: 'Estrutura Galpão SP',
|
||||
cliente_id: '2',
|
||||
template_id: '3',
|
||||
status: 'rascunho',
|
||||
progresso_percentual: 25,
|
||||
data_inicio: '2025-11-10',
|
||||
data_entrega_prevista: '2025-12-20',
|
||||
responsavel_id: '1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
clientes: mockClientes[1],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
numero_projeto: 'PRJ-2025-00003',
|
||||
nome_projeto: 'Ponte Rio Grande',
|
||||
cliente_id: '1',
|
||||
template_id: '1',
|
||||
status: 'revisao',
|
||||
progresso_percentual: 90,
|
||||
data_inicio: '2025-10-01',
|
||||
data_entrega_prevista: '2025-11-30',
|
||||
responsavel_id: '1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
clientes: mockClientes[0],
|
||||
},
|
||||
]
|
||||
|
||||
export const mockTopicos = [
|
||||
{ numero: '1', titulo: 'Atestado de Conformidade', obrigatorio: true },
|
||||
{ numero: '2.1', titulo: 'Desenhos de Fabricação', obrigatorio: true },
|
||||
{ numero: '2.2', titulo: 'Mapeamento de Soldas', obrigatorio: false },
|
||||
{ numero: '3.1', titulo: 'PIT (Plano de Inspeção e Testes)', obrigatorio: true },
|
||||
{ numero: '3.2', titulo: 'Procedimentos de Inspeção', obrigatorio: false },
|
||||
{ numero: '4.1', titulo: 'Procedimento de Soldagem (EPS/WPS)', obrigatorio: true },
|
||||
{ numero: '4.2', titulo: 'Qualificação de Procedimento (RQPS/PQR)', obrigatorio: true },
|
||||
{ numero: '5.1', titulo: 'Certificados de Metais de Base', obrigatorio: true },
|
||||
{ numero: '5.2.1', titulo: 'Certificados de Consumíveis - Solda', obrigatorio: true },
|
||||
{ numero: '5.2.2', titulo: 'Certificados de Consumíveis - Pintura', obrigatorio: false },
|
||||
{ numero: '5.3', titulo: 'Certificados de Parafusos', obrigatorio: false },
|
||||
{ numero: '5.4', titulo: 'Certificados de Eletrodos de Solda', obrigatorio: false },
|
||||
{ numero: '5.5', titulo: 'Certificados de Gases', obrigatorio: false },
|
||||
{ numero: '5.6', titulo: 'Certificados de Equipamentos', obrigatorio: false },
|
||||
{ numero: '5.7', titulo: 'Certificados de Qualificação de Soldadores', obrigatorio: true },
|
||||
{ numero: '5.8', titulo: 'Certificados de Qualificação de Inspetores', obrigatorio: true },
|
||||
{ numero: '6.1', titulo: 'Relatórios de Inspeção Visual', obrigatorio: true },
|
||||
{ numero: '6.2', titulo: 'Relatórios de Partícula Magnética', obrigatorio: false },
|
||||
{ numero: '6.3', titulo: 'Relatórios de Líquido Penetrante', obrigatorio: false },
|
||||
{ numero: '6.4', titulo: 'Relatórios de Ultrassom', obrigatorio: false },
|
||||
{ numero: '6.5', titulo: 'Relatórios de Radiografia', obrigatorio: false },
|
||||
{ numero: '7.1', titulo: 'Relatórios Dimensionais', obrigatorio: false },
|
||||
{ numero: '7.2', titulo: 'Relatórios de Teste de Carga', obrigatorio: false },
|
||||
{ numero: '8.1', titulo: 'Procedimento de Pintura', obrigatorio: false },
|
||||
{ numero: '8.2', titulo: 'Relatórios de Inspeção de Pintura', obrigatorio: false },
|
||||
{ numero: '8.3', titulo: 'Certificados de Tinta', obrigatorio: false },
|
||||
{ numero: '9.1', titulo: 'Fotos de Fabricação', obrigatorio: false },
|
||||
{ numero: '9.2', titulo: 'Registros de Rastreabilidade', obrigatorio: false },
|
||||
]
|
||||
224
src/lib/mutations.ts
Normal file
224
src/lib/mutations.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
// @ts-nocheck
|
||||
import { supabase } from './supabase'
|
||||
|
||||
// ============================================
|
||||
// TEMPLATES MUTATIONS
|
||||
// ============================================
|
||||
|
||||
export const createTemplate = async (data: {
|
||||
nome: string
|
||||
tipo: 'novo' | 'derivado'
|
||||
template_pai_id?: string | null
|
||||
topicos_selecionados: string[]
|
||||
descricao?: string
|
||||
}) => {
|
||||
const { data: result, error } = await supabase
|
||||
.from('templates_customizados')
|
||||
.insert([
|
||||
{
|
||||
nome: data.nome,
|
||||
tipo: data.tipo,
|
||||
template_pai_id: data.template_pai_id || null,
|
||||
topicos_selecionados: data.topicos_selecionados,
|
||||
total_topicos: data.topicos_selecionados.length,
|
||||
total_obrigatorios: data.topicos_selecionados.length, // TODO: calcular corretamente
|
||||
descricao: data.descricao || null,
|
||||
ativo: true,
|
||||
} as any,
|
||||
])
|
||||
.select()
|
||||
|
||||
if (error) throw error
|
||||
return result?.[0]
|
||||
}
|
||||
|
||||
export const updateTemplate = async (
|
||||
id: string,
|
||||
data: {
|
||||
nome?: string
|
||||
topicos_selecionados?: string[]
|
||||
descricao?: string
|
||||
}
|
||||
) => {
|
||||
const { data: result, error } = await supabase
|
||||
.from('templates_customizados')
|
||||
.update({
|
||||
...data,
|
||||
total_topicos: data.topicos_selecionados?.length,
|
||||
} as any)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
|
||||
if (error) throw error
|
||||
return result?.[0]
|
||||
}
|
||||
|
||||
export const deleteTemplate = async (id: string) => {
|
||||
const { error } = await supabase
|
||||
.from('templates_customizados')
|
||||
.update({ ativo: false } as any)
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DATABOOKS MUTATIONS
|
||||
// ============================================
|
||||
|
||||
export const createDatabook = async (data: {
|
||||
numero_projeto: string
|
||||
nome_projeto: string
|
||||
cliente_id?: string | null
|
||||
template_id?: string | null
|
||||
cliente_nome: string
|
||||
cliente_contato: string
|
||||
cliente_email: string
|
||||
cliente_telefone: string
|
||||
produto_nome: string
|
||||
produto_tipo: string
|
||||
produto_descricao: string
|
||||
produto_normas: string[]
|
||||
ordem_compra: string
|
||||
data_inicio: string
|
||||
data_entrega_prevista: string
|
||||
titulo_principal: string
|
||||
subtitulo: string
|
||||
cor_primaria: string
|
||||
cor_secundaria: string
|
||||
tamanho_pagina: 'A4' | 'Letter'
|
||||
orientacao: 'retrato' | 'paisagem'
|
||||
}) => {
|
||||
// Criar projeto
|
||||
const { data: projeto, error: projetoError } = await supabase
|
||||
.from('projetos')
|
||||
.insert([
|
||||
{
|
||||
numero_projeto: data.numero_projeto,
|
||||
nome_projeto: data.nome_projeto,
|
||||
cliente_id: data.cliente_id || null,
|
||||
template_id: data.template_id || null,
|
||||
status: 'rascunho',
|
||||
progresso_percentual: 0,
|
||||
} as any,
|
||||
])
|
||||
.select()
|
||||
|
||||
if (projetoError) throw projetoError
|
||||
|
||||
const projetoId = projeto?.[0]?.id
|
||||
|
||||
// Criar databook_mestre
|
||||
const { data: databook, error: databookError } = await supabase
|
||||
.from('databooks_mestres')
|
||||
.insert([
|
||||
{
|
||||
projeto_id: projetoId,
|
||||
cliente_nome: data.cliente_nome,
|
||||
cliente_contato: data.cliente_contato,
|
||||
cliente_email: data.cliente_email,
|
||||
cliente_telefone: data.cliente_telefone,
|
||||
produto_nome: data.produto_nome,
|
||||
produto_tipo: data.produto_tipo,
|
||||
produto_descricao: data.produto_descricao,
|
||||
produto_normas: data.produto_normas,
|
||||
numero_projeto: data.numero_projeto,
|
||||
ordem_compra: data.ordem_compra,
|
||||
data_inicio: data.data_inicio,
|
||||
data_entrega_prevista: data.data_entrega_prevista,
|
||||
titulo_principal: data.titulo_principal,
|
||||
subtitulo: data.subtitulo,
|
||||
cor_primaria: data.cor_primaria,
|
||||
cor_secundaria: data.cor_secundaria,
|
||||
tamanho_pagina: data.tamanho_pagina,
|
||||
orientacao: data.orientacao,
|
||||
} as any,
|
||||
])
|
||||
.select()
|
||||
|
||||
if (databookError) throw databookError
|
||||
|
||||
return { projeto: projeto?.[0], databook: databook?.[0] }
|
||||
}
|
||||
|
||||
export const updateDatabook = async (
|
||||
projetoId: string,
|
||||
data: {
|
||||
nome_projeto?: string
|
||||
status?: string
|
||||
progresso_percentual?: number
|
||||
}
|
||||
) => {
|
||||
const { data: result, error } = await supabase
|
||||
.from('projetos')
|
||||
.update(data as any)
|
||||
.eq('id', projetoId)
|
||||
.select()
|
||||
|
||||
if (error) throw error
|
||||
return result?.[0]
|
||||
}
|
||||
|
||||
export const deleteDatabook = async (projetoId: string) => {
|
||||
const { error } = await supabase
|
||||
.from('projetos')
|
||||
.delete()
|
||||
.eq('id', projetoId)
|
||||
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CLIENTES MUTATIONS
|
||||
// ============================================
|
||||
|
||||
export const createCliente = async (data: {
|
||||
nome: string
|
||||
contato?: string
|
||||
email?: string
|
||||
telefone?: string
|
||||
}) => {
|
||||
const { data: result, error } = await supabase
|
||||
.from('clientes')
|
||||
.insert([
|
||||
{
|
||||
nome: data.nome,
|
||||
contato: data.contato || null,
|
||||
email: data.email || null,
|
||||
telefone: data.telefone || null,
|
||||
ativo: true,
|
||||
} as any,
|
||||
])
|
||||
.select()
|
||||
|
||||
if (error) throw error
|
||||
return result?.[0]
|
||||
}
|
||||
|
||||
export const updateCliente = async (
|
||||
id: string,
|
||||
data: {
|
||||
nome?: string
|
||||
contato?: string
|
||||
email?: string
|
||||
telefone?: string
|
||||
}
|
||||
) => {
|
||||
const { data: result, error } = await supabase
|
||||
.from('clientes')
|
||||
.update(data as any)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
|
||||
if (error) throw error
|
||||
return result?.[0]
|
||||
}
|
||||
|
||||
export const deleteCliente = async (id: string) => {
|
||||
const { error } = await supabase
|
||||
.from('clientes')
|
||||
.update({ ativo: false } as any)
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
}
|
||||
609
src/lib/pdfGenerator.ts
Normal file
609
src/lib/pdfGenerator.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
import jsPDF from 'jspdf'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { PDFDocument } from 'pdf-lib'
|
||||
|
||||
interface DesignConfig {
|
||||
capa?: {
|
||||
corPrimaria: string
|
||||
corSecundaria: string
|
||||
titulo: string
|
||||
subtitulo: string
|
||||
cliente: string
|
||||
numeroDocumento: string
|
||||
contrato: string
|
||||
fornecedor: string
|
||||
logoCliente?: string
|
||||
logoFornecedor?: string
|
||||
}
|
||||
indice?: {
|
||||
corTitulo: string
|
||||
corLinha: string
|
||||
bilingue: boolean
|
||||
titulo: string
|
||||
}
|
||||
divisora?: {
|
||||
estilo: 'minimalista' | 'lateral' | 'corporativa'
|
||||
corPrimaria: string
|
||||
corSecundaria: string
|
||||
bilingue: boolean
|
||||
icone: string
|
||||
}
|
||||
cabecalho?: {
|
||||
corBorda: string
|
||||
altura: number
|
||||
estilo: 'simples' | 'completo' | 'minimalista'
|
||||
}
|
||||
rodape?: {
|
||||
corBorda: string
|
||||
altura: number
|
||||
estilo: 'simples' | 'completo' | 'minimalista'
|
||||
mostrarPagina: boolean
|
||||
}
|
||||
marcaAgua?: string
|
||||
}
|
||||
|
||||
interface Topico {
|
||||
id: string
|
||||
numero_topico: string
|
||||
titulo: string
|
||||
descricao?: string
|
||||
obrigatorio: boolean
|
||||
}
|
||||
|
||||
interface Documento {
|
||||
id: string
|
||||
titulo: string
|
||||
arquivo_url: string
|
||||
arquivo_tipo: string
|
||||
}
|
||||
|
||||
interface PDFGeneratorOptions {
|
||||
projeto: any
|
||||
topicos: Topico[]
|
||||
documentosPorSecao: { [key: string]: Documento[] }
|
||||
designConfig?: DesignConfig
|
||||
onProgress?: (progress: number, message: string) => void
|
||||
}
|
||||
|
||||
export class PDFGenerator {
|
||||
private pdf: jsPDF
|
||||
private pageNumber: number = 0
|
||||
private config: DesignConfig
|
||||
private projeto: any
|
||||
|
||||
constructor(options: PDFGeneratorOptions) {
|
||||
this.pdf = new jsPDF('p', 'mm', 'a4')
|
||||
this.projeto = options.projeto
|
||||
this.config = options.designConfig || this.getDefaultConfig()
|
||||
}
|
||||
|
||||
private getDefaultConfig(): DesignConfig {
|
||||
return {
|
||||
capa: {
|
||||
corPrimaria: '#1a365d',
|
||||
corSecundaria: '#2b6cb0',
|
||||
titulo: 'DATABOOK',
|
||||
subtitulo: '',
|
||||
cliente: '',
|
||||
numeroDocumento: '',
|
||||
contrato: '',
|
||||
fornecedor: ''
|
||||
},
|
||||
indice: {
|
||||
corTitulo: '#1a365d',
|
||||
corLinha: '#2b6cb0',
|
||||
bilingue: false,
|
||||
titulo: 'ÍNDICE'
|
||||
},
|
||||
divisora: {
|
||||
estilo: 'minimalista',
|
||||
corPrimaria: '#1a365d',
|
||||
corSecundaria: '#2b6cb0',
|
||||
bilingue: false,
|
||||
icone: ''
|
||||
},
|
||||
cabecalho: {
|
||||
corBorda: '#2b6cb0',
|
||||
altura: 15,
|
||||
estilo: 'simples'
|
||||
},
|
||||
rodape: {
|
||||
corBorda: '#cbd5e0',
|
||||
altura: 10,
|
||||
estilo: 'simples',
|
||||
mostrarPagina: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async generate(options: PDFGeneratorOptions): Promise<Blob> {
|
||||
const { topicos, documentosPorSecao, onProgress } = options
|
||||
|
||||
try {
|
||||
// 1. Gerar Capa
|
||||
onProgress?.(10, 'Gerando capa...')
|
||||
await this.generateCapa()
|
||||
|
||||
// 2. Gerar Índice
|
||||
onProgress?.(20, 'Gerando índice...')
|
||||
await this.generateIndice(topicos)
|
||||
|
||||
// 3. Gerar Seções
|
||||
let progress = 20
|
||||
const progressPerSection = 70 / topicos.length
|
||||
|
||||
for (const topico of topicos) {
|
||||
const docs = documentosPorSecao[topico.numero_topico] || []
|
||||
|
||||
onProgress?.(progress, `Gerando seção ${topico.numero_topico}...`)
|
||||
|
||||
// Divisora da seção
|
||||
await this.generateDivisora(topico)
|
||||
|
||||
// Documentos da seção
|
||||
if (docs.length > 0) {
|
||||
for (const doc of docs) {
|
||||
await this.generateDocumentPage(doc, topico)
|
||||
}
|
||||
} else {
|
||||
await this.generateNaoAplicavelPage(topico)
|
||||
}
|
||||
|
||||
progress += progressPerSection
|
||||
}
|
||||
|
||||
onProgress?.(95, 'Mesclando documentos originais...')
|
||||
|
||||
// Usa pdf-lib para mesclar tudo
|
||||
const finalPdfBytes = await this.mergeDocuments(topicos, documentosPorSecao)
|
||||
|
||||
const blob = new Blob([finalPdfBytes as unknown as ArrayBuffer], { type: 'application/pdf' })
|
||||
onProgress?.(100, 'PDF gerado com sucesso!')
|
||||
|
||||
return blob
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar PDF:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async generateCapa(): Promise<void> {
|
||||
const capa = this.config.capa!
|
||||
|
||||
// Criar elemento HTML para a capa
|
||||
const capaElement = document.createElement('div')
|
||||
capaElement.style.width = '210mm'
|
||||
capaElement.style.height = '297mm'
|
||||
capaElement.style.background = `linear-gradient(135deg, ${capa.corPrimaria} 0%, ${capa.corSecundaria} 100%)`
|
||||
capaElement.style.color = 'white'
|
||||
capaElement.style.padding = '40mm'
|
||||
capaElement.style.display = 'flex'
|
||||
capaElement.style.flexDirection = 'column'
|
||||
capaElement.style.justifyContent = 'space-between'
|
||||
capaElement.style.fontFamily = 'Arial, sans-serif'
|
||||
capaElement.style.position = 'absolute'
|
||||
capaElement.style.left = '-9999px'
|
||||
|
||||
capaElement.innerHTML = `
|
||||
<div>
|
||||
${capa.logoCliente ? `<img src="${capa.logoCliente}" style="max-width: 200px; max-height: 100px; margin-bottom: 30px;" />` : ''}
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<h1 style="font-size: 48px; font-weight: bold; margin: 0 0 20px 0;">${capa.titulo}</h1>
|
||||
${capa.subtitulo ? `<h2 style="font-size: 32px; font-weight: normal; margin: 0 0 40px 0;">${capa.subtitulo}</h2>` : ''}
|
||||
<div style="width: 60%; height: 3px; background: rgba(255,255,255,0.5); margin: 40px auto;"></div>
|
||||
<div style="margin-top: 40px;">
|
||||
<p style="font-size: 24px; font-weight: 600; margin: 10px 0;">${capa.numeroDocumento}</p>
|
||||
<p style="font-size: 20px; margin: 10px 0;">Contrato: ${capa.contrato}</p>
|
||||
<p style="font-size: 20px; margin: 10px 0;">Cliente: ${capa.cliente}</p>
|
||||
<p style="font-size: 18px; margin: 10px 0;">${new Date().getFullYear()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
${capa.logoFornecedor ? `<img src="${capa.logoFornecedor}" style="max-width: 150px; max-height: 75px;" />` : `<p style="font-size: 20px;">${capa.fornecedor}</p>`}
|
||||
</div>
|
||||
`
|
||||
|
||||
document.body.appendChild(capaElement)
|
||||
|
||||
const canvas = await html2canvas(capaElement, { scale: 2 })
|
||||
const imgData = canvas.toDataURL('image/png')
|
||||
|
||||
this.pdf.addImage(imgData, 'PNG', 0, 0, 210, 297)
|
||||
this.pageNumber++
|
||||
|
||||
document.body.removeChild(capaElement)
|
||||
}
|
||||
|
||||
private async generateIndice(topicos: Topico[]): Promise<void> {
|
||||
this.pdf.addPage()
|
||||
this.pageNumber++
|
||||
|
||||
const indice = this.config.indice!
|
||||
|
||||
// Título
|
||||
this.pdf.setFontSize(24)
|
||||
this.pdf.setTextColor(indice.corTitulo)
|
||||
this.pdf.setFont('helvetica', 'bold')
|
||||
this.pdf.text(indice.titulo, 105, 30, { align: 'center' })
|
||||
|
||||
// Linha divisória
|
||||
this.pdf.setDrawColor(indice.corLinha)
|
||||
this.pdf.setLineWidth(0.5)
|
||||
this.pdf.line(20, 40, 190, 40)
|
||||
|
||||
// Itens do índice
|
||||
let y = 55
|
||||
this.pdf.setFontSize(12)
|
||||
|
||||
topicos.forEach((topico, index) => {
|
||||
if (y > 270) {
|
||||
this.pdf.addPage()
|
||||
this.pageNumber++
|
||||
y = 30
|
||||
}
|
||||
|
||||
const indent = (topico.numero_topico.split('.').length - 1) * 5
|
||||
|
||||
this.pdf.setFont('helvetica', 'bold')
|
||||
this.pdf.setTextColor('#2b6cb0')
|
||||
this.pdf.text(topico.numero_topico, 20 + indent, y)
|
||||
|
||||
this.pdf.setFont('helvetica', 'normal')
|
||||
this.pdf.setTextColor('#000000')
|
||||
this.pdf.text(topico.titulo, 40 + indent, y, { maxWidth: 130 })
|
||||
|
||||
// Número da página (simulado)
|
||||
this.pdf.text((index + 3).toString(), 185, y, { align: 'right' })
|
||||
|
||||
y += 8
|
||||
})
|
||||
}
|
||||
|
||||
private async generateDivisora(topico: Topico): Promise<void> {
|
||||
this.pdf.addPage()
|
||||
this.pageNumber++
|
||||
|
||||
const divisora = this.config.divisora!
|
||||
|
||||
if (divisora.estilo === 'minimalista') {
|
||||
await this.generateDivisoraMinimalista(topico)
|
||||
} else if (divisora.estilo === 'lateral') {
|
||||
await this.generateDivisoraLateral(topico)
|
||||
} else {
|
||||
await this.generateDivisoraCorporativa(topico)
|
||||
}
|
||||
}
|
||||
|
||||
private async generateDivisoraMinimalista(topico: Topico): Promise<void> {
|
||||
const divisora = this.config.divisora!
|
||||
|
||||
// Watermark do número
|
||||
this.pdf.setFontSize(150)
|
||||
this.pdf.setTextColor(divisora.corPrimaria)
|
||||
this.pdf.setGState(this.pdf.GState({ opacity: 0.08 }))
|
||||
this.pdf.text(topico.numero_topico, 105, 160, { align: 'center' })
|
||||
this.pdf.setGState(this.pdf.GState({ opacity: 1 }))
|
||||
|
||||
// Conteúdo
|
||||
this.pdf.setFontSize(36)
|
||||
this.pdf.setTextColor(divisora.corSecundaria)
|
||||
this.pdf.setFont('helvetica', 'bold')
|
||||
this.pdf.text(`Seção ${topico.numero_topico}`, 105, 120, { align: 'center' })
|
||||
|
||||
this.pdf.setFontSize(32)
|
||||
this.pdf.setTextColor(divisora.corPrimaria)
|
||||
this.pdf.text(topico.titulo, 105, 140, { align: 'center', maxWidth: 160 })
|
||||
|
||||
if (topico.descricao) {
|
||||
this.pdf.setFontSize(16)
|
||||
this.pdf.setTextColor('#718096')
|
||||
this.pdf.setFont('helvetica', 'italic')
|
||||
this.pdf.text(topico.descricao, 105, 160, { align: 'center', maxWidth: 140 })
|
||||
}
|
||||
|
||||
// Linha decorativa
|
||||
this.pdf.setDrawColor(divisora.corSecundaria)
|
||||
this.pdf.setLineWidth(0.5)
|
||||
this.pdf.line(80, 175, 130, 175)
|
||||
}
|
||||
|
||||
private async generateDivisoraLateral(topico: Topico): Promise<void> {
|
||||
const divisora = this.config.divisora!
|
||||
|
||||
// Barra lateral
|
||||
this.pdf.setFillColor(divisora.corPrimaria)
|
||||
this.pdf.rect(0, 0, 20, 297, 'F')
|
||||
|
||||
// Número na barra
|
||||
this.pdf.setFontSize(48)
|
||||
this.pdf.setTextColor('#ffffff')
|
||||
this.pdf.setFont('helvetica', 'bold')
|
||||
this.pdf.text(topico.numero_topico, 10, 150, { align: 'center', angle: 0 })
|
||||
|
||||
// Conteúdo principal
|
||||
this.pdf.setFontSize(28)
|
||||
this.pdf.setTextColor(divisora.corPrimaria)
|
||||
this.pdf.text(topico.titulo, 35, 140, { maxWidth: 160 })
|
||||
|
||||
if (topico.descricao) {
|
||||
this.pdf.setFontSize(14)
|
||||
this.pdf.setTextColor('#718096')
|
||||
this.pdf.setFont('helvetica', 'italic')
|
||||
this.pdf.text(topico.descricao, 35, 160, { maxWidth: 160 })
|
||||
}
|
||||
|
||||
// Informações do projeto no rodapé
|
||||
this.pdf.setFontSize(10)
|
||||
this.pdf.setTextColor('#718096')
|
||||
this.pdf.setFont('helvetica', 'normal')
|
||||
this.pdf.text(`Projeto: ${this.projeto?.nome_projeto || ''}`, 35, 270)
|
||||
this.pdf.text(`Documento: ${this.config.capa?.numeroDocumento || ''}`, 35, 277)
|
||||
}
|
||||
|
||||
private async generateDivisoraCorporativa(topico: Topico): Promise<void> {
|
||||
const divisora = this.config.divisora!
|
||||
|
||||
// Header colorido
|
||||
this.pdf.setFillColor(divisora.corPrimaria)
|
||||
this.pdf.rect(0, 0, 210, 80, 'F')
|
||||
|
||||
// Conteúdo
|
||||
this.pdf.setFontSize(48)
|
||||
this.pdf.setTextColor(divisora.corPrimaria)
|
||||
this.pdf.setFont('helvetica', 'bold')
|
||||
this.pdf.text(topico.numero_topico, 105, 120, { align: 'center' })
|
||||
|
||||
this.pdf.setFontSize(28)
|
||||
this.pdf.text(topico.titulo, 105, 145, { align: 'center', maxWidth: 160 })
|
||||
|
||||
// Caixa de informações
|
||||
this.pdf.setDrawColor('#e2e8f0')
|
||||
this.pdf.setLineWidth(0.5)
|
||||
this.pdf.roundedRect(40, 170, 130, 60, 3, 3, 'S')
|
||||
|
||||
this.pdf.setFontSize(10)
|
||||
this.pdf.setTextColor('#718096')
|
||||
this.pdf.setFont('helvetica', 'bold')
|
||||
let infoY = 180
|
||||
|
||||
const infos = [
|
||||
['Projeto:', this.projeto?.nome_projeto?.substring(0, 40) || ''],
|
||||
['Cliente:', this.config.capa?.cliente || ''],
|
||||
['Contrato:', this.config.capa?.contrato || ''],
|
||||
['Documento:', this.config.capa?.numeroDocumento || '']
|
||||
]
|
||||
|
||||
infos.forEach(([label, value]) => {
|
||||
this.pdf.text(label, 45, infoY)
|
||||
this.pdf.setFont('helvetica', 'normal')
|
||||
this.pdf.setTextColor('#2d3748')
|
||||
this.pdf.text(value, 75, infoY)
|
||||
this.pdf.setFont('helvetica', 'bold')
|
||||
this.pdf.setTextColor('#718096')
|
||||
infoY += 12
|
||||
})
|
||||
}
|
||||
|
||||
private async generateDocumentPage(doc: Documento, topico: Topico): Promise<void> {
|
||||
// Se for imagem ou PDF, será processado depois com pdf-lib
|
||||
// Aqui apenas marcamos para processamento posterior
|
||||
if (doc.arquivo_url.startsWith('data:image') || doc.arquivo_url.startsWith('data:application/pdf')) {
|
||||
// Será processado na função mergeDocuments
|
||||
return
|
||||
}
|
||||
|
||||
// Para outros tipos de documento
|
||||
this.pdf.addPage()
|
||||
this.pageNumber++
|
||||
|
||||
// Cabeçalho
|
||||
await this.addHeader()
|
||||
|
||||
// Título do documento
|
||||
this.pdf.setFontSize(14)
|
||||
this.pdf.setTextColor('#000000')
|
||||
this.pdf.setFont('helvetica', 'bold')
|
||||
this.pdf.text(doc.titulo, 20, 35)
|
||||
|
||||
this.pdf.setFontSize(10)
|
||||
this.pdf.setTextColor('#718096')
|
||||
this.pdf.setFont('helvetica', 'normal')
|
||||
this.pdf.text(`Seção ${topico.numero_topico}`, 20, 42)
|
||||
|
||||
// Linha divisória
|
||||
this.pdf.setDrawColor('#e2e8f0')
|
||||
this.pdf.line(20, 45, 190, 45)
|
||||
|
||||
this.pdf.setFontSize(12)
|
||||
this.pdf.setTextColor('#718096')
|
||||
this.pdf.text(`Documento: ${doc.titulo}`, 105, 150, { align: 'center' })
|
||||
this.pdf.text(`Tipo: ${doc.arquivo_tipo}`, 105, 160, { align: 'center' })
|
||||
|
||||
// Rodapé
|
||||
await this.addFooter()
|
||||
}
|
||||
|
||||
private async mergeDocuments(topicos: Topico[], documentosPorSecao: { [key: string]: Documento[] }): Promise<Uint8Array> {
|
||||
const mergedPdf = await PDFDocument.create()
|
||||
|
||||
// Primeiro, adiciona capa e índice do jsPDF
|
||||
const jsPdfBytes = this.pdf.output('arraybuffer')
|
||||
const jsPdfDoc = await PDFDocument.load(jsPdfBytes)
|
||||
|
||||
// Copia apenas as primeiras páginas (capa e índice)
|
||||
let currentJsPdfPage = 0
|
||||
const totalJsPdfPages = jsPdfDoc.getPageCount()
|
||||
|
||||
// Adiciona capa e índice (primeiras 2 páginas)
|
||||
if (totalJsPdfPages >= 2) {
|
||||
const initialPages = await mergedPdf.copyPages(jsPdfDoc, [0, 1])
|
||||
initialPages.forEach(page => mergedPdf.addPage(page))
|
||||
currentJsPdfPage = 2
|
||||
}
|
||||
|
||||
// Agora processa cada seção
|
||||
for (const topico of topicos) {
|
||||
const docs = documentosPorSecao[topico.numero_topico] || []
|
||||
|
||||
// Adiciona página divisora da seção (do jsPDF)
|
||||
if (currentJsPdfPage < totalJsPdfPages) {
|
||||
const [divisoraPage] = await mergedPdf.copyPages(jsPdfDoc, [currentJsPdfPage])
|
||||
mergedPdf.addPage(divisoraPage)
|
||||
currentJsPdfPage++
|
||||
}
|
||||
|
||||
// Adiciona os documentos originais
|
||||
if (docs.length > 0) {
|
||||
for (const doc of docs) {
|
||||
try {
|
||||
console.log(`Processando documento: ${doc.titulo}, tipo: ${doc.arquivo_tipo}, URL: ${doc.arquivo_url.substring(0, 50)}...`)
|
||||
|
||||
// Busca o arquivo original
|
||||
let fileBytes: ArrayBuffer
|
||||
|
||||
if (doc.arquivo_url.startsWith('data:')) {
|
||||
// Converte data URL para bytes
|
||||
const base64Data = doc.arquivo_url.split(',')[1]
|
||||
const binaryString = atob(base64Data)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
fileBytes = bytes.buffer
|
||||
} else {
|
||||
// Busca do Supabase Storage
|
||||
const response = await fetch(doc.arquivo_url)
|
||||
fileBytes = await response.arrayBuffer()
|
||||
}
|
||||
|
||||
console.log(`Arquivo carregado, tamanho: ${fileBytes.byteLength} bytes`)
|
||||
|
||||
if (doc.arquivo_tipo.includes('pdf') || doc.arquivo_url.includes('application/pdf')) {
|
||||
// É um PDF - faz merge das páginas
|
||||
console.log('Processando como PDF...')
|
||||
const pdfDoc = await PDFDocument.load(fileBytes)
|
||||
const numPages = pdfDoc.getPageCount()
|
||||
console.log(`PDF tem ${numPages} páginas`)
|
||||
|
||||
const pdfPages = await mergedPdf.copyPages(pdfDoc, pdfDoc.getPageIndices())
|
||||
pdfPages.forEach(page => {
|
||||
mergedPdf.addPage(page)
|
||||
})
|
||||
console.log(`${numPages} páginas adicionadas ao PDF final`)
|
||||
} else if (doc.arquivo_tipo.includes('image') || doc.arquivo_url.startsWith('data:image')) {
|
||||
// É uma imagem - adiciona em página A4
|
||||
console.log('Processando como imagem...')
|
||||
let image
|
||||
if (doc.arquivo_tipo.includes('png') || doc.arquivo_url.includes('png')) {
|
||||
image = await mergedPdf.embedPng(new Uint8Array(fileBytes))
|
||||
} else {
|
||||
image = await mergedPdf.embedJpg(new Uint8Array(fileBytes))
|
||||
}
|
||||
|
||||
const page = mergedPdf.addPage([595.28, 841.89]) // A4 em pontos
|
||||
const { width, height } = image.scale(1)
|
||||
|
||||
// Calcula escala para caber na página mantendo proporção
|
||||
const scale = Math.min(595.28 / width, 841.89 / height)
|
||||
const scaledWidth = width * scale
|
||||
const scaledHeight = height * scale
|
||||
|
||||
// Centraliza a imagem
|
||||
const x = (595.28 - scaledWidth) / 2
|
||||
const y = (841.89 - scaledHeight) / 2
|
||||
|
||||
page.drawImage(image, {
|
||||
x,
|
||||
y,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight
|
||||
})
|
||||
console.log('Imagem adicionada ao PDF final')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Erro ao adicionar documento ${doc.titulo}:`, error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Adiciona página "Não Aplicável" (do jsPDF)
|
||||
if (currentJsPdfPage < totalJsPdfPages) {
|
||||
const [naoAplicavelPage] = await mergedPdf.copyPages(jsPdfDoc, [currentJsPdfPage])
|
||||
mergedPdf.addPage(naoAplicavelPage)
|
||||
currentJsPdfPage++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`PDF final tem ${mergedPdf.getPageCount()} páginas`)
|
||||
return await mergedPdf.save()
|
||||
}
|
||||
|
||||
private async generateNaoAplicavelPage(_topico: Topico): Promise<void> {
|
||||
this.pdf.addPage()
|
||||
this.pageNumber++
|
||||
|
||||
// Cabeçalho
|
||||
await this.addHeader()
|
||||
|
||||
// Conteúdo "Não Aplicável"
|
||||
this.pdf.setFontSize(24)
|
||||
this.pdf.setTextColor('#cbd5e0')
|
||||
this.pdf.setFont('helvetica', 'bold')
|
||||
this.pdf.text('Não Aplicável', 105, 145, { align: 'center' })
|
||||
|
||||
this.pdf.setFontSize(12)
|
||||
this.pdf.setTextColor('#718096')
|
||||
this.pdf.setFont('helvetica', 'normal')
|
||||
this.pdf.text('Nenhum documento disponível para esta seção', 105, 170, { align: 'center' })
|
||||
|
||||
// Rodapé
|
||||
await this.addFooter()
|
||||
}
|
||||
|
||||
private async addHeader(): Promise<void> {
|
||||
const header = this.config.cabecalho!
|
||||
|
||||
if (header.estilo === 'simples') {
|
||||
this.pdf.setDrawColor(header.corBorda)
|
||||
this.pdf.setLineWidth(0.5)
|
||||
this.pdf.line(20, 20, 190, 20)
|
||||
|
||||
this.pdf.setFontSize(10)
|
||||
this.pdf.setTextColor('#2d3748')
|
||||
this.pdf.text(this.projeto?.nome_projeto?.substring(0, 50) || '', 20, 17)
|
||||
this.pdf.text(this.config.capa?.numeroDocumento || '', 190, 17, { align: 'right' })
|
||||
}
|
||||
}
|
||||
|
||||
private async addFooter(): Promise<void> {
|
||||
const footer = this.config.rodape!
|
||||
|
||||
this.pdf.setDrawColor(footer.corBorda)
|
||||
this.pdf.setLineWidth(0.3)
|
||||
this.pdf.line(20, 280, 190, 280)
|
||||
|
||||
this.pdf.setFontSize(9)
|
||||
this.pdf.setTextColor('#718096')
|
||||
|
||||
this.pdf.text(`Rev. 01 | ${new Date().getFullYear()}`, 20, 287)
|
||||
|
||||
if (footer.mostrarPagina) {
|
||||
this.pdf.setFontSize(14)
|
||||
this.pdf.setTextColor(this.config.capa?.corPrimaria || '#1a365d')
|
||||
this.pdf.setFont('helvetica', 'bold')
|
||||
this.pdf.text(this.pageNumber.toString(), 105, 287, { align: 'center' })
|
||||
}
|
||||
|
||||
this.pdf.setFontSize(9)
|
||||
this.pdf.setTextColor('#718096')
|
||||
this.pdf.setFont('helvetica', 'normal')
|
||||
this.pdf.text(this.config.capa?.fornecedor || '', 190, 287, { align: 'right' })
|
||||
}
|
||||
}
|
||||
|
||||
// Função helper para gerar PDF
|
||||
export async function generateDatabookPDF(options: PDFGeneratorOptions): Promise<Blob> {
|
||||
const generator = new PDFGenerator(options)
|
||||
return await generator.generate(options)
|
||||
}
|
||||
73
src/lib/storage.ts
Normal file
73
src/lib/storage.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { supabase } from './supabase'
|
||||
|
||||
// ============================================
|
||||
// UPLOAD FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
export const uploadLogo = async (file: File, projectId: string, type: 'empresa' | 'cliente') => {
|
||||
const fileName = `${projectId}/${type}-${Date.now()}.${file.name.split('.').pop()}`
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('logos')
|
||||
.upload(fileName, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: false,
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// Obter URL pública
|
||||
const { data: publicUrl } = supabase.storage
|
||||
.from('logos')
|
||||
.getPublicUrl(fileName)
|
||||
|
||||
return publicUrl.publicUrl
|
||||
}
|
||||
|
||||
export const uploadMarcaAgua = async (file: File, projectId: string) => {
|
||||
const fileName = `${projectId}/marca-agua-${Date.now()}.${file.name.split('.').pop()}`
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('marca-agua')
|
||||
.upload(fileName, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: false,
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// Obter URL pública
|
||||
const { data: publicUrl } = supabase.storage
|
||||
.from('marca-agua')
|
||||
.getPublicUrl(fileName)
|
||||
|
||||
return publicUrl.publicUrl
|
||||
}
|
||||
|
||||
export const uploadDocumento = async (file: File, projectId: string) => {
|
||||
const fileName = `${projectId}/${Date.now()}-${file.name}`
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('documentos')
|
||||
.upload(fileName, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: false,
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// Obter URL pública
|
||||
const { data: publicUrl } = supabase.storage
|
||||
.from('documentos')
|
||||
.getPublicUrl(fileName)
|
||||
|
||||
return publicUrl.publicUrl
|
||||
}
|
||||
|
||||
export const deleteFile = async (bucket: string, filePath: string) => {
|
||||
const { error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.remove([filePath])
|
||||
|
||||
if (error) throw error
|
||||
}
|
||||
14
src/lib/store.ts
Normal file
14
src/lib/store.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { create } from 'zustand'
|
||||
import { User } from '@supabase/supabase-js'
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
setUser: (user: User | null) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
setUser: (user) => set({ user }),
|
||||
logout: () => set({ user: null }),
|
||||
}))
|
||||
16
src/lib/supabase.ts
Normal file
16
src/lib/supabase.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import type { Database } from './types'
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error('Missing Supabase environment variables. Please check your .env file.')
|
||||
}
|
||||
|
||||
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
},
|
||||
})
|
||||
64
src/lib/toast.ts
Normal file
64
src/lib/toast.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Simple toast notification system
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
interface ToastOptions {
|
||||
message: string
|
||||
type: ToastType
|
||||
duration?: number
|
||||
}
|
||||
|
||||
class ToastManager {
|
||||
private container: HTMLDivElement | null = null
|
||||
|
||||
private getContainer() {
|
||||
if (!this.container) {
|
||||
this.container = document.createElement('div')
|
||||
this.container.id = 'toast-container'
|
||||
this.container.className = 'fixed top-4 right-4 z-50 space-y-2'
|
||||
document.body.appendChild(this.container)
|
||||
}
|
||||
return this.container
|
||||
}
|
||||
|
||||
private show({ message, type, duration = 3000 }: ToastOptions) {
|
||||
const container = this.getContainer()
|
||||
const toast = document.createElement('div')
|
||||
|
||||
const colors = {
|
||||
success: 'bg-green-500',
|
||||
error: 'bg-red-500',
|
||||
info: 'bg-blue-500',
|
||||
warning: 'bg-yellow-500',
|
||||
}
|
||||
|
||||
toast.className = `${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg transform transition-all duration-300 translate-x-0 opacity-100`
|
||||
toast.textContent = message
|
||||
|
||||
container.appendChild(toast)
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('translate-x-full', 'opacity-0')
|
||||
setTimeout(() => {
|
||||
container.removeChild(toast)
|
||||
}, 300)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
success(message: string, duration?: number) {
|
||||
this.show({ message, type: 'success', duration })
|
||||
}
|
||||
|
||||
error(message: string, duration?: number) {
|
||||
this.show({ message, type: 'error', duration })
|
||||
}
|
||||
|
||||
info(message: string, duration?: number) {
|
||||
this.show({ message, type: 'info', duration })
|
||||
}
|
||||
|
||||
warning(message: string, duration?: number) {
|
||||
this.show({ message, type: 'warning', duration })
|
||||
}
|
||||
}
|
||||
|
||||
export const toast = new ToastManager()
|
||||
269
src/lib/types.ts
Normal file
269
src/lib/types.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[]
|
||||
|
||||
export interface Database {
|
||||
public: {
|
||||
Tables: {
|
||||
usuarios: {
|
||||
Row: {
|
||||
id: string
|
||||
email: string
|
||||
nome_completo: string
|
||||
perfil: 'admin' | 'gerente_qualidade' | 'engenheiro' | 'cliente'
|
||||
ativo: boolean
|
||||
created_at: string
|
||||
}
|
||||
Insert: Omit<Database['public']['Tables']['usuarios']['Row'], 'id' | 'created_at'>
|
||||
Update: Partial<Database['public']['Tables']['usuarios']['Insert']>
|
||||
}
|
||||
clientes: {
|
||||
Row: {
|
||||
id: string
|
||||
nome: string
|
||||
contato: string | null
|
||||
email: string | null
|
||||
telefone: string | null
|
||||
ativo: boolean
|
||||
inativado_em: string | null
|
||||
created_at: string
|
||||
}
|
||||
Insert: Omit<Database['public']['Tables']['clientes']['Row'], 'id' | 'created_at'>
|
||||
Update: Partial<Database['public']['Tables']['clientes']['Insert']>
|
||||
}
|
||||
templates_topicos: {
|
||||
Row: {
|
||||
id: string
|
||||
numero_topico: string
|
||||
titulo: string
|
||||
descricao: string | null
|
||||
obrigatorio: boolean
|
||||
ordem: number | null
|
||||
tipo_documentos: string[] | null
|
||||
tags_padrao: string[] | null
|
||||
categoria: string | null
|
||||
ativo: boolean
|
||||
inativado_em: string | null
|
||||
created_at: string
|
||||
}
|
||||
Insert: Omit<Database['public']['Tables']['templates_topicos']['Row'], 'id' | 'created_at'>
|
||||
Update: Partial<Database['public']['Tables']['templates_topicos']['Insert']>
|
||||
}
|
||||
templates_customizados: {
|
||||
Row: {
|
||||
id: string
|
||||
nome: string
|
||||
tipo: 'novo' | 'derivado'
|
||||
template_pai_id: string | null
|
||||
topicos_selecionados: string[]
|
||||
total_topicos: number | null
|
||||
total_obrigatorios: number | null
|
||||
descricao: string | null
|
||||
ativo: boolean
|
||||
criado_por: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: Omit<Database['public']['Tables']['templates_customizados']['Row'], 'id' | 'created_at' | 'updated_at'>
|
||||
Update: Partial<Database['public']['Tables']['templates_customizados']['Insert']>
|
||||
}
|
||||
projetos: {
|
||||
Row: {
|
||||
id: string
|
||||
numero_projeto: string
|
||||
nome_projeto: string
|
||||
cliente_id: string
|
||||
template_id: string | null
|
||||
status: 'rascunho' | 'em_andamento' | 'revisao' | 'finalizado' | 'cancelado'
|
||||
progresso_percentual: number
|
||||
data_inicio: string | null
|
||||
data_entrega_prevista: string | null
|
||||
responsavel_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: Omit<Database['public']['Tables']['projetos']['Row'], 'id' | 'created_at' | 'updated_at'>
|
||||
Update: Partial<Database['public']['Tables']['projetos']['Insert']>
|
||||
}
|
||||
databooks_mestres: {
|
||||
Row: {
|
||||
id: string
|
||||
projeto_id: string
|
||||
cliente_nome: string | null
|
||||
cliente_contato: string | null
|
||||
cliente_email: string | null
|
||||
cliente_telefone: string | null
|
||||
produto_nome: string
|
||||
produto_tipo: string | null
|
||||
produto_descricao: string | null
|
||||
produto_normas: string[] | null
|
||||
numero_projeto: string | null
|
||||
ordem_compra: string | null
|
||||
data_inicio: string | null
|
||||
data_entrega_prevista: string | null
|
||||
responsavel_id: string | null
|
||||
revisao_numero: string
|
||||
revisao_data: string
|
||||
revisao_autor_id: string | null
|
||||
revisao_motivo: string | null
|
||||
logo_empresa_url: string | null
|
||||
logo_cliente_url: string | null
|
||||
marca_agua_url: string | null
|
||||
cor_primaria: string | null
|
||||
cor_secundaria: string | null
|
||||
titulo_principal: string | null
|
||||
subtitulo: string | null
|
||||
texto_rodape_capa: string | null
|
||||
tamanho_pagina: 'A4' | 'Letter'
|
||||
orientacao: 'retrato' | 'paisagem'
|
||||
margem_superior_mm: number
|
||||
margem_lateral_mm: number
|
||||
incluir_marca_agua: boolean
|
||||
incluir_numero_pagina: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: Omit<Database['public']['Tables']['databooks_mestres']['Row'], 'id' | 'created_at' | 'updated_at'>
|
||||
Update: Partial<Database['public']['Tables']['databooks_mestres']['Insert']>
|
||||
}
|
||||
configuracoes_pastas: {
|
||||
Row: {
|
||||
id: string
|
||||
tipo_documento: string
|
||||
categoria_id: string | null
|
||||
caminho_local: string
|
||||
caminho_subtipo: string | null
|
||||
caminho_completo: string | null
|
||||
habilitado: boolean
|
||||
frequencia_atualizacao: 'manual' | 'ao_criar' | 'diario' | 'semanal'
|
||||
ultima_atualizacao: string | null
|
||||
incluir_subpastas: boolean
|
||||
formatos_aceitos: string[] | null
|
||||
tamanho_maximo_mb: number
|
||||
tags_obrigatorias: string[] | null
|
||||
palavras_chave_filtro: string[] | null
|
||||
palavras_chave_excluir: string[] | null
|
||||
ordem_docs: 'data' | 'nome' | 'relevancia'
|
||||
criado_por: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: Omit<Database['public']['Tables']['configuracoes_pastas']['Row'], 'id' | 'created_at' | 'updated_at'>
|
||||
Update: Partial<Database['public']['Tables']['configuracoes_pastas']['Insert']>
|
||||
}
|
||||
design_templates: {
|
||||
Row: {
|
||||
id: string
|
||||
nome: string
|
||||
descricao: string | null
|
||||
tipo: 'capa' | 'indice' | 'divisora' | 'cabecalho' | 'rodape' | 'guia_estilo'
|
||||
config: Json
|
||||
ativo: boolean
|
||||
criado_por: string | null
|
||||
criado_em: string
|
||||
atualizado_em: string
|
||||
}
|
||||
Insert: Omit<Database['public']['Tables']['design_templates']['Row'], 'id' | 'criado_em' | 'atualizado_em'>
|
||||
Update: Partial<Database['public']['Tables']['design_templates']['Insert']>
|
||||
}
|
||||
databook_design_aplicacoes: {
|
||||
Row: {
|
||||
id: string
|
||||
databook_id: string
|
||||
template_capa_id: string | null
|
||||
template_indice_id: string | null
|
||||
template_divisora_id: string | null
|
||||
template_cabecalho_id: string | null
|
||||
template_rodape_id: string | null
|
||||
template_guia_estilo_id: string | null
|
||||
aplicado_em: string
|
||||
atualizado_em: string
|
||||
}
|
||||
Insert: Omit<Database['public']['Tables']['databook_design_aplicacoes']['Row'], 'id' | 'aplicado_em' | 'atualizado_em'>
|
||||
Update: Partial<Database['public']['Tables']['databook_design_aplicacoes']['Insert']>
|
||||
}
|
||||
documentos_auto_indexados: {
|
||||
Row: {
|
||||
id: string
|
||||
databook_id: string
|
||||
secao_id: string | null
|
||||
secao_numero: string | null
|
||||
titulo: string
|
||||
numero_documento: string | null
|
||||
revisao: string | null
|
||||
arquivo_url: string
|
||||
arquivo_tipo: string | null
|
||||
conteudo_texto: string | null
|
||||
tags_automaticas: string[] | null
|
||||
tags_usuario: string[] | null
|
||||
relevancia_score: number | null
|
||||
confianca_classificacao: number | null
|
||||
ordem_na_secao: number | null
|
||||
data_documento: string | null
|
||||
aprovado: boolean
|
||||
processado_por_ia: string | null
|
||||
processado_em: string | null
|
||||
criado_em: string
|
||||
}
|
||||
Insert: Omit<Database['public']['Tables']['documentos_auto_indexados']['Row'], 'id' | 'criado_em'>
|
||||
Update: Partial<Database['public']['Tables']['documentos_auto_indexados']['Insert']>
|
||||
}
|
||||
categorias: {
|
||||
Row: {
|
||||
id: string
|
||||
nome: string
|
||||
descricao: string | null
|
||||
cor: string
|
||||
ativo: boolean
|
||||
created_at: string
|
||||
}
|
||||
Insert: Omit<Database['public']['Tables']['categorias']['Row'], 'id' | 'created_at'>
|
||||
Update: Partial<Database['public']['Tables']['categorias']['Insert']>
|
||||
}
|
||||
integracao_ia: {
|
||||
Row: {
|
||||
id: string
|
||||
provider: string
|
||||
api_key_encriptada: string
|
||||
api_key: string
|
||||
modelo_padrao: string
|
||||
maximo_tokens: number
|
||||
ativo: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: Omit<Database['public']['Tables']['integracao_ia']['Row'], 'id' | 'created_at' | 'updated_at'>
|
||||
Update: Partial<Database['public']['Tables']['integracao_ia']['Insert']>
|
||||
}
|
||||
logs_indexacao: {
|
||||
Row: {
|
||||
id: string
|
||||
projetos: {
|
||||
nome_projeto: string
|
||||
numero_projeto: string
|
||||
} | null
|
||||
total_documentos_indexados: number
|
||||
total_documentos_encontrados: number
|
||||
duracao_segundos: number
|
||||
status: 'sucesso' | 'erro' | 'em_andamento'
|
||||
created_at: string
|
||||
}
|
||||
Insert: Omit<Database['public']['Tables']['logs_indexacao']['Row'], 'id' | 'created_at'>
|
||||
Update: Partial<Database['public']['Tables']['logs_indexacao']['Insert']>
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Functions: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Enums: {
|
||||
[_ in never]: never
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/lib/utils.ts
Normal file
3
src/lib/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function cn(...classes: (string | undefined | null | false)[]): string {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
33
src/pages/Busca.tsx
Normal file
33
src/pages/Busca.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
export default function Busca() {
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Busca</h1>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar databooks, documentos, certificados..."
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 text-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{query ? (
|
||||
<p>Buscando por "{query}"...</p>
|
||||
) : (
|
||||
<p>Digite algo para buscar</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
src/pages/Configuracoes.tsx
Normal file
56
src/pages/Configuracoes.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react'
|
||||
import { Folder, Users, FileText, Cpu, Tag } from 'lucide-react'
|
||||
import PastasTab from '@/components/configuracoes/PastasTab'
|
||||
import CategoriasTab from '@/components/configuracoes/CategoriasTab'
|
||||
import UsuariosTab from '@/components/configuracoes/UsuariosTab'
|
||||
import LogsTab from '@/components/configuracoes/LogsTab'
|
||||
import IntegracaoIATab from '@/components/configuracoes/IntegracaoIATab'
|
||||
|
||||
type Tab = 'pastas' | 'categorias' | 'usuarios' | 'logs' | 'ia'
|
||||
|
||||
export default function Configuracoes() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('pastas')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'pastas' as Tab, label: 'Pastas e Documentos', icon: Folder },
|
||||
{ id: 'categorias' as Tab, label: 'Categorias', icon: Tag },
|
||||
{ id: 'usuarios' as Tab, label: 'Usuários', icon: Users },
|
||||
{ id: 'logs' as Tab, label: 'Logs', icon: FileText },
|
||||
{ id: 'ia' as Tab, label: 'Integrações IA', icon: Cpu },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Configurações</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="flex gap-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 pb-4 border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary dark:text-blue-400'
|
||||
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={20} />
|
||||
<span className="font-medium">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div>
|
||||
{activeTab === 'pastas' && <PastasTab />}
|
||||
{activeTab === 'categorias' && <CategoriasTab />}
|
||||
{activeTab === 'usuarios' && <UsuariosTab />}
|
||||
{activeTab === 'logs' && <LogsTab />}
|
||||
{activeTab === 'ia' && <IntegracaoIATab />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
408
src/pages/Dashboard.tsx
Normal file
408
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
// @ts-nocheck
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { FolderOpen, FileText, Clock, CheckCircle, Edit, Copy, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner'
|
||||
import Button from '@/components/common/Button'
|
||||
import Modal from '@/components/common/Modal'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { deleteDatabook } from '@/lib/mutations'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
// Função para obter cores do status
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'finalizado':
|
||||
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400'
|
||||
case 'revisao':
|
||||
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-400'
|
||||
case 'arquivado':
|
||||
return 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-400'
|
||||
case 'em_andamento':
|
||||
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400'
|
||||
case 'cancelado':
|
||||
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400'
|
||||
case 'rascunho':
|
||||
default:
|
||||
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'rascunho': return 'Rascunho'
|
||||
case 'em_andamento': return 'Em Andamento'
|
||||
case 'finalizado': return 'Concluído'
|
||||
case 'revisao': return 'Em Revisão'
|
||||
case 'arquivado': return 'Arquivado'
|
||||
case 'cancelado': return 'Cancelado'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
|
||||
const [projetoToDelete, setProjetoToDelete] = useState<any>(null)
|
||||
|
||||
const { data: projetos, isLoading } = useQuery({
|
||||
queryKey: ['projetos'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('projetos')
|
||||
.select('*, clientes(nome)')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// Calcular progresso real baseado em documentos
|
||||
const projetosComProgresso = await Promise.all(
|
||||
((data as any) || []).map(async (projeto: any) => {
|
||||
// Buscar total de tópicos do template
|
||||
const { count: totalTopicos } = await supabase
|
||||
.from('templates_topicos')
|
||||
.select('*', { count: 'exact' })
|
||||
|
||||
// Buscar documentos do projeto
|
||||
const { data: documentos, count: totalDocumentos } = await supabase
|
||||
.from('documentos_auto_indexados')
|
||||
.select('secao_numero', { count: 'exact' })
|
||||
.eq('databook_id', (projeto as any).id)
|
||||
|
||||
// Contar seções únicas com documentos
|
||||
const secoesUnicas = new Set(documentos?.map(d => d.secao_numero).filter(Boolean))
|
||||
const secoesComDocs = secoesUnicas.size
|
||||
|
||||
// Calcular percentual
|
||||
const progresso = (totalTopicos && totalTopicos > 0)
|
||||
? Math.round((secoesComDocs / totalTopicos) * 100)
|
||||
: 0
|
||||
|
||||
console.log(`Projeto ${projeto.numero_projeto}:`, {
|
||||
totalTopicos,
|
||||
totalDocumentos,
|
||||
secoesComDocs,
|
||||
progresso
|
||||
})
|
||||
|
||||
// Atualizar progresso no banco se mudou
|
||||
if (progresso !== projeto.progresso_percentual) {
|
||||
await supabase
|
||||
.from('projetos')
|
||||
.update({ progresso_percentual: progresso } as any)
|
||||
.eq('id', projeto.id)
|
||||
}
|
||||
|
||||
return {
|
||||
...projeto,
|
||||
progresso_percentual: progresso
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return projetosComProgresso
|
||||
},
|
||||
})
|
||||
|
||||
const { data: templates } = useQuery({
|
||||
queryKey: ['templates-count'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('templates_customizados')
|
||||
.select('id', { count: 'exact' })
|
||||
.eq('ativo', true)
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteDatabook,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projetos'] })
|
||||
toast.success('Projeto deletado com sucesso')
|
||||
setDeleteModalOpen(false)
|
||||
setProjetoToDelete(null)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Erro ao deletar projeto')
|
||||
},
|
||||
})
|
||||
|
||||
const handleDelete = (projeto: any) => {
|
||||
setProjetoToDelete(projeto)
|
||||
setDeleteModalOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (projetoToDelete) {
|
||||
deleteMutation.mutate(projetoToDelete.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (projeto: any) => {
|
||||
// Navegar para página de edição (a ser implementada)
|
||||
navigate(`/databook/${projeto.id}/editar`)
|
||||
}
|
||||
|
||||
const handleClone = async (projeto: any) => {
|
||||
try {
|
||||
// Buscar dados completos do projeto
|
||||
const { data: projetoCompleto } = await supabase
|
||||
.from('projetos')
|
||||
.select('*, databooks_mestres(*)')
|
||||
.eq('id', projeto.id)
|
||||
.single()
|
||||
|
||||
if (!projetoCompleto) {
|
||||
toast.error('Projeto não encontrado')
|
||||
return
|
||||
}
|
||||
|
||||
// Criar cópia do projeto
|
||||
const { data: novoProjeto } = await supabase
|
||||
.from('projetos')
|
||||
.insert([{
|
||||
numero_projeto: `${projeto.numero_projeto}-COPIA`,
|
||||
nome_projeto: `${projeto.nome_projeto} (Cópia)`,
|
||||
cliente_id: projeto.cliente_id,
|
||||
template_id: projeto.template_id,
|
||||
status: 'rascunho',
|
||||
progresso_percentual: 0,
|
||||
} as any])
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (novoProjeto && projetoCompleto.databooks_mestres) {
|
||||
// Copiar databook mestre
|
||||
const databookMestre = projetoCompleto.databooks_mestres
|
||||
await supabase
|
||||
.from('databooks_mestres')
|
||||
.insert([{
|
||||
projeto_id: novoProjeto.id,
|
||||
cliente_nome: databookMestre.cliente_nome,
|
||||
cliente_contato: databookMestre.cliente_contato,
|
||||
cliente_email: databookMestre.cliente_email,
|
||||
cliente_telefone: databookMestre.cliente_telefone,
|
||||
produto_nome: databookMestre.produto_nome,
|
||||
produto_tipo: databookMestre.produto_tipo,
|
||||
produto_descricao: databookMestre.produto_descricao,
|
||||
produto_normas: databookMestre.produto_normas,
|
||||
numero_projeto: `${databookMestre.numero_projeto}-COPIA`,
|
||||
ordem_compra: databookMestre.ordem_compra,
|
||||
cor_primaria: databookMestre.cor_primaria,
|
||||
cor_secundaria: databookMestre.cor_secundaria,
|
||||
titulo_principal: databookMestre.titulo_principal,
|
||||
subtitulo: databookMestre.subtitulo,
|
||||
tamanho_pagina: databookMestre.tamanho_pagina,
|
||||
orientacao: databookMestre.orientacao,
|
||||
} as any])
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['projetos'] })
|
||||
toast.success('Projeto clonado com sucesso!')
|
||||
} catch (error) {
|
||||
console.error('Erro ao clonar projeto:', error)
|
||||
toast.error('Erro ao clonar projeto')
|
||||
}
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
name: 'Total Projetos',
|
||||
value: projetos?.length || 0,
|
||||
icon: FolderOpen,
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
name: 'Em Andamento',
|
||||
value: projetos?.filter(p => p.status === 'em_andamento').length || 0,
|
||||
icon: Clock,
|
||||
color: 'bg-yellow-500',
|
||||
},
|
||||
{
|
||||
name: 'Finalizados',
|
||||
value: projetos?.filter(p => p.status === 'finalizado').length || 0,
|
||||
icon: CheckCircle,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
name: 'Templates',
|
||||
value: templates?.length || 0,
|
||||
icon: FileText,
|
||||
color: 'bg-purple-500',
|
||||
},
|
||||
]
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Dashboard</h1>
|
||||
<Button onClick={() => navigate('/databook/novo')}>
|
||||
Novo Databook
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.name} className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{stat.name}</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-2">{stat.value}</p>
|
||||
</div>
|
||||
<div className={`${stat.color} p-3 rounded-lg`}>
|
||||
<stat.icon className="text-white" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent Projects */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Projetos Recentes</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Projeto
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Cliente
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Progresso
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{projetos && projetos.length > 0 ? (
|
||||
projetos.map((projeto) => (
|
||||
<tr key={projeto.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{projeto.nome_projeto}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{projeto.numero_projeto}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{projeto.clientes?.nome || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(projeto.status)}`}>
|
||||
{getStatusLabel(projeto.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mr-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full"
|
||||
style={{ width: `${projeto.progresso_percentual}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">{projeto.progresso_percentual}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/databook/${projeto.id}`)}
|
||||
className="text-blue-600 hover:text-blue-800 p-1 hover:bg-blue-50 rounded transition-colors"
|
||||
title="Ver detalhes"
|
||||
>
|
||||
<FileText size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(projeto)}
|
||||
className="text-blue-600 hover:text-blue-800 p-1 hover:bg-blue-50 rounded transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClone(projeto)}
|
||||
className="text-green-600 hover:text-green-800 p-1 hover:bg-green-50 rounded transition-colors"
|
||||
title="Clonar"
|
||||
>
|
||||
<Copy size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(projeto)}
|
||||
className="text-red-600 hover:text-red-800 p-1 hover:bg-red-50 rounded transition-colors"
|
||||
title="Deletar"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||
Nenhum projeto encontrado. Crie seu primeiro databook!
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Confirmação de Exclusão */}
|
||||
<Modal
|
||||
isOpen={deleteModalOpen}
|
||||
onClose={() => setDeleteModalOpen(false)}
|
||||
title="Deletar Projeto"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
Tem certeza que deseja deletar o projeto <strong>{projetoToDelete?.nome_projeto}</strong>?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Esta ação não pode ser desfeita e todos os dados relacionados serão perdidos.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteModalOpen(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={confirmDelete}
|
||||
isLoading={deleteMutation.isPending}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Deletar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
250
src/pages/DatabookEdit.tsx
Normal file
250
src/pages/DatabookEdit.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import Button from '@/components/common/Button'
|
||||
import Input from '@/components/common/Input'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { updateDatabook } from '@/lib/mutations'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export default function DatabookEdit() {
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
nome_projeto: '',
|
||||
numero_projeto: '',
|
||||
status: 'rascunho',
|
||||
progresso_percentual: 0,
|
||||
})
|
||||
|
||||
const { data: projeto, isLoading: loadingProjeto } = useQuery({
|
||||
queryKey: ['projeto', id],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('projetos')
|
||||
.select('*, clientes(nome), databooks_mestres(*)')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data as any
|
||||
},
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (projeto) {
|
||||
setFormData({
|
||||
nome_projeto: projeto.nome_projeto,
|
||||
numero_projeto: projeto.numero_projeto,
|
||||
status: projeto.status,
|
||||
progresso_percentual: projeto.progresso_percentual,
|
||||
})
|
||||
}
|
||||
}, [projeto])
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => updateDatabook(id!, {
|
||||
nome_projeto: formData.nome_projeto,
|
||||
status: formData.status,
|
||||
progresso_percentual: formData.progresso_percentual,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projetos'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['projeto', id] })
|
||||
toast.success('Projeto atualizado com sucesso')
|
||||
navigate('/dashboard')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Erro ao atualizar projeto')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate()
|
||||
}
|
||||
|
||||
if (loadingProjeto) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate('/dashboard')}>
|
||||
<ChevronLeft size={20} />
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Editar Projeto</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nome do Projeto"
|
||||
value={formData.nome_projeto}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, nome_projeto: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Número do Projeto"
|
||||
value={formData.numero_projeto}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, numero_projeto: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Progresso (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.progresso_percentual}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, progresso_percentual: parseInt(e.target.value) || 0 }))}
|
||||
className="input-field dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Botões de Status Sequencial */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Status do Projeto
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Rascunho */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, status: 'rascunho' }))}
|
||||
className={`px-4 py-2 rounded-lg border-2 transition-all font-medium ${
|
||||
formData.status === 'rascunho'
|
||||
? 'border-gray-400 bg-gray-400 text-white'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
Rascunho
|
||||
</button>
|
||||
|
||||
{/* Em Andamento */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, status: 'em_andamento' }))}
|
||||
className={`px-4 py-2 rounded-lg border-2 transition-all font-medium ${
|
||||
formData.status === 'em_andamento'
|
||||
? 'border-blue-500 bg-blue-500 text-white'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:border-blue-500'
|
||||
}`}
|
||||
>
|
||||
Em Andamento
|
||||
</button>
|
||||
{/* Concluído */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, status: 'finalizado' }))}
|
||||
className={`px-4 py-2 rounded-lg border-2 transition-all font-medium ${
|
||||
formData.status === 'finalizado'
|
||||
? 'border-green-500 bg-green-500 text-white'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:border-green-500'
|
||||
}`}
|
||||
>
|
||||
Concluído
|
||||
</button>
|
||||
|
||||
{/* Em Revisão */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, status: 'revisao' }))}
|
||||
className={`px-4 py-2 rounded-lg border-2 transition-all font-medium ${
|
||||
formData.status === 'revisao'
|
||||
? 'border-yellow-500 bg-yellow-500 text-white'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:border-yellow-500'
|
||||
}`}
|
||||
>
|
||||
Em Revisão
|
||||
</button>
|
||||
|
||||
{/* Arquivado */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, status: 'arquivado' }))}
|
||||
className={`px-4 py-2 rounded-lg border-2 transition-all font-medium ${
|
||||
formData.status === 'arquivado'
|
||||
? 'border-purple-500 bg-purple-500 text-white'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:border-purple-500'
|
||||
}`}
|
||||
>
|
||||
Arquivado
|
||||
</button>
|
||||
|
||||
{/* Cancelado */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, status: 'cancelado' }))}
|
||||
className={`px-4 py-2 rounded-lg border-2 transition-all font-medium ${
|
||||
formData.status === 'cancelado'
|
||||
? 'border-red-500 bg-red-500 text-white'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:border-red-500'
|
||||
}`}
|
||||
>
|
||||
Cancelado
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Indicador de Status Atual */}
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Status atual: <span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{formData.status === 'rascunho' && 'Rascunho'}
|
||||
{formData.status === 'em_andamento' && 'Em Andamento'}
|
||||
{formData.status === 'finalizado' && 'Concluído'}
|
||||
{formData.status === 'revisao' && 'Em Revisão / Entregue'}
|
||||
{formData.status === 'arquivado' && 'Aceito / Arquivado'}
|
||||
{formData.status === 'cancelado' && 'Cancelado'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projeto?.databooks_mestres && (
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 space-y-2">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">Informações do Databook</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Cliente</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{projeto.databooks_mestres.cliente_nome}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Produto</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{projeto.databooks_mestres.produto_nome}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
isLoading={updateMutation.isPending}
|
||||
>
|
||||
Salvar Alterações
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
529
src/pages/DatabookNew.tsx
Normal file
529
src/pages/DatabookNew.tsx
Normal file
@@ -0,0 +1,529 @@
|
||||
// @ts-nocheck
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ChevronLeft, ChevronRight, Upload } from 'lucide-react'
|
||||
import Button from '@/components/common/Button'
|
||||
import Input from '@/components/common/Input'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
export default function DatabookNew() {
|
||||
const navigate = useNavigate()
|
||||
const [step, setStep] = useState(1)
|
||||
|
||||
// Buscar templates disponíveis
|
||||
const { data: templates } = useQuery({
|
||||
queryKey: ['templates-ativos'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('templates_customizados')
|
||||
.select('*')
|
||||
.eq('ativo', true)
|
||||
.order('tipo', { ascending: true })
|
||||
.order('nome', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data as any[]
|
||||
},
|
||||
})
|
||||
const [formData, setFormData] = useState({
|
||||
// Template
|
||||
template_id: '',
|
||||
// Cliente
|
||||
cliente_nome: '',
|
||||
cliente_contato: '',
|
||||
cliente_email: '',
|
||||
cliente_telefone: '',
|
||||
// Produto
|
||||
produto_nome: '',
|
||||
produto_tipo: 'offshore',
|
||||
produto_descricao: '',
|
||||
produto_normas: [] as string[],
|
||||
// Identificação
|
||||
numero_projeto: '',
|
||||
ordem_compra: '',
|
||||
data_inicio: '',
|
||||
data_entrega_prevista: '',
|
||||
// Revisão
|
||||
revisao_numero: 'Rev. 0',
|
||||
revisao_motivo: '',
|
||||
// Capa
|
||||
cor_primaria: '#1E40AF',
|
||||
cor_secundaria: '#64748B',
|
||||
titulo_principal: 'DATABOOK - ESTRUTURA METÁLICA',
|
||||
subtitulo: '',
|
||||
tamanho_pagina: 'A4' as 'A4' | 'Letter',
|
||||
orientacao: 'retrato' as 'retrato' | 'paisagem',
|
||||
// Arquivos
|
||||
logo_empresa: null as File | null,
|
||||
logo_cliente: null as File | null,
|
||||
marca_agua: null as File | null,
|
||||
})
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleFileChange = (field: 'logo_empresa' | 'logo_cliente' | 'marca_agua', file: File | null) => {
|
||||
setFormData(prev => ({ ...prev, [field]: file }))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const { createDatabook } = await import('@/lib/mutations')
|
||||
const { supabase } = await import('@/lib/supabase')
|
||||
const { toast } = await import('@/lib/toast')
|
||||
|
||||
// Primeiro, criar ou buscar cliente
|
||||
let clienteId = null
|
||||
if (formData.cliente_nome) {
|
||||
const { data: clientesExistentes } = await supabase
|
||||
.from('clientes')
|
||||
.select('id')
|
||||
.eq('nome', formData.cliente_nome)
|
||||
.limit(1)
|
||||
|
||||
if (clientesExistentes && clientesExistentes.length > 0) {
|
||||
clienteId = clientesExistentes[0].id
|
||||
} else {
|
||||
const { data: novoCliente } = await supabase
|
||||
.from('clientes')
|
||||
.insert([{
|
||||
nome: formData.cliente_nome,
|
||||
contato: formData.cliente_contato,
|
||||
email: formData.cliente_email,
|
||||
telefone: formData.cliente_telefone,
|
||||
} as any])
|
||||
.select()
|
||||
|
||||
clienteId = novoCliente?.[0]?.id
|
||||
}
|
||||
}
|
||||
|
||||
// Upload de arquivos (se houver)
|
||||
// TODO: Implementar upload para Supabase Storage
|
||||
|
||||
await createDatabook({
|
||||
numero_projeto: formData.numero_projeto || `PRJ-${Date.now()}`,
|
||||
nome_projeto: formData.produto_nome,
|
||||
cliente_id: clienteId,
|
||||
template_id: formData.template_id || null,
|
||||
cliente_nome: formData.cliente_nome,
|
||||
cliente_contato: formData.cliente_contato,
|
||||
cliente_email: formData.cliente_email,
|
||||
cliente_telefone: formData.cliente_telefone,
|
||||
produto_nome: formData.produto_nome,
|
||||
produto_tipo: formData.produto_tipo,
|
||||
produto_descricao: formData.produto_descricao,
|
||||
produto_normas: formData.produto_normas,
|
||||
ordem_compra: formData.ordem_compra,
|
||||
data_inicio: formData.data_inicio,
|
||||
data_entrega_prevista: formData.data_entrega_prevista,
|
||||
titulo_principal: formData.titulo_principal,
|
||||
subtitulo: formData.subtitulo,
|
||||
cor_primaria: formData.cor_primaria,
|
||||
cor_secundaria: formData.cor_secundaria,
|
||||
tamanho_pagina: formData.tamanho_pagina,
|
||||
orientacao: formData.orientacao,
|
||||
})
|
||||
|
||||
toast.success('Databook criado com sucesso!')
|
||||
navigate('/dashboard')
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao criar databook:', error)
|
||||
const { toast } = await import('@/lib/toast')
|
||||
toast.error(error.message || 'Erro ao criar databook')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate('/dashboard')}>
|
||||
<ChevronLeft size={20} />
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Novo Databook</h1>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div key={s} className="flex items-center flex-1">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${
|
||||
step >= s ? 'bg-primary dark:bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{s}
|
||||
</div>
|
||||
{s < 3 && (
|
||||
<div className={`flex-1 h-1 mx-4 ${
|
||||
step > s ? 'bg-primary dark:bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Dados Mestres */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-8">
|
||||
{/* Seleção de Template */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Selecionar Template</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Template do Databook *
|
||||
</label>
|
||||
<select
|
||||
value={formData.template_id}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, template_id: e.target.value }))}
|
||||
className="input-field"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione um template...</option>
|
||||
{templates && templates.length > 0 && (
|
||||
<>
|
||||
{/* Templates Padrão */}
|
||||
{templates.filter(t => t.tipo === 'padrao').length > 0 && (
|
||||
<optgroup label="Templates Padrão">
|
||||
{templates.filter(t => t.tipo === 'padrao').map(template => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.nome} ({template.total_topicos} seções)
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{/* Templates Customizados */}
|
||||
{templates.filter(t => t.tipo !== 'padrao').length > 0 && (
|
||||
<optgroup label="Meus Templates">
|
||||
{templates.filter(t => t.tipo !== 'padrao').map(template => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.nome} ({template.total_topicos} seções)
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
{formData.template_id && templates && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||
{templates.find(t => t.id === formData.template_id)?.descricao}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Dados do Cliente</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nome do Cliente"
|
||||
value={formData.cliente_nome}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, cliente_nome: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Contato"
|
||||
value={formData.cliente_contato}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, cliente_contato: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={formData.cliente_email}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, cliente_email: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Telefone"
|
||||
value={formData.cliente_telefone}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, cliente_telefone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Dados do Produto</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nome do Produto"
|
||||
value={formData.produto_nome}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, produto_nome: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tipo de Estrutura
|
||||
</label>
|
||||
<select
|
||||
value={formData.produto_tipo}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, produto_tipo: e.target.value }))}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="offshore">Offshore</option>
|
||||
<option value="galpao">Galpão</option>
|
||||
<option value="edificio">Edifício</option>
|
||||
<option value="ponte">Ponte</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Descrição
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.produto_descricao}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, produto_descricao: e.target.value }))}
|
||||
className="input-field"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Identificação</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Número do Projeto"
|
||||
value={formData.numero_projeto}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, numero_projeto: e.target.value }))}
|
||||
placeholder="PRJ-2025-00001"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Ordem de Compra"
|
||||
value={formData.ordem_compra}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, ordem_compra: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Data de Início"
|
||||
type="date"
|
||||
value={formData.data_inicio}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, data_inicio: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Data de Entrega Prevista"
|
||||
type="date"
|
||||
value={formData.data_entrega_prevista}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, data_entrega_prevista: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setStep(2)}>
|
||||
Próximo
|
||||
<ChevronRight size={20} className="ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Customizar Capa */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Branding e Logos</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Logo Empresa
|
||||
</label>
|
||||
<label className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-primary cursor-pointer block">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileChange('logo_empresa', e.target.files?.[0] || null)}
|
||||
/>
|
||||
<Upload className="mx-auto text-gray-400 dark:text-gray-500 mb-2" size={32} />
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{formData.logo_empresa ? formData.logo_empresa.name : 'Arraste ou clique'}
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Logo Cliente
|
||||
</label>
|
||||
<label className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-primary cursor-pointer block">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileChange('logo_cliente', e.target.files?.[0] || null)}
|
||||
/>
|
||||
<Upload className="mx-auto text-gray-400 dark:text-gray-500 mb-2" size={32} />
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{formData.logo_cliente ? formData.logo_cliente.name : 'Arraste ou clique'}
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Marca d'água
|
||||
</label>
|
||||
<label className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-primary cursor-pointer block">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileChange('marca_agua', e.target.files?.[0] || null)}
|
||||
/>
|
||||
<Upload className="mx-auto text-gray-400 dark:text-gray-500 mb-2" size={32} />
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{formData.marca_agua ? formData.marca_agua.name : 'Arraste ou clique'}
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cor Primária
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={formData.cor_primaria}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, cor_primaria: e.target.value }))}
|
||||
className="h-10 w-full rounded-lg cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cor Secundária
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={formData.cor_secundaria}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, cor_secundaria: e.target.value }))}
|
||||
className="h-10 w-full rounded-lg cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Textos da Capa</h2>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Título Principal"
|
||||
value={formData.titulo_principal}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, titulo_principal: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Subtítulo"
|
||||
value={formData.subtitulo}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, subtitulo: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Formatação</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Tamanho da Página
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={formData.tamanho_pagina === 'A4'}
|
||||
onChange={() => setFormData(prev => ({ ...prev, tamanho_pagina: 'A4' }))}
|
||||
className="text-primary focus:ring-primary"
|
||||
/>
|
||||
<span>A4</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={formData.tamanho_pagina === 'Letter'}
|
||||
onChange={() => setFormData(prev => ({ ...prev, tamanho_pagina: 'Letter' }))}
|
||||
className="text-primary focus:ring-primary"
|
||||
/>
|
||||
<span>Letter</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Orientação
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={formData.orientacao === 'retrato'}
|
||||
onChange={() => setFormData(prev => ({ ...prev, orientacao: 'retrato' }))}
|
||||
className="text-primary focus:ring-primary"
|
||||
/>
|
||||
<span>Retrato</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={formData.orientacao === 'paisagem'}
|
||||
onChange={() => setFormData(prev => ({ ...prev, orientacao: 'paisagem' }))}
|
||||
className="text-primary focus:ring-primary"
|
||||
/>
|
||||
<span>Paisagem</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={() => setStep(1)}>
|
||||
<ChevronLeft size={20} className="mr-2" />
|
||||
Anterior
|
||||
</Button>
|
||||
<Button onClick={() => setStep(3)}>
|
||||
Próximo
|
||||
<ChevronRight size={20} className="ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Confirmar */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Confirmar e Criar</h2>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Cliente</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{formData.cliente_nome}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Produto</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{formData.produto_nome}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Número do Projeto</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{formData.numero_projeto}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={() => setStep(2)}>
|
||||
<ChevronLeft size={20} className="mr-2" />
|
||||
Anterior
|
||||
</Button>
|
||||
<Button onClick={handleSave} isLoading={isLoading}>
|
||||
Criar Databook
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
875
src/pages/DatabookView.tsx
Normal file
875
src/pages/DatabookView.tsx
Normal file
@@ -0,0 +1,875 @@
|
||||
// @ts-nocheck
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
Eye,
|
||||
Upload,
|
||||
Trash2,
|
||||
RotateCw,
|
||||
Image as ImageIcon,
|
||||
ChevronRight,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import Button from '@/components/common/Button'
|
||||
import Modal from '@/components/common/Modal'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner'
|
||||
import DatabookPreview from '@/components/databook/DatabookPreview'
|
||||
import DesignSelector from '@/components/databook/DesignSelector'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { generateDatabookPDF } from '@/lib/pdfGenerator'
|
||||
import { useDesignConfig } from '@/hooks/useDesignConfig'
|
||||
import * as pdfjsLib from 'pdfjs-dist'
|
||||
|
||||
// Configurar worker do PDF.js
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`
|
||||
|
||||
interface Documento {
|
||||
id: string
|
||||
titulo: string
|
||||
arquivo_url: string
|
||||
arquivo_tipo: string
|
||||
data_documento: string
|
||||
tags_usuario: string[]
|
||||
ordem_na_secao: number
|
||||
arquivo_tamanho?: number // em bytes
|
||||
num_paginas?: number
|
||||
formato_pagina?: string // A4, A3, etc
|
||||
orientacao?: string // retrato ou paisagem
|
||||
}
|
||||
|
||||
export default function DatabookView() {
|
||||
const { id } = useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const [topicoSelecionado, setTopicoSelecionado] = useState<string | null>(null)
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
const [documentoPreview, setDocumentoPreview] = useState<Documento | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false)
|
||||
const [arquivosSelecionados, setArquivosSelecionados] = useState<File[]>([])
|
||||
const [arquivosMap, setArquivosMap] = useState<Map<string, File>>(new Map())
|
||||
const [draggedDoc, setDraggedDoc] = useState<Documento | null>(null)
|
||||
const [databookPreviewOpen, setDatabookPreviewOpen] = useState(false)
|
||||
const [generatingPDF, setGeneratingPDF] = useState(false)
|
||||
const [pdfProgress, setPdfProgress] = useState({ progress: 0, message: '' })
|
||||
|
||||
// Buscar configuração de design
|
||||
const { data: designConfig } = useDesignConfig(id)
|
||||
|
||||
// Buscar dados do projeto
|
||||
const { data: projeto, isLoading: loadingProjeto } = useQuery({
|
||||
queryKey: ['projeto', id],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('projetos')
|
||||
.select('*, clientes(nome), databooks_mestres(*), templates_customizados(*)')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
// Buscar tópicos do template
|
||||
const { data: topicos } = useQuery({
|
||||
queryKey: ['topicos-template', projeto?.template_id],
|
||||
queryFn: async () => {
|
||||
if (!projeto?.templates_customizados?.topicos_selecionados) return []
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('templates_topicos')
|
||||
.select('*')
|
||||
.in('numero_topico', projeto.templates_customizados.topicos_selecionados)
|
||||
.order('ordem', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
enabled: !!projeto?.template_id,
|
||||
})
|
||||
|
||||
// Buscar contagem de documentos por seção
|
||||
const { data: contagemDocs } = useQuery({
|
||||
queryKey: ['contagem-docs', id],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('documentos_auto_indexados')
|
||||
.select('secao_numero')
|
||||
.eq('databook_id', id)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// Contar documentos por seção
|
||||
const contagem: { [key: string]: number } = {}
|
||||
data.forEach(doc => {
|
||||
contagem[doc.secao_numero] = (contagem[doc.secao_numero] || 0) + 1
|
||||
})
|
||||
return contagem
|
||||
},
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
// Buscar TODOS os documentos para preview/PDF
|
||||
const { data: todosDocumentos } = useQuery({
|
||||
queryKey: ['todos-documentos', id],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('documentos_auto_indexados')
|
||||
.select('*')
|
||||
.eq('databook_id', id)
|
||||
.order('secao_numero', { ascending: true })
|
||||
.order('ordem_na_secao', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// Agrupar por seção
|
||||
const porSecao: { [key: string]: any[] } = {}
|
||||
data.forEach(doc => {
|
||||
if (!porSecao[doc.secao_numero]) {
|
||||
porSecao[doc.secao_numero] = []
|
||||
}
|
||||
porSecao[doc.secao_numero].push(doc)
|
||||
})
|
||||
return porSecao
|
||||
},
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
// Buscar documentos do tópico selecionado
|
||||
const { data: documentos, isLoading: loadingDocs } = useQuery({
|
||||
queryKey: ['documentos', id, topicoSelecionado],
|
||||
queryFn: async () => {
|
||||
const { data, error} = await supabase
|
||||
.from('documentos_auto_indexados')
|
||||
.select('*')
|
||||
.eq('databook_id', id)
|
||||
.eq('secao_numero', topicoSelecionado)
|
||||
.order('ordem_na_secao', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data as Documento[]
|
||||
},
|
||||
enabled: !!id && !!topicoSelecionado,
|
||||
})
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (documentos: any[]) => {
|
||||
// Tentar inserir com todos os campos
|
||||
let { error } = await supabase
|
||||
.from('documentos_auto_indexados')
|
||||
.insert(documentos as any)
|
||||
|
||||
// Se falhar (provavelmente porque as colunas não existem), tentar sem os novos campos
|
||||
if (error && error.message.includes('column')) {
|
||||
console.warn('Colunas de metadados não existem, inserindo sem elas:', error.message)
|
||||
const documentosSemMetadata = documentos.map(({ arquivo_tamanho, num_paginas, formato_pagina, orientacao, ...doc }) => doc)
|
||||
const { error: error2 } = await supabase
|
||||
.from('documentos_auto_indexados')
|
||||
.insert(documentosSemMetadata as any)
|
||||
|
||||
if (error2) throw error2
|
||||
} else if (error) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['documentos'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['contagem-docs'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['todos-documentos'] })
|
||||
toast.success('Documentos carregados com sucesso')
|
||||
setUploadModalOpen(false)
|
||||
setArquivosSelecionados([])
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Erro no upload:', error)
|
||||
toast.error(error.message || 'Erro ao fazer upload')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (docId: string) => {
|
||||
const { error } = await supabase
|
||||
.from('documentos_auto_indexados')
|
||||
.delete()
|
||||
.eq('id', docId)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['documentos'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['contagem-docs'] })
|
||||
toast.success('Documento deletado com sucesso')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Erro ao deletar documento')
|
||||
},
|
||||
})
|
||||
|
||||
// Função para extrair metadados do PDF
|
||||
const extractPDFMetadata = async (file: File) => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise
|
||||
const numPages = pdf.numPages
|
||||
|
||||
// Pegar primeira página para detectar dimensões
|
||||
const page = await pdf.getPage(1)
|
||||
const viewport = page.getViewport({ scale: 1 })
|
||||
const width = viewport.width
|
||||
const height = viewport.height
|
||||
|
||||
// Detectar orientação
|
||||
const orientacao = width > height ? 'paisagem' : 'retrato'
|
||||
|
||||
// Detectar formato aproximado (em pontos: 1 ponto = 1/72 polegada)
|
||||
// A4: 595 x 842 pontos
|
||||
// A3: 842 x 1191 pontos
|
||||
// A2: 1191 x 1684 pontos
|
||||
// A1: 1684 x 2384 pontos
|
||||
const maxDim = Math.max(width, height)
|
||||
const minDim = Math.min(width, height)
|
||||
|
||||
let formato = 'Custom'
|
||||
if (Math.abs(maxDim - 842) < 50 && Math.abs(minDim - 595) < 50) {
|
||||
formato = 'A4'
|
||||
} else if (Math.abs(maxDim - 1191) < 50 && Math.abs(minDim - 842) < 50) {
|
||||
formato = 'A3'
|
||||
} else if (Math.abs(maxDim - 1684) < 50 && Math.abs(minDim - 1191) < 50) {
|
||||
formato = 'A2'
|
||||
} else if (Math.abs(maxDim - 2384) < 50 && Math.abs(minDim - 1684) < 50) {
|
||||
formato = 'A1'
|
||||
}
|
||||
|
||||
return {
|
||||
num_paginas: numPages,
|
||||
formato_pagina: formato,
|
||||
orientacao: orientacao
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao extrair metadados do PDF:', error)
|
||||
return {
|
||||
num_paginas: 1,
|
||||
formato_pagina: 'A4',
|
||||
orientacao: 'retrato'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (arquivosSelecionados.length === 0 || !topicoSelecionado || !id) return
|
||||
|
||||
try {
|
||||
const documentos = []
|
||||
|
||||
// Converter cada arquivo para base64
|
||||
for (let index = 0; index < arquivosSelecionados.length; index++) {
|
||||
const file = arquivosSelecionados[index]
|
||||
|
||||
// Ler arquivo como base64
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
|
||||
// Extrair metadados do PDF
|
||||
let metadata = {
|
||||
num_paginas: 1,
|
||||
formato_pagina: 'A4',
|
||||
orientacao: 'retrato'
|
||||
}
|
||||
|
||||
if (file.type === 'application/pdf') {
|
||||
metadata = await extractPDFMetadata(file)
|
||||
}
|
||||
|
||||
// Armazenar no Map para acesso imediato
|
||||
const key = `${id}-${topicoSelecionado}-${file.name}`
|
||||
const newMap = new Map(arquivosMap)
|
||||
newMap.set(key, file)
|
||||
setArquivosMap(newMap)
|
||||
|
||||
// Adicionar à lista de documentos
|
||||
documentos.push({
|
||||
databook_id: id,
|
||||
secao_numero: topicoSelecionado,
|
||||
titulo: file.name.replace(/\.[^/.]+$/, ''), // Remove extensão
|
||||
arquivo_url: base64, // Armazenar base64 diretamente
|
||||
arquivo_tipo: file.type,
|
||||
arquivo_tamanho: file.size,
|
||||
data_documento: new Date().toISOString().split('T')[0],
|
||||
tags_usuario: [],
|
||||
ordem_na_secao: index + 1,
|
||||
aprovado: false,
|
||||
...metadata
|
||||
})
|
||||
}
|
||||
|
||||
if (documentos.length > 0) {
|
||||
uploadMutation.mutate(documentos)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Erro no upload:', error)
|
||||
toast.error('Erro ao processar arquivos')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreview = (doc: Documento) => {
|
||||
setDocumentoPreview(doc)
|
||||
|
||||
// Se a URL é base64 (data:), usar diretamente
|
||||
if (doc.arquivo_url.startsWith('data:')) {
|
||||
setPreviewUrl(doc.arquivo_url)
|
||||
}
|
||||
// Se é URL HTTP, usar diretamente
|
||||
else if (doc.arquivo_url.startsWith('http')) {
|
||||
setPreviewUrl(doc.arquivo_url)
|
||||
}
|
||||
// Fallback: buscar arquivo no Map
|
||||
else {
|
||||
const file = arquivosMap.get(doc.arquivo_url)
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file)
|
||||
setPreviewUrl(url)
|
||||
} else {
|
||||
setPreviewUrl(null)
|
||||
}
|
||||
}
|
||||
|
||||
setPreviewModalOpen(true)
|
||||
}
|
||||
|
||||
const handleRotate = async () => {
|
||||
// TODO: Implementar rotação de documento
|
||||
toast.info('Funcionalidade de rotação em desenvolvimento')
|
||||
}
|
||||
|
||||
const handleGeneratePDF = async () => {
|
||||
if (!projeto || !topicos || !todosDocumentos) {
|
||||
toast.error('Dados incompletos para gerar PDF')
|
||||
return
|
||||
}
|
||||
|
||||
setGeneratingPDF(true)
|
||||
setPdfProgress({ progress: 0, message: 'Iniciando geração...' })
|
||||
|
||||
try {
|
||||
const blob = await generateDatabookPDF({
|
||||
projeto,
|
||||
topicos,
|
||||
documentosPorSecao: todosDocumentos,
|
||||
designConfig,
|
||||
onProgress: (progress, message) => {
|
||||
setPdfProgress({ progress, message })
|
||||
}
|
||||
})
|
||||
|
||||
// Download do PDF
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `databook-${projeto.numero_projeto}.pdf`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
toast.success('PDF gerado com sucesso!')
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar PDF:', error)
|
||||
toast.error('Erro ao gerar PDF')
|
||||
} finally {
|
||||
setGeneratingPDF(false)
|
||||
setPdfProgress({ progress: 0, message: '' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragStart = (doc: Documento) => {
|
||||
setDraggedDoc(doc)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleDrop = async (targetDoc: Documento) => {
|
||||
if (!draggedDoc || !documentos) return
|
||||
|
||||
const draggedIndex = documentos.findIndex(d => d.id === draggedDoc.id)
|
||||
const targetIndex = documentos.findIndex(d => d.id === targetDoc.id)
|
||||
|
||||
if (draggedIndex === targetIndex) return
|
||||
|
||||
// Reordenar localmente
|
||||
const newDocs = [...documentos]
|
||||
newDocs.splice(draggedIndex, 1)
|
||||
newDocs.splice(targetIndex, 0, draggedDoc)
|
||||
|
||||
// Atualizar ordem no banco
|
||||
try {
|
||||
const updates = newDocs.map((doc, index) =>
|
||||
supabase
|
||||
.from('documentos_auto_indexados')
|
||||
.update({ ordem_na_secao: index + 1 } as any)
|
||||
.eq('id', doc.id)
|
||||
)
|
||||
|
||||
await Promise.all(updates)
|
||||
queryClient.invalidateQueries({ queryKey: ['documentos'] })
|
||||
toast.success('Ordem atualizada')
|
||||
} catch (error) {
|
||||
toast.error('Erro ao reordenar')
|
||||
}
|
||||
|
||||
setDraggedDoc(null)
|
||||
}
|
||||
|
||||
const getIndentLevel = (numeroTopico: string): number => {
|
||||
const parts = numeroTopico.split('.')
|
||||
return (parts.length - 1) * 16
|
||||
}
|
||||
|
||||
const getDocumentIcon = (tipo: string) => {
|
||||
if (tipo?.includes('image')) return <ImageIcon size={20} className="text-blue-500" />
|
||||
return <FileText size={20} className="text-gray-400" />
|
||||
}
|
||||
|
||||
if (loadingProjeto) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-8rem)] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Databook #{projeto?.numero_projeto}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Cliente: {projeto?.clientes?.nome} | Projeto: {projeto?.nome_projeto}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{id && <DesignSelector databookId={id} currentDesignConfig={designConfig} />}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDatabookPreviewOpen(true)}
|
||||
>
|
||||
<Eye size={20} className="mr-2" />
|
||||
Pré-visualizar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGeneratePDF}
|
||||
isLoading={generatingPDF}
|
||||
>
|
||||
<Download size={20} className="mr-2" />
|
||||
{generatingPDF ? `Gerando... ${pdfProgress.progress}%` : 'Gerar PDF'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Split View */}
|
||||
<div className="flex-1 flex gap-6 overflow-hidden">
|
||||
{/* Left Panel - Tópicos */}
|
||||
<div className="w-80 bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Índice</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{topicos?.length || 0} seções
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{topicos && topicos.length > 0 ? (
|
||||
<div className="p-2">
|
||||
{topicos.map((topico) => {
|
||||
const isSelected = topicoSelecionado === topico.numero_topico
|
||||
const docCount = contagemDocs?.[topico.numero_topico] || 0
|
||||
|
||||
return (
|
||||
<button
|
||||
key={topico.id}
|
||||
onClick={() => setTopicoSelecionado(topico.numero_topico)}
|
||||
className={`w-full text-left p-3 rounded-lg transition-colors mb-1 ${
|
||||
isSelected
|
||||
? 'bg-primary text-white'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
style={{ paddingLeft: `${12 + getIndentLevel(topico.numero_topico)}px` }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 mt-1.5 ${
|
||||
docCount > 0 ? 'bg-green-500' : 'bg-gray-300'
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono text-sm font-semibold flex-shrink-0 ${
|
||||
isSelected ? 'text-white' : 'text-primary dark:text-blue-400'
|
||||
}`}>
|
||||
{topico.numero_topico}
|
||||
</span>
|
||||
{topico.obrigatorio && (
|
||||
<span className={`flex-shrink-0 ${isSelected ? 'text-white' : 'text-red-500 dark:text-red-400'}`}>*</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-sm block break-words ${
|
||||
isSelected ? 'text-white' : 'text-gray-900 dark:text-gray-100'
|
||||
}`}>
|
||||
{topico.titulo}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={`text-xs ${
|
||||
isSelected ? 'text-white' : 'text-gray-500 dark:text-gray-400'
|
||||
}`}>
|
||||
{docCount}
|
||||
</span>
|
||||
{isSelected && <ChevronRight size={16} />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
Nenhum tópico definido
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Documentos */}
|
||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden flex flex-col">
|
||||
{topicoSelecionado ? (
|
||||
<>
|
||||
{/* Toolbar */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Seção {topicoSelecionado}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{documentos?.length || 0} documento(s)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
>
|
||||
<Upload size={16} className="mr-2" />
|
||||
Carregar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documents Grid */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loadingDocs ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : documentos && documentos.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-2">
|
||||
{documentos.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(doc)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={() => handleDrop(doc)}
|
||||
className="p-2 border border-gray-200 dark:border-gray-600 rounded-lg hover:border-primary dark:hover:border-blue-400 hover:shadow-sm transition-all cursor-move bg-white dark:bg-gray-700"
|
||||
>
|
||||
{/* Thumbnail compacto */}
|
||||
<button
|
||||
onClick={() => handlePreview(doc)}
|
||||
className="w-full h-20 bg-gray-100 dark:bg-gray-600 rounded flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-500 transition-colors mb-1.5"
|
||||
>
|
||||
{getDocumentIcon(doc.arquivo_tipo)}
|
||||
</button>
|
||||
|
||||
{/* Nome compacto */}
|
||||
<h3 className="text-xs font-medium text-gray-900 dark:text-gray-100 truncate mb-0.5" title={doc.titulo}>
|
||||
{doc.titulo}
|
||||
</h3>
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400 mb-1.5">
|
||||
{doc.num_paginas || 1} pág
|
||||
{doc.arquivo_tamanho ? ` - ${(doc.arquivo_tamanho / 1024).toFixed(0)} KB` : ''}
|
||||
{doc.formato_pagina ? ` - ${doc.formato_pagina}` : ' - A4'}
|
||||
{doc.orientacao === 'paisagem' ? ' (paisagem)' : ''}
|
||||
</p>
|
||||
|
||||
{/* Actions compactos */}
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<button
|
||||
onClick={() => handlePreview(doc)}
|
||||
className="p-1 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900 rounded transition-colors"
|
||||
title="Visualizar"
|
||||
>
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRotate()}
|
||||
className="p-1 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-600 rounded transition-colors"
|
||||
title="Girar"
|
||||
>
|
||||
<RotateCw size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(doc.id)}
|
||||
className="p-1 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900 rounded transition-colors"
|
||||
title="Deletar"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||
<FileText size={48} className="mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-lg font-medium">Nenhum documento</p>
|
||||
<p className="text-sm">Faça upload de documentos para esta seção</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
>
|
||||
<Upload size={16} className="mr-2" />
|
||||
Carregar Documentos
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||
<div className="text-center">
|
||||
<FileText size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-lg font-medium">Selecione uma seção</p>
|
||||
<p className="text-sm">Escolha uma seção no índice para gerenciar documentos</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Upload */}
|
||||
<Modal
|
||||
isOpen={uploadModalOpen}
|
||||
onClose={() => {
|
||||
setUploadModalOpen(false)
|
||||
setArquivosSelecionados([])
|
||||
}}
|
||||
title="Carregar Documentos"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Seção: {topicoSelecionado}
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center bg-gray-50 dark:bg-gray-700">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx"
|
||||
onChange={(e) => setArquivosSelecionados(Array.from(e.target.files || []))}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label htmlFor="file-upload" className="cursor-pointer">
|
||||
<Upload className="mx-auto text-gray-400 dark:text-gray-500 mb-2" size={32} />
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Clique para selecionar ou arraste arquivos aqui
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
PDF, JPG, PNG, DOC, DOCX (máx. 50MB)
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{arquivosSelecionados.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Arquivos selecionados ({arquivosSelecionados.length})
|
||||
</p>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{arquivosSelecionados.map((file, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded">
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100 truncate">{file.name}</span>
|
||||
<button
|
||||
onClick={() => setArquivosSelecionados(prev => prev.filter((_, idx) => idx !== i))}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setUploadModalOpen(false)
|
||||
setArquivosSelecionados([])
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={arquivosSelecionados.length === 0}
|
||||
isLoading={uploadMutation.isPending}
|
||||
>
|
||||
Carregar {arquivosSelecionados.length > 0 && `(${arquivosSelecionados.length})`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Modal de Preview */}
|
||||
<Modal
|
||||
isOpen={previewModalOpen}
|
||||
onClose={() => {
|
||||
setPreviewModalOpen(false)
|
||||
setDocumentoPreview(null)
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl)
|
||||
setPreviewUrl(null)
|
||||
}
|
||||
}}
|
||||
title="Visualizar Documento"
|
||||
>
|
||||
{documentoPreview && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">{documentoPreview.titulo}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(documentoPreview.data_documento).toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Tipo: {documentoPreview.arquivo_tipo || 'Desconhecido'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg overflow-hidden" style={{ minHeight: '400px', maxHeight: '600px' }}>
|
||||
{previewUrl ? (
|
||||
<>
|
||||
{/* Preview de Imagens */}
|
||||
{documentoPreview.arquivo_tipo?.startsWith('image/') && (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={documentoPreview.titulo}
|
||||
className="max-w-full max-h-[600px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview de PDF */}
|
||||
{documentoPreview.arquivo_tipo === 'application/pdf' && (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
className="w-full h-[600px]"
|
||||
title={documentoPreview.titulo}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Preview de Documentos Word/Excel (não suportado nativamente) */}
|
||||
{(documentoPreview.arquivo_tipo?.includes('word') ||
|
||||
documentoPreview.arquivo_tipo?.includes('document') ||
|
||||
documentoPreview.arquivo_tipo?.includes('sheet')) && (
|
||||
<div className="flex flex-col items-center justify-center h-[400px] text-center p-8">
|
||||
<FileText size={64} className="text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-2">
|
||||
Preview não disponível para este tipo de arquivo
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{documentoPreview.arquivo_tipo}
|
||||
</p>
|
||||
<a
|
||||
href={previewUrl}
|
||||
download={documentoPreview.titulo}
|
||||
className="text-primary dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Baixar arquivo para visualizar
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outros tipos de arquivo */}
|
||||
{!documentoPreview.arquivo_tipo?.startsWith('image/') &&
|
||||
documentoPreview.arquivo_tipo !== 'application/pdf' &&
|
||||
!documentoPreview.arquivo_tipo?.includes('word') &&
|
||||
!documentoPreview.arquivo_tipo?.includes('document') &&
|
||||
!documentoPreview.arquivo_tipo?.includes('sheet') && (
|
||||
<div className="flex flex-col items-center justify-center h-[400px] text-center p-8">
|
||||
<FileText size={64} className="text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-2">
|
||||
Tipo de arquivo não suportado para preview
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{documentoPreview.arquivo_tipo}
|
||||
</p>
|
||||
<a
|
||||
href={previewUrl}
|
||||
download={documentoPreview.titulo}
|
||||
className="text-primary dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Baixar arquivo
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-[400px] text-center p-8">
|
||||
<FileText size={64} className="text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-2">
|
||||
Arquivo não disponível para preview
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
O arquivo pode ter sido carregado em uma sessão anterior
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Preview do Databook Completo */}
|
||||
{databookPreviewOpen && (
|
||||
<DatabookPreview
|
||||
projeto={projeto}
|
||||
topicos={topicos || []}
|
||||
documentosPorSecao={todosDocumentos || {}}
|
||||
onClose={() => setDatabookPreviewOpen(false)}
|
||||
onGeneratePDF={handleGeneratePDF}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
324
src/pages/Databooks.tsx
Normal file
324
src/pages/Databooks.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
// @ts-nocheck
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { FolderOpen, Plus, Edit, Copy, Trash2, Eye } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner'
|
||||
import Button from '@/components/common/Button'
|
||||
import Modal from '@/components/common/Modal'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { deleteDatabook } from '@/lib/mutations'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
// Função para obter cores do status
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'finalizado':
|
||||
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400'
|
||||
case 'revisao':
|
||||
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-400'
|
||||
case 'arquivado':
|
||||
return 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-400'
|
||||
case 'em_andamento':
|
||||
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400'
|
||||
case 'cancelado':
|
||||
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400'
|
||||
case 'rascunho':
|
||||
default:
|
||||
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'rascunho': return 'Rascunho'
|
||||
case 'em_andamento': return 'Em Andamento'
|
||||
case 'finalizado': return 'Concluído'
|
||||
case 'revisao': return 'Em Revisão'
|
||||
case 'arquivado': return 'Arquivado'
|
||||
case 'cancelado': return 'Cancelado'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
export default function Databooks() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
|
||||
const [projetoToDelete, setProjetoToDelete] = useState<any>(null)
|
||||
|
||||
const { data: projetos, isLoading } = useQuery({
|
||||
queryKey: ['projetos'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('projetos')
|
||||
.select('*, clientes(nome)')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteDatabook,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projetos'] })
|
||||
toast.success('Databook deletado com sucesso')
|
||||
setDeleteModalOpen(false)
|
||||
setProjetoToDelete(null)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Erro ao deletar databook')
|
||||
},
|
||||
})
|
||||
|
||||
const handleDelete = (projeto: any) => {
|
||||
setProjetoToDelete(projeto)
|
||||
setDeleteModalOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (projetoToDelete) {
|
||||
deleteMutation.mutate(projetoToDelete.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClone = async (projeto: any) => {
|
||||
try {
|
||||
// Buscar dados completos do projeto
|
||||
const { data: projetoCompleto, error: fetchError } = await supabase
|
||||
.from('projetos')
|
||||
.select('*, databooks_mestres(*)')
|
||||
.eq('id', projeto.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !projetoCompleto) {
|
||||
toast.error('Projeto não encontrado')
|
||||
return
|
||||
}
|
||||
|
||||
// Gerar número único para o projeto clonado
|
||||
const timestamp = Date.now()
|
||||
const numeroProjetoUnico = `${projeto.numero_projeto}-COPIA-${timestamp}`
|
||||
|
||||
// Criar cópia do projeto
|
||||
const { data: novoProjeto, error: insertError } = await supabase
|
||||
.from('projetos')
|
||||
.insert([{
|
||||
numero_projeto: numeroProjetoUnico,
|
||||
nome_projeto: `${projeto.nome_projeto} (Cópia)`,
|
||||
cliente_id: projeto.cliente_id,
|
||||
template_id: projeto.template_id,
|
||||
status: 'rascunho',
|
||||
progresso_percentual: 0,
|
||||
} as any])
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError || !novoProjeto) {
|
||||
throw insertError
|
||||
}
|
||||
|
||||
// Verificar se existe databook mestre
|
||||
const databookMestre = Array.isArray(projetoCompleto.databooks_mestres)
|
||||
? projetoCompleto.databooks_mestres[0]
|
||||
: projetoCompleto.databooks_mestres
|
||||
|
||||
if (databookMestre) {
|
||||
// Copiar databook mestre apenas com campos válidos
|
||||
const { error: mestreError } = await supabase
|
||||
.from('databooks_mestres')
|
||||
.insert([{
|
||||
projeto_id: novoProjeto.id,
|
||||
cliente_nome: databookMestre.cliente_nome || null,
|
||||
cliente_contato: databookMestre.cliente_contato || null,
|
||||
cliente_email: databookMestre.cliente_email || null,
|
||||
cliente_telefone: databookMestre.cliente_telefone || null,
|
||||
produto_nome: databookMestre.produto_nome || 'Produto',
|
||||
produto_tipo: databookMestre.produto_tipo || null,
|
||||
produto_descricao: databookMestre.produto_descricao || null,
|
||||
produto_normas: databookMestre.produto_normas || null,
|
||||
numero_projeto: numeroProjetoUnico,
|
||||
ordem_compra: databookMestre.ordem_compra || null,
|
||||
cor_primaria: databookMestre.cor_primaria || null,
|
||||
cor_secundaria: databookMestre.cor_secundaria || null,
|
||||
titulo_principal: databookMestre.titulo_principal || null,
|
||||
subtitulo: databookMestre.subtitulo || null,
|
||||
tamanho_pagina: databookMestre.tamanho_pagina || 'A4',
|
||||
orientacao: databookMestre.orientacao || 'retrato',
|
||||
} as any])
|
||||
|
||||
if (mestreError) {
|
||||
console.error('Erro ao copiar databook mestre:', mestreError)
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['projetos'] })
|
||||
toast.success('Databook clonado com sucesso!')
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao clonar databook:', error)
|
||||
toast.error(error?.message || 'Erro ao clonar databook')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Databooks</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Gerencie todos os seus databooks
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/databook/novo')}>
|
||||
<Plus size={20} className="mr-2" />
|
||||
Novo Databook
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Lista de Databooks */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Projeto
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Cliente
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Progresso
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{projetos && projetos.length > 0 ? (
|
||||
projetos.map((projeto) => (
|
||||
<tr key={projeto.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<FolderOpen className="text-primary mr-3" size={20} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{projeto.nome_projeto}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{projeto.numero_projeto}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{projeto.clientes?.nome || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(projeto.status)}`}>
|
||||
{getStatusLabel(projeto.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mr-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full"
|
||||
style={{ width: `${projeto.progresso_percentual || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">{projeto.progresso_percentual || 0}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/databook/${projeto.id}`)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 p-1 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors"
|
||||
title="Ver detalhes"
|
||||
>
|
||||
<Eye size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/databook/${projeto.id}/editar`)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 p-1 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClone(projeto)}
|
||||
className="text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 p-1 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors"
|
||||
title="Clonar"
|
||||
>
|
||||
<Copy size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(projeto)}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 p-1 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
|
||||
title="Deletar"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
Nenhum databook encontrado. Crie seu primeiro databook!
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Confirmação de Exclusão */}
|
||||
<Modal
|
||||
isOpen={deleteModalOpen}
|
||||
onClose={() => setDeleteModalOpen(false)}
|
||||
title="Deletar Databook"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Tem certeza que deseja deletar o databook <strong>{projetoToDelete?.nome_projeto}</strong>?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
Esta ação não pode ser desfeita e todos os dados relacionados serão perdidos.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteModalOpen(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={confirmDelete}
|
||||
isLoading={deleteMutation.isPending}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Deletar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
391
src/pages/DesignDatabook.tsx
Normal file
391
src/pages/DesignDatabook.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
// @ts-nocheck
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Edit, Trash2, Eye, Download, Save, X } from 'lucide-react'
|
||||
import Button from '@/components/common/Button'
|
||||
import Input from '@/components/common/Input'
|
||||
import Modal from '@/components/common/Modal'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { toast } from '@/lib/toast'
|
||||
import TemplateEditor from '@/components/design/TemplateEditor'
|
||||
import TemplatePreview from '@/components/design/TemplatePreview'
|
||||
|
||||
interface Template {
|
||||
id: string
|
||||
nome: string
|
||||
descricao: string
|
||||
tipo: 'capa' | 'indice' | 'divisora' | 'cabecalho' | 'rodape' | 'guia_estilo'
|
||||
config: Record<string, any>
|
||||
ativo: boolean
|
||||
criado_em: string
|
||||
}
|
||||
|
||||
export default function DesignDatabook() {
|
||||
const queryClient = useQueryClient()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editingTemplate, setEditingTemplate] = useState<Template | null>(null)
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null)
|
||||
const [filterType, setFilterType] = useState<string>('all')
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
nome: '',
|
||||
descricao: '',
|
||||
tipo: 'capa' as Template['tipo'],
|
||||
config: {} as Record<string, any>,
|
||||
})
|
||||
|
||||
const { data: templates, isLoading } = useQuery({
|
||||
queryKey: ['design-templates'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('design_templates')
|
||||
.select('*')
|
||||
.order('criado_em', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data as Template[]
|
||||
},
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: typeof formData) => {
|
||||
const { error } = await supabase
|
||||
.from('design_templates')
|
||||
.insert([data as any])
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['design-templates'] })
|
||||
toast.success('Template criado com sucesso')
|
||||
setModalOpen(false)
|
||||
resetForm()
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: typeof formData }) => {
|
||||
const { error } = await supabase
|
||||
.from('design_templates')
|
||||
.update(data as any)
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['design-templates'] })
|
||||
toast.success('Template atualizado com sucesso')
|
||||
setModalOpen(false)
|
||||
setEditingTemplate(null)
|
||||
resetForm()
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { error } = await supabase
|
||||
.from('design_templates')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['design-templates'] })
|
||||
toast.success('Template deletado com sucesso')
|
||||
},
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
nome: '',
|
||||
descricao: '',
|
||||
tipo: 'capa',
|
||||
config: {},
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = (template: Template) => {
|
||||
setEditingTemplate(template)
|
||||
setFormData({
|
||||
nome: template.nome,
|
||||
descricao: template.descricao,
|
||||
tipo: template.tipo,
|
||||
config: template.config,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formData.nome.trim()) {
|
||||
toast.error('Nome do template é obrigatório')
|
||||
return
|
||||
}
|
||||
|
||||
if (editingTemplate) {
|
||||
updateMutation.mutate({ id: editingTemplate.id, data: formData })
|
||||
} else {
|
||||
createMutation.mutate(formData)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreview = (template: Template) => {
|
||||
setSelectedTemplate(template)
|
||||
setPreviewOpen(true)
|
||||
}
|
||||
|
||||
const filteredTemplates = templates?.filter(t =>
|
||||
filterType === 'all' ? true : t.tipo === filterType
|
||||
) || []
|
||||
|
||||
const typeLabels = {
|
||||
capa: '📄 Capa Frontal',
|
||||
indice: '📑 Índice Geral',
|
||||
divisora: '🔖 Divisora de Seção',
|
||||
cabecalho: '📋 Cabeçalho',
|
||||
rodape: '📋 Rodapé',
|
||||
guia_estilo: '🎨 Guia de Estilo',
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Design do Databook</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Crie e personalize os templates visuais e estruturais do seu databook
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => {
|
||||
resetForm()
|
||||
setEditingTemplate(null)
|
||||
setModalOpen(true)
|
||||
}}>
|
||||
<Plus size={20} className="mr-2" />
|
||||
Novo Template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setFilterType('all')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
filterType === 'all'
|
||||
? 'bg-primary dark:bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
{Object.entries(typeLabels).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilterType(key)}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
filterType === key
|
||||
? 'bg-primary dark:bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid de Templates */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredTemplates.length > 0 ? (
|
||||
filteredTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow overflow-hidden"
|
||||
>
|
||||
<div className="bg-gradient-to-r from-blue-500 via-blue-600 to-purple-600 dark:from-slate-700 dark:via-slate-800 dark:to-slate-900 h-32 flex items-center justify-center">
|
||||
<span className="text-6xl">
|
||||
{typeLabels[template.tipo].split(' ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">{template.nome}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{template.descricao}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mb-4">
|
||||
{typeLabels[template.tipo]}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handlePreview(template)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Eye size={16} />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(template)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-gray-50 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Edit size={16} />
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(template.id)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Deletar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full text-center py-12">
|
||||
<p className="text-gray-500 text-lg">
|
||||
Nenhum template encontrado. Crie seu primeiro template!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal de Criar/Editar */}
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => {
|
||||
setModalOpen(false)
|
||||
setEditingTemplate(null)
|
||||
resetForm()
|
||||
}}
|
||||
title={editingTemplate ? 'Editar Template' : 'Novo Template'}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Nome do Template"
|
||||
value={formData.nome}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, nome: e.target.value }))}
|
||||
placeholder="Ex: Capa Frontal Padrão"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Descrição
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.descricao}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, descricao: e.target.value }))}
|
||||
placeholder="Descrição do template..."
|
||||
className="input-field"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tipo de Template
|
||||
</label>
|
||||
<select
|
||||
value={formData.tipo}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, tipo: e.target.value as Template['tipo'] }))}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="capa">Capa Frontal</option>
|
||||
<option value="indice">Índice Geral</option>
|
||||
<option value="divisora">Divisora de Seção</option>
|
||||
<option value="cabecalho">Cabeçalho</option>
|
||||
<option value="rodape">Rodapé</option>
|
||||
<option value="guia_estilo">Guia de Estilo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<TemplateEditor
|
||||
tipo={formData.tipo}
|
||||
config={formData.config}
|
||||
onChange={(config) => setFormData(prev => ({ ...prev, config }))}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setModalOpen(false)
|
||||
setEditingTemplate(null)
|
||||
resetForm()
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
<Save size={18} className="mr-2" />
|
||||
{editingTemplate ? 'Atualizar' : 'Criar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Modal de Preview */}
|
||||
<div className="fixed inset-0 z-50" style={{ display: previewOpen ? 'flex' : 'none', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
onClick={() => setPreviewOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Modal - 56% da tela (70% - 20%), 90% altura (85% + 20% ajustado) */}
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full flex flex-col" style={{ maxWidth: '56vw', maxHeight: '90vh' }}>
|
||||
{/* Header com Botões */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{selectedTemplate?.nome || 'Preview'}</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPreviewOpen(false)}
|
||||
className="text-sm"
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
<Button className="text-sm">
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setPreviewOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - Scroll apenas do preview */}
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
{selectedTemplate && (
|
||||
<TemplatePreview
|
||||
tipo={selectedTemplate.tipo}
|
||||
config={selectedTemplate.config}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
57
src/pages/Login.tsx
Normal file
57
src/pages/Login.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/lib/store'
|
||||
import Button from '@/components/common/Button'
|
||||
import { BeamsBackground } from '@/components/ui/beams-background'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
|
||||
const handleSkipLogin = () => {
|
||||
// Entrar direto sem credenciais
|
||||
setUser({
|
||||
id: 'guest-user',
|
||||
email: 'guest@steelbook.com',
|
||||
nome_completo: 'Visitante',
|
||||
} as any)
|
||||
navigate('/dashboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<BeamsBackground intensity="strong">
|
||||
<div className="bg-white rounded-lg shadow-2xl p-8 w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-primary mb-2">SteelBook</h1>
|
||||
<p className="text-gray-600">Gestão Inteligente de Databooks</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
onClick={handleSkipLogin}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
>
|
||||
Entrar
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Modo Desenvolvimento</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-gray-500">
|
||||
Autenticação desabilitada para desenvolvimento
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
<p>Versão 1.0.0 - 2025</p>
|
||||
</div>
|
||||
</div>
|
||||
</BeamsBackground>
|
||||
)
|
||||
}
|
||||
265
src/pages/TemplateCreate.tsx
Normal file
265
src/pages/TemplateCreate.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import Button from '@/components/common/Button'
|
||||
import Input from '@/components/common/Input'
|
||||
import { TOPICOS_PADRAO } from '@/lib/constants'
|
||||
|
||||
export default function TemplateCreate() {
|
||||
const navigate = useNavigate()
|
||||
const [step, setStep] = useState(1)
|
||||
const [formData, setFormData] = useState({
|
||||
nome: '',
|
||||
tipo: 'novo' as 'novo' | 'derivado',
|
||||
template_pai_id: null as string | null,
|
||||
topicos_selecionados: [] as string[],
|
||||
descricao: '',
|
||||
})
|
||||
|
||||
const handleTopicoToggle = (numero: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
topicos_selecionados: prev.topicos_selecionados.includes(numero)
|
||||
? prev.topicos_selecionados.filter(t => t !== numero)
|
||||
: [...prev.topicos_selecionados, numero]
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
topicos_selecionados: TOPICOS_PADRAO.map(t => t.numero)
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSelectMinimo = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
topicos_selecionados: TOPICOS_PADRAO.filter(t => t.obrigatorio).map(t => t.numero)
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const { createTemplate } = await import('@/lib/mutations')
|
||||
|
||||
await createTemplate({
|
||||
nome: formData.nome,
|
||||
tipo: formData.tipo,
|
||||
template_pai_id: formData.template_pai_id,
|
||||
topicos_selecionados: formData.topicos_selecionados,
|
||||
descricao: formData.descricao,
|
||||
})
|
||||
|
||||
navigate('/templates')
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar template:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate('/templates')}>
|
||||
<ChevronLeft size={20} />
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Criar Template</h1>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div key={s} className="flex items-center flex-1">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${
|
||||
step >= s ? 'bg-primary text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{s}
|
||||
</div>
|
||||
{s < 3 && (
|
||||
<div className={`flex-1 h-1 mx-4 ${
|
||||
step > s ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Dados Básicos */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Dados Básicos</h2>
|
||||
|
||||
<Input
|
||||
label="Nome do Template"
|
||||
value={formData.nome}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, nome: e.target.value }))}
|
||||
placeholder="Ex: Padrão Galpão Civil"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Descrição (opcional)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.descricao}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, descricao: e.target.value }))}
|
||||
placeholder="Descreva o propósito deste template..."
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Tipo</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={formData.tipo === 'novo'}
|
||||
onChange={() => setFormData(prev => ({ ...prev, tipo: 'novo' }))}
|
||||
className="text-primary focus:ring-primary"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">Novo</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Criar template do zero</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={formData.tipo === 'derivado'}
|
||||
onChange={() => setFormData(prev => ({ ...prev, tipo: 'derivado' }))}
|
||||
className="text-primary focus:ring-primary"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">Padrão</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Template pré-definido reutilizável</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={formData.tipo === 'derivado'}
|
||||
onChange={() => setFormData(prev => ({ ...prev, tipo: 'derivado' }))}
|
||||
className="text-primary focus:ring-primary"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">Derivado</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Baseado em template existente</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setStep(2)}>
|
||||
Próximo
|
||||
<ChevronRight size={20} className="ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Seleção de Tópicos */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Selecionar Tópicos</h2>
|
||||
<div className="space-x-2">
|
||||
<Button size="sm" variant="outline" onClick={handleSelectAll}>
|
||||
Selecionar Todos
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleSelectMinimo}>
|
||||
Apenas Obrigatórios
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Selecionados: {formData.topicos_selecionados.length} / {TOPICOS_PADRAO.length}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 max-h-96 overflow-y-auto">
|
||||
{TOPICOS_PADRAO.map((topico) => (
|
||||
<label
|
||||
key={topico.numero}
|
||||
className="flex items-start gap-3 p-3 border border-gray-200 dark:border-gray-600 rounded-lg hover:border-primary cursor-pointer bg-white dark:bg-gray-700 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.topicos_selecionados.includes(topico.numero)}
|
||||
onChange={() => handleTopicoToggle(topico.numero)}
|
||||
className="mt-1 text-primary focus:ring-primary"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm text-gray-900 dark:text-gray-100">
|
||||
{topico.numero} - {topico.titulo}
|
||||
{topico.obrigatorio && (
|
||||
<span className="ml-2 text-xs text-red-600 dark:text-red-400">*</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={() => setStep(1)}>
|
||||
<ChevronLeft size={20} className="mr-2" />
|
||||
Anterior
|
||||
</Button>
|
||||
<Button onClick={() => setStep(3)}>
|
||||
Próximo
|
||||
<ChevronRight size={20} className="ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Revisar e Salvar */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Revisar e Salvar</h2>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Nome</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{formData.nome}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Tipo</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{formData.tipo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Total de Seções</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{formData.topicos_selecionados.length} de {TOPICOS_PADRAO.length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Seções Obrigatórias</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{formData.topicos_selecionados.filter(t =>
|
||||
TOPICOS_PADRAO.find(tp => tp.numero === t)?.obrigatorio
|
||||
).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={() => setStep(2)}>
|
||||
<ChevronLeft size={20} className="mr-2" />
|
||||
Anterior
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
Salvar Template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
272
src/pages/TemplateEdit.tsx
Normal file
272
src/pages/TemplateEdit.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import Button from '@/components/common/Button'
|
||||
import Input from '@/components/common/Input'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner'
|
||||
import { TOPICOS_PADRAO } from '@/lib/constants'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { updateTemplate } from '@/lib/mutations'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export default function TemplateEdit() {
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const [step, setStep] = useState(1)
|
||||
const [formData, setFormData] = useState({
|
||||
nome: '',
|
||||
tipo: 'novo' as 'novo' | 'derivado',
|
||||
template_pai_id: null as string | null,
|
||||
topicos_selecionados: [] as string[],
|
||||
descricao: '',
|
||||
})
|
||||
|
||||
const { data: template, isLoading } = useQuery({
|
||||
queryKey: ['template', id],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('templates_customizados')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data as any
|
||||
},
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setFormData({
|
||||
nome: template.nome,
|
||||
tipo: template.tipo,
|
||||
template_pai_id: template.template_pai_id,
|
||||
topicos_selecionados: template.topicos_selecionados || [],
|
||||
descricao: template.descricao || '',
|
||||
})
|
||||
}
|
||||
}, [template])
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => updateTemplate(id!, {
|
||||
nome: formData.nome,
|
||||
topicos_selecionados: formData.topicos_selecionados,
|
||||
descricao: formData.descricao,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['templates'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['template', id] })
|
||||
toast.success('Template atualizado com sucesso')
|
||||
navigate('/templates')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Erro ao atualizar template')
|
||||
},
|
||||
})
|
||||
|
||||
const handleTopicoToggle = (numero: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
topicos_selecionados: prev.topicos_selecionados.includes(numero)
|
||||
? prev.topicos_selecionados.filter(t => t !== numero)
|
||||
: [...prev.topicos_selecionados, numero]
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
topicos_selecionados: TOPICOS_PADRAO.map(t => t.numero)
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSelectMinimo = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
topicos_selecionados: TOPICOS_PADRAO.filter(t => t.obrigatorio).map(t => t.numero)
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate()
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate('/templates')}>
|
||||
<ChevronLeft size={20} />
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Editar Template</h1>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div key={s} className="flex items-center flex-1">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${
|
||||
step >= s ? 'bg-primary text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{s}
|
||||
</div>
|
||||
{s < 3 && (
|
||||
<div className={`flex-1 h-1 mx-4 ${
|
||||
step > s ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Dados Básicos */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Dados Básicos</h2>
|
||||
|
||||
<Input
|
||||
label="Nome do Template"
|
||||
value={formData.nome}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, nome: e.target.value }))}
|
||||
placeholder="Ex: Padrão Galpão Civil"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Descrição (opcional)</label>
|
||||
<textarea
|
||||
value={formData.descricao}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, descricao: e.target.value }))}
|
||||
placeholder="Descreva o propósito deste template..."
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setStep(2)}>
|
||||
Próximo
|
||||
<ChevronRight size={20} className="ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Seleção de Tópicos */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Selecionar Tópicos</h2>
|
||||
<div className="space-x-2">
|
||||
<Button size="sm" variant="outline" onClick={handleSelectAll}>
|
||||
Selecionar Todos
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleSelectMinimo}>
|
||||
Apenas Obrigatórios
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Selecionados: {formData.topicos_selecionados.length} / {TOPICOS_PADRAO.length}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 max-h-96 overflow-y-auto">
|
||||
{TOPICOS_PADRAO.map((topico) => (
|
||||
<label
|
||||
key={topico.numero}
|
||||
className="flex items-start gap-3 p-3 border border-gray-200 dark:border-gray-600 rounded-lg hover:border-primary cursor-pointer bg-white dark:bg-gray-700 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.topicos_selecionados.includes(topico.numero)}
|
||||
onChange={() => handleTopicoToggle(topico.numero)}
|
||||
className="mt-1 text-primary focus:ring-primary"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm text-gray-900 dark:text-gray-100">
|
||||
{topico.numero} - {topico.titulo}
|
||||
{topico.obrigatorio && (
|
||||
<span className="ml-2 text-xs text-red-600 dark:text-red-400">*</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={() => setStep(1)}>
|
||||
<ChevronLeft size={20} className="mr-2" />
|
||||
Anterior
|
||||
</Button>
|
||||
<Button onClick={() => setStep(3)}>
|
||||
Próximo
|
||||
<ChevronRight size={20} className="ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Revisar e Salvar */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Revisar e Salvar</h2>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Nome</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{formData.nome}</p>
|
||||
</div>
|
||||
{formData.descricao && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Descrição</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{formData.descricao}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Tipo</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{formData.tipo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Total de Seções</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{formData.topicos_selecionados.length} de {TOPICOS_PADRAO.length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Seções Obrigatórias</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{formData.topicos_selecionados.filter(t =>
|
||||
TOPICOS_PADRAO.find(tp => tp.numero === t)?.obrigatorio
|
||||
).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={() => setStep(2)}>
|
||||
<ChevronLeft size={20} className="mr-2" />
|
||||
Anterior
|
||||
</Button>
|
||||
<Button onClick={handleSave} isLoading={updateMutation.isPending}>
|
||||
Salvar Alterações
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
288
src/pages/Templates.tsx
Normal file
288
src/pages/Templates.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FileText, Plus, Edit, Copy, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import Button from '@/components/common/Button'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner'
|
||||
import Modal from '@/components/common/Modal'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { deleteTemplate, createTemplate } from '@/lib/mutations'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export default function Templates() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
|
||||
const [templateToDelete, setTemplateToDelete] = useState<any>(null)
|
||||
|
||||
const { data: allTemplates, isLoading } = useQuery({
|
||||
queryKey: ['templates'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('templates_customizados')
|
||||
.select('*')
|
||||
.eq('ativo', true)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data as any[]
|
||||
},
|
||||
})
|
||||
|
||||
// Separar templates pré-definidos (tipo 'padrao') dos customizados
|
||||
const templatesPadrao = allTemplates?.filter(t => t.tipo === 'padrao') || []
|
||||
const templates = allTemplates?.filter(t => t.tipo !== 'padrao') || []
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteTemplate,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['templates'] })
|
||||
toast.success('Template deletado com sucesso')
|
||||
setDeleteModalOpen(false)
|
||||
setTemplateToDelete(null)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Erro ao deletar template')
|
||||
},
|
||||
})
|
||||
|
||||
const cloneMutation = useMutation({
|
||||
mutationFn: createTemplate,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['templates'] })
|
||||
toast.success('Template clonado com sucesso')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Erro ao clonar template')
|
||||
},
|
||||
})
|
||||
|
||||
const handleDelete = (template: any) => {
|
||||
setTemplateToDelete(template)
|
||||
setDeleteModalOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (templateToDelete) {
|
||||
deleteMutation.mutate(templateToDelete.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClone = async (template: any) => {
|
||||
await cloneMutation.mutateAsync({
|
||||
nome: `${template.nome} (Cópia)`,
|
||||
tipo: template.tipo,
|
||||
template_pai_id: template.id,
|
||||
topicos_selecionados: template.topicos_selecionados,
|
||||
descricao: template.descricao,
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = (template: any) => {
|
||||
navigate(`/templates/${template.id}/editar`)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Templates</h1>
|
||||
<Button onClick={() => navigate('/templates/criar')}>
|
||||
<Plus size={20} className="mr-2" />
|
||||
Novo Template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Templates Pré-definidos */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Templates Pré-definidos</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/templates/criar')}
|
||||
>
|
||||
<Plus size={16} className="mr-1" />
|
||||
Adicionar Template Padrão
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{templatesPadrao.length > 0 ? (
|
||||
templatesPadrao.map((template) => (
|
||||
<div key={template.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-primary transition-colors bg-white dark:bg-gray-900">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="bg-primary-100 dark:bg-blue-900/30 p-2 rounded-lg">
|
||||
<FileText className="text-primary dark:text-blue-400" size={24} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleEdit(template)}
|
||||
className="text-blue-600 hover:text-blue-800 p-1 hover:bg-blue-50 rounded transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClone(template)}
|
||||
className="text-green-600 hover:text-green-800 p-1 hover:bg-green-50 rounded transition-colors"
|
||||
title="Clonar"
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(template)}
|
||||
className="text-red-600 hover:text-red-800 p-1 hover:bg-red-50 rounded transition-colors"
|
||||
title="Deletar"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">{template.nome}</h3>
|
||||
{template.descricao && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">{template.descricao}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||
{template.total_topicos || template.topicos_selecionados?.length || 0} seções
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-3 text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
Nenhum template padrão cadastrado. Crie templates padrão para facilitar a criação de novos projetos.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meus Templates */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Meus Templates</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Nome
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Tipo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Seções
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Criado em
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{templates && templates.length > 0 ? (
|
||||
templates.map((template) => (
|
||||
<tr key={template.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{template.nome}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
|
||||
{template.tipo}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{template.total_topicos} / 28
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(template.created_at).toLocaleDateString('pt-BR')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(template)}
|
||||
className="text-blue-600 hover:text-blue-800 p-1 hover:bg-blue-50 rounded transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClone(template)}
|
||||
className="text-green-600 hover:text-green-800 p-1 hover:bg-green-50 rounded transition-colors"
|
||||
title="Clonar"
|
||||
disabled={cloneMutation.isPending}
|
||||
>
|
||||
<Copy size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(template)}
|
||||
className="text-red-600 hover:text-red-800 p-1 hover:bg-red-50 rounded transition-colors"
|
||||
title="Deletar"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
Nenhum template customizado. Crie seu primeiro template!
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Confirmação de Inativação */}
|
||||
<Modal
|
||||
isOpen={deleteModalOpen}
|
||||
onClose={() => setDeleteModalOpen(false)}
|
||||
title="Inativar Template"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Tem certeza que deseja inativar o template <strong>{templateToDelete?.nome}</strong>?
|
||||
</p>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>Importante:</strong> O template será ocultado e não aparecerá mais na criação de novos databooks,
|
||||
mas continuará disponível para todos os databooks existentes que já o utilizam.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
Você poderá reativar este template posteriormente se necessário.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteModalOpen(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={confirmDelete}
|
||||
isLoading={deleteMutation.isPending}
|
||||
className="bg-orange-600 hover:bg-orange-700"
|
||||
>
|
||||
Inativar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
502
src/pages/TopicosGestao.tsx
Normal file
502
src/pages/TopicosGestao.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
// @ts-nocheck
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Edit, Trash2, GripVertical, Save, X } from 'lucide-react'
|
||||
import Button from '@/components/common/Button'
|
||||
import Input from '@/components/common/Input'
|
||||
import Modal from '@/components/common/Modal'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface Topico {
|
||||
id: string
|
||||
numero_topico: string
|
||||
titulo: string
|
||||
descricao: string
|
||||
obrigatorio: boolean
|
||||
ordem: number
|
||||
categoria: string
|
||||
categoria_cor?: string
|
||||
}
|
||||
|
||||
export default function TopicosGestao() {
|
||||
const queryClient = useQueryClient()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editingTopico, setEditingTopico] = useState<Topico | null>(null)
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
|
||||
const [topicoToDelete, setTopicoToDelete] = useState<Topico | null>(null)
|
||||
const [draggedItem, setDraggedItem] = useState<Topico | null>(null)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
numero_topico: '',
|
||||
titulo: '',
|
||||
descricao: '',
|
||||
obrigatorio: false,
|
||||
categoria: '',
|
||||
})
|
||||
|
||||
const { data: topicos, isLoading } = useQuery({
|
||||
queryKey: ['topicos'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('templates_topicos')
|
||||
.select('*')
|
||||
.order('ordem', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
return data as Topico[]
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
const { data: categorias } = useQuery({
|
||||
queryKey: ['categorias'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('categorias')
|
||||
.select('*')
|
||||
.eq('ativo', true)
|
||||
.order('nome', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: typeof formData) => {
|
||||
const maxOrdem = topicos?.length ? Math.max(...topicos.map(t => t.ordem)) : 0
|
||||
const { error } = await supabase
|
||||
.from('templates_topicos')
|
||||
.insert([{
|
||||
...data,
|
||||
ordem: maxOrdem + 1,
|
||||
} as any])
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['topicos'] })
|
||||
toast.success('Tópico criado com sucesso')
|
||||
resetForm()
|
||||
setModalOpen(false)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Erro ao criar tópico')
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: typeof formData }) => {
|
||||
const { error } = await supabase
|
||||
.from('templates_topicos')
|
||||
.update(data as any)
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['topicos'] })
|
||||
toast.success('Tópico atualizado com sucesso')
|
||||
resetForm()
|
||||
setModalOpen(false)
|
||||
setEditingTopico(null)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Erro ao atualizar tópico')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { error } = await supabase
|
||||
.from('templates_topicos')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['topicos'] })
|
||||
toast.success('Tópico deletado com sucesso')
|
||||
setDeleteModalOpen(false)
|
||||
setTopicoToDelete(null)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Erro ao deletar tópico')
|
||||
},
|
||||
})
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: async (reorderedTopicos: Topico[]) => {
|
||||
const updates = reorderedTopicos.map((topico, index) =>
|
||||
supabase
|
||||
.from('templates_topicos')
|
||||
.update({ ordem: index + 1 } as any)
|
||||
.eq('id', topico.id)
|
||||
)
|
||||
|
||||
await Promise.all(updates)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['topicos'] })
|
||||
toast.success('Ordem atualizada com sucesso')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Erro ao atualizar ordem')
|
||||
},
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
numero_topico: '',
|
||||
titulo: '',
|
||||
descricao: '',
|
||||
obrigatorio: false,
|
||||
categoria: '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = (topico: Topico) => {
|
||||
setEditingTopico(topico)
|
||||
setFormData({
|
||||
numero_topico: topico.numero_topico,
|
||||
titulo: topico.titulo,
|
||||
descricao: topico.descricao || '',
|
||||
obrigatorio: topico.obrigatorio,
|
||||
categoria: topico.categoria || '',
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = (topico: Topico) => {
|
||||
setTopicoToDelete(topico)
|
||||
setDeleteModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (editingTopico) {
|
||||
updateMutation.mutate({ id: editingTopico.id, data: formData })
|
||||
} else {
|
||||
createMutation.mutate(formData)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragStart = (topico: Topico) => {
|
||||
setDraggedItem(topico)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleDrop = (targetTopico: Topico) => {
|
||||
if (!draggedItem || !topicos) return
|
||||
|
||||
const draggedIndex = topicos.findIndex(t => t.id === draggedItem.id)
|
||||
const targetIndex = topicos.findIndex(t => t.id === targetTopico.id)
|
||||
|
||||
if (draggedIndex === targetIndex) return
|
||||
|
||||
const newTopicos = [...topicos]
|
||||
newTopicos.splice(draggedIndex, 1)
|
||||
newTopicos.splice(targetIndex, 0, draggedItem)
|
||||
|
||||
// Renumerar hierarquicamente
|
||||
const renumbered = renumberTopicos(newTopicos)
|
||||
reorderMutation.mutate(renumbered)
|
||||
setDraggedItem(null)
|
||||
}
|
||||
|
||||
const renumberTopicos = (topicos: Topico[]): Topico[] => {
|
||||
let mainCounter = 0
|
||||
let currentMain = ''
|
||||
const subCounters: { [key: string]: number } = {}
|
||||
|
||||
return topicos.map((topico, index) => {
|
||||
const parts = topico.numero_topico.split('.')
|
||||
const isMainTopic = parts.length === 1
|
||||
|
||||
if (isMainTopic) {
|
||||
// É um tópico principal (1, 2, 3, etc)
|
||||
mainCounter++
|
||||
currentMain = mainCounter.toString()
|
||||
subCounters[currentMain] = 0
|
||||
|
||||
return {
|
||||
...topico,
|
||||
numero_topico: currentMain,
|
||||
ordem: index + 1,
|
||||
}
|
||||
} else {
|
||||
// É um subtópico (1.1, 1.2, 2.1, etc)
|
||||
// Usa o último tópico principal encontrado
|
||||
if (!subCounters[currentMain]) {
|
||||
subCounters[currentMain] = 0
|
||||
}
|
||||
subCounters[currentMain]++
|
||||
|
||||
return {
|
||||
...topico,
|
||||
numero_topico: `${currentMain}.${subCounters[currentMain]}`,
|
||||
ordem: index + 1,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getIndentLevel = (numeroTopico: string): number => {
|
||||
const parts = numeroTopico.split('.')
|
||||
return (parts.length - 1) * 32 // 32px por nível
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Gestão de Tópicos</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Gerencie os tópicos padrão do databook</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={() => {
|
||||
resetForm()
|
||||
setEditingTopico(null)
|
||||
setModalOpen(true)
|
||||
}}>
|
||||
<Plus size={20} className="mr-2" />
|
||||
Novo Tópico
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Tópicos ({topicos?.length || 0})
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Arraste e solte para reordenar
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{topicos && topicos.length > 0 ? (
|
||||
topicos.map((topico) => (
|
||||
<div
|
||||
key={topico.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(topico)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={() => handleDrop(topico)}
|
||||
className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-move transition-colors"
|
||||
style={{ paddingLeft: `${16 + getIndentLevel(topico.numero_topico)}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<GripVertical className="text-gray-400 dark:text-gray-500 flex-shrink-0" size={20} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="font-mono font-semibold text-primary dark:text-blue-400 flex-shrink-0">
|
||||
{topico.numero_topico}
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{topico.titulo}
|
||||
</span>
|
||||
{topico.obrigatorio && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-800 flex-shrink-0">
|
||||
Obrigatório
|
||||
</span>
|
||||
)}
|
||||
{topico.categoria && (() => {
|
||||
const categoria = categorias?.find((c: any) => c.nome === topico.categoria)
|
||||
const cor = categoria?.cor || '#3B82F6'
|
||||
return (
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs font-medium rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: `${cor}20`,
|
||||
color: cor
|
||||
}}
|
||||
>
|
||||
{topico.categoria}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{topico.descricao && (
|
||||
<p className="text-sm text-gray-600 mt-1">{topico.descricao}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleEdit(topico)}
|
||||
className="text-blue-600 hover:text-blue-800 p-2 hover:bg-blue-50 rounded transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(topico)}
|
||||
className="text-red-600 hover:text-red-800 p-2 hover:bg-red-50 rounded transition-colors"
|
||||
title="Deletar"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-12 text-center text-gray-500">
|
||||
Nenhum tópico cadastrado. Crie seu primeiro tópico!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Criar/Editar */}
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => {
|
||||
setModalOpen(false)
|
||||
setEditingTopico(null)
|
||||
resetForm()
|
||||
}}
|
||||
title={editingTopico ? 'Editar Tópico' : 'Novo Tópico'}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Número do Tópico"
|
||||
value={formData.numero_topico}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, numero_topico: e.target.value }))}
|
||||
placeholder="Ex: 1.1"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Categoria
|
||||
</label>
|
||||
<select
|
||||
value={formData.categoria}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, categoria: e.target.value }))}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">Selecione uma categoria...</option>
|
||||
{categorias?.map((cat: any) => (
|
||||
<option key={cat.id} value={cat.nome}>
|
||||
{cat.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Título"
|
||||
value={formData.titulo}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, titulo: e.target.value }))}
|
||||
placeholder="Ex: Atestado de Conformidade"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Descrição
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.descricao}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, descricao: e.target.value }))}
|
||||
placeholder="Descrição opcional do tópico..."
|
||||
className="input-field"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.obrigatorio}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, obrigatorio: e.target.checked }))}
|
||||
className="text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Tópico obrigatório
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setModalOpen(false)
|
||||
setEditingTopico(null)
|
||||
resetForm()
|
||||
}}
|
||||
>
|
||||
<X size={18} className="mr-2" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
<Save size={18} className="mr-2" />
|
||||
{editingTopico ? 'Atualizar' : 'Criar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Modal de Confirmação de Deleção */}
|
||||
<Modal
|
||||
isOpen={deleteModalOpen}
|
||||
onClose={() => setDeleteModalOpen(false)}
|
||||
title="Deletar Tópico"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Tem certeza que deseja deletar o tópico{' '}
|
||||
<strong>{topicoToDelete?.numero_topico} - {topicoToDelete?.titulo}</strong>?
|
||||
</p>
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
<strong>Atenção:</strong> Esta ação não pode ser desfeita. O tópico será permanentemente deletado.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteModalOpen(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => topicoToDelete && deleteMutation.mutate(topicoToDelete.id)}
|
||||
isLoading={deleteMutation.isPending}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Deletar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user