diff --git a/frontend/types.ts b/frontend/types.ts index 1a04f68..0f5c204 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -241,6 +241,7 @@ export interface Procedimento { codigo?: string; codigoInterno?: string; descricao?: string; + descricao_mobile?: string; especialidadeId: string; especialidadeNome: string; valorParticular: number; diff --git a/frontend/views/AdminViews.tsx b/frontend/views/AdminViews.tsx index 7510024..1d9e050 100644 --- a/frontend/views/AdminViews.tsx +++ b/frontend/views/AdminViews.tsx @@ -457,6 +457,7 @@ export const ProcedimentosView: React.FC = () => { codigoInterno: data.codigoInterno, categoria: data.categoria, descricao: data.descricao, + descricao_mobile: data.descricao_mobile || undefined, especialidadeId: data.especialidadeId, especialidadeNome: esp?.nome || '', valorParticular: parseFloat(data.valorParticular), @@ -644,6 +645,12 @@ export const ProcedimentosView: React.FC = () => { +
+ + +
diff --git a/frontend/views/LancarGTO.tsx b/frontend/views/LancarGTO.tsx index 3285961..a1d6651 100644 --- a/frontend/views/LancarGTO.tsx +++ b/frontend/views/LancarGTO.tsx @@ -1,713 +1,877 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { - Users, - Trash2, - Check, - Building2, - AlertCircle, - PlusCircle, - Camera, - Stethoscope, - ChevronRight, - Search, - X -} from 'lucide-react'; -import { PageHeader } from '../components/PageHeader.tsx'; -import { HybridBackend } from '../services/backend.ts'; -import { Paciente, Dentista, Procedimento, Especialidade } from '../types.ts'; -import { useToast } from '../contexts/ToastContext.tsx'; -import { create } from 'zustand'; - -// --- STORES --- -interface GTOItem { - id: string; - procedimentoId: string; - codigoTUSS: string; - codigoInterno: string; - descricao: string; - dente?: string; - face?: string; - arco?: string; - quantidade: number; - valorUnitario: number; - valorTotal: number; - observacao?: string; -} - -interface GTOStore { - paciente: Paciente | null; - dentista: Dentista | null; - credenciada: string; - items: GTOItem[]; - pendingSearch: string; - setPaciente: (p: Paciente | null) => void; - setDentista: (d: Dentista | null) => void; - setCredenciada: (c: string) => void; - addItem: (item: GTOItem) => void; - removeItem: (id: string) => void; - clear: () => void; - setPendingSearch: (s: string) => void; -} - -const useGTOStore = create((set) => ({ - paciente: null, - dentista: null, - credenciada: 'CASSEMS - SEDE CENTRAL', - items: [], - pendingSearch: '', - setPaciente: (paciente) => set({ paciente }), - setDentista: (dentista) => set({ dentista }), - setCredenciada: (credenciada) => set({ credenciada }), - addItem: (item) => set((state) => ({ - items: [...state.items, item].slice(0, 20) - })), - removeItem: (id) => set((state) => ({ - items: state.items.filter(i => i.id !== id) - })), - clear: () => set({ paciente: null, dentista: null, items: [] }), - setPendingSearch: (pendingSearch) => set({ pendingSearch }), -})); - -export { useGTOStore }; - -// --- ODONTOGRAM DATA (4x2 Grid Layout) --- -const DENTES_ADULTO = { - q1: [14, 13, 12, 11, 18, 17, 16, 15], // Top: 14-11, Bottom: 18-15 - q2: [21, 22, 23, 24, 25, 26, 27, 28], // Top: 21-24, Bottom: 25-28 - q4: [44, 43, 42, 41, 48, 47, 46, 45], // Top: 44-41, Bottom: 48-45 - q3: [31, 32, 33, 34, 35, 36, 37, 38], // Top: 31-34, Bottom: 35-38 - extras: { q1: 19, q2: 29, q4: 49, q3: 39 } -}; - -const DENTES_CRIANCA = { - q1: [null, 53, 52, 51, null, null, 55, 54], - q2: [61, 62, 63, null, 64, 65, null, null], - q4: [null, 83, 82, 81, null, null, 85, 84], // fix: eram 43-46 (permanentes) - q3: [71, 72, 73, null, 74, 75, null, null] -}; - -const normalize = (s: string) => - s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim(); - -// --- COMPONENT --- -export const LancarGTO: React.FC = () => { - const store = useGTOStore(); - const toast = useToast(); - - const [pacientes, setPacientes] = useState([]); - const [dentistas, setDentistas] = useState([]); - const [especialidades, setEspecialidades] = useState([]); - const [procedimentos, setProcedimentos] = useState([]); - - const [selectedEsp, setSelectedEsp] = useState(''); - const [selectedProc, setSelectedProc] = useState(null); - const [pacienteTipo, setPacienteTipo] = useState<'ADULTO' | 'CRIANCA'>('ADULTO'); - const [showMixed, setShowMixed] = useState(false); - const [selectedDentes, setSelectedDentes] = useState([]); - const [selectedArco, setSelectedArco] = useState<'AI' | 'AS' | null>(null); - const [face, setFace] = useState(''); - const [obs, setObs] = useState(''); - const [patientSearchOpen, setPatientSearchOpen] = useState(false); - const [patientSearchTerm, setPatientSearchTerm] = useState(''); - - useEffect(() => { - const load = async () => { - const p = await HybridBackend.getPacientes(); - const d = await HybridBackend.getDentistas(); - const es = await HybridBackend.getEspecialidades(); - const pr = await HybridBackend.getProcedimentos(); - setPacientes(Array.isArray(p) ? p : (p?.data ?? [])); - setDentistas(d ?? []); - setEspecialidades(es ?? []); - setProcedimentos(pr ?? []); - }; - load(); - }, []); - - const filteredProcedimentos = useMemo(() => { - if (!selectedEsp) return []; - return procedimentos.filter(p => p.especialidadeId === selectedEsp); - }, [selectedEsp, procedimentos]); - - const filteredPatients = useMemo(() => { - if (!patientSearchTerm) return pacientes.slice(0, 30); - const term = normalize(patientSearchTerm); - return pacientes.filter(p => - normalize(p.nome).includes(term) || - (p.cpf && p.cpf.includes(patientSearchTerm.replace(/\D/g, ''))) - ).slice(0, 30); - }, [pacientes, patientSearchTerm]); // eslint-disable-line react-hooks/exhaustive-deps - - // Auto-seleciona paciente quando vem da agenda (pendingSearch) - useEffect(() => { - const { pendingSearch } = store; - if (!pendingSearch || pacientes.length === 0) return; - const term = normalize(pendingSearch); - const matches = pacientes.filter(p => { - const n = normalize(p.nome); - return n === term || n.includes(term) || term.includes(n); - }); - if (matches.length === 1) { - store.setPaciente(matches[0]); - } else if (matches.length > 1) { - setPatientSearchTerm(pendingSearch); - setPatientSearchOpen(true); - } else { - setPatientSearchOpen(true); - } - store.setPendingSearch(''); - }, [pacientes, store.pendingSearch]); // eslint-disable-line react-hooks/exhaustive-deps - - const toggleDente = (num: string) => { - if (selectedProc?.tipo_regiao !== 'DENTE') { - toast.error("ESTE PROCEDIMENTO NÃO PERMITE SELEÇÃO DE DENTE INDIVIDUAL."); - return; - } - setSelectedArco(null); - setSelectedDentes(prev => - prev.includes(num) ? prev.filter(d => d !== num) : [...prev, num] - ); - }; - - const selectArco = (arco: 'AI' | 'AS') => { - if (selectedProc?.tipo_regiao !== 'ARCO') { - toast.error("ESTE PROCEDIMENTO NÃO PERMITE SELEÇÃO DE ARCO."); - return; - } - setSelectedDentes([]); - setSelectedArco(arco); - }; - - const handleAddItem = () => { - if (!selectedProc) { - toast.error("SELECIONE UM PROCEDIMENTO PRIMEIRO."); - return; - } - - // Validação de Região - if (selectedProc.tipo_regiao === 'DENTE' && selectedDentes.length === 0) { - toast.error("ESTE PROCEDIMENTO EXIGE A SELEÇÃO DE AO MENOS UM DENTE."); - return; - } - - if (selectedProc.tipo_regiao === 'ARCO' && !selectedArco) { - toast.error("ESTE PROCEDIMENTO EXIGE A SELEÇÃO DE UM ARCO (AI/AS)."); - return; - } - - // Validação de Face - if (selectedProc.exige_face && (!face || face.trim() === '')) { - toast.error("A FACE É OBRIGATÓRIA PARA ESTE PROCEDIMENTO."); - return; - } - - const newItem: GTOItem = { - id: Math.random().toString(36).substring(7), - procedimentoId: selectedProc.id, - codigoTUSS: selectedProc.codigo || '000000', - codigoInterno: selectedProc.codigoInterno || '', - descricao: selectedProc.nome, - dente: selectedDentes.join(', '), - arco: selectedArco || undefined, - face: face, - quantidade: 1, - valorUnitario: selectedProc.valorParticular || 0, - valorTotal: selectedProc.valorParticular || 0, - observacao: obs, - }; - - store.addItem(newItem); - toast.success("ADICIONADO AO RASCUNHO."); - - // Reset selection fields - setSelectedDentes([]); - setSelectedArco(null); - setFace(''); - setObs(''); - }; - - const handleFinalize = async () => { - if (!store.paciente || !store.dentista || store.items.length === 0) { - toast.error("PACIENTE, DENTISTA E PELO MENOS 1 ITEM SÃO OBRIGATÓRIOS."); - return; - } - - try { - toast.success("GTO GERADA COM SUCESSO!"); - store.clear(); - setSelectedEsp(''); - setSelectedProc(null); - } catch { - toast.error("ERRO AO GERAR GTO."); - } - }; - - const totalGeral = store.items.reduce((sum, i) => sum + i.valorTotal, 0); - - const toggleMixed = () => { - if (showMixed) { - // Ao fechar: remove os dentes do tipo expandido da seleção - if (pacienteTipo === 'ADULTO') { - setSelectedDentes(prev => prev.filter(d => parseInt(d) < 50)); - } else { - setSelectedDentes(prev => prev.filter(d => parseInt(d) >= 50)); - } - } - setShowMixed(prev => !prev); - }; - - const renderDenteBtn = (num: number | string | null, keyIdx?: number) => { - if (num === null) return
; - const sStr = num.toString(); - const isDeciduo = parseInt(sStr) >= 50; - const isSelected = selectedDentes.includes(sStr); - const isDisabled = !!selectedProc && selectedProc.tipo_regiao !== 'DENTE'; - - return ( - - ); - }; - - return ( -
- - -
- - {/* LEFT PANEL: ODONTOGRAM & ITEMS */} -
- - {/* ODONTOGRAM BOX */} -
-
-
- - -
-
- - {/* Dynamic Odontogram Grid */} -
-
-
- -
- {/* Q1 & Q2 (SUPERIOR) */} -
- {pacienteTipo === 'ADULTO' && renderDenteBtn(DENTES_ADULTO.extras.q1)} -
- {(pacienteTipo === 'ADULTO' ? DENTES_ADULTO.q1 : DENTES_CRIANCA.q1).map((d, i) => renderDenteBtn(d, i))} -
-
-
-
- {(pacienteTipo === 'ADULTO' ? DENTES_ADULTO.q2 : DENTES_CRIANCA.q2).map((d, i) => renderDenteBtn(d, i))} -
- {pacienteTipo === 'ADULTO' && renderDenteBtn(DENTES_ADULTO.extras.q2)} -
- - {/* Q4 & Q3 (INFERIOR) */} -
- {pacienteTipo === 'ADULTO' && renderDenteBtn(DENTES_ADULTO.extras.q4)} -
- {(pacienteTipo === 'ADULTO' ? DENTES_ADULTO.q4 : DENTES_CRIANCA.q4).map((d, i) => renderDenteBtn(d, i))} -
-
-
-
- {(pacienteTipo === 'ADULTO' ? DENTES_ADULTO.q3 : DENTES_CRIANCA.q3).map((d, i) => renderDenteBtn(d, i))} -
- {pacienteTipo === 'ADULTO' && renderDenteBtn(DENTES_ADULTO.extras.q3)} -
-
-
- - {/* SEÇÃO MISTO — expande abaixo do grid principal */} - {showMixed && ( -
-

- {pacienteTipo === 'ADULTO' ? '— DENTES DECÍDUOS —' : '— DENTES PERMANENTES —'} -

-
-
-
-
- {/* Q1 & Q2 MISTO */} -
- {pacienteTipo === 'CRIANCA' && renderDenteBtn(DENTES_ADULTO.extras.q1)} -
- {(pacienteTipo === 'ADULTO' ? DENTES_CRIANCA.q1 : DENTES_ADULTO.q1).map((d, i) => renderDenteBtn(d, i))} -
-
-
-
- {(pacienteTipo === 'ADULTO' ? DENTES_CRIANCA.q2 : DENTES_ADULTO.q2).map((d, i) => renderDenteBtn(d, i))} -
- {pacienteTipo === 'CRIANCA' && renderDenteBtn(DENTES_ADULTO.extras.q2)} -
- {/* Q4 & Q3 MISTO */} -
- {pacienteTipo === 'CRIANCA' && renderDenteBtn(DENTES_ADULTO.extras.q4)} -
- {(pacienteTipo === 'ADULTO' ? DENTES_CRIANCA.q4 : DENTES_ADULTO.q4).map((d, i) => renderDenteBtn(d, i))} -
-
-
-
- {(pacienteTipo === 'ADULTO' ? DENTES_CRIANCA.q3 : DENTES_ADULTO.q3).map((d, i) => renderDenteBtn(d, i))} -
- {pacienteTipo === 'CRIANCA' && renderDenteBtn(DENTES_ADULTO.extras.q3)} -
-
-
-
- )} - - {/* BOTÃO EXPANSOR — dentição mista */} - - -
- - -
-
- - {/* RASCUNHO LIST */} -
-
- PROCEDIMENTOS DO RASCUNHO -
{store.items.length}/20 ITENS
-
- -
- {store.items.length === 0 && ( -
-
- -
- NÃO HÁ ITENS ADICIONADOS -
- )} - - {store.items.map((item) => ( -
-
-
-
- COD {item.codigoTUSS} -

{item.descricao}

-
-
- {store.paciente?.nome} -
-
- -
- -
-
DENTE: {item.dente || '--'}
-
ARCO: {item.arco || '--'}
-
FACE: {item.face || '--'}
-
VALOR: R$ {item.valorTotal.toFixed(2)}
-
- - {item.observacao && ( -
- OBS: {item.observacao} -
- )} -
- ))} - -
- -
-

TOTAL ACUMULADO

-

R$ {totalGeral.toFixed(2)}

-
-
-
-
-
- - {/* RIGHT PANEL: WORKFLOW SELECTORS */} -
- - {/* STEP 1: PACIENTE */} -
-
- 01. Paciente -
- {store.paciente ? ( -
-
-
- {store.paciente.nome.charAt(0)} -
- {store.paciente.nome} -
- -
- ) : ( - - )} -
- - {/* STEP 2: ESPECIALIDADE & PROCEDIMENTO */} -
-
- 02. Especialidade -
-
- - - {selectedEsp && ( -
- - - {selectedProc && ( -
-

REGRAS DE SELEÇÃO

-
- - - {selectedProc.tipo_regiao === 'DENTE' ? 'EXIGE DENTE(S)' : selectedProc.tipo_regiao === 'ARCO' ? 'EXIGE ARCO (AI/AS)' : 'PROCEDIMENTO GERAL'} - - {selectedProc.exige_face && ( - - EXIGE FACE CLINICA - - )} -
-
- )} -
- )} -
-
- - {/* STEP 3: DENTISTA */} -
-
- 03. Dentista Executor -
- -
- - {/* STEP 4: DETALHES (FACE/OBS) */} -
-
-
- - setFace(e.target.value.toUpperCase())} - disabled={selectedProc?.tipo_regiao === 'GERAL' && !selectedProc?.exige_face} - className={`w-full p-4 border-2 rounded-2xl text-sm font-black outline-none transition-all - ${selectedProc?.exige_face ? 'bg-red-50 border-red-100 focus:border-red-400' : 'bg-gray-50 border-gray-100 focus:border-amber-400'} - `} - placeholder={selectedProc?.exige_face ? "DIGITE AS FACES..." : "OPCIONAL"} - /> -
-
- -
-