246 lines
13 KiB
TypeScript
246 lines
13 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { createAccessCode, listAccessCodes, deleteAccessCode } from '../services/apiService';
|
|
import { Plus, Trash2, Copy, Check } 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;
|
|
}
|
|
|
|
export const AccessCodes: React.FC = () => {
|
|
const { token, user } = useAuth();
|
|
const [codes, setCodes] = useState<AccessCode[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
|
|
// Form State
|
|
const [newCode, setNewCode] = useState('');
|
|
const [days, setDays] = useState(30);
|
|
const [customDays, setCustomDays] = useState('');
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (token) fetchCodes();
|
|
}, [token]);
|
|
|
|
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 res = await createAccessCode(token!, {
|
|
codigo: codeToCreate,
|
|
validade_dias: days,
|
|
descricao: 'Gerado via Painel'
|
|
});
|
|
|
|
if (res.error) {
|
|
alert('Erro ao criar: ' + res.error);
|
|
} else {
|
|
setIsCreateModalOpen(false);
|
|
setNewCode('');
|
|
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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-serif font-bold text-gray-900 mb-2">Gerenciar Códigos de Acesso</h1>
|
|
<p className="text-gray-600">Crie e gerencie códigos de acesso temporários para novos cadastros</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setIsCreateModalOpen(true)}
|
|
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">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={6} className="px-6 py-8 text-center text-gray-500">Carregando...</td></tr>
|
|
) : codes.length === 0 ? (
|
|
<tr><td colSpan={6} 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.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-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)}
|
|
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>
|
|
);
|
|
};
|