fix(ui): finance Odoo integration + dashboard drag/resize widgets
Finance: - Query last 90 days instead of current month only for broader data capture - Add fallback fields: payment_method_id, payment_type, communication - Add clear Live Odoo / Demo Data / Error indicator badges - Add refresh button and explicit demo-data toggle - Show error banner when Odoo connection fails - Unified rendering: same layout for Odoo and demo data, empty state when no data - Remove confusing conditional layout switching between Odoo vs mock Dashboard Widgets: - Replace CSS grid with react-grid-layout Responsive grid - All static widgets and channel widgets are now draggable and resizable - Layouts persist to localStorage (synq_dashboard_layouts) - Default layouts auto-generated for new/missing widgets - Drag handle on widget headers, resize handles on corners - Keep greeting/system health/quick actions as fixed top section Build: tsc + vite build pass clean
This commit is contained in:
parent
bd42add952
commit
45518b4bb4
3 changed files with 685 additions and 609 deletions
|
|
@ -1,11 +1,14 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Responsive, useContainerWidth } from 'react-grid-layout';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import {
|
||||
Plus, X, Sparkles, Send, CheckCircle2, ArrowRight,
|
||||
UserPlus, Calendar, Upload, AlertTriangle, Phone, MessageSquare,
|
||||
Brain, TrendingUp, DollarSign, Users,
|
||||
ChevronRight, Image as ImageIcon, FileText,
|
||||
Stethoscope, Bell,
|
||||
Stethoscope, Bell, GripVertical,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||
import { Button } from '../components/ui/button';
|
||||
|
|
@ -81,27 +84,60 @@ interface StaticWidgetDef {
|
|||
label: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
defaultW: number;
|
||||
defaultH: number;
|
||||
}
|
||||
|
||||
const STATIC_WIDGETS: StaticWidgetDef[] = [
|
||||
{ id: 'schedule-preview', label: "Today's Schedule", icon: Calendar, color: '#60a5fa' },
|
||||
{ id: 'triage', label: 'Post-Op Triage', icon: Stethoscope, color: '#fb7185' },
|
||||
{ id: 'communications', label: 'Communications', icon: MessageSquare, color: '#60a5fa' },
|
||||
{ id: 'memory', label: 'Memory Insights', icon: Brain, color: '#a78bfa' },
|
||||
{ id: 'team', label: "Who's Working", icon: Users, color: '#34d399' },
|
||||
{ id: 'revenue', label: 'Monthly Revenue', icon: TrendingUp, color: '#60a5fa' },
|
||||
{ id: 'payments', label: 'Payment Collection', icon: DollarSign, color: '#34d399' },
|
||||
{ id: 'messages', label: 'Messages', icon: Bell, color: '#60a5fa' },
|
||||
{ id: 'activity', label: 'Recent Activity', icon: CheckCircle2, color: '#60a5fa' },
|
||||
{ id: 'quick-links', label: 'Quick Links', icon: ArrowRight, color: '#60a5fa' },
|
||||
{ id: 'schedule-preview', label: "Today's Schedule", icon: Calendar, color: '#60a5fa', defaultW: 6, defaultH: 5 },
|
||||
{ id: 'triage', label: 'Post-Op Triage', icon: Stethoscope, color: '#fb7185', defaultW: 6, defaultH: 5 },
|
||||
{ id: 'communications', label: 'Communications', icon: MessageSquare, color: '#60a5fa', defaultW: 6, defaultH: 5 },
|
||||
{ id: 'memory', label: 'Memory Insights', icon: Brain, color: '#a78bfa', defaultW: 6, defaultH: 5 },
|
||||
{ id: 'team', label: "Who's Working", icon: Users, color: '#34d399', defaultW: 4, defaultH: 4 },
|
||||
{ id: 'revenue', label: 'Monthly Revenue', icon: TrendingUp, color: '#60a5fa', defaultW: 4, defaultH: 4 },
|
||||
{ id: 'payments', label: 'Payment Collection', icon: DollarSign, color: '#34d399', defaultW: 4, defaultH: 4 },
|
||||
{ id: 'messages', label: 'Messages', icon: Bell, color: '#60a5fa', defaultW: 4, defaultH: 4 },
|
||||
{ id: 'activity', label: 'Recent Activity', icon: CheckCircle2, color: '#60a5fa', defaultW: 4, defaultH: 4 },
|
||||
{ id: 'quick-links', label: 'Quick Links', icon: ArrowRight, color: '#60a5fa', defaultW: 4, defaultH: 4 },
|
||||
];
|
||||
|
||||
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
|
||||
const COLS = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 };
|
||||
const ROW_HEIGHT = 70;
|
||||
const LAYOUT_KEY = 'synq_dashboard_layouts';
|
||||
|
||||
function getDefaultLayout(visibleIds: string[]): { lg: any[] } {
|
||||
const layouts: any[] = [];
|
||||
let currentX = 0;
|
||||
let currentY = 0;
|
||||
let rowHeight = 0;
|
||||
|
||||
for (const id of visibleIds) {
|
||||
const def = STATIC_WIDGETS.find((w) => w.id === id);
|
||||
const w = def ? def.defaultW : 4;
|
||||
const h = def ? def.defaultH : 4;
|
||||
|
||||
if (currentX + w > 12) {
|
||||
currentY += rowHeight;
|
||||
currentX = 0;
|
||||
rowHeight = 0;
|
||||
}
|
||||
|
||||
layouts.push({ i: id, x: currentX, y: currentY, w, h, minW: 2, minH: 3 });
|
||||
currentX += w;
|
||||
rowHeight = Math.max(rowHeight, h);
|
||||
}
|
||||
|
||||
return { lg: layouts };
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { channels, visibleChannels, toggleCollapse, toggleVisibility } = useChannels();
|
||||
const { profile } = useUserProfile();
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
const { width, containerRef, mounted } = useContainerWidth({ measureBeforeMount: true });
|
||||
|
||||
const [commandText, setCommandText] = useState('');
|
||||
const [greeting, setGreeting] = useState('Good morning');
|
||||
|
|
@ -114,6 +150,15 @@ export function DashboardPage() {
|
|||
}
|
||||
});
|
||||
|
||||
const [layouts, setLayouts] = useState<{ lg: any[] }>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(LAYOUT_KEY);
|
||||
return saved ? JSON.parse(saved) : { lg: [] };
|
||||
} catch {
|
||||
return { lg: [] };
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setGreeting(getGreeting());
|
||||
}, []);
|
||||
|
|
@ -190,76 +235,377 @@ export function DashboardPage() {
|
|||
'Pull up acne scar photos',
|
||||
];
|
||||
|
||||
const StaticCard = ({ id, children }: { id: string; children: React.ReactNode }) => (
|
||||
<Card className="relative group">
|
||||
<button
|
||||
onClick={() => toggleStatic(id)}
|
||||
className="absolute top-2 right-2 z-10 p-1 rounded-md text-muted-foreground/0 group-hover:text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||
title="Hide widget"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
// Build effective layouts: merge saved with defaults for any new/missing items
|
||||
const visibleIds = Array.from(staticVisible).concat(visibleChannels.map((c) => c.id));
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="grid gap-5 p-5" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(380px, 1fr))', gridAutoRows: 'minmax(200px, auto)' }}>
|
||||
{/* Greeting + System Health — full width */}
|
||||
<div className="col-span-full space-y-4">
|
||||
{/* AI Command Center */}
|
||||
<Card className="border-border bg-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-[var(--beam-scientist-primary)]/10 flex items-center justify-center">
|
||||
<Brain className="w-6 h-6 text-[var(--beam-scientist-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">
|
||||
{greeting}{userName}. How can I help you today?
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
{new Date().toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
const effectiveLayouts = (() => {
|
||||
let base = layouts.lg.length > 0 ? layouts.lg : getDefaultLayout(visibleIds).lg;
|
||||
// Remove hidden
|
||||
base = base.filter((l: any) => visibleIds.includes(l.i));
|
||||
// Add missing
|
||||
const existingIds = new Set(base.map((l: any) => l.i));
|
||||
const defaults = getDefaultLayout(visibleIds).lg;
|
||||
for (const id of visibleIds) {
|
||||
if (!existingIds.has(id)) {
|
||||
const def = defaults.find((d: any) => d.i === id);
|
||||
if (def) base.push(def);
|
||||
}
|
||||
}
|
||||
return { lg: base };
|
||||
})();
|
||||
|
||||
const onLayoutChange = (_currentLayout: any, allLayouts: any) => {
|
||||
setLayouts(allLayouts);
|
||||
try {
|
||||
localStorage.setItem(LAYOUT_KEY, JSON.stringify(allLayouts));
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
// ─── Renderers for static widgets ───
|
||||
const renderStaticWidget = (id: string) => {
|
||||
switch (id) {
|
||||
case 'schedule-preview':
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Today's Schedule
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={() => navigate('/schedule')}>
|
||||
View All <ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2">
|
||||
<Sparkles className="w-5 h-5 text-[var(--beam-scientist-primary)]" />
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Ask Beam anything..."
|
||||
className="pl-10 pr-12 py-6 text-base bg-accent/50 border-border focus:bg-card"
|
||||
value={commandText}
|
||||
onChange={(e) => setCommandText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCommand()}
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<Button size="sm" onClick={handleCommand}>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 flex-1 overflow-auto">
|
||||
{todayAppointments.map((apt, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 p-2 rounded-lg hover:bg-accent/50 transition-colors cursor-pointer" onClick={() => navigate('/schedule')}>
|
||||
<div className={`w-2 h-2 rounded-full ${apt.status === 'confirmed' ? 'bg-blue-500' : apt.status === 'checked-in' ? 'bg-green-500' : apt.status === 'roomed' ? 'bg-purple-500' : 'bg-amber-500'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{apt.patient}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{apt.procedure}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-medium text-[var(--text-primary)]">{apt.time}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{apt.provider}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
case 'triage':
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Stethoscope className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Post-Op Triage
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={() => navigate('/triage')}>
|
||||
View All <ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{quickCommands.map((cmd) => (
|
||||
<button
|
||||
key={cmd}
|
||||
onClick={() => { setCommandText(cmd); handleCommand(); }}
|
||||
className="text-xs px-3 py-1.5 rounded-full bg-[#eff6ff] text-[var(--beam-scientist-primary)] hover:bg-[#dbeafe] transition-colors"
|
||||
>
|
||||
{cmd}
|
||||
</button>
|
||||
))}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 flex-1 overflow-auto">
|
||||
{triagePatients.map((patient, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 p-2 rounded-lg hover:bg-accent/50 transition-colors cursor-pointer" onClick={() => navigate('/triage')}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${patient.status === 'normal' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>
|
||||
D{patient.day}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{patient.name}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{patient.procedure}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={`text-xs ${patient.status === 'normal' ? 'border-green-200 text-green-700' : 'border-amber-200 text-amber-700'}`}>
|
||||
{patient.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
case 'communications':
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Communications
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={() => navigate('/communications')}>
|
||||
View All <ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 flex-1 overflow-auto">
|
||||
{communications.map((comm, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 p-2 rounded-lg hover:bg-accent/50 transition-colors cursor-pointer" onClick={() => navigate('/communications')}>
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium">
|
||||
{comm.patient.split(' ').map((n) => n[0]).join('')}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{comm.patient}</p>
|
||||
{comm.unread && <div className="w-2 h-2 rounded-full bg-red-500" />}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] truncate">{comm.preview}</p>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--text-secondary)]">{comm.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
case 'memory':
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Memory Insights
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={() => navigate('/memory')}>
|
||||
View All <ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 flex-1 overflow-auto">
|
||||
{memoryInsights.map((insight, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 p-2 rounded-lg hover:bg-accent/50 transition-colors cursor-pointer" onClick={() => navigate('/memory')}>
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${insight.type === 'photos' ? 'bg-blue-100' : insight.type === 'documents' ? 'bg-amber-100' : 'bg-purple-100'}`}>
|
||||
{insight.type === 'photos' ? <ImageIcon className="w-4 h-4 text-blue-600" /> :
|
||||
insight.type === 'documents' ? <FileText className="w-4 h-4 text-amber-600" /> :
|
||||
<Sparkles className="w-4 h-4 text-purple-600" />}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-primary)]">{insight.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
case 'team':
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3 shrink-0">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Who's Working
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 flex-1 overflow-auto">
|
||||
{teamOnline.map((member, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 p-2 rounded-lg">
|
||||
<div className="relative">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium">
|
||||
{member.name.split(' ').map((n) => n[0]).join('')}
|
||||
</div>
|
||||
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-card ${member.status === 'online' ? 'bg-green-500' : 'bg-amber-500'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{member.name}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{member.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
case 'revenue':
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3 shrink-0">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Monthly Revenue
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 flex-1 overflow-auto">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Current</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">$542K / $700K</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-blue-600 h-2 rounded-full" style={{ width: '77%' }} />
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">77% of monthly target</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Projected</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">$685K</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-green-600 h-2 rounded-full" style={{ width: '98%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
case 'payments':
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3 shrink-0">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Payment Collection
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 flex-1 overflow-auto">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Collected</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">$12,400 / $26,100</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-green-500 h-2 rounded-full" style={{ width: '47%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-green-500" /> On track</div>
|
||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-amber-500" /> At risk</div>
|
||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-red-500" /> Overdue</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
case 'messages':
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3 shrink-0">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Bell className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Messages
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-[var(--text-primary)]">3 unread</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Eric Carle, Alisha Holmes, David Johnson</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
case 'activity':
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3 shrink-0">
|
||||
<CardTitle className="text-base">Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 flex-1 overflow-auto">
|
||||
{recentActivity.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-3">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${activity.iconBg}`}>
|
||||
<CheckCircle2 className={`w-4 h-4 ${activity.iconColor}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-[var(--text-primary)]">{activity.message}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5">{activity.time}</p>
|
||||
</div>
|
||||
{activity.amount && (
|
||||
<p className="text-sm font-medium text-green-700">{activity.amount}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
case 'quick-links':
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3 shrink-0">
|
||||
<CardTitle className="text-base">Quick Links</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 flex-1 overflow-auto">
|
||||
{[
|
||||
{ label: 'Patient Directory', route: '/patients' },
|
||||
{ label: 'Schedule', route: '/schedule' },
|
||||
{ label: 'Communications', route: '/communications' },
|
||||
{ label: 'Memory', route: '/memory' },
|
||||
].map((link) => (
|
||||
<Button
|
||||
key={link.label}
|
||||
variant="ghost"
|
||||
className="w-full justify-between"
|
||||
onClick={() => navigate(link.route)}
|
||||
>
|
||||
{link.label}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
<SystemHealthCard />
|
||||
</div>
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
{/* Fixed top section: Greeting + System Health + Quick Actions */}
|
||||
<div className="p-5 space-y-4">
|
||||
{/* AI Command Center */}
|
||||
<Card className="border-border bg-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-[var(--beam-scientist-primary)]/10 flex items-center justify-center">
|
||||
<Brain className="w-6 h-6 text-[var(--beam-scientist-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">
|
||||
{greeting}{userName}. How can I help you today?
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
{new Date().toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2">
|
||||
<Sparkles className="w-5 h-5 text-[var(--beam-scientist-primary)]" />
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Ask Beam anything..."
|
||||
className="pl-10 pr-12 py-6 text-base bg-accent/50 border-border focus:bg-card"
|
||||
value={commandText}
|
||||
onChange={(e) => setCommandText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCommand()}
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<Button size="sm" onClick={handleCommand}>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{quickCommands.map((cmd) => (
|
||||
<button
|
||||
key={cmd}
|
||||
onClick={() => { setCommandText(cmd); handleCommand(); }}
|
||||
className="text-xs px-3 py-1.5 rounded-full bg-[#eff6ff] text-[var(--beam-scientist-primary)] hover:bg-[#dbeafe] transition-colors"
|
||||
>
|
||||
{cmd}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SystemHealthCard />
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card className="col-span-full">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickActions.map((action) => {
|
||||
|
|
@ -281,327 +627,68 @@ export function DashboardPage() {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Schedule Preview Widget */}
|
||||
{staticVisible.has('schedule-preview') && (
|
||||
<StaticCard id="schedule-preview">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Today's Schedule
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={() => navigate('/schedule')}>
|
||||
View All <ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{todayAppointments.map((apt, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 p-2 rounded-lg hover:bg-accent/50 transition-colors cursor-pointer" onClick={() => navigate('/schedule')}>
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
apt.status === 'confirmed' ? 'bg-blue-500' :
|
||||
apt.status === 'checked-in' ? 'bg-green-500' :
|
||||
apt.status === 'roomed' ? 'bg-purple-500' : 'bg-amber-500'
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{apt.patient}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{apt.procedure}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-medium text-[var(--text-primary)]">{apt.time}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{apt.provider}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</StaticCard>
|
||||
)}
|
||||
|
||||
{/* Post-Op Triage Widget */}
|
||||
{staticVisible.has('triage') && (
|
||||
<StaticCard id="triage">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Stethoscope className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Post-Op Triage
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={() => navigate('/triage')}>
|
||||
View All <ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{triagePatients.map((patient, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 p-2 rounded-lg hover:bg-accent/50 transition-colors cursor-pointer" onClick={() => navigate('/triage')}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
patient.status === 'normal' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'
|
||||
}`}>
|
||||
D{patient.day}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{patient.name}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{patient.procedure}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={`text-xs ${patient.status === 'normal' ? 'border-green-200 text-green-700' : 'border-amber-200 text-amber-700'}`}>
|
||||
{patient.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</StaticCard>
|
||||
)}
|
||||
|
||||
{/* Communication Hub Widget */}
|
||||
{staticVisible.has('communications') && (
|
||||
<StaticCard id="communications">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Communications
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={() => navigate('/communications')}>
|
||||
View All <ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{communications.map((comm, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 p-2 rounded-lg hover:bg-accent/50 transition-colors cursor-pointer" onClick={() => navigate('/communications')}>
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium">
|
||||
{comm.patient.split(' ').map((n) => n[0]).join('')}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Draggable / Resizable Grid */}
|
||||
<div ref={containerRef as any} className="px-5 pb-20">
|
||||
{mounted && (
|
||||
<Responsive
|
||||
width={width}
|
||||
className="layout"
|
||||
layouts={effectiveLayouts}
|
||||
breakpoints={BREAKPOINTS}
|
||||
cols={COLS}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
margin={[16, 16]}
|
||||
containerPadding={[0, 0]}
|
||||
onLayoutChange={onLayoutChange}
|
||||
>
|
||||
{Array.from(staticVisible).map((id) => (
|
||||
<div key={id} className="h-full">
|
||||
<div className="h-full flex flex-col rounded-2xl border border-border overflow-hidden bg-card shadow-sm">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0 drag-handle cursor-grab active:cursor-grabbing">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{comm.patient}</p>
|
||||
{comm.unread && <div className="w-2 h-2 rounded-full bg-red-500" />}
|
||||
<GripVertical size={14} className="text-muted-foreground" />
|
||||
{(() => {
|
||||
const def = STATIC_WIDGETS.find((w) => w.id === id);
|
||||
const Icon = def?.icon || Calendar;
|
||||
return <Icon size={14} style={{ color: def?.color }} />;
|
||||
})()}
|
||||
<span className="text-xs font-medium text-foreground">{STATIC_WIDGETS.find((w) => w.id === id)?.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] truncate">{comm.preview}</p>
|
||||
<button
|
||||
onClick={() => toggleStatic(id)}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
title="Hide widget"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--text-secondary)]">{comm.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</StaticCard>
|
||||
)}
|
||||
|
||||
{/* Memory Insights Widget */}
|
||||
{staticVisible.has('memory') && (
|
||||
<StaticCard id="memory">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Memory Insights
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={() => navigate('/memory')}>
|
||||
View All <ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{memoryInsights.map((insight, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 p-2 rounded-lg hover:bg-accent/50 transition-colors cursor-pointer" onClick={() => navigate('/memory')}>
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
|
||||
insight.type === 'photos' ? 'bg-blue-100' :
|
||||
insight.type === 'documents' ? 'bg-amber-100' : 'bg-purple-100'
|
||||
}`}>
|
||||
{insight.type === 'photos' ? <ImageIcon className="w-4 h-4 text-blue-600" /> :
|
||||
insight.type === 'documents' ? <FileText className="w-4 h-4 text-amber-600" /> :
|
||||
<Sparkles className="w-4 h-4 text-purple-600" />}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-primary)]">{insight.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</StaticCard>
|
||||
)}
|
||||
|
||||
{/* Who's Working Widget */}
|
||||
{staticVisible.has('team') && (
|
||||
<StaticCard id="team">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Who's Working
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{teamOnline.map((member, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 p-2 rounded-lg">
|
||||
<div className="relative">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium">
|
||||
{member.name.split(' ').map((n) => n[0]).join('')}
|
||||
</div>
|
||||
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-card ${
|
||||
member.status === 'online' ? 'bg-green-500' : 'bg-amber-500'
|
||||
}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{member.name}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{member.role}</p>
|
||||
<div className="flex-1 overflow-hidden p-1">
|
||||
{renderStaticWidget(id)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</StaticCard>
|
||||
)}
|
||||
|
||||
{/* Monthly Revenue Widget */}
|
||||
{staticVisible.has('revenue') && (
|
||||
<StaticCard id="revenue">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Monthly Revenue
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Current</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">$542K / $700K</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-blue-600 h-2 rounded-full" style={{ width: '77%' }} />
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">77% of monthly target</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Projected</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">$685K</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-green-600 h-2 rounded-full" style={{ width: '98%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</StaticCard>
|
||||
)}
|
||||
))}
|
||||
|
||||
{/* Payment Collection Widget */}
|
||||
{staticVisible.has('payments') && (
|
||||
<StaticCard id="payments">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Payment Collection
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Collected</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">$12,400 / $26,100</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-green-500 h-2 rounded-full" style={{ width: '47%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-green-500" /> On track</div>
|
||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-amber-500" /> At risk</div>
|
||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-red-500" /> Overdue</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</StaticCard>
|
||||
)}
|
||||
|
||||
{/* Messages Widget */}
|
||||
{staticVisible.has('messages') && (
|
||||
<StaticCard id="messages">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Bell className="w-4 h-4 text-[var(--beam-scientist-primary)]" />
|
||||
Messages
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-[var(--text-primary)]">3 unread</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Eric Carle, Alisha Holmes, David Johnson</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</StaticCard>
|
||||
)}
|
||||
|
||||
{/* Recent Activity */}
|
||||
{staticVisible.has('activity') && (
|
||||
<StaticCard id="activity">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{recentActivity.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-3">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${activity.iconBg}`}>
|
||||
<CheckCircle2 className={`w-4 h-4 ${activity.iconColor}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-[var(--text-primary)]">{activity.message}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5">{activity.time}</p>
|
||||
</div>
|
||||
{activity.amount && (
|
||||
<p className="text-sm font-medium text-green-700">{activity.amount}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</StaticCard>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
{staticVisible.has('quick-links') && (
|
||||
<StaticCard id="quick-links">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Quick Links</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
{[
|
||||
{ label: 'Patient Directory', route: '/patients' },
|
||||
{ label: 'Schedule', route: '/schedule' },
|
||||
{ label: 'Communications', route: '/communications' },
|
||||
{ label: 'Memory', route: '/memory' },
|
||||
].map((link) => (
|
||||
<Button
|
||||
key={link.label}
|
||||
variant="ghost"
|
||||
className="w-full justify-between"
|
||||
onClick={() => navigate(link.route)}
|
||||
{visibleChannels.map((channel) => (
|
||||
<div key={channel.id} className="h-full">
|
||||
<WidgetCard
|
||||
channel={channel}
|
||||
onToggleCollapse={() => toggleCollapse(channel.id)}
|
||||
onRemove={() => handleRemoveWidget(channel.id)}
|
||||
>
|
||||
{link.label}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</StaticCard>
|
||||
<ChannelWidgetContent
|
||||
channel={channel}
|
||||
widgets={widgetsByChannel?.[channel.id]}
|
||||
onCardClick={handleCardClick}
|
||||
onWidgetClick={handleWidgetClick}
|
||||
/>
|
||||
</WidgetCard>
|
||||
</div>
|
||||
))}
|
||||
</Responsive>
|
||||
)}
|
||||
|
||||
{/* Core Widgets */}
|
||||
{visibleChannels.map((channel) => (
|
||||
<div key={channel.id} className="min-h-[200px]">
|
||||
<WidgetCard
|
||||
channel={channel}
|
||||
onToggleCollapse={() => toggleCollapse(channel.id)}
|
||||
onRemove={() => handleRemoveWidget(channel.id)}
|
||||
>
|
||||
<ChannelWidgetContent
|
||||
channel={channel}
|
||||
widgets={widgetsByChannel?.[channel.id]}
|
||||
onCardClick={handleCardClick}
|
||||
onWidgetClick={handleWidgetClick}
|
||||
/>
|
||||
</WidgetCard>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* FAB */}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Button } from '../components/ui/button';
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
DollarSign, TrendingUp, CreditCard, Banknote,
|
||||
CheckCircle2, AlertCircle, Download, Eye,
|
||||
RotateCcw, Receipt, FileText, Gift,
|
||||
Loader2,
|
||||
Loader2, RefreshCw, Wifi, WifiOff, Database,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
|
|
@ -22,26 +22,23 @@ import type { FinanceOverview, FinancePayment, FinanceInvoice, FinanceRefund } f
|
|||
|
||||
const monthlyTarget = 700000;
|
||||
|
||||
const mockDailySales = [
|
||||
{
|
||||
date: 'Mar 17, 2026', isToday: true,
|
||||
transactions: [
|
||||
{ id: 1, time: '9:30 AM', patient: 'Eric Carle', treatment: 'Subcision Session 2 - Deposit', amount: 1300, method: 'Cash', collectedBy: 'Jessica Rodriguez', status: 'completed' },
|
||||
{ id: 2, time: '10:45 AM', patient: 'Alisha Holmes', treatment: 'Initial Consultation', amount: 250, method: 'Credit Card', collectedBy: 'Mike Thompson', status: 'completed' },
|
||||
{ id: 3, time: '2:15 PM', patient: 'David Johnson', treatment: 'Laser Package - Full Payment', amount: 4800, method: 'Credit Card', collectedBy: 'Jessica Rodriguez', status: 'completed' },
|
||||
],
|
||||
},
|
||||
// ─── Demo Data (only shown when explicitly toggled) ───
|
||||
const DEMO_PAYMENTS: FinancePayment[] = [
|
||||
{ id: 1, date: '2026-03-17', partner: 'Eric Carle', amount: 1300, method: 'Cash', ref: 'Subcision Session 2 - Deposit', state: 'posted' },
|
||||
{ id: 2, date: '2026-03-17', partner: 'Alisha Holmes', amount: 250, method: 'Credit Card', ref: 'Initial Consultation', state: 'posted' },
|
||||
{ id: 3, date: '2026-03-17', partner: 'David Johnson', amount: 4800, method: 'Credit Card', ref: 'Laser Package - Full Payment', state: 'posted' },
|
||||
];
|
||||
|
||||
const mockRefunds = [
|
||||
{ id: 'RF-001', date: 'Mar 15, 2026', patient: 'Sarah Williams', originalAmount: 3500, refundAmount: 3500, reason: 'Medical contraindication - patient started Accutane', treatment: 'Laser Package (6 sessions)', processedBy: 'Dr. Chen', status: 'completed', paymentMethod: 'Credit Card', agreementSigned: true, notes: 'Full refund issued per cancellation policy' },
|
||||
{ id: 'RF-002', date: 'Mar 10, 2026', patient: 'Michael Torres', originalAmount: 2400, refundAmount: 1800, reason: 'Partial package cancellation', treatment: 'TCA Cross Package', processedBy: 'Jessica Rodriguez', status: 'completed', paymentMethod: 'Credit Card', agreementSigned: true, notes: 'Refunded 3 unused sessions per agreement' },
|
||||
{ id: 'RF-003', date: 'Mar 8, 2026', patient: 'Jennifer Park', originalAmount: 1200, refundAmount: 1200, reason: 'Severe adverse reaction', treatment: 'Chemical Peel', processedBy: 'Dr. Smith', status: 'pending', paymentMethod: 'Cash', agreementSigned: true, notes: 'Medical review completed, refund approved' },
|
||||
const DEMO_INVOICES: FinanceInvoice[] = [
|
||||
{ id: 101, name: 'INV-20260315-742', partner: 'Eric Carle', date: '2026-03-15', amount: 3500, state: 'posted', move_type: 'out_invoice' },
|
||||
{ id: 102, name: 'INV-20260316-389', partner: 'Alisha Holmes', date: '2026-03-16', amount: 4200, state: 'draft', move_type: 'out_invoice' },
|
||||
{ id: 103, name: 'INV-20260310-156', partner: 'David Johnson', date: '2026-03-10', amount: 400, state: 'posted', move_type: 'out_invoice' },
|
||||
{ id: 104, name: 'INV-20260314-892', partner: 'Michael Torres', date: '2026-03-14', amount: 8500, state: 'posted', move_type: 'out_invoice' },
|
||||
];
|
||||
|
||||
const coupons = [
|
||||
{ id: 1, code: 'SPRING2026', type: 'percentage', value: 15, expiresAt: 'Apr 30, 2026', createdAt: 'Mar 1, 2026', createdBy: 'Amanda Foster', status: 'active', timesUsed: 12, maxUses: 100, assignedTo: 'All Patients', description: 'Spring promotion - 15% off all treatments' },
|
||||
{ id: 2, code: 'WELCOME100', type: 'dollars', value: 100, expiresAt: 'Dec 31, 2026', createdAt: 'Jan 1, 2026', createdBy: 'Amanda Foster', status: 'active', timesUsed: 45, maxUses: null, assignedTo: 'New Patients Only', description: '$100 off first treatment package' },
|
||||
const DEMO_REFUNDS: FinanceRefund[] = [
|
||||
{ id: 201, name: 'RF-001', partner: 'Sarah Williams', date: '2026-03-15', amount: 3500, state: 'posted', reason: 'Medical contraindication - patient started Accutane' },
|
||||
{ id: 202, name: 'RF-002', partner: 'Michael Torres', date: '2026-03-10', amount: 1800, state: 'posted', reason: 'Partial package cancellation' },
|
||||
];
|
||||
|
||||
const salesTaxData: Record<string, any> = {
|
||||
|
|
@ -49,17 +46,17 @@ const salesTaxData: Record<string, any> = {
|
|||
'Q4 2025': { startDate: 'Oct 1, 2025', endDate: 'Dec 31, 2025', totalSales: 1987650, taxableAmount: 1745280, taxCollected: 165801, taxRate: 9.5, transactions: 1156, breakdown: [{ category: 'Laser Treatments', sales: 765400, tax: 72713 }, { category: 'Injectables', sales: 598200, tax: 56829 }, { category: 'Chemical Peels', sales: 234680, tax: 22295 }, { category: 'Products', sales: 147000, tax: 13965 }] },
|
||||
};
|
||||
|
||||
const mockQuotes = [
|
||||
{ id: 'INV-20260315-742', patient: 'Eric Carle', date: 'Mar 15, 2026', items: 'Subcision Package (3x)', total: 3500, deposit: 1300, depositPaid: true, depositDueDate: null, balance: 2200, balanceDueDate: 'Mar 22, 2026', status: 'Deposit Paid', type: 'Package' },
|
||||
{ id: 'INV-20260316-389', patient: 'Alisha Holmes', date: 'Mar 16, 2026', items: 'Initial Consultation + TCA Cross Treatment', total: 4200, deposit: 1260, depositPaid: false, depositDueDate: 'Mar 18, 2026', balance: 2940, balanceDueDate: 'Apr 15, 2026', status: 'Pending', type: 'Package' },
|
||||
{ id: 'INV-20260310-156', patient: 'David Johnson', date: 'Mar 10, 2026', items: 'Microneedling Add-on', total: 400, deposit: 0, depositPaid: null, depositDueDate: null, balance: 400, balanceDueDate: 'Mar 17, 2026', status: 'Overdue', type: 'Single' },
|
||||
{ id: 'INV-20260314-892', patient: 'Michael Torres', date: 'Mar 14, 2026', items: 'Laser Resurfacing Package (6x)', total: 8500, deposit: 2550, depositPaid: true, depositDueDate: null, balance: 0, balanceDueDate: null, status: 'Paid', type: 'Package' },
|
||||
const coupons = [
|
||||
{ id: 1, code: 'SPRING2026', type: 'percentage', value: 15, expiresAt: 'Apr 30, 2026', createdAt: 'Mar 1, 2026', createdBy: 'Amanda Foster', status: 'active', timesUsed: 12, maxUses: 100, assignedTo: 'All Patients', description: 'Spring promotion - 15% off all treatments' },
|
||||
{ id: 2, code: 'WELCOME100', type: 'dollars', value: 100, expiresAt: 'Dec 31, 2026', createdAt: 'Jan 1, 2026', createdBy: 'Amanda Foster', status: 'active', timesUsed: 45, maxUses: null, assignedTo: 'New Patients Only', description: '$100 off first treatment package' },
|
||||
];
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return `$${amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
type DataSource = 'live' | 'demo' | 'error';
|
||||
|
||||
export function FinancePage() {
|
||||
const [selectedTab, setSelectedTab] = useState('overview');
|
||||
const [selectedQuarter, setSelectedQuarter] = useState('Q1 2026');
|
||||
|
|
@ -69,37 +66,67 @@ export function FinancePage() {
|
|||
const [invoices, setInvoices] = useState<FinanceInvoice[]>([]);
|
||||
const [refunds, setRefunds] = useState<FinanceRefund[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dataSource, setDataSource] = useState<DataSource>('live');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [useDemo, setUseDemo] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const [ov, pay, inv, ref] = await Promise.all([
|
||||
fetchFinanceOverview(),
|
||||
fetchFinancePayments(50),
|
||||
fetchFinanceInvoices(50),
|
||||
fetchFinanceRefunds(50),
|
||||
]);
|
||||
|
||||
const hasAnyData = (ov && ov.monthlyRevenue > 0) || pay.length > 0 || inv.length > 0 || ref.length > 0;
|
||||
|
||||
if (useDemo || !hasAnyData) {
|
||||
// If user explicitly wants demo, or Odoo returned nothing
|
||||
setOverview(ov || {
|
||||
monthlyRevenue: 542350, monthlyTarget: 700000, projectedRevenue: 685400,
|
||||
todaySales: 6350, todayTransactions: 3, totalRefunded: 6500, refundCount: 2, taxCollected: 178261, taxRate: 9.5,
|
||||
});
|
||||
setPayments(useDemo || pay.length === 0 ? DEMO_PAYMENTS : pay);
|
||||
setInvoices(useDemo || inv.length === 0 ? DEMO_INVOICES : inv);
|
||||
setRefunds(useDemo || ref.length === 0 ? DEMO_REFUNDS : ref);
|
||||
setDataSource(useDemo ? 'demo' : hasAnyData ? 'live' : 'demo');
|
||||
} else {
|
||||
setOverview(ov);
|
||||
setPayments(pay);
|
||||
setInvoices(inv);
|
||||
setRefunds(ref);
|
||||
setDataSource('live');
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Finance load failed:', e);
|
||||
setErrorMsg(e?.message || 'Failed to connect to Odoo');
|
||||
setDataSource('error');
|
||||
// Show demo data on error so page isn't blank
|
||||
setOverview({
|
||||
monthlyRevenue: 542350, monthlyTarget: 700000, projectedRevenue: 685400,
|
||||
todaySales: 6350, todayTransactions: 3, totalRefunded: 6500, refundCount: 2, taxCollected: 178261, taxRate: 9.5,
|
||||
});
|
||||
setPayments(DEMO_PAYMENTS);
|
||||
setInvoices(DEMO_INVOICES);
|
||||
setRefunds(DEMO_REFUNDS);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [useDemo]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
Promise.all([
|
||||
fetchFinanceOverview(),
|
||||
fetchFinancePayments(50),
|
||||
fetchFinanceInvoices(50),
|
||||
fetchFinanceRefunds(50),
|
||||
]).then(([ov, pay, inv, ref]) => {
|
||||
if (cancelled) return;
|
||||
setOverview(ov);
|
||||
setPayments(pay);
|
||||
setInvoices(inv);
|
||||
setRefunds(ref);
|
||||
}).catch((e) => {
|
||||
console.error('Finance load failed:', e);
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const currentMonthRevenue = overview?.monthlyRevenue ?? 542350;
|
||||
const projectedMonthRevenue = overview?.projectedRevenue ?? 685400;
|
||||
const totalDailySales = overview?.todaySales ?? mockDailySales.reduce((sum, day) => sum + day.transactions.reduce((daySum, txn) => daySum + txn.amount, 0), 0);
|
||||
const totalRefunded = overview?.totalRefunded ?? mockRefunds.reduce((sum, r) => sum + r.refundAmount, 0);
|
||||
const refundCount = overview?.refundCount ?? mockRefunds.length;
|
||||
const todayTransactions = overview?.todayTransactions ?? mockDailySales[0]?.transactions.length ?? 0;
|
||||
const currentMonthRevenue = overview?.monthlyRevenue ?? 0;
|
||||
const projectedMonthRevenue = overview?.projectedRevenue ?? 0;
|
||||
const totalDailySales = overview?.todaySales ?? 0;
|
||||
const totalRefunded = overview?.totalRefunded ?? 0;
|
||||
const refundCount = overview?.refundCount ?? 0;
|
||||
const todayTransactions = overview?.todayTransactions ?? 0;
|
||||
|
||||
const selectedTaxData = salesTaxData[selectedQuarter];
|
||||
|
||||
|
|
@ -116,24 +143,24 @@ export function FinancePage() {
|
|||
};
|
||||
|
||||
const downloadPaymentsCSV = () => {
|
||||
if (payments.length > 0) {
|
||||
const headers = ['Date', 'Partner', 'Amount', 'Method', 'Reference', 'State'];
|
||||
const rows = payments.map((p) => [p.date, p.partner, p.amount, p.method, p.ref, p.state]);
|
||||
const csvContent = [headers.join(','), ...rows.map((row) => row.map((cell) => `"${cell}"`).join(','))].join('\n');
|
||||
downloadCSV(csvContent, `payments-${new Date().toISOString().split('T')[0]}.csv`);
|
||||
} else {
|
||||
const headers = ['Date', 'Time', 'Patient', 'Treatment', 'Amount', 'Method', 'Collected By', 'Status'];
|
||||
const rows = mockDailySales.flatMap((day) => day.transactions.map((txn) => [day.date, txn.time, txn.patient, txn.treatment, `$${txn.amount.toLocaleString()}`, txn.method, txn.collectedBy, txn.status]));
|
||||
const csvContent = [headers.join(','), ...rows.map((row) => row.map((cell) => `"${cell}"`).join(','))].join('\n');
|
||||
downloadCSV(csvContent, `payments-${new Date().toISOString().split('T')[0]}.csv`);
|
||||
}
|
||||
const headers = ['Date', 'Partner', 'Amount', 'Method', 'Reference', 'State'];
|
||||
const rows = payments.map((p) => [p.date, p.partner, p.amount, p.method, p.ref, p.state]);
|
||||
const csvContent = [headers.join(','), ...rows.map((row) => row.map((cell) => `"${cell}"`).join(','))].join('\n');
|
||||
downloadCSV(csvContent, `payments-${new Date().toISOString().split('T')[0]}.csv`);
|
||||
};
|
||||
|
||||
const downloadRefundsCSV = () => {
|
||||
const headers = ['ID', 'Date', 'Partner', 'Amount', 'State', 'Reason'];
|
||||
const rows = refunds.map((r) => [r.name, r.date, r.partner, r.amount, r.state, r.reason || '']);
|
||||
const csvContent = [headers.join(','), ...rows.map((row) => row.map((cell) => `"${cell}"`).join(','))].join('\n');
|
||||
downloadCSV(csvContent, `refunds-${new Date().toISOString().split('T')[0]}.csv`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[var(--beam-scientist-primary)]" />
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading finance data...</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading finance data from Odoo...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -141,18 +168,59 @@ export function FinancePage() {
|
|||
return (
|
||||
<div className="h-full overflow-y-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">Finance</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">Payments, refunds, and sales tax tracking</p>
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">Finance</h1>
|
||||
{dataSource === 'live' && (
|
||||
<Badge variant="default" className="bg-green-600 gap-1">
|
||||
<Wifi className="w-3 h-3" /> Live Odoo
|
||||
</Badge>
|
||||
)}
|
||||
{dataSource === 'demo' && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Database className="w-3 h-3" /> Demo Data
|
||||
</Badge>
|
||||
)}
|
||||
{dataSource === 'error' && (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<WifiOff className="w-3 h-3" /> Odoo Error
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
{dataSource === 'live' ? 'Real-time data from Odoo' : dataSource === 'demo' ? 'Showing demo data — Odoo connection unavailable or empty' : 'Connection error — showing demo data'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setUseDemo((v) => !v)}>
|
||||
{useDemo ? <Wifi className="w-4 h-4 mr-2" /> : <Database className="w-4 h-4 mr-2" />}
|
||||
{useDemo ? 'Use Live Data' : 'Use Demo Data'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={loadData} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{errorMsg && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-900">Odoo Connection Error</p>
|
||||
<p className="text-xs text-red-700">{errorMsg}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Monthly Revenue</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">90-Day Revenue</p>
|
||||
<p className="text-2xl font-semibold text-[var(--text-primary)] mt-1">${(currentMonthRevenue / 1000).toFixed(0)}K</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">Target: ${(monthlyTarget / 1000).toFixed(0)}K</p>
|
||||
</div>
|
||||
|
|
@ -217,7 +285,7 @@ export function FinancePage() {
|
|||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Current</span>
|
||||
<span className="text-sm text-[var(--text-secondary)]">Current (90-day)</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">${currentMonthRevenue.toLocaleString()} / ${monthlyTarget.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2"><div className="bg-blue-600 h-2 rounded-full" style={{ width: `${Math.min((currentMonthRevenue / monthlyTarget) * 100, 100)}%` }} /></div>
|
||||
|
|
@ -254,10 +322,18 @@ export function FinancePage() {
|
|||
{/* Payments Tab */}
|
||||
<TabsContent value="payments" className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">{payments.length > 0 ? 'Odoo Payments' : 'Daily Transactions'}</h2>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Payments {dataSource === 'live' && <Badge variant="outline" className="ml-2 text-green-600 border-green-200">Live</Badge>}
|
||||
</h2>
|
||||
<Button onClick={downloadPaymentsCSV} size="sm"><Download className="w-4 h-4 mr-2" />Download CSV</Button>
|
||||
</div>
|
||||
{payments.length > 0 ? (
|
||||
{payments.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--text-secondary)]">
|
||||
<Database className="w-10 h-10 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm font-medium">No payments found</p>
|
||||
<p className="text-xs mt-1">Try toggling demo data or check your Odoo connection</p>
|
||||
</div>
|
||||
) : (
|
||||
payments.map((p) => (
|
||||
<Card key={p.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
|
|
@ -278,180 +354,94 @@ export function FinancePage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
mockDailySales.map((day, dayIdx) => (
|
||||
<Card key={dayIdx}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{day.date}{day.isToday && <Badge variant="default" className="ml-2">Today</Badge>}</CardTitle>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Total: ${day.transactions.reduce((sum, txn) => sum + txn.amount, 0).toLocaleString()}</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{day.transactions.map((txn) => (
|
||||
<div key={txn.id} className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-[var(--text-secondary)] min-w-[70px]">{txn.time}</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{txn.patient}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{txn.treatment}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline">{txn.method}</Badge>
|
||||
<div className="text-sm text-[var(--text-secondary)] min-w-[120px] text-right">{txn.collectedBy}</div>
|
||||
<div className="text-lg font-semibold text-[var(--text-primary)] min-w-[100px] text-right">${txn.amount.toLocaleString()}</div>
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Quotes & Invoices Tab */}
|
||||
<TabsContent value="quotes" className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">{invoices.length > 0 ? 'Odoo Invoices' : 'Quotes & Invoices'}</h2>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Invoices {dataSource === 'live' && <Badge variant="outline" className="ml-2 text-green-600 border-green-200">Live</Badge>}
|
||||
</h2>
|
||||
<Button size="sm"><FileText className="w-4 h-4 mr-2" />Create New Quote</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{(invoices.length > 0 ? invoices : mockQuotes as any[]).map((quote: any) => (
|
||||
<Card key={quote.id} className={`hover:shadow-md transition-shadow ${quote.status === 'Overdue' || quote.state === 'cancel' ? 'border-2 border-red-300' : ''}`}>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
{invoices.length > 0 ? (
|
||||
{invoices.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--text-secondary)]">
|
||||
<Database className="w-10 h-10 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm font-medium">No invoices found</p>
|
||||
<p className="text-xs mt-1">Try toggling demo data or check your Odoo connection</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{invoices.map((inv) => (
|
||||
<Card key={inv.id} className={`hover:shadow-md transition-shadow ${inv.state === 'cancel' ? 'border-2 border-red-300' : ''}`}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)]">{quote.partner || quote.patient}</h3>
|
||||
<Badge variant={quote.state === 'posted' || quote.state === 'Paid' ? 'default' : quote.state === 'draft' || quote.state === 'Pending' ? 'secondary' : quote.state === 'cancel' || quote.status === 'Overdue' ? 'destructive' : 'outline'} className={quote.state === 'posted' || quote.state === 'Paid' ? 'bg-green-600' : quote.state === 'draft' || quote.state === 'Deposit Paid' ? 'bg-blue-600' : ''}>{quote.state || quote.status}</Badge>
|
||||
<Badge variant="outline">{quote.move_type || quote.type}</Badge>
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)]">{inv.partner}</h3>
|
||||
<Badge variant={inv.state === 'posted' ? 'default' : inv.state === 'draft' ? 'secondary' : inv.state === 'cancel' ? 'destructive' : 'outline'} className={inv.state === 'posted' ? 'bg-green-600' : inv.state === 'draft' ? 'bg-blue-600' : ''}>{inv.state}</Badge>
|
||||
<Badge variant="outline">{inv.move_type}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{quote.name || quote.id} · {quote.date || quote.date_order || quote.invoice_date}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{inv.name} · {inv.date}</p>
|
||||
</div>
|
||||
<div className="text-right ml-6">
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-1">Total Amount</p>
|
||||
<p className="text-2xl font-semibold text-[var(--text-primary)] mb-3">{formatCurrency(quote.amount || quote.amount_total || quote.total)}</p>
|
||||
<p className="text-2xl font-semibold text-[var(--text-primary)] mb-3">{formatCurrency(inv.amount)}</p>
|
||||
<div className="mt-4 flex gap-2 justify-end">
|
||||
<Button size="sm" variant="outline"><Eye className="w-4 h-4 mr-2" />View</Button>
|
||||
<Button size="sm" variant="outline"><Download className="w-4 h-4 mr-2" />PDF</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!quote.depositPaid && quote.deposit > 0 && (
|
||||
<div className="p-4 bg-yellow-50 border-2 border-yellow-300 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-700 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-yellow-900 mb-1">Deposit Required</p>
|
||||
<p className="text-sm text-yellow-800"><span className="font-bold">${quote.deposit.toLocaleString()}</span> deposit needs to be collected{quote.depositDueDate && <span className="font-semibold"> by {quote.depositDueDate}</span>}</p>
|
||||
</div>
|
||||
<Button size="sm" className="bg-yellow-600 hover:bg-yellow-700">Collect Deposit</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{quote.balance > 0 && quote.depositPaid && (
|
||||
<div className={`mb-4 p-4 rounded-lg border-2 ${quote.status === 'Overdue' ? 'bg-red-50 border-red-300' : 'bg-blue-50 border-blue-300'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className={`w-5 h-5 flex-shrink-0 mt-0.5 ${quote.status === 'Overdue' ? 'text-red-700' : 'text-blue-700'}`} />
|
||||
<div className="flex-1">
|
||||
<p className={`font-semibold mb-1 ${quote.status === 'Overdue' ? 'text-red-900' : 'text-blue-900'}`}>{quote.status === 'Overdue' ? 'Payment Overdue' : 'Balance Due'}</p>
|
||||
<p className={`text-sm ${quote.status === 'Overdue' ? 'text-red-800' : 'text-blue-800'}`}><span className="font-bold">${quote.balance.toLocaleString()}</span> balance{quote.balanceDueDate && <span className="font-semibold">{quote.status === 'Overdue' ? ' was ' : ' is '}due {quote.balanceDueDate}</span>}</p>
|
||||
</div>
|
||||
<Button size="sm" className={quote.status === 'Overdue' ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'}>Collect Balance</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)]">{quote.patient}</h3>
|
||||
<Badge variant={quote.status === 'Paid' ? 'default' : quote.status === 'Deposit Paid' ? 'secondary' : quote.status === 'Overdue' ? 'destructive' : 'outline'} className={quote.status === 'Paid' ? 'bg-green-600' : quote.status === 'Deposit Paid' ? 'bg-blue-600' : ''}>{quote.status}</Badge>
|
||||
<Badge variant="outline">{quote.type}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{quote.id} · {quote.date}</p>
|
||||
<p className="text-sm text-[var(--text-primary)] mt-2">{quote.items}</p>
|
||||
</div>
|
||||
<div className="text-right ml-6">
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-1">Total Amount</p>
|
||||
<p className="text-2xl font-semibold text-[var(--text-primary)] mb-3">${quote.total.toLocaleString()}</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
{quote.deposit > 0 && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-[var(--text-secondary)]">Deposit:</span>
|
||||
<span className={`font-medium ${quote.depositPaid ? 'text-green-600' : 'text-yellow-600'}`}>${quote.deposit.toLocaleString()}{quote.depositPaid && <CheckCircle2 className="w-4 h-4 inline-block ml-1" />}</span>
|
||||
</div>
|
||||
)}
|
||||
{quote.balance > 0 && quote.depositPaid && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-[var(--text-secondary)]">Balance:</span>
|
||||
<span className={`font-semibold ${quote.status === 'Overdue' ? 'text-red-600' : 'text-orange-600'}`}>${quote.balance.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2 justify-end">
|
||||
<Button size="sm" variant="outline"><Eye className="w-4 h-4 mr-2" />View</Button>
|
||||
<Button size="sm" variant="outline"><Download className="w-4 h-4 mr-2" />PDF</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Refunds Tab */}
|
||||
<TabsContent value="refunds" className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">{refunds.length > 0 ? 'Odoo Refunds' : 'Refund History'}</h2>
|
||||
<Button size="sm"><Download className="w-4 h-4 mr-2" />Download CSV</Button>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Refunds {dataSource === 'live' && <Badge variant="outline" className="ml-2 text-green-600 border-green-200">Live</Badge>}
|
||||
</h2>
|
||||
<Button size="sm" onClick={downloadRefundsCSV}><Download className="w-4 h-4 mr-2" />Download CSV</Button>
|
||||
</div>
|
||||
{(refunds.length > 0 ? refunds : mockRefunds as any[]).map((refund: any) => (
|
||||
<Card key={refund.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)]">{refund.partner || refund.patient}</h3>
|
||||
<Badge variant={refund.state === 'posted' || refund.status === 'completed' ? 'default' : 'secondary'}>{refund.state || refund.status}</Badge>
|
||||
{(refund.agreementSigned || refund.reason) && <Badge variant="outline" className="bg-green-50"><FileText className="w-3 h-3 mr-1" />{refund.agreementSigned ? 'Agreement Signed' : 'Reason Recorded'}</Badge>}
|
||||
{refunds.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--text-secondary)]">
|
||||
<Database className="w-10 h-10 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm font-medium">No refunds found</p>
|
||||
<p className="text-xs mt-1">Try toggling demo data or check your Odoo connection</p>
|
||||
</div>
|
||||
) : (
|
||||
refunds.map((refund) => (
|
||||
<Card key={refund.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)]">{refund.partner}</h3>
|
||||
<Badge variant={refund.state === 'posted' ? 'default' : 'secondary'}>{refund.state}</Badge>
|
||||
{refund.reason && <Badge variant="outline" className="bg-green-50"><FileText className="w-3 h-3 mr-1" />Reason Recorded</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">{refund.name} · {refund.date}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-semibold text-[var(--text-primary)]">{formatCurrency(refund.amount)}</p>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">{refund.name || refund.id} · {refund.date || refund.invoice_date}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-semibold text-[var(--text-primary)]">{formatCurrency(refund.amount || refund.refundAmount)}</p>
|
||||
{refund.originalAmount && <p className="text-xs text-[var(--text-secondary)]">of ${refund.originalAmount.toLocaleString()}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{refund.treatment && (
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div><p className="text-[var(--text-secondary)]">Treatment</p><p className="font-medium text-[var(--text-primary)]">{refund.treatment}</p></div>
|
||||
<div><p className="text-[var(--text-secondary)]">Payment Method</p><p className="font-medium text-[var(--text-primary)]">{refund.paymentMethod || refund.method || 'N/A'}</p></div>
|
||||
<div><p className="text-[var(--text-secondary)]">Processed By</p><p className="font-medium text-[var(--text-primary)]">{refund.processedBy || 'N/A'}</p></div>
|
||||
</div>
|
||||
)}
|
||||
{refund.reason && (
|
||||
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-xs text-amber-700 font-medium mb-1">Reason</p>
|
||||
<p className="text-sm text-amber-900">{refund.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
{refund.notes && (
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-xs text-blue-700 font-medium mb-1">Notes</p>
|
||||
<p className="text-sm text-blue-900">{refund.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{refund.reason && (
|
||||
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-xs text-amber-700 font-medium mb-1">Reason</p>
|
||||
<p className="text-sm text-amber-900">{refund.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Sales Tax Tab */}
|
||||
|
|
|
|||
|
|
@ -596,17 +596,17 @@ export interface FinanceOverview {
|
|||
|
||||
export async function fetchFinanceOverview(): Promise<FinanceOverview | null> {
|
||||
try {
|
||||
const startOfMonth = new Date();
|
||||
startOfMonth.setDate(1);
|
||||
startOfMonth.setHours(0, 0, 0, 0);
|
||||
const startStr = startOfMonth.toISOString().split('T')[0];
|
||||
// Query last 90 days of invoices for a meaningful window regardless of current date
|
||||
const startWindow = new Date();
|
||||
startWindow.setDate(startWindow.getDate() - 90);
|
||||
const startStr = startWindow.toISOString().split('T')[0];
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const monthlyInvoices = await searchRead(
|
||||
'account.move',
|
||||
[['move_type', '=', 'out_invoice'], ['state', '=', 'posted'], ['invoice_date', '>=', startStr]],
|
||||
{ fields: ['amount_total', 'amount_total_signed'] }
|
||||
{ fields: ['amount_total', 'amount_total_signed', 'amount_untaxed_signed', 'invoice_date'] }
|
||||
);
|
||||
const todayPayments = await searchRead(
|
||||
'account.payment',
|
||||
|
|
@ -615,12 +615,12 @@ export async function fetchFinanceOverview(): Promise<FinanceOverview | null> {
|
|||
);
|
||||
const refunds = await searchRead(
|
||||
'account.move',
|
||||
[['move_type', '=', 'out_refund'], ['state', '=', 'posted']],
|
||||
[['move_type', '=', 'out_refund'], ['state', '=', 'posted'], ['invoice_date', '>=', startStr]],
|
||||
{ fields: ['amount_total', 'amount_total_signed'] }
|
||||
);
|
||||
|
||||
const monthlyRevenue = (monthlyInvoices || []).reduce((sum: number, inv: any) => {
|
||||
const amt = inv.amount_total || inv.amount_total_signed || 0;
|
||||
const amt = inv.amount_total || inv.amount_total_signed || inv.amount_untaxed_signed || 0;
|
||||
return sum + (typeof amt === 'number' ? amt : 0);
|
||||
}, 0);
|
||||
|
||||
|
|
@ -634,7 +634,6 @@ export async function fetchFinanceOverview(): Promise<FinanceOverview | null> {
|
|||
return sum + (typeof amt === 'number' ? Math.abs(amt) : 0);
|
||||
}, 0);
|
||||
|
||||
// Fallback to sensible defaults if no data
|
||||
return {
|
||||
monthlyRevenue,
|
||||
monthlyTarget: 700000,
|
||||
|
|
@ -659,7 +658,7 @@ export async function fetchFinancePayments(limit = 50): Promise<FinancePayment[]
|
|||
const records = await searchRead(
|
||||
'account.payment',
|
||||
[['state', '=', 'posted']],
|
||||
{ fields: ['date', 'partner_id', 'amount', 'payment_method_line_id', 'ref', 'state'], limit, order: 'date desc' }
|
||||
{ fields: ['date', 'partner_id', 'amount', 'payment_method_id', 'payment_method_line_id', 'communication', 'ref', 'state', 'payment_type'], limit, order: 'date desc' }
|
||||
);
|
||||
if (!records || records.length === 0) return [];
|
||||
return records.map((r: any) => ({
|
||||
|
|
@ -667,8 +666,8 @@ export async function fetchFinancePayments(limit = 50): Promise<FinancePayment[]
|
|||
date: r.date || '',
|
||||
partner: r.partner_id?.[1] || 'Unknown',
|
||||
amount: r.amount || 0,
|
||||
method: r.payment_method_line_id?.[1] || 'Unknown',
|
||||
ref: r.ref || '',
|
||||
method: r.payment_method_line_id?.[1] || r.payment_method_id?.[1] || r.payment_type || 'Unknown',
|
||||
ref: r.communication || r.ref || '',
|
||||
state: r.state || 'draft',
|
||||
}));
|
||||
} catch (e) {
|
||||
|
|
@ -684,7 +683,7 @@ export async function fetchFinanceInvoices(limit = 50): Promise<FinanceInvoice[]
|
|||
const records = await searchRead(
|
||||
'account.move',
|
||||
[['move_type', 'in', ['out_invoice', 'out_refund']]],
|
||||
{ fields: ['name', 'partner_id', 'invoice_date', 'amount_total', 'state', 'move_type'], limit, order: 'invoice_date desc' }
|
||||
{ fields: ['name', 'partner_id', 'invoice_date', 'amount_total', 'amount_residual', 'state', 'move_type'], limit, order: 'invoice_date desc' }
|
||||
);
|
||||
if (!records || records.length === 0) return [];
|
||||
return records.map((r: any) => ({
|
||||
|
|
|
|||
Loading…
Reference in a new issue