Initial commit DBMaker - Oficiais e Funcionando

This commit is contained in:
Marcos
2026-03-22 17:12:45 -03:00
commit 9cee4943f8
144 changed files with 31465 additions and 0 deletions

116
src/App.tsx Normal file
View 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

View 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>
)
}

View 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>
)
}

View 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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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)} />
</>
)
}

View 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>
);
}

View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
)
}

View 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
View 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
View 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
View 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
View 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
View 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>
)
}

View 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
View 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>
)
}

View 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
View 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
View 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 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
View 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>
)
}