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:
cavalier8030 2026-05-05 20:52:19 -07:00
parent bd42add952
commit 45518b4bb4
3 changed files with 685 additions and 609 deletions

View file

@ -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 */}

View file

@ -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 */}

View file

@ -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) => ({