first commit
This commit is contained in:
commit
c9fb67e751
423 files changed
+53287
No files matched your search
@@ -0,0 +1,444 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, FormControl, InputLabel, Select, MenuItem, Alert } from '@mui/material';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import { TipoAtendimento } from '@prisma/client';
|
||||
import api from '../../services/api'; // Adicionar esta importação
|
||||
import { XMarkIcon, PaperClipIcon } from '@heroicons/react/24/outline'; // Alterado para v2
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { uploadFile } from '../../services/upload';
|
||||
|
||||
// Implementar debounce manualmente ao invés de usar lodash
|
||||
function debounce(fn: Function, ms = 300) {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
return function (this: any, ...args: any[]) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn.apply(this, args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
interface AtendimentoFormProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: any) => Promise<void>;
|
||||
clienteId?: string;
|
||||
processoId?: string;
|
||||
initialData?: {
|
||||
titulo?: string;
|
||||
agendamentoId?: string;
|
||||
tipo?: string;
|
||||
atendimentoInicialId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AnexoTemp {
|
||||
id?: string;
|
||||
file: File;
|
||||
nome: string;
|
||||
progresso: number;
|
||||
erro?: string;
|
||||
}
|
||||
|
||||
export function AtendimentoForm({ open, onClose, onSave, clienteId, processoId, initialData }: AtendimentoFormProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
const [selectedClient, setSelectedClient] = useState<any>(null);
|
||||
const [atendimentoInicial, setAtendimentoInicial] = useState<any>(null);
|
||||
const [atendimentosIniciais, setAtendimentosIniciais] = useState<any[]>([]);
|
||||
const [formData, setFormData] = useState({
|
||||
titulo: '',
|
||||
tipo: TipoAtendimento.INICIAL,
|
||||
conteudo: '',
|
||||
clienteId: clienteId || '',
|
||||
processoId: processoId || '',
|
||||
atendimentoInicialId: '',
|
||||
});
|
||||
const [anexos, setAnexos] = useState<AnexoTemp[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// Resetar form ao abrir
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData({
|
||||
titulo: '',
|
||||
tipo: TipoAtendimento.INICIAL,
|
||||
conteudo: '',
|
||||
clienteId: clienteId || '',
|
||||
processoId: processoId || '',
|
||||
atendimentoInicialId: '',
|
||||
});
|
||||
}
|
||||
}, [open, clienteId, processoId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && initialData) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
titulo: initialData.titulo || prev.titulo,
|
||||
clienteId: clienteId || prev.clienteId,
|
||||
processoId: processoId || prev.processoId,
|
||||
tipo: initialData.tipo || prev.tipo,
|
||||
atendimentoInicialId: initialData.atendimentoInicialId || prev.atendimentoInicialId
|
||||
}));
|
||||
}
|
||||
}, [open, initialData, clienteId, processoId]);
|
||||
|
||||
const [clientes, setClientes] = useState<any[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false); // Adicionar estado para prevenir múltiplos submits
|
||||
|
||||
// Buscar lista de clientes
|
||||
useEffect(() => {
|
||||
const fetchClientes = async () => {
|
||||
try {
|
||||
const response = await api.get('/clientes');
|
||||
setClientes(response.data);
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar clientes:', err);
|
||||
}
|
||||
};
|
||||
fetchClientes();
|
||||
}, []);
|
||||
|
||||
// Busca de clientes
|
||||
const searchClients = useCallback(
|
||||
debounce(async (term: string) => {
|
||||
if (term.length < 3) return;
|
||||
try {
|
||||
const response = await api.get(`/clientes/search?q=${term}`);
|
||||
setSearchResults(response.data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar clientes:', error);
|
||||
}
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
// Carregar atendimentos iniciais quando selecionar cliente
|
||||
useEffect(() => {
|
||||
if (formData.clienteId && formData.tipo !== TipoAtendimento.INICIAL) {
|
||||
api.get(`/atendimentos/iniciais/${formData.clienteId}`)
|
||||
.then(response => setAtendimentosIniciais(response.data))
|
||||
.catch(error => console.error('Erro ao carregar atendimentos:', error));
|
||||
}
|
||||
}, [formData.clienteId, formData.tipo]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
],
|
||||
content: '',
|
||||
onUpdate: ({ editor }) => {
|
||||
setFormData(prev => ({ ...prev, conteudo: editor.getHTML() }));
|
||||
},
|
||||
});
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
const novosAnexos = acceptedFiles.map(file => ({
|
||||
file,
|
||||
nome: file.name,
|
||||
progresso: 0
|
||||
}));
|
||||
setAnexos(prev => [...prev, ...novosAnexos]);
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: 10485760 // 10MB
|
||||
});
|
||||
|
||||
const handleRemoveAnexo = (index: number) => {
|
||||
setAnexos(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isSaving) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
|
||||
if (!formData.clienteId) {
|
||||
setError('Selecione um cliente');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Buscar dados do cliente caso não esteja selecionado
|
||||
if (!selectedClient) {
|
||||
try {
|
||||
const response = await api.get(`/clientes/${formData.clienteId}`);
|
||||
setSelectedClient(response.data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar cliente:', error);
|
||||
setError('Erro ao buscar dados do cliente');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Criar atendimento primeiro para ter o protocolo
|
||||
const response = await api.post('/atendimentos', {
|
||||
...formData,
|
||||
agendamentoId: initialData?.agendamentoId,
|
||||
});
|
||||
|
||||
const atendimentoId = response.data.id;
|
||||
const protocolo = response.data.protocolo;
|
||||
|
||||
// Upload dos anexos com protocolo
|
||||
if (anexos.length > 0) {
|
||||
const anexosPromises = anexos.map(async (anexo) => {
|
||||
try {
|
||||
const clienteNome = selectedClient?.nome || 'cliente';
|
||||
return await uploadFile(
|
||||
anexo.file,
|
||||
clienteNome,
|
||||
'atendimento',
|
||||
protocolo
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Erro no upload:', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const anexosUpload = await Promise.all(anexosPromises);
|
||||
const anexosSalvos = anexosUpload.filter(Boolean);
|
||||
|
||||
if (anexosSalvos.length > 0) {
|
||||
await api.patch(`/atendimentos/${atendimentoId}`, {
|
||||
anexos: anexosSalvos
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Limpar form e fechar modal
|
||||
setFormData({
|
||||
titulo: '',
|
||||
tipo: TipoAtendimento.INICIAL,
|
||||
conteudo: '',
|
||||
clienteId: '',
|
||||
processoId: '',
|
||||
atendimentoInicialId: '',
|
||||
});
|
||||
|
||||
await onSave(response.data);
|
||||
onClose();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erro ao salvar atendimento:', err);
|
||||
setError(err.response?.data?.error || 'Erro ao criar atendimento');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 bg-gray-500 bg-opacity-75 ${open ? 'flex' : 'hidden'} z-50`}>
|
||||
<div className="relative bg-white rounded-lg max-w-4xl w-full m-auto p-6 overflow-y-auto max-h-[90vh]">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Novo Atendimento</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-500">
|
||||
<XMarkIcon className="h-6 w-6" /> {/* Alterado para XMarkIcon */}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Busca de Cliente */}
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cliente
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
searchClients(e.target.value);
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Buscar cliente..."
|
||||
/>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg">
|
||||
{searchResults.map(client => (
|
||||
<div
|
||||
key={client.id}
|
||||
className="px-4 py-2 hover:bg-gray-100 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedClient(client);
|
||||
setFormData(prev => ({ ...prev, clienteId: client.id }));
|
||||
setSearchTerm(client.nome);
|
||||
setSearchResults([]);
|
||||
}}
|
||||
>
|
||||
<div className="font-medium">{client.nome}</div>
|
||||
<div className="text-sm text-gray-500">{client.cpfCnpj}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tipo de Atendimento */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tipo de Atendimento
|
||||
</label>
|
||||
<select
|
||||
value={formData.tipo}
|
||||
onChange={(e) => setFormData({ ...formData, tipo: e.target.value as TipoAtendimento })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
{Object.values(TipoAtendimento).map((tipo) => (
|
||||
<option key={tipo} value={tipo}>
|
||||
{tipo.replace('_', ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Atendimento Inicial (apenas para tipos diferentes de INICIAL) */}
|
||||
{formData.tipo !== TipoAtendimento.INICIAL && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Atendimento Inicial
|
||||
</label>
|
||||
<select
|
||||
value={formData.atendimentoInicialId}
|
||||
onChange={(e) => setFormData({ ...formData, atendimentoInicialId: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required={formData.tipo !== TipoAtendimento.INICIAL}
|
||||
>
|
||||
<option value="">Selecione um atendimento inicial</option>
|
||||
{atendimentosIniciais.map((atend) => (
|
||||
<option key={atend.id} value={atend.id}>
|
||||
{atend.protocolo} - {atend.titulo}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Título do Atendimento"
|
||||
value={formData.titulo}
|
||||
onChange={(e) => setFormData({ ...formData, titulo: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Editor de rich text */}
|
||||
<div className="prose max-w-none">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Conteúdo do Atendimento
|
||||
</label>
|
||||
<div className="min-h-[200px] border rounded-lg">
|
||||
<div className="border-b p-2 space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
className={`px-2 py-1 rounded ${editor?.isActive('bold') ? 'bg-gray-200' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
Negrito
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
className={`px-2 py-1 rounded ${editor?.isActive('italic') ? 'bg-gray-200' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
Itálico
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload de anexos */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Anexos
|
||||
</label>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-dashed rounded-md ${
|
||||
isDragActive ? 'border-blue-400 bg-blue-50' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-1 text-center">
|
||||
<PaperClipIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<input {...getInputProps()} />
|
||||
<p className="pl-1">Arraste arquivos aqui ou clique para selecionar</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Máximo 10MB por arquivo</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Anexos */}
|
||||
{anexos.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-gray-700">Arquivos para upload</h4>
|
||||
<ul className="mt-2 divide-y divide-gray-200">
|
||||
{anexos.map((anexo, index) => (
|
||||
<li key={index} className="py-3 flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
<PaperClipIcon className="h-5 w-5 text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-900">{anexo.nome}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{anexo.erro && (
|
||||
<span className="text-xs text-red-500 mr-2">{anexo.erro}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveAnexo(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border-l-4 border-red-500 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Botões de ação dentro do form */}
|
||||
<div className="flex justify-end space-x-4 mt-6 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSaving ? 'Salvando...' : 'Salvar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user