photum/frontend/pages/AccessCodes.tsx

294 lines
16 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { createAccessCode, listAccessCodes, deleteAccessCode, getCompanies } from '../services/apiService';
import { Plus, Trash2, Copy, Check, ChevronDown } from 'lucide-react';
import { UserRole } from '../types';
interface AccessCode {
id: string;
codigo: string;
descricao: string;
validade_dias: number;
criado_em: string;
expira_em: string;
ativo: boolean;
usos: number;
empresa_id?: string;
empresa_nome?: string;
}
export const AccessCodes: React.FC = () => {
const { token, user } = useAuth();
const [codes, setCodes] = useState<AccessCode[]>([]);
const [loading, setLoading] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [companies, setCompanies] = useState<Array<{id: string; nome: string}>>([]);
// Form State
const [newCode, setNewCode] = useState('');
const [days, setDays] = useState(30);
const [customDays, setCustomDays] = useState('');
const [copiedId, setCopiedId] = useState<string | null>(null);
const [selectedCompanyId, setSelectedCompanyId] = useState<string>('');
useEffect(() => {
if (token) fetchCodes();
}, [token]);
const fetchCompanies = async () => {
const res = await getCompanies();
if (res.data) {
setCompanies(res.data);
}
};
const openCreateModal = () => {
setIsCreateModalOpen(true);
fetchCompanies();
};
const fetchCodes = async () => {
setLoading(true);
const res = await listAccessCodes(token!);
if (res.data) {
setCodes(res.data);
}
setLoading(false);
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
const codeToCreate = newCode.trim() || generateRandomCode();
const payload: any = {
codigo: codeToCreate,
validade_dias: days,
descricao: 'Gerado via Painel'
};
if (selectedCompanyId) {
payload.empresa_id = selectedCompanyId;
}
const res = await createAccessCode(token!, payload);
if (res.error) {
alert('Erro ao criar: ' + res.error);
} else {
setIsCreateModalOpen(false);
setNewCode('');
setSelectedCompanyId('');
fetchCodes();
}
};
const handleDelete = async (id: string) => {
if (!confirm('Excluir este código? Ele não poderá mais ser usado.')) return;
const res = await deleteAccessCode(token!, id);
if (!res.error) {
fetchCodes();
} else {
alert('Erro ao excluir');
}
};
const generateRandomCode = () => {
const year = new Date().getFullYear();
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
return `PHOTUM${year}-${random}`;
};
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
if (!user || (user.role !== UserRole.SUPERADMIN && user.role !== UserRole.BUSINESS_OWNER)) {
return <div className="p-8 text-center">Acesso restrito.</div>;
}
return (
<div className="min-h-screen bg-gray-50 pt-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Gerenciar Códigos de Acesso</h1>
<p className="mt-2 text-gray-600">Crie e gerencie códigos de acesso temporários para novos cadastros</p>
</div>
<button
onClick={openCreateModal}
className="bg-[#492E61] text-white px-6 py-3 rounded-lg font-medium hover:bg-[#5a3a7a] transition-colors flex items-center gap-2 mb-8 shadow-sm"
>
<Plus size={20} />
Gerar Novo Código
</button>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Código</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Empresa</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Validade (Dias)</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Data de Criação</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Data de Expiração</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading ? (
<tr><td colSpan={7} className="px-6 py-8 text-center text-gray-500">Carregando...</td></tr>
) : codes.length === 0 ? (
<tr><td colSpan={7} className="px-6 py-8 text-center text-gray-500">Nenhum código gerado.</td></tr>
) : (
codes.map(code => (
<tr key={code.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4">
<span className={`inline-block px-3 py-1 text-xs font-medium rounded-full ${code.ativo ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{code.validade_dias === -1 ? 'Infinito' : `${code.validade_dias} dias`}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 group">
<span className="font-mono font-bold text-[#492E61]">{code.codigo}</span>
<button
onClick={() => copyToClipboard(code.codigo, code.id)}
className="text-gray-400 hover:text-[#492E61] opacity-0 group-hover:opacity-100 transition-opacity"
title="Copiar Código"
>
{copiedId === code.id ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
</div>
</td>
<td className="px-6 py-4 text-gray-600">
{code.empresa_nome || <span className="text-gray-400 italic">Todas as empresas</span>}
</td>
<td className="px-6 py-4 text-gray-600">{code.validade_dias === -1 ? 'Nunca expira' : `${code.validade_dias} dias`}</td>
<td className="px-6 py-4 text-sm text-gray-500">{formatDate(code.criado_em)}</td>
<td className="px-6 py-4 text-sm text-gray-500">{code.validade_dias === -1 ? '-' : formatDate(code.expira_em)}</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => handleDelete(code.id)}
className="inline-flex items-center px-3 py-1.5 bg-red-50 text-red-600 rounded-md hover:bg-red-100 transition-colors text-sm font-medium"
>
<Trash2 size={14} className="mr-1.5" />
Excluir
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Modal Create */}
{isCreateModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 animate-fadeIn">
<h2 className="text-xl font-bold mb-4">Novo Código de Acesso</h2>
<form onSubmit={handleCreate}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Código (Opcional)</label>
<input
type="text"
value={newCode}
onChange={e => setNewCode(e.target.value.toUpperCase())}
placeholder={generateRandomCode()}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#492E61] focus:border-transparent outline-none"
/>
<p className="text-xs text-gray-500 mt-1">Deixe em branco para gerar automaticamente.</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Empresa (Opcional)</label>
<select
value={selectedCompanyId}
onChange={e => setSelectedCompanyId(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#492E61] focus:border-transparent outline-none appearance-none bg-white"
>
<option value="">Todas as empresas</option>
{companies.map(company => (
<option key={company.id} value={company.id}>
{company.nome}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">Deixe em branco para permitir uso por todas as empresas.</p>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">Validade</label>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{[7, 15, 30, 60, 90].map(d => (
<button
key={d}
type="button"
onClick={() => { setDays(d); setCustomDays(''); }}
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${days === d ? 'bg-[#492E61] text-white border-[#492E61]' : 'bg-white text-gray-700 border-gray-200 hover:border-[#492E61]'}`}
>
{d} dias
</button>
))}
<button
type="button"
onClick={() => { setDays(-1); setCustomDays(''); }}
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${days === -1 ? 'bg-[#492E61] text-white border-[#492E61]' : 'bg-white text-gray-700 border-gray-200 hover:border-[#492E61]'}`}
>
Nunca Expira
</button>
</div>
{days !== -1 && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Outro:</span>
<input
type="number"
min="1"
value={customDays}
onChange={e => {
const val = parseInt(e.target.value);
setCustomDays(e.target.value);
if (!isNaN(val) && val > 0) setDays(val);
}}
className="w-24 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:ring-2 focus:ring-[#492E61] focus:border-transparent outline-none"
placeholder="Dias"
/>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => {
setIsCreateModalOpen(false);
setSelectedCompanyId('');
}}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-[#492E61] text-white rounded-lg hover:bg-[#5a3a7a]"
>
Gerar Código
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
);
};