feat(protese): Fase 2 frontend — área do Laboratório (painel + bancada)
Workspace tipo='laboratorio' ganha menu próprio (PAINEL DO LAB + BANCADA), isolado das telas clínicas — espelha o tratamento de tipo='sala'. lab-bancada reaproveita ProteseView (modo bancada); lab-home é o novo LabHomeView (fila por status, atrasadas, entregues e faturamento do mês a partir do que já existe). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+14
-1
@@ -31,6 +31,7 @@ import { ReportsView } from './views/ReportsView.tsx';
|
||||
import { ComissoesView } from './views/ComissoesView.tsx';
|
||||
import { GlosasView } from './views/GlosasView.tsx';
|
||||
import { SalaHomeView } from './views/SalaHomeView.tsx';
|
||||
import { LabHomeView } from './views/LabHomeView.tsx';
|
||||
import { ConfiguracoesView } from './views/ConfiguracoesView.tsx';
|
||||
import { OrcamentosView } from './views/OrcamentosView.tsx';
|
||||
import { ContratosView } from './views/ContratosView.tsx';
|
||||
@@ -84,6 +85,8 @@ export type ViewKey =
|
||||
| 'minhas-salas'
|
||||
| 'alugar-salas'
|
||||
| 'sala-home'
|
||||
| 'lab-home'
|
||||
| 'lab-bancada'
|
||||
| 'marketplace-profissionais'
|
||||
| 'criar-clinica'
|
||||
| 'rx-config'
|
||||
@@ -134,6 +137,8 @@ const ROUTE_MAP: Record<string, ViewKey> = {
|
||||
'/minhas-salas': 'minhas-salas',
|
||||
'/alugar-salas': 'alugar-salas',
|
||||
'/sala-home': 'sala-home',
|
||||
'/lab-home': 'lab-home',
|
||||
'/lab-bancada': 'lab-bancada',
|
||||
'/marketplace-profissionais': 'marketplace-profissionais',
|
||||
'/criar-clinica': 'criar-clinica',
|
||||
'/rx-config': 'rx-config',
|
||||
@@ -189,6 +194,8 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
||||
'minhas-salas': '/minhas-salas',
|
||||
'alugar-salas': '/alugar-salas',
|
||||
'sala-home': '/sala-home',
|
||||
'lab-home': '/lab-home',
|
||||
'lab-bancada': '/lab-bancada',
|
||||
'marketplace-profissionais': '/marketplace-profissionais',
|
||||
'criar-clinica': '/criar-clinica',
|
||||
'rx-config': '/rx-config',
|
||||
@@ -287,6 +294,8 @@ const App: React.FC = () => {
|
||||
if (view === 'salas' || view === 'minhas-salas' || view === 'alugar-salas') return isPluginActive('locacao-salas');
|
||||
// Contexto de SALA: isola da clínica — só painel da sala/salas/conta/notificações.
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return ['sala-home', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
|
||||
// Contexto de LABORATÓRIO: área do protético — painel do lab + bancada/conta/notificações.
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
|
||||
// Catálogo de PLUGINS: EXCLUSIVO do superadmin (já retorna true acima); ninguém mais.
|
||||
if (view === 'plugins') return false;
|
||||
// Admin/donos têm acesso amplo, MAS as telas de superadmin são exclusivas do superadmin.
|
||||
@@ -305,7 +314,7 @@ const App: React.FC = () => {
|
||||
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
|
||||
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
|
||||
biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'lab-home', 'lab-bancada', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
|
||||
};
|
||||
|
||||
@@ -316,6 +325,8 @@ const App: React.FC = () => {
|
||||
if (role === 'superadmin') return 'superadmin-overview';
|
||||
// Contexto de SALA (workspace tipo='sala') sempre cai no painel da sala.
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return 'sala-home';
|
||||
// Contexto de LABORATÓRIO (workspace tipo='laboratorio') cai no painel do lab.
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return 'lab-home';
|
||||
if (role === 'paciente') return 'tratamentos';
|
||||
if (role === 'donosala') return 'minhas-salas';
|
||||
return 'dashboard';
|
||||
@@ -476,6 +487,8 @@ const App: React.FC = () => {
|
||||
case 'minhas-salas': return <SalasPlugin view="minhas" />;
|
||||
case 'alugar-salas': return <SalasPlugin view="alugar" />;
|
||||
case 'sala-home': return <SalaHomeView />;
|
||||
case 'lab-home': return <LabHomeView />;
|
||||
case 'lab-bancada': return <ProteseView />;
|
||||
case 'marketplace-profissionais': return <ProfissionaisPlugin />;
|
||||
case 'criar-clinica': return <CreateClinicView onCreated={() => { window.location.reload(); }} />;
|
||||
case 'rx-config': return <RxConfigView />;
|
||||
|
||||
@@ -61,6 +61,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
{ id: 'superadmin-financeiro', label: 'FINANCEIRO', icon: DollarSign },
|
||||
{ id: 'superadmin-notificacoes', label: 'NOTIFICAÇÕES', icon: Bell },
|
||||
{ id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen },
|
||||
{ id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
|
||||
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
|
||||
{ id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard },
|
||||
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
|
||||
{ id: 'pacientes', label: 'PACIENTES', icon: Users },
|
||||
@@ -97,11 +99,17 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
|
||||
// PAINEL DA SALA só existe quando o workspace ativo é uma sala.
|
||||
if (item.id === 'sala-home') return (activeWorkspace as any)?.tipo === 'sala';
|
||||
// PAINEL DO LAB / BANCADA só existem no workspace de laboratório.
|
||||
if (item.id === 'lab-home' || item.id === 'lab-bancada') return (activeWorkspace as any)?.tipo === 'laboratorio';
|
||||
|
||||
// Contexto de SALA (workspace tipo='sala'): isola da clínica — menu base só conta/notificações.
|
||||
// (MINHAS SALAS / ALUGAR SALAS são itens de plugin, mostrados à parte.)
|
||||
if ((activeWorkspace as any)?.tipo === 'sala') return ['sala-home', 'notificacoes', 'configuracoes'].includes(item.id);
|
||||
|
||||
// Contexto de LABORATÓRIO (workspace tipo='laboratorio'): área própria do protético —
|
||||
// painel do lab + bancada de produção, sem as telas clínicas da clínica.
|
||||
if ((activeWorkspace as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id);
|
||||
|
||||
// plugins e gestão de tutores: exclusivo superadmin
|
||||
if (item.id === 'plugins' || item.id === 'admin-gestao-tutores') return false;
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
|
||||
// Painel do LABORATÓRIO (protético): aparece quando o workspace ativo é tipo='laboratorio'.
|
||||
// Resumo a partir do que já existe — a Bancada (OS) + o financeiro do workspace do lab.
|
||||
export const LabHomeView: React.FC = () => {
|
||||
const ws: any = HybridBackend.getActiveWorkspace();
|
||||
const [os, setOs] = useState<any[]>([]);
|
||||
const [fin, setFin] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [b, f] = await Promise.all([HybridBackend.getProteseBancada(), HybridBackend.getFinanceiro()]);
|
||||
setOs(Array.isArray(b) ? b : []);
|
||||
setFin(Array.isArray(f) ? f : []);
|
||||
} catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const ativas = os.filter(o => o.status !== 'entregue' && o.status !== 'cancelado');
|
||||
const atrasadas = os.filter(o => o.atrasada).length;
|
||||
const ymMes = new Date().toISOString().slice(0, 7);
|
||||
const entreguesMes = os.filter(o => o.status === 'entregue' && (o.updated_at || '').slice(0, 7) === ymMes).length;
|
||||
// Faturamento do mês: RECEITA de prótese lançada no workspace do lab (espelho da entrega).
|
||||
const fatMes = fin
|
||||
.filter(l => l.origem === 'protese' && (l.data_competencia || l.datavencimento || '').slice(0, 7) === ymMes)
|
||||
.reduce((s, l) => s + Number(l.valor || 0), 0);
|
||||
|
||||
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any; sub?: string }> = ({ titulo, valor, cor, Icon, sub }) => (
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
|
||||
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
|
||||
{sub && <p className="mt-2 text-[10px] font-bold text-gray-400 uppercase">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Distribuição por status (fila de produção).
|
||||
const STATUS_ORD = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
|
||||
const STATUS_LABEL: Record<string, string> = { solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Produção', prova: 'Prova', ajuste: 'Ajuste', finalizado: 'Finalizado' };
|
||||
const porStatus = STATUS_ORD.map(s => ({ s, n: ativas.filter(o => o.status === s).length })).filter(x => x.n > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<PageHeader title="PAINEL DO LABORATÓRIO">
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold transition-all shadow-sm">
|
||||
<RefreshCw size={16} /> <span className="hidden sm:inline">ATUALIZAR</span>
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm px-6 py-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-white shrink-0" style={{ backgroundColor: '#2d6a4f' }}><FlaskConical size={20} /></div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-black text-gray-900 uppercase truncate">{ws?.nome || 'Laboratório'}</p>
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase">Área de gestão do protético</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPI titulo="Na bancada" valor={String(ativas.length)} cor="text-teal-600" Icon={Inbox} sub="OS em produção" />
|
||||
<KPI titulo="Atrasadas" valor={String(atrasadas)} cor="text-red-500" Icon={AlertTriangle} sub="Prazo vencido" />
|
||||
<KPI titulo="Entregues no mês" valor={String(entreguesMes)} cor="text-green-600" Icon={CheckCircle2} sub="Concluídas" />
|
||||
<KPI titulo="Faturamento do mês" valor={fmtBRL(fatMes)} cor="text-violet-600" Icon={Wallet} sub="Receita de prótese" />
|
||||
</div>
|
||||
|
||||
{/* Fila por status */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2">
|
||||
<Clock size={16} className="text-teal-600" />
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase">Fila de produção</h3>
|
||||
</div>
|
||||
{porStatus.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhuma OS em produção.</div>
|
||||
) : (
|
||||
<div className="p-4 flex flex-wrap gap-3">
|
||||
{porStatus.map(({ s, n }) => (
|
||||
<div key={s} className="px-4 py-3 rounded-xl bg-gray-50 border border-gray-100 min-w-[120px]">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{STATUS_LABEL[s]}</p>
|
||||
<p className="text-2xl font-black text-gray-800">{n}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Próximas entregas */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase">Próximas entregas</h3>
|
||||
</div>
|
||||
{ativas.filter(o => o.prazo_entrega).length === 0 ? (
|
||||
<div className="py-10 text-center text-gray-400 text-sm font-bold uppercase">Sem prazos agendados.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-gray-50/60"><tr>{['Paciente', 'Tipo', 'Prazo', 'Status', 'Valor'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">{h}</th>)}</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{ativas.filter(o => o.prazo_entrega).sort((a, b) => (a.prazo_entrega || '').localeCompare(b.prazo_entrega || '')).slice(0, 12).map(o => (
|
||||
<tr key={o.id} className={`hover:bg-gray-50/50 ${o.atrasada ? 'bg-red-50/40' : ''}`}>
|
||||
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{o.paciente_nome || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 uppercase">{o.tipo || '—'}</td>
|
||||
<td className={`px-4 py-3 whitespace-nowrap font-bold ${o.atrasada ? 'text-red-600' : 'text-gray-500'}`}>{(o.prazo_entrega || '').slice(0, 10).split('-').reverse().join('/')}</td>
|
||||
<td className="px-4 py-3"><span className="px-2 py-0.5 rounded-full text-[9px] font-black uppercase bg-teal-100 text-teal-700">{STATUS_LABEL[o.status] || o.status}</span></td>
|
||||
<td className="px-4 py-3 font-black text-gray-800">{fmtBRL(o.custo)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user