445 lines
15 KiB
TypeScript
445 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|