saveinmed/frontend/src/pages/dashboard/admin/CompaniesPage.tsx

589 lines
32 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react'
import { adminService, Company, CreateCompanyRequest } from '@/services/adminService'
import { useCepLookup } from '@/hooks/useCepLookup'
import { formatCNPJ, validateCNPJ } from '@/utils/cnpj'
import { formatPhone } from '@/utils/phone'
export function CompaniesPage() {
const [companies, setCompanies] = useState<Company[]>([])
const [loading, setLoading] = useState(true)
const [filterStatus, setFilterStatus] = useState<'all' | 'verified' | 'pending'>('all')
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [showModal, setShowModal] = useState(false)
const [editingCompany, setEditingCompany] = useState<Company | null>(null)
const [companyDocuments, setCompanyDocuments] = useState<any[]>([])
const [formData, setFormData] = useState<CreateCompanyRequest>({
cnpj: '',
corporate_name: '',
fantasy_name: '',
category: 'farmacia',
license_number: '',
latitude: -16.3281,
longitude: -48.9530,
city: 'Anápolis',
state: 'GO',
phone: '',
email: '',
founded_at: '',
})
const [cep, setCep] = useState('')
const [cnpjError, setCnpjError] = useState<string | null>(null)
const { loading: cepLoading, error: cepError, lookup } = useCepLookup()
const handleCepChange = async (value: string) => {
const clean = value.replace(/\D/g, '')
setCep(value)
if (clean.length === 8) {
const data = await lookup(clean)
if (data) {
setFormData(prev => ({
...prev,
city: data.localidade,
state: data.uf,
latitude: data.latitude,
longitude: data.longitude
}))
}
}
}
const pageSize = 50
useEffect(() => {
loadCompanies()
}, [page])
const loadCompanies = async () => {
setLoading(true)
try {
const data = await adminService.listCompanies(page, pageSize)
setCompanies(data.tenants || [])
setTotal(data.total)
} catch (err) {
console.error('Error loading companies:', err)
} finally {
setLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Only validate CNPJ on create (not on edit, since existing CNPJs may be legacy/test values)
if (!editingCompany) {
if (!formData.cnpj) {
setCnpjError('CNPJ ├® obrigat├│rio')
return
}
const cnpjDigits = formData.cnpj.replace(/\D/g, '')
if (cnpjDigits.length !== 14) {
setCnpjError('CNPJ deve ter 14 dígitos')
return
}
if (!validateCNPJ(formData.cnpj)) {
setCnpjError('CNPJ inválido. Verifique o dígito verificador')
return
}
}
// Strip CNPJ formatting before sending to API
const payload = {
...formData,
cnpj: formData.cnpj.replace(/\D/g, ''),
}
try {
if (editingCompany) {
await adminService.updateCompany(editingCompany.id, payload)
} else {
await adminService.createCompany(payload)
}
setShowModal(false)
resetForm()
void loadCompanies()
} catch (err) {
console.error('Error saving company:', err)
alert('Erro ao salvar empresa')
}
}
const handleDelete = async (id: string) => {
if (!confirm('Tem certeza que deseja excluir esta empresa?')) return
try {
await adminService.deleteCompany(id)
loadCompanies()
} catch (err) {
console.error('Error deleting company:', err)
alert('Erro ao excluir empresa')
}
}
const handleVerify = async (id: string) => {
try {
await adminService.verifyCompany(id)
loadCompanies()
} catch (err) {
console.error('Error verifying company:', err)
}
}
const openEdit = async (company: Company) => {
setEditingCompany(company)
setCnpjError(null)
setFormData({
cnpj: formatCNPJ(company.cnpj ?? ''),
corporate_name: company.corporate_name ?? '',
fantasy_name: company.fantasy_name ?? '',
category: company.category ?? 'farmacia',
license_number: company.license_number ?? '',
latitude: typeof company.latitude === 'number' ? company.latitude : -16.3281,
longitude: typeof company.longitude === 'number' ? company.longitude : -48.9530,
city: company.city ?? '',
state: company.state ?? '',
phone: company.phone ?? '',
email: company.email ?? '',
founded_at: company.founded_at
? company.founded_at.slice(0, 10)
: '',
})
setShowModal(true)
try {
const docs = await adminService.getCompanyDocuments(company.id)
setCompanyDocuments(Array.isArray(docs) ? docs : [])
} catch (err) {
console.error('Failed to load company docs', err)
setCompanyDocuments([])
}
}
const resetForm = () => {
setEditingCompany(null)
setCep('')
setCnpjError(null)
setCompanyDocuments([])
setFormData({
cnpj: '',
corporate_name: '',
fantasy_name: '',
category: 'farmacia',
license_number: '',
latitude: -16.3281,
longitude: -48.9530,
city: 'Anápolis',
state: 'GO',
phone: '',
email: '',
founded_at: '',
})
}
const openCreate = () => {
resetForm()
setShowModal(true)
}
const filteredCompanies = companies.filter(c => {
if (filterStatus === 'all') return true
if (filterStatus === 'verified') return c.is_verified
if (filterStatus === 'pending') return !c.is_verified
return true
})
const totalPages = Math.ceil(total / pageSize)
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-gray-900">Empresas</h1>
<div className="flex rounded-md bg-gray-100 p-1">
<button
onClick={() => setFilterStatus('all')}
className={`rounded px-3 py-1 text-sm font-medium transition-all ${filterStatus === 'all' ? 'bg-white shadow text-gray-900' : 'text-gray-500 hover:text-gray-700'}`}
>
Todas
</button>
<button
onClick={() => setFilterStatus('pending')}
className={`rounded px-3 py-1 text-sm font-medium transition-all ${filterStatus === 'pending' ? 'bg-white shadow text-yellow-700' : 'text-gray-500 hover:text-gray-700'}`}
>
Pendentes
</button>
<button
onClick={() => setFilterStatus('verified')}
className={`rounded px-3 py-1 text-sm font-medium transition-all ${filterStatus === 'verified' ? 'bg-white shadow text-green-700' : 'text-gray-500 hover:text-gray-700'}`}
>
Verificadas
</button>
</div>
</div>
<button
onClick={openCreate}
className="rounded-lg px-4 py-2 text-sm font-medium text-white hover:opacity-90"
style={{ backgroundColor: '#0F4C81' }}
>
+ Nova Empresa
</button>
</div>
{/* Table */}
<div className="overflow-hidden rounded-lg bg-white shadow">
<table className="min-w-full divide-y divide-gray-200">
<thead className="text-white" style={{ backgroundColor: '#0F4C81' }}>
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">Razúo Social</th>
<th className="px-4 py-3 text-left text-sm font-medium">CNPJ</th>
<th className="px-4 py-3 text-left text-sm font-medium">Categoria</th>
<th className="px-4 py-3 text-left text-sm font-medium">Cidade</th>
<th className="px-4 py-3 text-center text-sm font-medium">Verificada</th>
<th className="px-4 py-3 text-right text-sm font-medium">AºÁes</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{loading ? (
<tr>
<td colSpan={6} className="py-8 text-center text-gray-500">
Carregando...
</td>
</tr>
) : filteredCompanies.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-gray-500">
Nenhuma empresa encontrada
</td>
</tr>
) : (
filteredCompanies.map((company) => (
<tr key={company.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium text-gray-900">{company.corporate_name}</td>
<td className="px-4 py-3 text-sm text-gray-600">{company.cnpj}</td>
<td className="px-4 py-3">
<span className="rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800 capitalize">
{company.category}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{company.city}/{company.state}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleVerify(company.id)}
className={`rounded-full px-2 py-1 text-xs font-medium ${company.is_verified
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{company.is_verified ? 'Ô£ô Verificada' : 'Pendente'}
</button>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => openEdit(company)}
className="mr-2 text-sm text-blue-600 hover:underline"
>
Editar
</button>
<button
onClick={() => handleDelete(company.id)}
className="text-sm text-red-600 hover:underline"
>
Excluir
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-600">
Mostrando {(page - 1) * pageSize + 1} a {Math.min(page * pageSize, total)} de {total}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="rounded border px-3 py-1 text-sm disabled:opacity-50"
>
Anterior
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page >= totalPages}
className="rounded border px-3 py-1 text-sm disabled:opacity-50"
>
Prxima
</button>
</div>
</div>
)}
{/* Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl max-h-[90vh] overflow-y-auto">
<h2 className="mb-4 text-xl font-bold">
{editingCompany ? 'Editar Empresa' : 'Nova Empresa'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* CNPJ */}
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">
CNPJ
{editingCompany && <span className="ml-2 text-xs text-gray-400">(núo editível)</span>}
</label>
<input
type="text"
value={formData.cnpj}
onChange={(e) => {
const formatted = formatCNPJ(e.target.value)
setFormData({ ...formData, cnpj: formatted })
if (cnpjError) setCnpjError(null)
}}
maxLength={18}
placeholder="00.000.000/0000-00"
disabled={!!editingCompany}
className={`mt-1 w-full rounded border px-3 py-2 font-mono focus:ring-1 outline-none ${editingCompany
? 'border-gray-200 bg-gray-100 text-gray-500 cursor-not-allowed'
: cnpjError
? 'border-red-500 bg-red-50 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
}`}
required={!editingCompany}
/>
{cnpjError && (
<p className="mt-1 text-xs text-red-600">{cnpjError}</p>
)}
</div>
{/* Nome Fantasia */}
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Nome Fantasia</label>
<input
type="text"
value={formData.fantasy_name || ''}
onChange={(e) => setFormData({ ...formData, fantasy_name: e.target.value })}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
placeholder="Nome fantasia da empresa"
/>
</div>
{/* Razão Social */}
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Razúo Social</label>
<input
type="text"
value={formData.corporate_name}
onChange={(e) => setFormData({ ...formData, corporate_name: e.target.value })}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
required
/>
</div>
{/* Data de Abertura */}
<div>
<label className="block text-sm font-medium text-gray-700">Data de Abertura</label>
<input
type="date"
value={formData.founded_at || ''}
onChange={(e) => setFormData({ ...formData, founded_at: e.target.value })}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
/>
</div>
{/* Categoria */}
<div>
<label className="block text-sm font-medium text-gray-700">Categoria</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
>
<option value="farmacia">Farmícia</option>
<option value="distribuidora">Distribuidora</option>
<option value="admin">Admin</option>
</select>
</div>
{/* Telefone */}
<div>
<label className="block text-sm font-medium text-gray-700">Telefone</label>
<input
type="tel"
value={formData.phone || ''}
onChange={(e) => setFormData({ ...formData, phone: formatPhone(e.target.value) })}
placeholder="(00) 00000-0000"
maxLength={15}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
/>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
value={formData.email || ''}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="contato@empresa.com.br"
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
/>
</div>
{/* Licença Sanitária */}
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Licenºa Sanitíria (Nmero)</label>
<input
type="text"
value={formData.license_number}
onChange={(e) => setFormData({ ...formData, license_number: e.target.value })}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
required
/>
</div>
{editingCompany && (
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Upload de Documento (Licenºa PDF/Imagem)
</label>
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
await adminService.uploadDocument(file, 'LICENSE', editingCompany.id);
alert('Documento enviado com sucesso!');
// reload docs
const docs = await adminService.getCompanyDocuments(editingCompany.id)
setCompanyDocuments(docs)
} catch (err) {
console.error('Upload failed', err);
alert('Falha ao enviar documento.');
}
// Reset input so it can be used again
e.target.value = '';
}}
className="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded file:border-0
file:text-sm file:font-semibold
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100"
/>
{(companyDocuments ?? []).length > 0 && (
<div className="mt-4">
<p className="text-sm font-medium text-gray-700 mb-2">Documentos Anexados:</p>
<ul className="space-y-2">
{(companyDocuments ?? []).map(doc => (
<li key={doc.id} className="flex items-center justify-between p-2 text-sm border rounded bg-gray-50">
<div className="flex items-center">
<span className="font-semibold text-gray-800 mr-2">{doc.type}:</span>
<span className="text-gray-600 truncate max-w-xs">{(doc.url ?? '').split('/').pop()}</span>
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">{doc.status}</span>
</div>
<a href={(import.meta.env.VITE_API_URL?.replace(/\/api\/?$/, '') || 'http://localhost:8214') + doc.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 ml-4 font-medium">
Abrir
</a>
</li>
))}
</ul>
</div>
)}
</div>
)}
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">
CEP
{cepLoading && <span className="ml-2 text-xs text-blue-500 animate-pulse">­ƒöì Buscando...</span>}
</label>
<input
type="text"
value={cep}
onChange={(e) => handleCepChange(e.target.value)}
placeholder="00000-000"
maxLength={9}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
/>
{cepError && <p className="mt-1 text-xs text-red-500">{cepError}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Cidade</label>
<input
type="text"
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Estado</label>
<input
type="text"
value={formData.state}
onChange={(e) => setFormData({ ...formData, state: e.target.value })}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
maxLength={2}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Latitude</label>
<input
type="number"
step="any"
value={Number.isFinite(formData.latitude) ? formData.latitude : ''}
onChange={(e) => {
const v = parseFloat(e.target.value)
setFormData({ ...formData, latitude: Number.isFinite(v) ? v : 0 })
}}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Longitude</label>
<input
type="number"
step="any"
value={Number.isFinite(formData.longitude) ? formData.longitude : ''}
onChange={(e) => {
const v = parseFloat(e.target.value)
setFormData({ ...formData, longitude: Number.isFinite(v) ? v : 0 })
}}
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
required
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<button
type="button"
onClick={() => setShowModal(false)}
className="rounded border px-4 py-2 text-sm"
>
Cancelar
</button>
<button
type="submit"
className="rounded px-4 py-2 text-sm text-white hover:opacity-90"
style={{ backgroundColor: '#0F4C81' }}
>
Salvar
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}