Compare commits

...

4 Commits

Author SHA1 Message Date
e770826fb6 fix(auth): merge profile and persist to localStorage
- Impact: prevents profile loss on reload
chore(assignment): add professional assignment form
- Impact: enables assigning professionals to patients via UI
2025-10-11 23:04:08 -03:00
João Gustavo
8bf953a689 add-assignament-endpoints 2025-10-11 19:20:26 -03:00
João Gustavo
52c3e544f3 Merge branch 'feature/add-duties-enpoints' into feature/patiente-medical-assignment 2025-10-11 18:53:49 -03:00
João Gustavo
535dfa0503 loging-medical-page 2025-10-11 18:43:41 -03:00
8 changed files with 268 additions and 20 deletions

View File

@ -317,6 +317,16 @@ export default function PacientesPage() {
</Dialog>
)}
{/* Assignment dialog */}
{assignDialogOpen && assignPatientId && (
<AssignmentForm
patientId={assignPatientId}
open={assignDialogOpen}
onClose={() => { setAssignDialogOpen(false); setAssignPatientId(null); }}
onSaved={() => { setAssignDialogOpen(false); setAssignPatientId(null); loadAll(); }}
/>
)}
<div className="text-sm text-muted-foreground">Mostrando {filtered.length} de {patients.length}</div>
</div>
);

View File

@ -737,23 +737,45 @@ const ProfissionalPage = () => {
);
}
// carregar laudos ao montar
// carregar laudos ao montar - somente dos pacientes atribuídos ao médico logado
useEffect(() => {
let mounted = true;
(async () => {
try {
await loadReports();
// obter assignments para o usuário logado
const assignments = await import('@/lib/assignment').then(m => m.listAssignmentsForUser(user?.id || ''));
const patientIds = Array.isArray(assignments) ? assignments.map(a => String(a.patient_id)).filter(Boolean) : [];
if (patientIds.length === 0) {
if (mounted) setLaudos([]);
return;
}
// carregar relatórios para cada paciente encontrado (useReports não tem batch by multiple ids, então carregamos por paciente)
const allReports: any[] = [];
for (const pid of patientIds) {
try {
const rels = await import('@/lib/reports').then(m => m.listarRelatoriosPorPaciente(pid));
if (Array.isArray(rels)) allReports.push(...rels);
} catch (err) {
console.warn('[LaudoManager] falha ao carregar relatórios para paciente', pid, err);
}
}
if (mounted) {
setLaudos(allReports);
}
} catch (e) {
// erro tratado no hook
console.warn('[LaudoManager] erro ao carregar laudos para pacientes atribuídos:', e);
if (mounted) setLaudos(reports || []);
}
if (mounted) setLaudos(reports || []);
})();
return () => { mounted = false; };
}, [loadReports]);
}, [user?.id]);
// sincroniza quando reports mudarem no hook
// sincroniza quando reports mudarem no hook (fallback)
useEffect(() => {
setLaudos(reports || []);
if (!laudos || laudos.length === 0) setLaudos(reports || []);
}, [reports]);
const [activeTab, setActiveTab] = useState("descobrir");

View File

@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { assignRoleToUser, listAssignmentsForPatient } from "@/lib/assignment";
import { assignRoleToUser, listAssignmentsForPatient, PatientAssignmentRole } from "@/lib/assignment";
import { listarProfissionais } from "@/lib/api";
type Props = {
@ -22,7 +22,8 @@ export default function AssignmentForm({ patientId, open, onClose, onSaved }: Pr
const { toast } = useToast();
const [professionals, setProfessionals] = useState<any[]>([]);
const [selectedProfessional, setSelectedProfessional] = useState<string | null>(null);
const [role, setRole] = useState<string>("doctor");
// default to Portuguese role values expected by the backend
const [role, setRole] = useState<PatientAssignmentRole>("medico");
const [loading, setLoading] = useState(false);
const [existing, setExisting] = useState<any[]>([]);
@ -48,11 +49,11 @@ export default function AssignmentForm({ patientId, open, onClose, onSaved }: Pr
}, [open, patientId]);
async function handleSave() {
if (!selectedProfessional) return toast({ title: 'Selecione um profissional', variant: 'warning' });
if (!selectedProfessional) return toast({ title: 'Selecione um profissional', variant: 'default' });
setLoading(true);
try {
await assignRoleToUser({ patient_id: patientId, user_id: selectedProfessional, role });
toast({ title: 'Atribuição criada', variant: 'success' });
await assignRoleToUser({ patient_id: patientId, user_id: selectedProfessional, role });
toast({ title: 'Atribuição criada', variant: 'default' });
onSaved && onSaved();
onClose();
} catch (err: any) {
@ -85,11 +86,7 @@ export default function AssignmentForm({ patientId, open, onClose, onSaved }: Pr
</Select>
</div>
<div>
<Label>Role</Label>
<Input value={role} onChange={(e) => setRole(e.target.value)} />
<div className="text-xs text-muted-foreground mt-1">Ex: doctor, nurse</div>
</div>
{/* role input removed - only professional select remains; role defaults to 'medico' on submit */}
{existing && existing.length > 0 && (
<div>

View File

@ -2,6 +2,7 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useMemo, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { loginUser, logoutUser, AuthenticationError } from '@/lib/auth'
import { getUserInfo } from '@/lib/api'
import { ENV_CONFIG } from '@/lib/env-config'
import { isExpired, parseJwt } from '@/lib/jwt'
import { httpClient } from '@/lib/http'
@ -131,6 +132,35 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Restaurar sessão válida
const userData = JSON.parse(storedUser) as UserData
setToken(storedToken)
// Tentar buscar profile consolidado (user-info) e mesclar
try {
const info = await getUserInfo()
if (info?.profile) {
const mapped = {
cpf: (info.profile as any).cpf ?? userData.profile?.cpf,
crm: (info.profile as any).crm ?? userData.profile?.crm,
telefone: info.profile.phone ?? userData.profile?.telefone,
foto_url: info.profile.avatar_url ?? userData.profile?.foto_url,
}
if (userData.profile) {
userData.profile = { ...userData.profile, ...mapped }
} else {
userData.profile = mapped
}
// Persistir o usuário atualizado no localStorage para evitar
// que 'auth_user.profile' fique vazio após um reload completo
try {
if (typeof window !== 'undefined') {
localStorage.setItem(AUTH_STORAGE_KEYS.USER, JSON.stringify(userData))
}
} catch (e) {
console.warn('[AUTH] Falha ao persistir user (profile) no localStorage:', e)
}
}
} catch (err) {
console.warn('[AUTH] Falha ao buscar user-info na restauração de sessão:', err)
}
setUser(userData)
setAuthStatus('authenticated')
@ -195,6 +225,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.warn('[AUTH] Erro ao buscar user-info após login (não crítico):', err)
}
// Após login, tentar buscar profile consolidado e mesclar antes de persistir
try {
const info = await getUserInfo()
if (info?.profile && response.user) {
const mapped = {
cpf: (info.profile as any).cpf ?? response.user.profile?.cpf,
crm: (info.profile as any).crm ?? response.user.profile?.crm,
telefone: info.profile.phone ?? response.user.profile?.telefone,
foto_url: info.profile.avatar_url ?? response.user.profile?.foto_url,
}
if (response.user.profile) {
response.user.profile = { ...response.user.profile, ...mapped }
} else {
response.user.profile = mapped
}
}
} catch (err) {
console.warn('[AUTH] Falha ao buscar user-info após login (não crítico):', err)
}
saveAuthData(
response.access_token,
response.user,

View File

@ -838,6 +838,12 @@ export async function buscarMedicosPorIds(ids: Array<string | number>): Promise<
return unique;
}
// Alias/backwards-compat: listarProfissionais usado por components
export async function listarProfissionais(params?: { page?: number; limit?: number; q?: string; }): Promise<Medico[]> {
// Reuse listarMedicos implementation to avoid duplication
return await listarMedicos(params);
}
// Dentro de lib/api.ts
export async function criarMedico(input: MedicoInput): Promise<Medico> {
console.log("Enviando os dados para a API:", input); // Log para depuração

View File

@ -78,7 +78,9 @@ export async function assignRoleToUser(input: CreateAssignmentInput): Promise<Pa
statusText: response.statusText,
body: errorBody,
});
throw new Error(`Erro ao atribuir função: ${response.statusText} (${response.status})`);
// Include body (when available) to help debugging (e.g., constraint violations)
const bodySnippet = errorBody ? ` - body: ${errorBody}` : '';
throw new Error(`Erro ao atribuir função: ${response.statusText} (${response.status})${bodySnippet}`);
}
const createdAssignment = await response.json();
@ -131,3 +133,52 @@ export async function listAssignmentsForPatient(patientId: string): Promise<Pati
throw error;
}
}
/**
* Lista todas as atribuições para um dado usuário (médico/enfermeiro).
* Útil para obter os patient_id dos pacientes atribuídos ao usuário.
*/
export async function listAssignmentsForUser(userId: string): Promise<PatientAssignment[]> {
console.log(`🔍 [ASSIGNMENT] Listando atribuições para o usuário: ${userId}`);
const url = `${ASSIGNMENTS_URL}?user_id=eq.${userId}`;
try {
const headers = getHeaders();
console.debug('[ASSIGNMENT] GET', url, 'headers(masked)=', {
...headers,
Authorization: headers.Authorization ? '<<masked>>' : undefined,
});
const response = await fetch(url, {
method: 'GET',
headers,
});
// dump raw text for debugging when content-type isn't JSON or when empty
const contentType = response.headers.get('content-type') || '';
const txt = await response.clone().text().catch(() => '');
console.debug('[ASSIGNMENT] response status=', response.status, response.statusText, 'content-type=', contentType, 'bodyPreview=', txt ? (txt.length > 1000 ? txt.slice(0,1000) + '...[truncated]' : txt) : '<empty>');
if (!response.ok) {
const errorBody = txt || '';
console.error("❌ [ASSIGNMENT] Erro ao listar atribuições por usuário:", {
status: response.status,
statusText: response.statusText,
body: errorBody,
});
throw new Error(`Erro ao listar atribuições por usuário: ${response.status} ${response.statusText} - body: ${errorBody}`);
}
let assignments: any = [];
try {
assignments = await response.json();
} catch (e) {
console.warn('[ASSIGNMENT] não foi possível parsear JSON, usando texto cru como fallback');
assignments = txt ? JSON.parse(txt) : [];
}
console.log(`✅ [ASSIGNMENT] ${Array.isArray(assignments) ? assignments.length : 0} atribuições encontradas para o usuário.`);
return Array.isArray(assignments) ? assignments : [];
} catch (error) {
console.error("❌ [ASSIGNMENT] Erro inesperado ao listar atribuições por usuário:", error);
throw error;
}
}

View File

@ -43,6 +43,7 @@
"@radix-ui/react-toggle": "latest",
"@radix-ui/react-toggle-group": "latest",
"@radix-ui/react-tooltip": "latest",
"@supabase/supabase-js": "^2.75.0",
"@vercel/analytics": "1.3.1",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
@ -70,6 +71,7 @@
"zod": "3.25.67"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@eslint/js": "^9.36.0",
"@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22",
@ -86,7 +88,6 @@
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5",
"typescript-eslint": "^8.45.0",
"@eslint/eslintrc": "^3"
"typescript-eslint": "^8.45.0"
}
}

View File

@ -107,6 +107,9 @@ importers:
'@radix-ui/react-tooltip':
specifier: latest
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@supabase/supabase-js':
specifier: ^2.75.0
version: 2.75.0
'@vercel/analytics':
specifier: 1.3.1
version: 1.3.1(next@15.5.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
@ -1230,6 +1233,28 @@ packages:
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@supabase/auth-js@2.75.0':
resolution: {integrity: sha512-J8TkeqCOMCV4KwGKVoxmEBuDdHRwoInML2vJilthOo7awVCro2SM+tOcpljORwuBQ1vHUtV62Leit+5wlxrNtw==}
'@supabase/functions-js@2.75.0':
resolution: {integrity: sha512-18yk07Moj/xtQ28zkqswxDavXC3vbOwt1hDuYM3/7xPnwwpKnsmPyZ7bQ5th4uqiJzQ135t74La9tuaxBR6e7w==}
'@supabase/node-fetch@2.6.15':
resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==}
engines: {node: 4.x || >=6.0.0}
'@supabase/postgrest-js@2.75.0':
resolution: {integrity: sha512-YfBz4W/z7eYCFyuvHhfjOTTzRrQIvsMG2bVwJAKEVVUqGdzqfvyidXssLBG0Fqlql1zJFgtsPpK1n4meHrI7tg==}
'@supabase/realtime-js@2.75.0':
resolution: {integrity: sha512-B4Xxsf2NHd5cEnM6MGswOSPSsZKljkYXpvzKKmNxoUmNQOfB7D8HOa6NwHcUBSlxcjV+vIrYKcYXtavGJqeGrw==}
'@supabase/storage-js@2.75.0':
resolution: {integrity: sha512-wpJMYdfFDckDiHQaTpK+Ib14N/O2o0AAWWhguKvmmMurB6Unx17GGmYp5rrrqCTf8S1qq4IfIxTXxS4hzrUySg==}
'@supabase/supabase-js@2.75.0':
resolution: {integrity: sha512-8UN/vATSgS2JFuJlMVr51L3eUDz+j1m7Ww63wlvHLKULzCDaVWYzvacCjBTLW/lX/vedI2LBI4Vg+01G9ufsJQ==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@ -1366,6 +1391,9 @@ packages:
'@types/pako@2.0.4':
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
'@types/phoenix@1.6.6':
resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
@ -1392,6 +1420,9 @@ packages:
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.45.0':
resolution: {integrity: sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -3117,6 +3148,9 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
trim-canvas@0.1.2:
resolution: {integrity: sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==}
@ -3223,6 +3257,12 @@ packages:
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@ -3248,6 +3288,18 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
@ -4240,6 +4292,48 @@ snapshots:
'@standard-schema/utils@0.3.0': {}
'@supabase/auth-js@2.75.0':
dependencies:
'@supabase/node-fetch': 2.6.15
'@supabase/functions-js@2.75.0':
dependencies:
'@supabase/node-fetch': 2.6.15
'@supabase/node-fetch@2.6.15':
dependencies:
whatwg-url: 5.0.0
'@supabase/postgrest-js@2.75.0':
dependencies:
'@supabase/node-fetch': 2.6.15
'@supabase/realtime-js@2.75.0':
dependencies:
'@supabase/node-fetch': 2.6.15
'@types/phoenix': 1.6.6
'@types/ws': 8.18.1
ws: 8.18.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@supabase/storage-js@2.75.0':
dependencies:
'@supabase/node-fetch': 2.6.15
'@supabase/supabase-js@2.75.0':
dependencies:
'@supabase/auth-js': 2.75.0
'@supabase/functions-js': 2.75.0
'@supabase/node-fetch': 2.6.15
'@supabase/postgrest-js': 2.75.0
'@supabase/realtime-js': 2.75.0
'@supabase/storage-js': 2.75.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@ -4357,6 +4451,8 @@ snapshots:
'@types/pako@2.0.4': {}
'@types/phoenix@1.6.6': {}
'@types/prop-types@15.7.15': {}
'@types/quill@1.3.10':
@ -4382,6 +4478,10 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.18.5
'@typescript-eslint/eslint-plugin@8.45.0(@typescript-eslint/parser@8.45.0(eslint@9.36.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.36.0(jiti@2.5.1))(typescript@5.9.2)':
dependencies:
'@eslint-community/regexpp': 4.12.1
@ -6328,6 +6428,8 @@ snapshots:
dependencies:
is-number: 7.0.0
tr46@0.0.3: {}
trim-canvas@0.1.2: {}
ts-api-utils@2.1.0(typescript@5.9.2):
@ -6488,6 +6590,13 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@ -6535,6 +6644,8 @@ snapshots:
word-wrap@1.2.5: {}
ws@8.18.3: {}
yallist@5.0.0: {}
yocto-queue@0.1.0: {}