| 'use client'; |
|
|
| import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; |
| import { useAppStore } from '@/stores/app-store'; |
| import type { InvoiceRow, View, ChatMessage } from '@/stores/app-store'; |
| import { |
| Upload, |
| FileText, |
| BarChart3, |
| Bot, |
| Download, |
| CreditCard, |
| Key, |
| User, |
| LogOut, |
| Menu, |
| X, |
| ChevronDown, |
| ChevronUp, |
| Search, |
| Trash2, |
| Send, |
| Copy, |
| Eye, |
| EyeOff, |
| AlertCircle, |
| CheckCircle, |
| Loader2, |
| ArrowRight, |
| Sparkles, |
| Shield, |
| Zap, |
| Globe, |
| Layers, |
| RefreshCw, |
| FileDown, |
| FileJson, |
| Quote, |
| ChevronRight, |
| ScanLine, |
| Database, |
| Lock, |
| Mail, |
| UserPlus, |
| Inbox, |
| MessageSquare, |
| CircleDollarSign, |
| CopyCheck, |
| Terminal, |
| ArrowLeft, |
| Crown, |
| Building2, |
| Check, |
| MousePointerClick, |
| TrendingUp, |
| Bell, |
| Clock, |
| Pencil, |
| LogIn, |
| Sun, |
| Moon, |
| ArrowUpDown, |
| Command, |
| Settings, |
| } from 'lucide-react'; |
| import type { LucideIcon } from 'lucide-react'; |
| import { toast } from 'sonner'; |
| import { |
| AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, |
| XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, |
| } from 'recharts'; |
| import { PLAN_LIMITS, PLAN_LABELS, PLAN_PRICES } from '@/lib/auth'; |
| import { useTheme } from 'next-themes'; |
|
|
| |
|
|
| const currencyFmt = new Intl.NumberFormat('en-US', { |
| style: 'currency', |
| currency: 'USD', |
| }); |
|
|
| function formatCurrency(amount: number | null, currency?: string): string { |
| if (amount === null || amount === undefined) return '--'; |
| try { |
| return new Intl.NumberFormat('en-US', { |
| style: 'currency', |
| currency: currency || 'USD', |
| }).format(amount); |
| } catch { |
| return currency ? `${currency} ${amount.toFixed(2)}` : `$${amount.toFixed(2)}`; |
| } |
| } |
|
|
| function getPasswordStrength(pw: string): { label: string; color: string; width: string } { |
| if (!pw) return { label: '', color: 'bg-zinc-700', width: 'w-0' }; |
| let score = 0; |
| if (pw.length >= 6) score++; |
| if (pw.length >= 10) score++; |
| if (/[A-Z]/.test(pw)) score++; |
| if (/[0-9]/.test(pw)) score++; |
| if (/[^A-Za-z0-9]/.test(pw)) score++; |
| if (score <= 2) return { label: 'Weak', color: 'bg-red-500', width: 'w-1/3' }; |
| if (score <= 3) return { label: 'Medium', color: 'bg-amber-500', width: 'w-2/3' }; |
| return { label: 'Strong', color: 'bg-green-500', width: 'w-full' }; |
| } |
|
|
| const DASH_TABS = [ |
| { id: 'upload', label: 'Upload', icon: Upload }, |
| { id: 'invoices', label: 'Invoices', icon: FileText }, |
| { id: 'analytics', label: 'Analytics', icon: BarChart3 }, |
| { id: 'chat', label: 'AI Chat', icon: Bot }, |
| { id: 'export', label: 'Export', icon: Download }, |
| { id: 'upgrade', label: 'Upgrade', icon: Crown }, |
| { id: 'api', label: 'API Access', icon: Key }, |
| { id: 'profile', label: 'Profile', icon: User }, |
| { id: 'activity', label: 'Activity', icon: Clock }, |
| { id: 'settings', label: 'Settings', icon: Settings }, |
| ] as const; |
|
|
| function relativeTime(dateStr: string): string { |
| const now = Date.now(); |
| const then = new Date(dateStr).getTime(); |
| const diffMs = now - then; |
| const diffMins = Math.floor(diffMs / 60000); |
| if (diffMins < 1) return 'Just now'; |
| if (diffMins < 60) return `${diffMins}m ago`; |
| const diffHours = Math.floor(diffMins / 60); |
| if (diffHours < 24) return `${diffHours}h ago`; |
| const diffDays = Math.floor(diffHours / 24); |
| if (diffDays < 30) return `${diffDays}d ago`; |
| return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); |
| } |
|
|
| |
|
|
| export default function Home() { |
| const store = useAppStore(); |
| const { theme, setTheme } = useTheme(); |
|
|
| |
| const [authMode, setAuthMode] = useState<'login' | 'signup'>('login'); |
| const [loginEmail, setLoginEmail] = useState(''); |
| const [loginPassword, setLoginPassword] = useState(''); |
| const [signupName, setSignupName] = useState(''); |
| const [signupEmail, setSignupEmail] = useState(''); |
| const [signupPassword, setSignupPassword] = useState(''); |
| const [signupConfirm, setSignupConfirm] = useState(''); |
| const [signupTerms, setSignupTerms] = useState(false); |
| const [authLoading, setAuthLoading] = useState(false); |
| const [authError, setAuthError] = useState(''); |
|
|
| |
| const [invoiceSearch, setInvoiceSearch] = useState(''); |
| const [invoiceFilter, setInvoiceFilter] = useState('all'); |
| const [expandedInvoice, setExpandedInvoice] = useState<string | null>(null); |
| const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set()); |
| const [chatInput, setChatInput] = useState(''); |
| const [showApiKey, setShowApiKey] = useState(false); |
| const [profileCurrentPw, setProfileCurrentPw] = useState(''); |
| const [profileNewPw, setProfileNewPw] = useState(''); |
| const [profileConfirmPw, setProfileConfirmPw] = useState(''); |
| const [profilePwLoading, setProfilePwLoading] = useState(false); |
|
|
| const [showScrollTop, setShowScrollTop] = useState(false); |
|
|
| |
| const [mobileMenuOpen, setMobileMenuOpen] = useState(false); |
| const [dashMobileMenu, setDashMobileMenu] = useState(false); |
|
|
| const [editingInvoice, setEditingInvoice] = useState<InvoiceRow | null>(null); |
| const [editForm, setEditForm] = useState<Record<string, string>>({}); |
| const [editSaving, setEditSaving] = useState(false); |
| const [dateFrom, setDateFrom] = useState(''); |
| const [dateTo, setDateTo] = useState(''); |
| const [sortField, setSortField] = useState<string>('createdAt'); |
| const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); |
|
|
| |
| const [activityLogs, setActivityLogs] = useState<Array<{id:string;type:string;title:string;description:string;createdAt:string;metadata:string|null}>>([]); |
| const [activityLoading, setActivityLoading] = useState(false); |
|
|
| const fetchActivity = useCallback(async () => { |
| setActivityLoading(true); |
| try { |
| const res = await fetch('/api/activity'); |
| if (res.ok) { |
| const data = await res.json(); |
| setActivityLogs(data.activities); |
| } |
| } catch { } |
| finally { setActivityLoading(false); } |
| }, []); |
|
|
| useEffect(() => { |
| if (store.view === 'dashboard' && store.activeDashTab === 'activity') { |
| fetchActivity(); |
| } |
| }, [store.view, store.activeDashTab, fetchActivity]); |
|
|
| const [showNotifications, setShowNotifications] = useState(false); |
| const [showShortcuts, setShowShortcuts] = useState(false); |
| const [showCommandPalette, setShowCommandPalette] = useState(false); |
| const [commandSearch, setCommandSearch] = useState(''); |
|
|
| const notifications = [ |
| ...(store.invoices.length > 0 ? [{ |
| id: '1', |
| title: 'Invoices processed', |
| description: `${store.invoices.length} invoice(s) in your account`, |
| time: 'Just now', |
| icon: FileText, |
| color: 'text-teal-500', |
| }] : []), |
| { |
| id: '2', |
| title: 'Welcome to OmniParse', |
| description: 'Upload your first invoice to get started', |
| time: 'Getting started', |
| icon: Sparkles, |
| color: 'text-amber-500', |
| }, |
| { |
| id: '3', |
| title: 'AI Chat available', |
| description: store.user?.plan === 'free' |
| ? 'Upgrade to Pro to unlock AI Chat' |
| : 'Ask questions about your invoices', |
| time: 'Feature', |
| icon: Bot, |
| color: 'text-amber-500', |
| }, |
| ]; |
|
|
| |
| const [dateFormat, setDateFormat] = useState<'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD'>('MM/DD/YYYY'); |
| const [defaultCurrency, setDefaultCurrency] = useState('USD'); |
| const [compactView, setCompactView] = useState(false); |
| const [settingsSaved, setSettingsSaved] = useState(false); |
|
|
| const saveSettings = useCallback(() => { |
| setSettingsSaved(true); |
| toast.success('Settings saved successfully'); |
| setTimeout(() => setSettingsSaved(false), 2000); |
| }, []); |
|
|
| |
| const [selectedFiles, setSelectedFiles] = useState<File[]>([]); |
| const [dragOver, setDragOver] = useState(false); |
| const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
| const toggleSort = useCallback((field: string) => { |
| if (sortField === field) { |
| setSortDir(d => d === 'asc' ? 'desc' : 'asc'); |
| } else { |
| setSortField(field); |
| setSortDir('asc'); |
| } |
| }, [sortField]); |
|
|
| |
| const filteredInvoices = useMemo(() => { |
| let result = store.invoices.filter((inv) => { |
| if (invoiceSearch && !inv.vendor?.toLowerCase().includes(invoiceSearch.toLowerCase())) return false; |
| if (invoiceFilter === 'done') return inv.status === 'done' && !inv.isDuplicate; |
| if (invoiceFilter === 'review') return inv.status === 'review' || inv.isDuplicate; |
| if (invoiceFilter === 'duplicates') return inv.isDuplicate; |
| if (dateFrom && inv.invDate && inv.invDate < dateFrom) return false; |
| if (dateTo && inv.invDate && inv.invDate > dateTo) return false; |
| return true; |
| }); |
| result.sort((a, b) => { |
| let cmp = 0; |
| const f = sortField; |
| if (f === 'vendor') cmp = (a.vendor || '').localeCompare(b.vendor || ''); |
| else if (f === 'invNumber') cmp = (a.invNumber || '').localeCompare(b.invNumber || ''); |
| else if (f === 'invDate') cmp = (a.invDate || '').localeCompare(b.invDate || ''); |
| else if (f === 'amount') cmp = (a.amount ?? 0) - (b.amount ?? 0); |
| else if (f === 'total') cmp = (a.total ?? 0) - (b.total ?? 0); |
| else if (f === 'confidence') cmp = (a.confidence ?? 0) - (b.confidence ?? 0); |
| else if (f === 'status') cmp = a.status.localeCompare(b.status); |
| else cmp = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); |
| return sortDir === 'asc' ? cmp : -cmp; |
| }); |
| return result; |
| }, [store.invoices, invoiceSearch, invoiceFilter, dateFrom, dateTo, sortField, sortDir]); |
|
|
| |
| const chatEndRef = useRef<HTMLDivElement>(null); |
|
|
| |
| const featuresRef = useRef<HTMLElement>(null); |
| const pricingRef = useRef<HTMLElement>(null); |
|
|
| |
| |
| |
|
|
| const fetchMe = useCallback(async () => { |
| const { setUser, setView, setLoading } = useAppStore.getState(); |
| try { |
| const res = await fetch('/api/auth/me'); |
| if (res.ok) { |
| const data = await res.json(); |
| setUser(data.user); |
| setView('dashboard'); |
| } else { |
| setUser(null); |
| setLoading(false); |
| } |
| } catch { |
| setUser(null); |
| setLoading(false); |
| } |
| }, []); |
|
|
| const fetchLogin = useCallback(async () => { |
| setAuthError(''); |
| setAuthLoading(true); |
| try { |
| const res = await fetch('/api/auth/login', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ email: loginEmail, password: loginPassword }), |
| }); |
| const data = await res.json(); |
| if (!res.ok) { |
| setAuthError(data.error || 'Login failed'); |
| setAuthLoading(false); |
| return; |
| } |
| const { setUser, setView } = useAppStore.getState(); |
| setUser(data.user); |
| setView('dashboard'); |
| toast.success('Welcome back!'); |
| } catch { |
| setAuthError('Network error. Please try again.'); |
| } finally { |
| setAuthLoading(false); |
| } |
| }, [loginEmail, loginPassword]); |
|
|
| const fetchSignup = useCallback(async () => { |
| setAuthError(''); |
| if (signupPassword !== signupConfirm) { |
| setAuthError('Passwords do not match'); |
| return; |
| } |
| setAuthLoading(true); |
| try { |
| const res = await fetch('/api/auth/signup', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ name: signupName, email: signupEmail, password: signupPassword, termsAccepted: true }), |
| }); |
| const data = await res.json(); |
| if (!res.ok) { |
| setAuthError(data.error || 'Signup failed'); |
| setAuthLoading(false); |
| return; |
| } |
| const { setUser, setView } = useAppStore.getState(); |
| setUser(data.user); |
| setView('dashboard'); |
| toast.success('Account created successfully!'); |
| } catch { |
| setAuthError('Network error. Please try again.'); |
| } finally { |
| setAuthLoading(false); |
| } |
| }, [signupName, signupEmail, signupPassword, signupConfirm]); |
|
|
| const fetchLogout = useCallback(() => { |
| fetch('/api/auth/logout', { method: 'POST' }).catch(() => {}); |
| toast.success('Logged out'); |
| window.location.href = '/'; |
| }, []); |
|
|
| const fetchInvoices = useCallback(async (filter?: string) => { |
| const { setLoading, setInvoices } = useAppStore.getState(); |
| setLoading(true); |
| try { |
| const params = filter && filter !== 'all' ? `?filter=${filter}` : ''; |
| const res = await fetch(`/api/invoices${params}`); |
| if (res.ok) { |
| const data = await res.json(); |
| setInvoices(data.invoices); |
| } |
| } catch { |
| toast.error('Failed to load invoices'); |
| } finally { |
| setLoading(false); |
| } |
| }, []); |
|
|
| const fetchUpload = useCallback(async () => { |
| if (selectedFiles.length === 0) return; |
| const { setUploadLoading, setUploadResults } = useAppStore.getState(); |
| setUploadLoading(true); |
| try { |
| const formData = new FormData(); |
| for (const file of selectedFiles) { |
| formData.append('files', file); |
| } |
| const res = await fetch('/api/invoices/upload', { |
| method: 'POST', |
| body: formData, |
| }); |
| const data = await res.json(); |
| if (!res.ok) { |
| toast.error(data.error || 'Upload failed'); |
| setUploadLoading(false); |
| return; |
| } |
| setUploadResults(data.invoices); |
| setSelectedFiles([]); |
| toast.success(`${data.invoices.length} invoice(s) processed successfully!`); |
| fetchInvoices(invoiceFilter); |
| } catch { |
| toast.error('Upload failed. Please try again.'); |
| } finally { |
| setUploadLoading(false); |
| } |
| }, [selectedFiles, invoiceFilter, fetchInvoices]); |
|
|
| const fetchDeleteInvoice = useCallback(async (id: string) => { |
| if (!window.confirm('Are you sure you want to delete this invoice? This action cannot be undone.')) return; |
| try { |
| const res = await fetch('/api/invoices', { |
| method: 'DELETE', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ id }), |
| }); |
| if (res.ok) { |
| toast.success('Invoice deleted'); |
| setSelectedInvoices(prev => { const n = new Set(prev); n.delete(id); return n; }); |
| fetchInvoices(invoiceFilter); |
| if (expandedInvoice === id) setExpandedInvoice(null); |
| } else { |
| const data = await res.json(); |
| toast.error(data.error || 'Failed to delete'); |
| } |
| } catch { |
| toast.error('Failed to delete invoice'); |
| } |
| }, [invoiceFilter, expandedInvoice, fetchInvoices]); |
|
|
| const fetchBatchDelete = useCallback(async () => { |
| const ids = Array.from(selectedInvoices); |
| if (ids.length === 0) return; |
| if (!window.confirm(`Delete ${ids.length} invoice(s)? This action cannot be undone.`)) return; |
| try { |
| await Promise.all(ids.map(id => |
| fetch('/api/invoices', { |
| method: 'DELETE', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ id }), |
| }) |
| )); |
| toast.success(`${ids.length} invoice(s) deleted`); |
| setSelectedInvoices(new Set()); |
| fetchInvoices(invoiceFilter); |
| } catch { |
| toast.error('Failed to delete some invoices'); |
| } |
| }, [selectedInvoices, invoiceFilter, fetchInvoices]); |
|
|
| const toggleSelect = useCallback((id: string) => { |
| setSelectedInvoices(prev => { |
| const next = new Set(prev); |
| if (next.has(id)) next.delete(id); |
| else next.add(id); |
| return next; |
| }); |
| }, []); |
|
|
| const toggleSelectAll = useCallback(() => { |
| if (selectedInvoices.size === filteredInvoices.length) { |
| setSelectedInvoices(new Set()); |
| } else { |
| setSelectedInvoices(new Set(filteredInvoices.map(i => i.id))); |
| } |
| }, [selectedInvoices.size, filteredInvoices]); |
|
|
| const fetchChat = useCallback(async () => { |
| if (!chatInput.trim()) return; |
| const { addChatMessage, chatHistory, setChatLoading } = useAppStore.getState(); |
| const userMsg: ChatMessage = { role: 'user', content: chatInput.trim() }; |
| addChatMessage(userMsg); |
| const historyForApi = [...chatHistory, userMsg].map((m) => ({ role: m.role, content: m.content })); |
| setChatInput(''); |
| setChatLoading(true); |
| try { |
| const res = await fetch('/api/chat', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ message: userMsg.content, history: historyForApi.slice(0, -1) }), |
| }); |
| const data = await res.json(); |
| if (!res.ok) { |
| addChatMessage({ role: 'assistant', content: data.error || 'Something went wrong.' }); |
| } else { |
| addChatMessage({ role: 'assistant', content: data.reply }); |
| } |
| } catch { |
| addChatMessage({ role: 'assistant', content: 'Network error. Please try again.' }); |
| } finally { |
| setChatLoading(false); |
| } |
| }, [chatInput]); |
|
|
| const exportCsv = useCallback(() => { |
| window.open('/api/invoices/export/csv', '_blank'); |
| toast.success('CSV download started'); |
| }, []); |
|
|
| const exportJson = useCallback(() => { |
| const { user } = useAppStore.getState(); |
| if (user?.plan === 'free') { |
| toast.error('JSON export is available on Pro+ plans. Please upgrade.'); |
| return; |
| } |
| window.open('/api/invoices/export/json', '_blank'); |
| toast.success('JSON download started'); |
| }, []); |
|
|
| const openEditModal = useCallback((inv: InvoiceRow) => { |
| setEditingInvoice(inv); |
| setEditForm({ |
| vendor: inv.vendor || '', |
| invNumber: inv.invNumber || '', |
| invDate: inv.invDate || '', |
| dueDate: inv.dueDate || '', |
| amount: String(inv.amount || ''), |
| vatAmount: String(inv.vatAmount || ''), |
| total: String(inv.total || ''), |
| currency: inv.currency, |
| status: inv.status === 'review' ? 'review' : 'done', |
| }); |
| }, []); |
|
|
| const saveEdit = useCallback(async () => { |
| if (!editingInvoice) return; |
| setEditSaving(true); |
| try { |
| const res = await fetch('/api/invoices/update', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ id: editingInvoice.id, ...editForm }), |
| }); |
| if (res.ok) { |
| toast.success('Invoice updated'); |
| setEditingInvoice(null); |
| fetchInvoices(invoiceFilter); |
| } else { |
| const data = await res.json(); |
| toast.error(data.error || 'Update failed'); |
| } |
| } catch { |
| toast.error('Failed to update invoice'); |
| } finally { |
| setEditSaving(false); |
| } |
| }, [editingInvoice, editForm, invoiceFilter, fetchInvoices]); |
|
|
| |
|
|
| useEffect(() => { |
| fetchMe(); |
| }, [fetchMe]); |
|
|
| useEffect(() => { |
| if (store.view === 'dashboard' && store.activeDashTab === 'invoices') { |
| fetchInvoices(invoiceFilter); |
| } |
| }, [store.view, store.activeDashTab, invoiceFilter, fetchInvoices]); |
|
|
| useEffect(() => { |
| chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); |
| }, [store.chatHistory, store.chatLoading]); |
|
|
| |
|
|
| const handleDragOver = useCallback((e: React.DragEvent) => { |
| e.preventDefault(); |
| setDragOver(true); |
| }, []); |
|
|
| const handleDragLeave = useCallback((e: React.DragEvent) => { |
| e.preventDefault(); |
| setDragOver(false); |
| }, []); |
|
|
| const handleDrop = useCallback((e: React.DragEvent) => { |
| e.preventDefault(); |
| setDragOver(false); |
| const files = Array.from(e.dataTransfer.files).filter( |
| (f) => f.type === 'application/pdf' || f.type.startsWith('image/') |
| ); |
| setSelectedFiles((prev) => [...prev, ...files]); |
| }, []); |
|
|
| const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { |
| if (e.target.files) { |
| setSelectedFiles((prev) => [...prev, ...Array.from(e.target.files!)]); |
| } |
| }, []); |
|
|
| const removeFile = useCallback((index: number) => { |
| setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); |
| }, []); |
|
|
| |
|
|
| const scrollToFeatures = useCallback(() => { |
| featuresRef.current?.scrollIntoView({ behavior: 'smooth' }); |
| setMobileMenuOpen(false); |
| }, []); |
|
|
| const scrollToPricing = useCallback(() => { |
| pricingRef.current?.scrollIntoView({ behavior: 'smooth' }); |
| setMobileMenuOpen(false); |
| }, []); |
|
|
| |
| useEffect(() => { |
| const handler = (e: KeyboardEvent) => { |
| if ((e.ctrlKey || e.metaKey) && e.key === 'k') { |
| e.preventDefault(); |
| const { view } = useAppStore.getState(); |
| if (view === 'dashboard') { |
| setShowCommandPalette(true); |
| } |
| } |
| if ((e.ctrlKey || e.metaKey) && e.key === '/') { |
| e.preventDefault(); |
| const { view } = useAppStore.getState(); |
| if (view === 'dashboard') { |
| setShowShortcuts(true); |
| } |
| } |
| }; |
| window.addEventListener('keydown', handler); |
| return () => window.removeEventListener('keydown', handler); |
| }, []); |
|
|
| useEffect(() => { |
| const handleScroll = () => { |
| setShowScrollTop(window.scrollY > 400); |
| }; |
| window.addEventListener('scroll', handleScroll, { passive: true }); |
| return () => window.removeEventListener('scroll', handleScroll); |
| }, []); |
|
|
| |
|
|
| if (store.isLoading) { |
| return ( |
| <div className="min-h-screen flex items-center justify-center"> |
| <Loader2 className="w-8 h-8 animate-spin text-amber-500" /> |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
|
|
| if (store.view === 'landing') { |
| return ( |
| <div className="min-h-screen flex flex-col noise-overlay"> |
| {/* ── Navbar ── */} |
| <header className="fixed top-0 left-0 right-0 z-50 nav-blur"> |
| <nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"> |
| <div className="flex items-center gap-2"> |
| <div className="logo-glow w-8 h-8 rounded-lg bg-amber-500 flex items-center justify-center"> |
| <ScanLine className="w-5 h-5 text-zinc-900" /> |
| </div> |
| <span className="text-lg font-bold text-foreground">OmniParse</span> |
| </div> |
| <div className="hidden md:flex items-center gap-8"> |
| <button onClick={scrollToFeatures} className="btn-ghost text-sm"> |
| Features |
| </button> |
| <button onClick={scrollToPricing} className="btn-ghost text-sm"> |
| Pricing |
| </button> |
| </div> |
| <div className="hidden md:flex items-center gap-3"> |
| <button |
| onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} |
| className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/[0.04] transition-colors" |
| aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'} |
| > |
| {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />} |
| </button> |
| <button |
| onClick={() => { setAuthMode('login'); store.setView('auth'); }} |
| className="btn-ghost text-sm" |
| > |
| Log in |
| </button> |
| <button |
| onClick={() => { setAuthMode('signup'); store.setView('auth'); }} |
| className="btn-primary text-sm" |
| > |
| Get Started |
| </button> |
| </div> |
| <button |
| className="md:hidden text-muted-foreground hover:text-foreground" |
| onClick={() => setMobileMenuOpen(!mobileMenuOpen)} |
| aria-label="Toggle navigation menu" |
| > |
| {mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />} |
| </button> |
| </nav> |
| {mobileMenuOpen && ( |
| <div className="md:hidden border-t border-white/[0.06] bg-background/95 backdrop-blur-md fade-in mobile-nav-slide"> |
| <div className="px-4 py-4 space-y-3"> |
| <button onClick={scrollToFeatures} className="block w-full text-left text-sm text-muted-foreground hover:text-foreground py-2"> |
| Features |
| </button> |
| <button onClick={scrollToPricing} className="block w-full text-left text-sm text-muted-foreground hover:text-foreground py-2"> |
| Pricing |
| </button> |
| <div className="pt-2 border-t border-white/[0.06] space-y-2"> |
| <button |
| onClick={() => { setTheme(theme === 'dark' ? 'light' : 'dark'); }} |
| className="flex items-center gap-3 w-full text-sm text-muted-foreground hover:text-foreground py-2" |
| > |
| {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />} |
| {theme === 'dark' ? 'Light mode' : 'Dark mode'} |
| </button> |
| <button |
| onClick={() => { setAuthMode('login'); store.setView('auth'); setMobileMenuOpen(false); }} |
| className="block w-full text-sm text-muted-foreground hover:text-foreground py-2" |
| > |
| Log in |
| </button> |
| <button |
| onClick={() => { setAuthMode('signup'); store.setView('auth'); setMobileMenuOpen(false); }} |
| className="btn-primary text-sm block w-full text-center" |
| > |
| Get Started |
| </button> |
| </div> |
| </div> |
| </div> |
| )} |
| </header> |
| |
| <main className="flex-1"> |
| {/* ── Hero Section ── */} |
| <section className="relative min-h-[90vh] flex items-center justify-center grid-bg hero-grid-enhanced overflow-hidden pt-16"> |
| <div className="hero-orb" /> |
| <div className="hero-orb-2" /> |
| <div className="relative z-10 max-w-4xl mx-auto px-4 text-center"> |
| <div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] text-sm text-muted-foreground mb-8 fade-in float-animation"> |
| <Sparkles className="w-4 h-4 text-amber-500" /> |
| Powered by advanced AI extraction |
| </div> |
| <h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold tracking-tight mb-6 fade-in"> |
| Invoice Processing,{' '} |
| <span className="gradient-text">Reimagined</span> |
| </h1> |
| <p className="text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto mb-10 fade-in"> |
| Upload any invoice and let AI extract vendor details, amounts, dates, and line items in seconds. Export structured data to CSV, JSON, or connect via API. |
| </p> |
| <div className="flex flex-col sm:flex-row items-center justify-center gap-4 fade-in"> |
| <button |
| onClick={() => { setAuthMode('signup'); store.setView('auth'); }} |
| className="btn-shine pulse-glow flex items-center gap-2 px-8 py-3.5 rounded-xl bg-amber-500 text-zinc-900 font-semibold text-base hover:bg-amber-400 transition-all hover:shadow-lg hover:shadow-amber-500/20" |
| > |
| Start Free Trial |
| <ArrowRight className="w-5 h-5" /> |
| </button> |
| <button |
| onClick={scrollToFeatures} |
| className="flex items-center gap-2 px-8 py-3.5 rounded-xl border border-white/[0.1] text-foreground font-medium text-base hover:bg-white/[0.04] transition-all" |
| > |
| See How It Works |
| <ChevronDown className="w-5 h-5" /> |
| </button> |
| </div> |
| </div> |
| <div className="hidden lg:block absolute top-32 right-16 float-animation-delayed"> |
| <div className="glass-card p-3 flex items-center gap-2 text-xs text-muted-foreground"> |
| <Shield className="w-4 h-4 text-teal-500" /> |
| <span>SOC 2 Compliant</span> |
| </div> |
| </div> |
| </section> |
| |
| {/* ── Stats Bar ── */} |
| <section className="border-y border-white/[0.06] bg-white/[0.01]"> |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 grid grid-cols-1 sm:grid-cols-3 gap-8"> |
| <div className="stat-animate text-center"> |
| <div className="text-3xl sm:text-4xl font-bold text-foreground">10K+</div> |
| <div className="text-sm text-muted-foreground mt-1">Invoices Processed</div> |
| </div> |
| <div className="stat-animate text-center"> |
| <div className="text-3xl sm:text-4xl font-bold text-foreground">99.2%</div> |
| <div className="text-sm text-muted-foreground mt-1">Accuracy Rate</div> |
| </div> |
| <div className="stat-animate text-center"> |
| <div className="text-3xl sm:text-4xl font-bold text-foreground">500+</div> |
| <div className="text-sm text-muted-foreground mt-1">Companies Trust Us</div> |
| </div> |
| </div> |
| </section> |
| |
| <hr className="section-glow-divider my-0" /> |
| |
| {/* ── How It Works ── */} |
| <section className="py-20 sm:py-28"> |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> |
| <div className="text-center mb-16"> |
| <h2 className="text-3xl sm:text-4xl font-bold mb-4">How It Works</h2> |
| <p className="text-muted-foreground text-lg max-w-xl mx-auto">Three simple steps to transform your invoices into structured data</p> |
| </div> |
| <div className="relative grid grid-cols-1 md:grid-cols-3 gap-8"> |
| <article className="step-connector text-center section-reveal"> |
| <div className="w-14 h-14 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-5"> |
| <span className="text-xl font-bold text-amber-500">1</span> |
| </div> |
| <h3 className="text-xl font-semibold mb-3">Upload</h3> |
| <p className="text-muted-foreground text-sm leading-relaxed"> |
| Drag and drop your invoice PDFs or images. We support batch uploads of multiple files at once. |
| </p> |
| </article> |
| <article className="step-connector text-center section-reveal" style={{ animationDelay: '0.15s' }}> |
| <div className="w-14 h-14 rounded-full bg-teal-500/10 border border-teal-500/20 flex items-center justify-center mx-auto mb-5"> |
| <span className="text-xl font-bold text-teal-500">2</span> |
| </div> |
| <h3 className="text-xl font-semibold mb-3">Extract</h3> |
| <p className="text-muted-foreground text-sm leading-relaxed"> |
| Our AI automatically extracts vendor details, line items, amounts, dates, and detects duplicates. |
| </p> |
| </article> |
| <article className="step-connector text-center section-reveal" style={{ animationDelay: '0.3s' }}> |
| <div className="w-14 h-14 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-5"> |
| <span className="text-xl font-bold text-amber-500">3</span> |
| </div> |
| <h3 className="text-xl font-semibold mb-3">Export</h3> |
| <p className="text-muted-foreground text-sm leading-relaxed"> |
| Download your data as CSV or JSON, or integrate directly with your systems using our REST API. |
| </p> |
| </article> |
| </div> |
| </div> |
| </section> |
| |
| <hr className="section-glow-divider my-0" /> |
| |
| {/* ── Features Section ── */} |
| <section ref={featuresRef} className="py-20 sm:py-28 bg-white/[0.01]"> |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> |
| <div className="text-center mb-16"> |
| <h2 className="text-3xl sm:text-4xl font-bold mb-4">Everything You Need</h2> |
| <p className="text-muted-foreground text-lg max-w-xl mx-auto">Powerful features designed to streamline your invoice processing workflow</p> |
| </div> |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> |
| <article className="feature-card-gradient feature-amber p-6 card-enter"> |
| <div className="feature-icon w-11 h-11 rounded-lg flex items-center justify-center mb-4"> |
| <Zap className="w-5 h-5" /> |
| </div> |
| <h3 className="text-base font-semibold mb-2">Instant Extraction</h3> |
| <p className="text-sm text-muted-foreground leading-relaxed"> |
| Extract all invoice fields in under 3 seconds per document with near-perfect accuracy. |
| </p> |
| </article> |
| <article className="feature-card-gradient feature-teal p-6 card-enter"> |
| <div className="feature-icon w-11 h-11 rounded-lg flex items-center justify-center mb-4"> |
| <Shield className="w-5 h-5" /> |
| </div> |
| <h3 className="text-base font-semibold mb-2">Duplicate Detection</h3> |
| <p className="text-sm text-muted-foreground leading-relaxed"> |
| Automatically flags potential duplicate invoices based on vendor and amount matching. |
| </p> |
| </article> |
| <article className="feature-card-gradient feature-red p-6 card-enter"> |
| <div className="feature-icon w-11 h-11 rounded-lg flex items-center justify-center mb-4"> |
| <Bot className="w-5 h-5" /> |
| </div> |
| <h3 className="text-base font-semibold mb-2">AI Chat Assistant</h3> |
| <p className="text-sm text-muted-foreground leading-relaxed"> |
| Ask questions about your invoices, get spending insights, and analyze vendor relationships. |
| </p> |
| </article> |
| <article className="feature-card-gradient feature-green p-6 card-enter"> |
| <div className="feature-icon w-11 h-11 rounded-lg flex items-center justify-center mb-4"> |
| <Globe className="w-5 h-5" /> |
| </div> |
| <h3 className="text-base font-semibold mb-2">Multi-Currency</h3> |
| <p className="text-sm text-muted-foreground leading-relaxed"> |
| Automatically detects and processes invoices in USD, EUR, GBP, CAD, AUD, and more. |
| </p> |
| </article> |
| <article className="feature-card-gradient feature-sky p-6 card-enter"> |
| <div className="feature-icon w-11 h-11 rounded-lg flex items-center justify-center mb-4"> |
| <Layers className="w-5 h-5" /> |
| </div> |
| <h3 className="text-base font-semibold mb-2">Bulk Processing</h3> |
| <p className="text-sm text-muted-foreground leading-relaxed"> |
| Upload dozens of invoices at once. Our system processes them in parallel for maximum speed. |
| </p> |
| </article> |
| <article className="feature-card-gradient feature-rose p-6 card-enter"> |
| <div className="feature-icon w-11 h-11 rounded-lg flex items-center justify-center mb-4"> |
| <Lock className="w-5 h-5" /> |
| </div> |
| <h3 className="text-base font-semibold mb-2">Enterprise Security</h3> |
| <p className="text-sm text-muted-foreground leading-relaxed"> |
| Your data is encrypted at rest and in transit. SOC 2 compliant infrastructure. |
| </p> |
| </article> |
| </div> |
| </div> |
| </section> |
| |
| <hr className="section-glow-divider my-0" /> |
| |
| {/* ── Testimonials ── */} |
| <section className="py-20 sm:py-28"> |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> |
| <div className="text-center mb-16"> |
| <h2 className="text-3xl sm:text-4xl font-bold mb-4">Trusted by Teams Worldwide</h2> |
| <p className="text-muted-foreground text-lg max-w-xl mx-auto">See what our customers have to say</p> |
| </div> |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> |
| <blockquote className="testimonial-card p-6 card-enter" style={{ animationDelay: '0ms' }}> |
| <Quote className="w-8 h-8 text-amber-500/40 mb-4" /> |
| <p className="text-sm text-muted-foreground leading-relaxed mb-6"> |
| “OmniParse reduced our invoice processing time from hours to minutes. The accuracy is outstanding and the duplicate detection has saved us from overpaying vendors multiple times.” |
| </p> |
| <footer className="flex items-center gap-3"> |
| <div className="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center"> |
| <span className="text-sm font-semibold text-amber-500">SM</span> |
| </div> |
| <div> |
| <div className="text-sm font-medium">Sarah Mitchell</div> |
| <div className="text-xs text-muted-foreground">CFO, TechVentures Inc.</div> |
| </div> |
| </footer> |
| </blockquote> |
| <blockquote className="testimonial-card p-6 card-enter" style={{ animationDelay: '100ms' }}> |
| <Quote className="w-8 h-8 text-teal-500/40 mb-4" /> |
| <p className="text-sm text-muted-foreground leading-relaxed mb-6"> |
| “We process over 500 invoices per month. The API integration was seamless and the structured JSON output fits perfectly into our ERP system. A game changer for our accounting team.” |
| </p> |
| <footer className="flex items-center gap-3"> |
| <div className="w-10 h-10 rounded-full bg-teal-500/10 flex items-center justify-center"> |
| <span className="text-sm font-semibold text-teal-500">JK</span> |
| </div> |
| <div> |
| <div className="text-sm font-medium">James Kim</div> |
| <div className="text-xs text-muted-foreground">CTO, DataFlow Systems</div> |
| </div> |
| </footer> |
| </blockquote> |
| <blockquote className="testimonial-card p-6 card-enter" style={{ animationDelay: '200ms' }}> |
| <Quote className="w-8 h-8 text-amber-500/40 mb-4" /> |
| <p className="text-sm text-muted-foreground leading-relaxed mb-6"> |
| “The AI chat feature is incredible. I can ask questions like 'Which vendor had the highest spending this quarter?' and get instant answers. It's like having a financial analyst on demand.” |
| </p> |
| <footer className="flex items-center gap-3"> |
| <div className="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center"> |
| <span className="text-sm font-semibold text-amber-500">RP</span> |
| </div> |
| <div> |
| <div className="text-sm font-medium">Rachel Patel</div> |
| <div className="text-xs text-muted-foreground">VP Finance, Meridian Group</div> |
| </div> |
| </footer> |
| </blockquote> |
| </div> |
| </div> |
| </section> |
| |
| <hr className="section-glow-divider my-0" /> |
| |
| {/* ── FAQ ── */} |
| <section className="py-20 sm:py-28 bg-white/[0.01]"> |
| <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8"> |
| <div className="text-center mb-16"> |
| <h2 className="text-3xl sm:text-4xl font-bold mb-4">Frequently Asked Questions</h2> |
| </div> |
| <div className="space-y-3"> |
| <details className="glass-card group"> |
| <summary className="flex items-center justify-between cursor-pointer p-5 text-sm font-medium"> |
| What file formats are supported? |
| <ChevronDown className="w-4 h-4 text-muted-foreground group-open:rotate-180 transition-transform" /> |
| </summary> |
| <div className="px-5 pb-5 text-sm text-muted-foreground leading-relaxed"> |
| We support PDF files and common image formats including PNG, JPG, and JPEG. You can upload multiple files at once for batch processing. |
| </div> |
| </details> |
| <details className="glass-card group"> |
| <summary className="flex items-center justify-between cursor-pointer p-5 text-sm font-medium"> |
| How accurate is the extraction? |
| <ChevronDown className="w-4 h-4 text-muted-foreground group-open:rotate-180 transition-transform" /> |
| </summary> |
| <div className="px-5 pb-5 text-sm text-muted-foreground leading-relaxed"> |
| Our AI achieves 99.2% accuracy on standard invoices. Each extraction includes a confidence score so you can review lower-confidence results. The system also flags potential duplicates automatically. |
| </div> |
| </details> |
| <details className="glass-card group"> |
| <summary className="flex items-center justify-between cursor-pointer p-5 text-sm font-medium"> |
| Is my data secure? |
| <ChevronDown className="w-4 h-4 text-muted-foreground group-open:rotate-180 transition-transform" /> |
| </summary> |
| <div className="px-5 pb-5 text-sm text-muted-foreground leading-relaxed"> |
| Absolutely. All data is encrypted at rest and in transit. We use SOC 2 compliant infrastructure and your documents are never shared with third parties. You can delete your data at any time. |
| </div> |
| </details> |
| <details className="glass-card group"> |
| <summary className="flex items-center justify-between cursor-pointer p-5 text-sm font-medium"> |
| Can I integrate OmniParse with my existing tools? |
| <ChevronDown className="w-4 h-4 text-muted-foreground group-open:rotate-180 transition-transform" /> |
| </summary> |
| <div className="px-5 pb-5 text-sm text-muted-foreground leading-relaxed"> |
| Yes! We provide a REST API with your unique API key. You can upload invoices, retrieve processed data, and trigger exports programmatically. Full API documentation is available in your dashboard. |
| </div> |
| </details> |
| <details className="glass-card group"> |
| <summary className="flex items-center justify-between cursor-pointer p-5 text-sm font-medium"> |
| What happens when I reach my plan limit? |
| <ChevronDown className="w-4 h-4 text-muted-foreground group-open:rotate-180 transition-transform" /> |
| </summary> |
| <div className="px-5 pb-5 text-sm text-muted-foreground leading-relaxed"> |
| You will receive a notification when you are approaching your monthly limit. Once reached, you can upgrade your plan or wait for the next billing cycle. Enterprise plans have unlimited processing. |
| </div> |
| </details> |
| </div> |
| </div> |
| </section> |
| |
| <hr className="section-glow-divider my-0" /> |
| |
| {/* ── Pricing Section ── */} |
| <section ref={pricingRef} className="py-20 sm:py-28"> |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> |
| <div className="text-center mb-16"> |
| <h2 className="text-3xl sm:text-4xl font-bold mb-4">Simple, Transparent Pricing</h2> |
| <p className="text-muted-foreground text-lg max-w-xl mx-auto">Choose the plan that fits your needs. Upgrade or downgrade anytime.</p> |
| </div> |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6"> |
| {[ |
| { name: 'Free', price: 0, limit: '20 invoices/mo', features: ['Basic extraction', 'CSV export', 'Email support', '1 user'] }, |
| { name: 'Pro', price: 129, limit: '2,000 invoices/mo', features: ['Advanced extraction', 'CSV & JSON export', 'AI Chat assistant', 'API access', 'Priority support', '5 users'], highlight: true }, |
| { name: 'Business', price: 29, limit: '200 invoices/mo', features: ['Standard extraction', 'CSV & JSON export', 'API access', 'Email & chat support', '3 users'] }, |
| { name: 'Enterprise', price: 499, limit: 'Unlimited', features: ['Custom AI models', 'All export formats', 'Dedicated AI Chat', 'Full API access', 'Dedicated support', 'Unlimited users', 'SLA guarantee'] }, |
| ].map((plan) => ( |
| <article |
| key={plan.name} |
| className={`glass-card p-6 flex flex-col relative ${plan.highlight ? 'pricing-highlight amber-glow' : ''}`} |
| > |
| {plan.highlight && <div className="pricing-ribbon">Most Popular</div>} |
| <div className="mb-5"> |
| <h3 className="text-lg font-semibold mb-1">{plan.name}</h3> |
| <div className="text-3xl font-bold text-foreground"> |
| ${plan.price}<span className="text-sm font-normal text-muted-foreground">/mo</span> |
| </div> |
| <div className="text-sm text-muted-foreground mt-1">{plan.limit}</div> |
| </div> |
| <ul className="space-y-3 mb-8 flex-1"> |
| {plan.features.map((f) => ( |
| <li key={f} className="flex items-center gap-2 text-sm text-muted-foreground"> |
| <Check className="w-4 h-4 text-teal-500 shrink-0" /> |
| {f} |
| </li> |
| ))} |
| </ul> |
| <button |
| onClick={() => { |
| if (plan.name === 'Free') { |
| setAuthMode('signup'); |
| store.setView('auth'); |
| } else { |
| toast.info('Coming soon! This plan will be available shortly.'); |
| } |
| }} |
| className={`btn-shine w-full py-2.5 rounded-lg text-sm font-medium transition-colors ${ |
| plan.highlight |
| ? 'bg-amber-500 text-zinc-900 hover:bg-amber-400' |
| : 'border border-white/[0.1] text-foreground hover:bg-white/[0.04]' |
| }`} |
| > |
| {plan.name === 'Free' ? 'Get Started' : 'Coming Soon'} |
| </button> |
| </article> |
| ))} |
| </div> |
| </div> |
| </section> |
| </main> |
| |
| {/* ── Footer ── */} |
| <footer className="border-t border-white/[0.06] bg-white/[0.01] mt-auto"> |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 grid grid-cols-2 md:grid-cols-4 gap-8"> |
| <div> |
| <h4 className="text-sm font-semibold mb-4">Product</h4> |
| <ul className="space-y-2 text-sm"> |
| <li><span className="footer-link-underline">Features</span></li> |
| <li><span className="footer-link-underline">Pricing</span></li> |
| <li><span className="footer-link-underline">API Docs</span></li> |
| <li><span className="footer-link-underline">Changelog</span></li> |
| </ul> |
| </div> |
| <div> |
| <h4 className="text-sm font-semibold mb-4">Company</h4> |
| <ul className="space-y-2 text-sm"> |
| <li><span className="footer-link-underline">About</span></li> |
| <li><span className="footer-link-underline">Blog</span></li> |
| <li><span className="footer-link-underline">Careers</span></li> |
| <li><span className="footer-link-underline">Contact</span></li> |
| </ul> |
| </div> |
| <div> |
| <h4 className="text-sm font-semibold mb-4">Resources</h4> |
| <ul className="space-y-2 text-sm"> |
| <li><span className="footer-link-underline">Documentation</span></li> |
| <li><span className="footer-link-underline">Help Center</span></li> |
| <li><span className="footer-link-underline">Status Page</span></li> |
| </ul> |
| </div> |
| <div> |
| <h4 className="text-sm font-semibold mb-4">Legal</h4> |
| <ul className="space-y-2 text-sm"> |
| <li><span className="footer-link-underline">Privacy Policy</span></li> |
| <li><span className="footer-link-underline">Terms of Service</span></li> |
| <li><span className="footer-link-underline">Security</span></li> |
| </ul> |
| </div> |
| </div> |
| <div className="border-t border-white/[0.06]"> |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col sm:flex-row items-center justify-between gap-4"> |
| <div className="flex items-center gap-2"> |
| <div className="w-6 h-6 rounded-md bg-amber-500 flex items-center justify-center"> |
| <ScanLine className="w-3.5 h-3.5 text-zinc-900" /> |
| </div> |
| <span className="text-sm text-muted-foreground">OmniParse AI</span> |
| </div> |
| <p className="text-xs text-muted-foreground"> |
| © {new Date().getFullYear()} OmniParse AI. All rights reserved. |
| </p> |
| </div> |
| </div> |
| </footer> |
| |
| {/* ── Scroll to Top ── */} |
| <button |
| onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} |
| className={`scroll-top-btn ${showScrollTop ? 'visible' : ''}`} |
| aria-label="Scroll to top" |
| > |
| <ChevronUp className="w-5 h-5" /> |
| </button> |
| |
| {/* ── HF Space Download ── */} |
| <a |
| href="/omniparse-hf-space.zip" |
| download |
| className="fixed bottom-20 right-5 z-50 bg-amber-500 hover:bg-amber-400 text-black font-semibold text-xs px-3 py-2 rounded-lg shadow-lg transition-colors" |
| > |
| ⬇ Download HF Space Files (.zip) |
| </a> |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
|
|
| if (store.view === 'auth') { |
| const pwStrength = getPasswordStrength(authMode === 'signup' ? signupPassword : ''); |
| return ( |
| <div className="min-h-screen flex flex-col items-center justify-center px-4 py-12 noise-overlay"> |
| <div className="w-full max-w-md"> |
| <button |
| onClick={() => store.setView('landing')} |
| className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-8 transition-colors" |
| > |
| <ArrowLeft className="w-4 h-4" /> |
| Back to home |
| </button> |
| <div className="glass-card p-6 sm:p-8 amber-glow view-enter"> |
| <div className="text-center mb-8"> |
| <div className="w-12 h-12 rounded-xl bg-amber-500 flex items-center justify-center mx-auto mb-4"> |
| <ScanLine className="w-7 h-7 text-zinc-900" /> |
| </div> |
| <h1 className="text-2xl font-bold"> |
| {authMode === 'login' ? 'Welcome back' : 'Create your account'} |
| </h1> |
| <p className="text-sm text-muted-foreground mt-1"> |
| {authMode === 'login' ? 'Sign in to your OmniParse account' : 'Get started with OmniParse for free'} |
| </p> |
| </div> |
| |
| {/* Tab Toggle */} |
| <div className="flex border border-white/[0.08] rounded-lg p-1 mb-6"> |
| <button |
| onClick={() => { setAuthMode('login'); setAuthError(''); }} |
| className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${ |
| authMode === 'login' ? 'bg-amber-500 text-zinc-900' : 'text-muted-foreground hover:text-foreground' |
| }`} |
| > |
| Log in |
| </button> |
| <button |
| onClick={() => { setAuthMode('signup'); setAuthError(''); }} |
| className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${ |
| authMode === 'signup' ? 'bg-amber-500 text-zinc-900' : 'text-muted-foreground hover:text-foreground' |
| }`} |
| > |
| Sign up |
| </button> |
| </div> |
| |
| {/* Error Display */} |
| {authError && ( |
| <div className="flex items-center gap-2 p-3 mb-6 rounded-lg bg-red-500/10 border border-red-500/20 text-sm text-red-400 fade-in"> |
| <AlertCircle className="w-4 h-4 shrink-0" /> |
| {authError} |
| </div> |
| )} |
| |
| {authMode === 'login' ? ( |
| <form |
| onSubmit={(e) => { e.preventDefault(); fetchLogin(); }} |
| className="space-y-4" |
| > |
| <div> |
| <label className="input-label" htmlFor="login-email"> |
| Email |
| </label> |
| <div className="relative"> |
| <Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> |
| <input |
| id="login-email" |
| type="email" |
| value={loginEmail} |
| onChange={(e) => setLoginEmail(e.target.value)} |
| required |
| placeholder="you@company.com" |
| className="input-field input-field-glow pl-10" |
| /> |
| </div> |
| </div> |
| <div> |
| <label className="input-label" htmlFor="login-password"> |
| Password |
| </label> |
| <div className="relative"> |
| <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> |
| <input |
| id="login-password" |
| type="password" |
| value={loginPassword} |
| onChange={(e) => setLoginPassword(e.target.value)} |
| required |
| placeholder="Enter your password" |
| className="input-field input-field-glow pl-10" |
| /> |
| </div> |
| </div> |
| <button |
| type="submit" |
| disabled={authLoading} |
| className="btn-primary text-sm w-full flex items-center justify-center gap-2 disabled:opacity-50" |
| > |
| {authLoading && <Loader2 className="w-4 h-4 animate-spin" />} |
| Sign In |
| </button> |
| <div className="text-center"> |
| <button |
| type="button" |
| onClick={() => toast.info('Password reset feature coming soon')} |
| className="text-xs text-muted-foreground hover:text-amber-500 transition-colors" |
| > |
| Forgot your password? |
| </button> |
| </div> |
| <div className="text-center"> |
| <p className="text-xs text-muted-foreground mt-4"> |
| Demo credentials: <span className="text-amber-500">demo@omniparse.ai</span> / <span className="text-amber-500">demo1234</span> |
| </p> |
| </div> |
| </form> |
| ) : ( |
| <form |
| onSubmit={(e) => { e.preventDefault(); fetchSignup(); }} |
| className="space-y-4" |
| > |
| <div> |
| <label className="input-label" htmlFor="signup-name"> |
| Name |
| </label> |
| <div className="relative"> |
| <User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> |
| <input |
| id="signup-name" |
| type="text" |
| value={signupName} |
| onChange={(e) => setSignupName(e.target.value)} |
| required |
| placeholder="Your full name" |
| className="input-field input-field-glow pl-10" |
| /> |
| </div> |
| </div> |
| <div> |
| <label className="input-label" htmlFor="signup-email"> |
| Email |
| </label> |
| <div className="relative"> |
| <Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> |
| <input |
| id="signup-email" |
| type="email" |
| value={signupEmail} |
| onChange={(e) => setSignupEmail(e.target.value)} |
| required |
| placeholder="you@company.com" |
| className="input-field input-field-glow pl-10" |
| /> |
| </div> |
| </div> |
| <div> |
| <label className="input-label" htmlFor="signup-password"> |
| Password |
| </label> |
| <div className="relative"> |
| <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> |
| <input |
| id="signup-password" |
| type="password" |
| value={signupPassword} |
| onChange={(e) => setSignupPassword(e.target.value)} |
| required |
| minLength={6} |
| placeholder="At least 6 characters" |
| className="input-field input-field-glow pl-10" |
| /> |
| </div> |
| {signupPassword && ( |
| <div className="mt-2"> |
| <div className="h-1.5 w-full bg-zinc-800 rounded-full overflow-hidden"> |
| <div className={`h-full ${pwStrength.color} ${pwStrength.width} rounded-full transition-all duration-300`} /> |
| </div> |
| <p className="text-xs text-muted-foreground mt-1">{pwStrength.label}</p> |
| </div> |
| )} |
| </div> |
| <div> |
| <label className="input-label" htmlFor="signup-confirm"> |
| Confirm Password |
| </label> |
| <div className="relative"> |
| <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> |
| <input |
| id="signup-confirm" |
| type="password" |
| value={signupConfirm} |
| onChange={(e) => setSignupConfirm(e.target.value)} |
| required |
| placeholder="Confirm your password" |
| className="input-field input-field-glow pl-10" |
| /> |
| </div> |
| </div> |
| <label className="flex items-start gap-2.5 cursor-pointer group"> |
| <input |
| type="checkbox" |
| checked={signupTerms} |
| onChange={(e) => setSignupTerms(e.target.checked)} |
| className="mt-0.5 h-4 w-4 rounded border-zinc-600 bg-zinc-800 text-amber-500 focus:ring-amber-500 focus:ring-offset-0 cursor-pointer" |
| /> |
| <span className="text-xs text-muted-foreground leading-relaxed"> |
| I agree to the{' '} |
| <span className="text-amber-500 hover:text-amber-400 underline underline-offset-2">Terms of Service</span> |
| {' '}and{' '} |
| <span className="text-amber-500 hover:text-amber-400 underline underline-offset-2">Privacy Policy</span> |
| </span> |
| </label> |
| <button |
| type="submit" |
| disabled={authLoading || !signupTerms} |
| className="btn-primary text-sm w-full flex items-center justify-center gap-2 disabled:opacity-50" |
| > |
| {authLoading && <Loader2 className="w-4 h-4 animate-spin" />} |
| Create Account |
| </button> |
| </form> |
| )} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
|
|
| if (store.view === 'dashboard' && store.user) { |
| const planLimit = PLAN_LIMITS[store.user.plan] ?? PLAN_LIMITS.free; |
| const planLabel = PLAN_LABELS[store.user.plan] ?? store.user.plan; |
| const invoiceCount = store.invoices.length; |
| const totalAmount = store.invoices.reduce((s, i) => s + (i.total ?? 0), 0); |
| const dupCount = store.invoices.filter((i) => i.isDuplicate).length; |
| const avgConfidence = store.invoices.length > 0 |
| ? store.invoices.reduce((s, i) => s + (i.confidence ?? 0), 0) / store.invoices.length |
| : 0; |
| const usagePercent = planLimit === Infinity ? 0 : Math.min((invoiceCount / planLimit) * 100, 100); |
|
|
| return ( |
| <div className="min-h-screen flex flex-col noise-overlay"> |
| {/* ── Dashboard Header ── */} |
| <header className="sticky top-0 z-50 nav-blur"> |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"> |
| <div className="flex items-center gap-3"> |
| <div className="logo-glow w-8 h-8 rounded-lg bg-amber-500 flex items-center justify-center"> |
| <ScanLine className="w-5 h-5 text-zinc-900" /> |
| </div> |
| <span className="font-bold text-foreground hidden sm:inline">OmniParse</span> |
| <span className={`${store.user.plan === 'free' || store.user.plan === 'Free' ? 'badge-muted' : 'badge-amber'}`}> |
| {planLabel} |
| </span> |
| </div> |
| <div className="flex items-center gap-3"> |
| <button |
| onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} |
| className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/[0.04] transition-colors" |
| aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'} |
| > |
| {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />} |
| </button> |
| <button |
| onClick={() => { store.setActiveDashTab('upload'); }} |
| className="hidden sm:flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 transition-colors" |
| > |
| <Upload className="w-3.5 h-3.5" /> |
| Upload |
| </button> |
| <button |
| onClick={() => setShowCommandPalette(true)} |
| className="hidden sm:flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-white/[0.04] text-muted-foreground hover:text-foreground hover:bg-white/[0.06] border border-white/[0.06] transition-colors" |
| > |
| <Search className="w-3.5 h-3.5" /> |
| Search... |
| <kbd className="kbd text-[10px] ml-1">Ctrl+K</kbd> |
| </button> |
| <button |
| onClick={exportCsv} |
| className="hidden sm:flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-teal-500/10 text-teal-500 hover:bg-teal-500/20 transition-colors" |
| > |
| <FileDown className="w-3.5 h-3.5" /> |
| Export CSV |
| </button> |
| <button |
| onClick={() => { window.open('/api/invoices/export/xlsx', '_blank'); toast.success('Excel download started'); }} |
| className="hidden sm:flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 transition-colors" |
| > |
| <FileJson className="w-3.5 h-3.5" /> |
| Export XLSX |
| </button> |
| <div className="relative"> |
| <button |
| onClick={() => setShowNotifications(!showNotifications)} |
| className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/[0.04] transition-colors" |
| aria-label="Notifications" |
| > |
| <Bell className="w-4 h-4" /> |
| {store.invoices.length > 0 && ( |
| <span className="notification-badge" /> |
| )} |
| </button> |
| {showNotifications && ( |
| <> |
| <div className="fixed inset-0 z-40" onClick={() => setShowNotifications(false)} /> |
| <div className="notification-dropdown z-50"> |
| <div className="px-4 py-3 border-b border-white/[0.06]"> |
| <h3 className="text-sm font-semibold">Notifications</h3> |
| </div> |
| <div className="max-h-80 overflow-y-auto"> |
| {notifications.map((n) => { |
| const NIcon = n.icon; |
| return ( |
| <div key={n.id} className="notification-item flex items-start gap-3"> |
| <div className={`mt-0.5 ${n.color}`}> |
| <NIcon className="w-4 h-4" /> |
| </div> |
| <div className="min-w-0 flex-1"> |
| <div className="text-sm font-medium">{n.title}</div> |
| <div className="text-xs text-muted-foreground mt-0.5">{n.description}</div> |
| </div> |
| </div> |
| ); |
| })} |
| </div> |
| </div> |
| </> |
| )} |
| </div> |
| <div className="flex items-center gap-2 ml-2"> |
| <div className="w-8 h-8 rounded-full bg-white/[0.06] flex items-center justify-center text-sm font-medium text-amber-500"> |
| {store.user.name.charAt(0).toUpperCase()} |
| </div> |
| <span className="text-sm font-medium hidden sm:inline">{store.user.name}</span> |
| </div> |
| <button |
| onClick={fetchLogout} |
| className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/[0.04] transition-colors" |
| aria-label="Log out" |
| title="Log out" |
| > |
| <LogOut className="w-4 h-4" /> |
| </button> |
| <button |
| className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground" |
| onClick={() => setDashMobileMenu(!dashMobileMenu)} |
| aria-label="Toggle dashboard menu" |
| > |
| {dashMobileMenu ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />} |
| </button> |
| </div> |
| </div> |
| |
| {/* Mobile tab menu */} |
| {dashMobileMenu && ( |
| <div className="md:hidden border-t border-white/[0.06] bg-background/95 backdrop-blur-md fade-in mobile-nav-slide"> |
| <nav className="px-4 py-3 space-y-1 overflow-x-auto"> |
| <button |
| onClick={() => { setTheme(theme === 'dark' ? 'light' : 'dark'); setDashMobileMenu(false); }} |
| className="flex items-center gap-3 w-full text-left px-3 py-2.5 rounded-lg text-sm text-muted-foreground hover:text-foreground hover:bg-white/[0.03] transition-colors" |
| > |
| {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />} |
| {theme === 'dark' ? 'Light mode' : 'Dark mode'} |
| </button> |
| <div className="h-px bg-white/[0.06] my-1" /> |
| {DASH_TABS.map((tab) => { |
| const Icon = tab.icon; |
| return ( |
| <button |
| key={tab.id} |
| onClick={() => { store.setActiveDashTab(tab.id); setDashMobileMenu(false); }} |
| className={`flex items-center gap-3 w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors ${ |
| store.activeDashTab === tab.id |
| ? 'bg-amber-500/10 text-amber-500' |
| : 'text-muted-foreground hover:text-foreground hover:bg-white/[0.03]' |
| }`} |
| > |
| <Icon className="w-4 h-4" /> |
| {tab.label} |
| </button> |
| ); |
| })} |
| </nav> |
| </div> |
| )} |
| </header> |
|
|
| {} |
| <div className="hidden md:block border-b border-white/[0.06] bg-white/[0.01]"> |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> |
| <nav className="flex gap-1 -mb-px overflow-x-auto" aria-label="Dashboard tabs"> |
| {DASH_TABS.map((tab) => { |
| const Icon = tab.icon; |
| return ( |
| <button |
| key={tab.id} |
| onClick={() => store.setActiveDashTab(tab.id)} |
| className={`dash-tab flex items-center gap-2 ${store.activeDashTab === tab.id ? 'active' : ''}`} |
| > |
| <Icon className="w-4 h-4" /> |
| {tab.label} |
| </button> |
| ); |
| })} |
| </nav> |
| </div> |
| </div> |
|
|
| {} |
| <main className="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-6 sm:py-8"> |
| <div className="view-enter"> |
| {/* ── Stats Overview ── */} |
| <section className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6" aria-label="Dashboard statistics"> |
| <div className="stat-card-glass card-accent-amber card-enter"> |
| <div className="flex items-center gap-2 mb-1"> |
| <FileText className="w-4 h-4 text-amber-500" /> |
| <span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total Invoices</span> |
| </div> |
| <div className="text-2xl font-bold text-foreground">{store.invoices.length}</div> |
| </div> |
| <div className="stat-card-glass card-accent-teal card-enter"> |
| <div className="flex items-center gap-2 mb-1"> |
| <CircleDollarSign className="w-4 h-4 text-teal-500" /> |
| <span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total Amount</span> |
| </div> |
| <div className="text-2xl font-bold text-foreground"> |
| {formatCurrency(store.invoices.reduce((sum, inv) => sum + (inv.total ?? 0), 0))} |
| </div> |
| </div> |
| <div className="stat-card-glass card-accent-red card-enter"> |
| <div className="flex items-center gap-2 mb-1"> |
| <AlertCircle className="w-4 h-4 text-red-400" /> |
| <span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Duplicates</span> |
| </div> |
| <div className="text-2xl font-bold text-foreground">{store.invoices.filter(i => i.isDuplicate).length}</div> |
| </div> |
| <div className="stat-card-glass card-accent-green card-enter"> |
| <div className="flex items-center gap-2 mb-1"> |
| <TrendingUp className="w-4 h-4 text-green-400" /> |
| <span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Avg Confidence</span> |
| </div> |
| <div className="text-2xl font-bold text-foreground"> |
| {store.invoices.length > 0 |
| ? `${((store.invoices.reduce((sum, inv) => sum + (inv.confidence ?? 0), 0) / store.invoices.length) * 100).toFixed(1)}%` |
| : '--'} |
| </div> |
| </div> |
| </section> |
| |
| {/* ═══════════════════════════════════════════════════════════════════════ */} |
| {/* TAB 1: UPLOAD */} |
| {/* ═══════════════════════════════════════════════════════════════════════ */} |
| {store.activeDashTab === 'upload' && ( |
| <section className="fade-in tab-content-area"> |
| {/* Welcome Panel */} |
| <div className="glass-card welcome-panel p-5 mb-6 card-enter"> |
| <div className="relative"> |
| <div className="flex items-center gap-3 mb-3"> |
| <div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center"> |
| <Sparkles className="w-5 h-5 text-amber-500" /> |
| </div> |
| <div> |
| <h2 className="text-lg font-bold">Welcome back, {store.user?.name?.split(' ')[0] || 'there'}!</h2> |
| <p className="text-sm text-muted-foreground">Here's a quick overview of your account</p> |
| </div> |
| <span className="ml-auto hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-amber-500/10 text-amber-500 capitalize badge-pill"> |
| <span className="badge-pill-dot bg-amber-500" /> |
| <Crown className="w-3 h-3" /> |
| {store.user?.plan || 'Free'} Plan |
| </span> |
| </div> |
| <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4"> |
| <button onClick={() => store.setActiveDashTab('invoices')} className="welcome-stat-btn"> |
| <FileText className="w-4 h-4 welcome-stat-icon" /> |
| <div> |
| <div className="text-lg font-bold">{store.invoices.length}</div> |
| <div className="text-xs text-muted-foreground">Invoices</div> |
| </div> |
| </button> |
| <button onClick={() => store.setActiveDashTab('analytics')} className="welcome-stat-btn"> |
| <TrendingUp className="w-4 h-4 welcome-stat-icon" /> |
| <div> |
| <div className="text-lg font-bold">{formatCurrency(store.invoices.reduce((s, i) => s + (i.total ?? 0), 0))}</div> |
| <div className="text-xs text-muted-foreground">Total Spent</div> |
| </div> |
| </button> |
| <button onClick={() => store.setActiveDashTab('invoices')} className="welcome-stat-btn"> |
| <AlertCircle className="w-4 h-4 welcome-stat-icon" /> |
| <div> |
| <div className="text-lg font-bold">{store.invoices.filter(i => i.isDuplicate).length}</div> |
| <div className="text-xs text-muted-foreground">Duplicates</div> |
| </div> |
| </button> |
| <button onClick={() => store.setActiveDashTab('activity')} className="welcome-stat-btn"> |
| <Clock className="w-4 h-4 welcome-stat-icon" /> |
| <div> |
| <div className="text-lg font-bold">{activityLogs.length}</div> |
| <div className="text-xs text-muted-foreground">Activities</div> |
| </div> |
| </button> |
| </div> |
| </div> |
| </div> |
| <div className="mb-4"> |
| <h2 className="text-xl font-bold">Upload Invoices</h2> |
| <p className="text-sm text-muted-foreground mt-1">Drag and drop PDF or image files to extract invoice data</p> |
| </div> |
| |
| {/* Usage Progress */} |
| <div className="glass-card p-4 mb-6"> |
| <div className="flex items-center justify-between mb-2"> |
| <span className="text-sm text-muted-foreground">Monthly usage</span> |
| <span className="text-sm font-medium"> |
| {invoiceCount} / {planLimit === Infinity ? 'Unlimited' : planLimit} |
| </span> |
| </div> |
| <div className="h-2 w-full bg-zinc-800 rounded-full overflow-hidden"> |
| <div |
| className={`progress-bar ${usagePercent > 90 ? '!bg-red-500' : ''}`} |
| style={{ width: `${planLimit === Infinity ? 0 : usagePercent}%` }} |
| /> |
| </div> |
| </div> |
| |
| {/* Drop Zone */} |
| <div |
| className={`drop-zone drop-zone-animated p-8 sm:p-12 text-center ${dragOver ? 'drag-over' : ''}`} |
| onDragOver={handleDragOver} |
| onDragLeave={handleDragLeave} |
| onDrop={handleDrop} |
| onClick={() => fileInputRef.current?.click()} |
| role="button" |
| tabIndex={0} |
| aria-label="Click or drag files to upload" |
| onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click(); }} |
| > |
| <input |
| ref={fileInputRef} |
| type="file" |
| multiple |
| accept=".pdf,.png,.jpg,.jpeg" |
| className="hidden" |
| onChange={handleFileSelect} |
| /> |
| <Upload className="w-10 h-10 text-amber-500/60 mx-auto mb-4" /> |
| <p className="text-sm font-medium mb-1"> |
| Drop your invoice files here, or click to browse |
| </p> |
| <p className="text-xs text-muted-foreground"> |
| Supports PDF, PNG, JPG, JPEG. You can upload multiple files at once. |
| </p> |
| </div> |
| |
| {/* Selected Files List */} |
| {selectedFiles.length > 0 && ( |
| <div className="glass-card p-4 mt-4 fade-in"> |
| <h3 className="text-sm font-medium mb-3">{selectedFiles.length} file(s) selected</h3> |
| <ul className="space-y-2"> |
| {selectedFiles.map((file, idx) => ( |
| <li key={`${file.name}-${idx}`} className="flex items-center justify-between text-sm py-2 px-3 rounded-lg bg-white/[0.02]"> |
| <div className="flex items-center gap-2 min-w-0"> |
| <FileText className="w-4 h-4 text-amber-500 shrink-0" /> |
| <span className="truncate">{file.name}</span> |
| <span className="text-xs text-muted-foreground shrink-0"> |
| {(file.size / 1024).toFixed(1)} KB |
| </span> |
| </div> |
| <button |
| onClick={(e) => { e.stopPropagation(); removeFile(idx); }} |
| className="p-1 rounded text-muted-foreground hover:text-red-400 transition-colors shrink-0" |
| aria-label={`Remove ${file.name}`} |
| > |
| <X className="w-4 h-4" /> |
| </button> |
| </li> |
| ))} |
| </ul> |
| <button |
| onClick={fetchUpload} |
| disabled={store.uploadLoading} |
| className="btn-primary text-sm mt-4 w-full flex items-center justify-center gap-2 disabled:opacity-50" |
| > |
| {store.uploadLoading ? ( |
| <> |
| <Loader2 className="w-4 h-4 animate-spin" /> |
| Processing... |
| </> |
| ) : ( |
| <> |
| <Upload className="w-4 h-4" /> |
| Upload and Extract |
| </> |
| )} |
| </button> |
| </div> |
| )} |
|
|
| {} |
| {store.uploadResults && store.uploadResults.length > 0 && ( |
| <div className="mt-6 fade-in"> |
| <h3 className="text-sm font-medium mb-3 flex items-center gap-2"> |
| <CheckCircle className="w-4 h-4 text-green-500" /> |
| Results |
| </h3> |
| <div className="glass-card overflow-hidden"> |
| <div className="max-h-96 overflow-y-auto scrollbar-thin"> |
| <table className="data-table text-sm"> |
| <thead className="border-b border-white/[0.06] bg-white/[0.02]"> |
| <tr> |
| <th className="text-left px-4 py-3 font-medium text-muted-foreground">Vendor</th> |
| <th className="text-left px-4 py-3 font-medium text-muted-foreground">Invoice #</th> |
| <th className="text-right px-4 py-3 font-medium text-muted-foreground">Amount</th> |
| <th className="text-right px-4 py-3 font-medium text-muted-foreground">Confidence</th> |
| <th className="text-center px-4 py-3 font-medium text-muted-foreground">Status</th> |
| </tr> |
| </thead> |
| <tbody> |
| {store.uploadResults.map((inv) => ( |
| <tr key={inv.id} className="border-b border-white/[0.04] hover:bg-white/[0.02]"> |
| <td className="px-4 py-3">{inv.vendor || '--'}</td> |
| <td className="px-4 py-3 text-muted-foreground">{inv.invNumber || '--'}</td> |
| <td className="px-4 py-3 text-right font-medium">{formatCurrency(inv.total, inv.currency)}</td> |
| <td className="px-4 py-3 text-right">{inv.confidence ? `${(inv.confidence * 100).toFixed(1)}%` : '--'}</td> |
| <td className="px-4 py-3 text-center"> |
| <span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium ${ |
| inv.isDuplicate ? 'status-duplicate' : 'status-done' |
| }`}> |
| {inv.isDuplicate ? 'Duplicate' : 'Done'} |
| </span> |
| </td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| )} |
|
|
| {} |
| {!store.uploadLoading && (!store.uploadResults || store.uploadResults.length === 0) && selectedFiles.length === 0 && ( |
| <div className="text-center py-8 text-muted-foreground"> |
| <Inbox className="w-10 h-10 mx-auto mb-3 opacity-40" /> |
| <p className="text-sm">Upload your first invoice to get started</p> |
| </div> |
| )} |
| </section> |
| )} |
|
|
| {} |
| {} |
| {} |
| {store.activeDashTab === 'invoices' && ( |
| <section className="fade-in tab-content-area"> |
| <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> |
| <div> |
| <h2 className="text-xl font-bold">Invoices</h2> |
| <p className="text-sm text-muted-foreground mt-1"> |
| {filteredInvoices.length} invoice(s) found |
| <span className="ml-2 text-xs text-muted-foreground/60">Ctrl+K</span> |
| </p> |
| </div> |
| <button |
| onClick={() => fetchInvoices(invoiceFilter)} |
| className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors self-start" |
| > |
| <RefreshCw className="w-4 h-4" /> |
| Refresh |
| </button> |
| </div> |
| |
| {/* Search and Filter */} |
| <div className="flex flex-col sm:flex-row gap-3 mb-4"> |
| <div className="relative flex-1"> |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> |
| <input |
| type="text" |
| value={invoiceSearch} |
| onChange={(e) => setInvoiceSearch(e.target.value)} |
| placeholder="Search by vendor name..." |
| className="input-field input-field-glow pl-10" |
| /> |
| </div> |
| <div className="flex gap-2"> |
| {(['all', 'done', 'review', 'duplicates'] as const).map((f) => ( |
| <button |
| key={f} |
| onClick={() => setInvoiceFilter(f)} |
| className={`px-3 py-2 rounded-lg text-xs font-medium transition-colors ${ |
| invoiceFilter === f |
| ? 'bg-amber-500 text-zinc-900' |
| : 'bg-white/[0.04] text-muted-foreground hover:text-foreground border border-white/[0.08]' |
| }`} |
| > |
| {f === 'all' ? 'All' : f === 'done' ? 'Done' : f === 'review' ? 'Review' : 'Duplicates'} |
| </button> |
| ))} |
| </div> |
| </div> |
| |
| {/* Date Range Filter */} |
| <div className="flex flex-col sm:flex-row gap-3 mb-4"> |
| <div className="flex-1"> |
| <label className="input-label">From Date</label> |
| <input |
| type="date" |
| value={dateFrom} |
| onChange={(e) => setDateFrom(e.target.value)} |
| className="input-field input-field-glow" |
| /> |
| </div> |
| <div className="flex-1"> |
| <label className="input-label">To Date</label> |
| <input |
| type="date" |
| value={dateTo} |
| onChange={(e) => setDateTo(e.target.value)} |
| className="input-field input-field-glow" |
| /> |
| </div> |
| {(dateFrom || dateTo) && ( |
| <div className="flex items-end"> |
| <button |
| onClick={() => { setDateFrom(''); setDateTo(''); }} |
| className="px-3 py-2.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground border border-white/[0.08] hover:border-white/[0.15] transition-colors" |
| > |
| Clear dates |
| </button> |
| </div> |
| )} |
| </div> |
| |
| {/* Duplicate Alert Panel */} |
| {!store.isLoading && store.invoices.some(i => i.isDuplicate) && ( |
| <div className="glass-card duplicate-alert p-4 mb-4 card-enter"> |
| <div className="flex items-start gap-3"> |
| <div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center shrink-0"> |
| <CopyCheck className="w-5 h-5 text-amber-500" /> |
| </div> |
| <div className="flex-1 min-w-0"> |
| <h3 className="text-sm font-semibold text-amber-500">Duplicate Invoices Detected</h3> |
| <p className="text-xs text-muted-foreground mt-1"> |
| {store.invoices.filter(i => i.isDuplicate).length} potential duplicate(s) found. Review and decide whether to keep or remove them. |
| </p> |
| <div className="flex flex-wrap gap-2 mt-3"> |
| <button |
| onClick={() => { setInvoiceFilter('duplicates'); }} |
| className="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 transition-colors" |
| > |
| <Eye className="w-3.5 h-3.5" /> |
| View Duplicates |
| </button> |
| <button |
| onClick={() => { |
| const dupIds = store.invoices.filter(i => i.isDuplicate).map(i => i.id); |
| if (window.confirm(`Delete ${dupIds.length} duplicate invoice(s)?`)) { |
| Promise.all(dupIds.map(id => fetch('/api/invoices', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }))) |
| .then(() => { toast.success(`${dupIds.length} duplicate(s) removed`); fetchInvoices(invoiceFilter); }) |
| .catch(() => toast.error('Failed to remove duplicates')); |
| } |
| }} |
| className="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors" |
| > |
| <Trash2 className="w-3.5 h-3.5" /> |
| Remove All Duplicates |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {/* Selection Toolbar */} |
| {selectedInvoices.size > 0 && ( |
| <div className="flex items-center justify-between p-3 mb-4 rounded-lg bg-amber-500/10 border border-amber-500/20 fade-in"> |
| <span className="text-sm font-medium text-amber-500"> |
| {selectedInvoices.size} invoice(s) selected |
| </span> |
| <div className="flex items-center gap-2"> |
| <button |
| onClick={() => setSelectedInvoices(new Set())} |
| className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1" |
| > |
| Clear selection |
| </button> |
| <button |
| onClick={fetchBatchDelete} |
| className="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors" |
| > |
| <Trash2 className="w-3.5 h-3.5" /> |
| Delete Selected |
| </button> |
| </div> |
| </div> |
| )} |
| |
| {/* Loading Skeletons */} |
| {store.isLoading && ( |
| <div className="glass-card p-4 space-y-3"> |
| {Array.from({ length: 5 }).map((_, i) => ( |
| <div key={i} className="skeleton-shimmer h-12 w-full" /> |
| ))} |
| </div> |
| )} |
| |
| {/* Invoice Table */} |
| {!store.isLoading && filteredInvoices.length > 0 && ( |
| <div className="glass-card overflow-hidden"> |
| <div className="max-h-96 overflow-y-auto scrollbar-thin"> |
| <table className="data-table text-sm"> |
| <thead className="border-b border-white/[0.06] bg-white/[0.02] sticky top-0"> |
| <tr> |
| <th className="px-3 py-3 w-10"> |
| <input |
| type="checkbox" |
| checked={selectedInvoices.size === filteredInvoices.length && filteredInvoices.length > 0} |
| onChange={toggleSelectAll} |
| className="invoice-checkbox" |
| aria-label="Select all invoices" |
| /> |
| </th> |
| <th |
| className="sort-header text-left px-4 py-3 font-medium text-muted-foreground" |
| onClick={() => toggleSort('vendor')} |
| > |
| <div className="flex items-center gap-1"> |
| Vendor |
| {sortField === 'vendor' ? ( |
| <span className="sort-indicator">{sortDir === 'asc' ? '↑' : '↓'}</span> |
| ) : ( |
| <span className="sort-header-inactive">↕</span> |
| )} |
| </div> |
| </th> |
| <th |
| className="sort-header text-left px-4 py-3 font-medium text-muted-foreground hidden sm:table-cell" |
| onClick={() => toggleSort('invNumber')} |
| > |
| <div className="flex items-center gap-1"> |
| Invoice # |
| {sortField === 'invNumber' ? ( |
| <span className="sort-indicator">{sortDir === 'asc' ? '↑' : '↓'}</span> |
| ) : ( |
| <span className="sort-header-inactive">↕</span> |
| )} |
| </div> |
| </th> |
| <th |
| className="sort-header text-left px-4 py-3 font-medium text-muted-foreground hidden md:table-cell" |
| onClick={() => toggleSort('invDate')} |
| > |
| <div className="flex items-center gap-1"> |
| Date |
| {sortField === 'invDate' ? ( |
| <span className="sort-indicator">{sortDir === 'asc' ? '↑' : '↓'}</span> |
| ) : ( |
| <span className="sort-header-inactive">↕</span> |
| )} |
| </div> |
| </th> |
| <th |
| className="sort-header text-right px-4 py-3 font-medium text-muted-foreground" |
| onClick={() => toggleSort('amount')} |
| > |
| <div className="flex items-center justify-end gap-1"> |
| Amount |
| {sortField === 'amount' ? ( |
| <span className="sort-indicator">{sortDir === 'asc' ? '↑' : '↓'}</span> |
| ) : ( |
| <span className="sort-header-inactive">↕</span> |
| )} |
| </div> |
| </th> |
| <th |
| className="sort-header text-right px-4 py-3 font-medium text-muted-foreground hidden sm:table-cell" |
| onClick={() => toggleSort('total')} |
| > |
| <div className="flex items-center justify-end gap-1"> |
| Total |
| {sortField === 'total' ? ( |
| <span className="sort-indicator">{sortDir === 'asc' ? '↑' : '↓'}</span> |
| ) : ( |
| <span className="sort-header-inactive">↕</span> |
| )} |
| </div> |
| </th> |
| <th |
| className="sort-header text-center px-4 py-3 font-medium text-muted-foreground" |
| onClick={() => toggleSort('status')} |
| > |
| <div className="flex items-center justify-center gap-1"> |
| Status |
| {sortField === 'status' ? ( |
| <span className="sort-indicator">{sortDir === 'asc' ? '↑' : '↓'}</span> |
| ) : ( |
| <span className="sort-header-inactive">↕</span> |
| )} |
| </div> |
| </th> |
| <th |
| className="sort-header text-right px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell" |
| onClick={() => toggleSort('confidence')} |
| > |
| <div className="flex items-center justify-end gap-1"> |
| Confidence |
| {sortField === 'confidence' ? ( |
| <span className="sort-indicator">{sortDir === 'asc' ? '↑' : '↓'}</span> |
| ) : ( |
| <span className="sort-header-inactive">↕</span> |
| )} |
| </div> |
| </th> |
| <th className="text-center px-4 py-3 font-medium text-muted-foreground" colSpan={2}>Actions</th> |
| </tr> |
| </thead> |
| <tbody> |
| {filteredInvoices.map((inv) => ( |
| <InvoiceTableBody |
| key={inv.id} |
| invoice={inv} |
| isExpanded={expandedInvoice === inv.id} |
| onToggle={() => setExpandedInvoice(expandedInvoice === inv.id ? null : inv.id)} |
| onDelete={() => fetchDeleteInvoice(inv.id)} |
| isSelected={selectedInvoices.has(inv.id)} |
| onSelect={() => toggleSelect(inv.id)} |
| onEdit={() => openEditModal(inv)} |
| /> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| </div> |
| )} |
| |
| {/* Empty State */} |
| {!store.isLoading && filteredInvoices.length === 0 && ( |
| <div className="empty-state"> |
| <Inbox className="w-12 h-12" /> |
| <p className="text-sm mt-2">No invoices found</p> |
| <p className="text-xs text-muted-foreground mt-1">Upload your first invoice to get started</p> |
| </div> |
| )} |
| </section> |
| )} |
|
|
| {} |
| {} |
| {} |
| {store.activeDashTab === 'analytics' && ( |
| <AnalyticsTab invoices={store.invoices} /> |
| )} |
|
|
| {} |
| {} |
| {} |
| {store.activeDashTab === 'chat' && ( |
| <section className="fade-in tab-content-area"> |
| <div className="flex items-center justify-between mb-6"> |
| <div> |
| <h2 className="text-xl font-bold">AI Chat</h2> |
| <p className="text-sm text-muted-foreground mt-1">Ask questions about your invoices</p> |
| </div> |
| {store.chatHistory.length > 0 && ( |
| <button |
| onClick={() => { store.clearChat(); toast.success('Chat cleared'); }} |
| className="text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1" |
| > |
| <Trash2 className="w-3.5 h-3.5" /> |
| Clear |
| </button> |
| )} |
| </div> |
| |
| {/* Plan gate */} |
| {store.user.plan === 'free' && ( |
| <div className="glass-card p-8 text-center mb-6"> |
| <Bot className="w-12 h-12 text-amber-500/40 mx-auto mb-4" /> |
| <h3 className="text-lg font-semibold mb-2">Upgrade to Use AI Chat</h3> |
| <p className="text-sm text-muted-foreground mb-4"> |
| AI Chat is available on Pro and Enterprise plans. Get instant insights about your invoices, spending analysis, and more. |
| </p> |
| <button |
| onClick={() => store.setActiveDashTab('upgrade')} |
| className="btn-primary text-sm inline-flex items-center gap-2" |
| > |
| <Crown className="w-4 h-4" /> |
| Upgrade Plan |
| </button> |
| </div> |
| )} |
| |
| {/* Chat Messages */} |
| {store.chatHistory.length === 0 && store.user.plan !== 'free' && ( |
| <div className="glass-card p-12 text-center mb-4"> |
| <MessageSquare className="w-12 h-12 text-muted-foreground/30 mx-auto mb-4" /> |
| <h3 className="text-base font-medium mb-2">Start a conversation</h3> |
| <p className="text-sm text-muted-foreground mb-6"> |
| Ask about your invoices, get spending insights, or request analysis. |
| </p> |
| <div className="flex flex-wrap justify-center gap-2"> |
| {['Summarize my invoices', 'Who is my top vendor?', 'Show spending trends'].map((suggestion) => ( |
| <button |
| key={suggestion} |
| onClick={() => setChatInput(suggestion)} |
| className="px-3 py-1.5 rounded-full text-xs font-medium bg-white/[0.04] border border-white/[0.08] text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors" |
| > |
| {suggestion} |
| </button> |
| ))} |
| </div> |
| </div> |
| )} |
| |
| <div className="space-y-4 mb-4 max-h-[500px] overflow-y-auto"> |
| {store.chatHistory.map((msg, idx) => ( |
| <div |
| key={idx} |
| className={`chat-bubble flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`} |
| > |
| <div className="flex flex-col"> |
| <div className="flex items-end gap-2"> |
| {msg.role === 'assistant' && ( |
| <div className="w-6 h-6 rounded-full bg-teal-500/20 flex items-center justify-center shrink-0"> |
| <Bot className="w-3.5 h-3.5 text-teal-500" /> |
| </div> |
| )} |
| <div className={msg.role === 'user' ? 'chat-message-user' : 'chat-message-assistant'}> |
| <p className="whitespace-pre-wrap text-sm leading-relaxed">{msg.content}</p> |
| </div> |
| </div> |
| </div> |
| </div> |
| ))} |
| {store.chatLoading && ( |
| <div className="chat-bubble flex justify-start"> |
| <div className="bg-white/[0.06] rounded-2xl rounded-bl-md px-4 py-3 flex items-center gap-1.5"> |
| <span className="w-2 h-2 rounded-full bg-muted-foreground typing-dot" /> |
| <span className="w-2 h-2 rounded-full bg-muted-foreground typing-dot" /> |
| <span className="w-2 h-2 rounded-full bg-muted-foreground typing-dot" /> |
| </div> |
| </div> |
| )} |
| <div ref={chatEndRef} /> |
| </div> |
| |
| {/* Chat Input */} |
| {store.user.plan !== 'free' && ( |
| <div className="flex gap-3"> |
| <input |
| type="text" |
| value={chatInput} |
| onChange={(e) => setChatInput(e.target.value)} |
| onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); fetchChat(); } }} |
| placeholder="Ask about your invoices..." |
| disabled={store.chatLoading} |
| className="input-field input-field-glow rounded-xl disabled:opacity-50" |
| /> |
| <div className="tooltip-wrapper"> |
| <button |
| onClick={fetchChat} |
| disabled={store.chatLoading || !chatInput.trim()} |
| className="p-3 rounded-xl bg-amber-500 text-zinc-900 hover:bg-amber-400 transition-colors disabled:opacity-50" |
| aria-label="Send message" |
| > |
| <Send className="w-5 h-5" /> |
| </button> |
| <span className="tooltip-text">Send</span> |
| </div> |
| </div> |
| )} |
| </section> |
| )} |
|
|
| {} |
| {} |
| {} |
| {store.activeDashTab === 'export' && ( |
| <section className="fade-in tab-content-area"> |
| <div className="mb-6"> |
| <h2 className="text-xl font-bold">Export Data</h2> |
| <p className="text-sm text-muted-foreground mt-1">Download your invoice data in your preferred format</p> |
| </div> |
| |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> |
| {/* CSV Export */} |
| <article className="glass-card p-6"> |
| <div className="flex items-center gap-3 mb-4"> |
| <div className="w-11 h-11 rounded-lg bg-teal-500/10 flex items-center justify-center"> |
| <FileDown className="w-5 h-5 text-teal-500" /> |
| </div> |
| <div> |
| <h3 className="text-base font-semibold">CSV Export</h3> |
| <p className="text-xs text-muted-foreground">Comma-separated values</p> |
| </div> |
| </div> |
| <p className="text-sm text-muted-foreground mb-6 leading-relaxed"> |
| Download all your invoices as a CSV file, compatible with Excel, Google Sheets, and any spreadsheet application. Includes all fields: vendor, amounts, dates, status, and confidence scores. |
| </p> |
| <button |
| onClick={exportCsv} |
| className="w-full py-2.5 rounded-lg bg-teal-500 text-zinc-900 font-medium text-sm hover:bg-teal-400 transition-colors flex items-center justify-center gap-2" |
| > |
| <Download className="w-4 h-4" /> |
| Download CSV |
| </button> |
| </article> |
| |
| {/* JSON Export */} |
| <article className="glass-card p-6"> |
| <div className="flex items-center gap-3 mb-4"> |
| <div className="w-11 h-11 rounded-lg bg-amber-500/10 flex items-center justify-center"> |
| <FileJson className="w-5 h-5 text-amber-500" /> |
| </div> |
| <div> |
| <div className="flex items-center gap-2"> |
| <h3 className="text-base font-semibold">JSON Export</h3> |
| {store.user?.plan === 'free' && ( |
| <span className="text-xs font-medium px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/20"> |
| Pro+ |
| </span> |
| )} |
| </div> |
| <p className="text-xs text-muted-foreground">Structured JSON data with raw extraction</p> |
| </div> |
| </div> |
| <p className="text-sm text-muted-foreground mb-6 leading-relaxed"> |
| Download structured JSON data including raw extraction results, line items, and bank details. Perfect for API integrations and programmatic processing. |
| </p> |
| <button |
| onClick={exportJson} |
| className="btn-primary text-sm w-full flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" |
| disabled={store.user?.plan === 'free'} |
| > |
| <Download className="w-4 h-4" /> |
| {store.user?.plan === 'free' ? 'Upgrade Required' : 'Download JSON'} |
| </button> |
| </article> |
| {/* XLSX Export */} |
| <article className="glass-card p-6"> |
| <div className="flex items-center gap-3 mb-4"> |
| <div className="w-11 h-11 rounded-lg bg-amber-500/10 flex items-center justify-center"> |
| <FileJson className="w-5 h-5 text-amber-500" /> |
| </div> |
| <div> |
| <h3 className="text-base font-semibold">Excel Export</h3> |
| <p className="text-xs text-muted-foreground">XLSX spreadsheet format</p> |
| </div> |
| </div> |
| <p className="text-sm text-muted-foreground mb-6 leading-relaxed"> |
| Download a professionally formatted Excel spreadsheet with styled headers, currency formatting, color-coded statuses, and a summary total row. Compatible with Microsoft Excel, Google Sheets, and LibreOffice Calc. |
| </p> |
| <button |
| onClick={() => { window.open('/api/invoices/export/xlsx', '_blank'); toast.success('Excel download started'); }} |
| className="btn-primary text-sm w-full flex items-center justify-center gap-2" |
| > |
| <Download className="w-4 h-4" /> |
| Download Excel |
| </button> |
| </article> |
| </div> |
| |
| {/* Empty state hint */} |
| {invoiceCount === 0 && ( |
| <div className="glass-card p-8 text-center mt-6"> |
| <Database className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" /> |
| <p className="text-sm text-muted-foreground">No invoices to export yet. Upload some invoices first.</p> |
| </div> |
| )} |
| </section> |
| )} |
|
|
| {} |
| {} |
| {} |
| {store.activeDashTab === 'upgrade' && ( |
| <section className="fade-in tab-content-area"> |
| <div className="mb-6"> |
| <h2 className="text-xl font-bold">Upgrade Your Plan</h2> |
| <p className="text-sm text-muted-foreground mt-1">You are currently on the <span className="text-amber-500 font-medium">{planLabel}</span> plan</p> |
| </div> |
| |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6"> |
| {[ |
| { name: 'Free', price: 0, limit: '20 invoices/mo', features: ['Basic extraction', 'CSV export', 'Email support'] }, |
| { name: 'Pro', price: 129, limit: '2,000 invoices/mo', features: ['Advanced extraction', 'CSV & JSON export', 'AI Chat assistant', 'API access', 'Priority support'] }, |
| { name: 'Business', price: 29, limit: '200 invoices/mo', features: ['Standard extraction', 'CSV & JSON export', 'API access', 'Email & chat support'] }, |
| { name: 'Enterprise', price: 499, limit: 'Unlimited', features: ['Custom AI models', 'All export formats', 'Dedicated AI Chat', 'Full API access', 'Dedicated support', 'SLA guarantee'] }, |
| ].map((plan) => { |
| const isCurrent = plan.name.toLowerCase() === store.user.plan; |
| return ( |
| <article |
| key={plan.name} |
| className={`glass-card p-6 flex flex-col relative ${plan.name === 'Pro' ? 'pricing-highlight' : ''}`} |
| > |
| {plan.name === 'Pro' && <div className="pricing-ribbon">Most Popular</div>} |
| <div className="mb-5"> |
| <div className="flex items-center justify-between mb-1"> |
| <h3 className="text-lg font-semibold">{plan.name}</h3> |
| {isCurrent && ( |
| <span className="text-xs font-medium px-2 py-0.5 rounded-full bg-teal-500/10 text-teal-500 border border-teal-500/20"> |
| Current |
| </span> |
| )} |
| </div> |
| <div className="text-3xl font-bold text-foreground"> |
| ${plan.price}<span className="text-sm font-normal text-muted-foreground">/mo</span> |
| </div> |
| <div className="text-sm text-muted-foreground mt-1">{plan.limit}</div> |
| </div> |
| <ul className="space-y-3 mb-6 flex-1"> |
| {plan.features.map((f) => ( |
| <li key={f} className="flex items-center gap-2 text-sm text-muted-foreground"> |
| <Check className="w-4 h-4 text-teal-500 shrink-0" /> |
| {f} |
| </li> |
| ))} |
| </ul> |
| <button |
| onClick={() => { |
| if (isCurrent) return; |
| toast.info('Coming soon! Plan upgrades will be available shortly.'); |
| }} |
| disabled={isCurrent} |
| className={`w-full py-2.5 rounded-lg text-sm font-medium transition-colors ${ |
| isCurrent |
| ? 'bg-white/[0.04] text-muted-foreground cursor-not-allowed' |
| : 'bg-amber-500 text-zinc-900 hover:bg-amber-400' |
| }`} |
| > |
| {isCurrent ? 'Current Plan' : 'Upgrade'} |
| </button> |
| </article> |
| ); |
| })} |
| </div> |
| </section> |
| )} |
|
|
| {} |
| {} |
| {} |
| {store.activeDashTab === 'api' && ( |
| <section className="fade-in tab-content-area"> |
| <div className="mb-6"> |
| <h2 className="text-xl font-bold">API Access</h2> |
| <p className="text-sm text-muted-foreground mt-1">Use your API key to integrate OmniParse into your workflow</p> |
| </div> |
| |
| {/* API Key Display */} |
| <div className="glass-card p-6 mb-6"> |
| <h3 className="text-sm font-semibold mb-3">Your API Key</h3> |
| <div className="flex items-center gap-3"> |
| <div className="flex-1 bg-white/[0.04] border border-white/[0.08] rounded-lg px-4 py-2.5 font-mono text-sm overflow-hidden"> |
| {showApiKey |
| ? (store.user.apiKey || 'Not available') |
| : (store.user.apiKey ? store.user.apiKey.slice(0, 10) + '...' + store.user.apiKey.slice(-4) : 'Not available') |
| } |
| </div> |
| <div className="tooltip-wrapper"> |
| <button |
| onClick={() => setShowApiKey(!showApiKey)} |
| className="p-2.5 rounded-lg bg-white/[0.04] border border-white/[0.08] text-muted-foreground hover:text-foreground transition-colors" |
| aria-label={showApiKey ? 'Hide API key' : 'Show API key'} |
| > |
| {showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} |
| </button> |
| <span className="tooltip-text">{showApiKey ? 'Hide' : 'Show'}</span> |
| </div> |
| <div className="tooltip-wrapper"> |
| <button |
| onClick={() => { |
| if (store.user.apiKey) { |
| navigator.clipboard.writeText(store.user.apiKey); |
| toast.success('API key copied to clipboard'); |
| } |
| }} |
| className="p-2.5 rounded-lg bg-white/[0.04] border border-white/[0.08] text-muted-foreground hover:text-foreground transition-colors" |
| aria-label="Copy API key" |
| > |
| <Copy className="w-4 h-4" /> |
| </button> |
| <span className="tooltip-text">Copy</span> |
| </div> |
| </div> |
| </div> |
| |
| {/* cURL Example */} |
| <div className="glass-card p-6 mb-6"> |
| <h3 className="text-sm font-semibold mb-3 flex items-center gap-2"> |
| <Terminal className="w-4 h-4 text-amber-500" /> |
| Quick Start Example |
| </h3> |
| <div className="code-block"> |
| <pre>{`curl -X POST /api/invoices/upload \\ |
| -H "Cookie: op_session=YOUR_SESSION" \\ |
| -F "files=@invoice.pdf"`}</pre> |
| </div> |
| </div> |
| |
| {/* Endpoint Documentation */} |
| <div className="glass-card p-6"> |
| <h3 className="text-sm font-semibold mb-4">Available Endpoints</h3> |
| <div className="space-y-4"> |
| {[ |
| { method: 'GET', path: '/api/auth/me', desc: 'Get current user info' }, |
| { method: 'POST', path: '/api/invoices/upload', desc: 'Upload and process invoice files' }, |
| { method: 'GET', path: '/api/invoices', desc: 'List all invoices with optional filters' }, |
| { method: 'DELETE', path: '/api/invoices', desc: 'Delete an invoice by ID' }, |
| { method: 'GET', path: '/api/invoices/export/csv', desc: 'Export all invoices as CSV' }, |
| { method: 'GET', path: '/api/invoices/export/json', desc: 'Export all invoices as JSON (Pro+)' }, |
| { method: 'POST', path: '/api/chat', desc: 'Send a message to the AI assistant (Pro+)' }, |
| ].map((ep) => ( |
| <div key={ep.path + ep.method} className="flex items-start gap-3 py-2 border-b border-white/[0.04] last:border-0"> |
| <span className={`text-xs font-bold px-2 py-0.5 rounded ${ |
| ep.method === 'GET' ? 'bg-teal-500/10 text-teal-500' : |
| ep.method === 'POST' ? 'bg-amber-500/10 text-amber-500' : |
| 'bg-red-500/10 text-red-500' |
| }`}> |
| {ep.method} |
| </span> |
| <div className="min-w-0"> |
| <div className="text-sm font-mono text-foreground">{ep.path}</div> |
| <div className="text-xs text-muted-foreground">{ep.desc}</div> |
| </div> |
| </div> |
| ))} |
| </div> |
| </div> |
| </section> |
| )} |
|
|
| {} |
| {} |
| {} |
| {store.activeDashTab === 'profile' && ( |
| <section className="fade-in tab-content-area"> |
| <div className="mb-6"> |
| <h2 className="text-xl font-bold">Profile</h2> |
| <p className="text-sm text-muted-foreground mt-1">Manage your account settings</p> |
| </div> |
| |
| {/* User Info Grid */} |
| <div className="glass-card p-6 mb-6"> |
| <h3 className="text-sm font-semibold mb-4">Account Information</h3> |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> |
| <div className="p-3 rounded-lg bg-white/[0.02]"> |
| <div className="text-xs text-muted-foreground mb-1">Email</div> |
| <div className="text-sm font-medium flex items-center gap-2"> |
| <Mail className="w-4 h-4 text-amber-500" /> |
| {store.user.email} |
| </div> |
| </div> |
| <div className="p-3 rounded-lg bg-white/[0.02]"> |
| <div className="text-xs text-muted-foreground mb-1">Name</div> |
| <div className="text-sm font-medium flex items-center gap-2"> |
| <User className="w-4 h-4 text-amber-500" /> |
| {store.user.name} |
| </div> |
| </div> |
| <div className="p-3 rounded-lg bg-white/[0.02]"> |
| <div className="text-xs text-muted-foreground mb-1">Plan</div> |
| <div className="text-sm font-medium flex items-center gap-2"> |
| <Crown className="w-4 h-4 text-amber-500" /> |
| {planLabel} |
| </div> |
| </div> |
| <div className="p-3 rounded-lg bg-white/[0.02]"> |
| <div className="text-xs text-muted-foreground mb-1">Member Since</div> |
| <div className="text-sm font-medium flex items-center gap-2"> |
| <Building2 className="w-4 h-4 text-amber-500" /> |
| {new Date(store.user.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} |
| </div> |
| </div> |
| <div className="p-3 rounded-lg bg-white/[0.02] sm:col-span-2"> |
| <div className="text-xs text-muted-foreground mb-1">API Key</div> |
| <div className="text-sm font-mono text-muted-foreground flex items-center gap-2"> |
| <Key className="w-4 h-4 text-amber-500 shrink-0" /> |
| <span className="truncate">{store.user.apiKey ? store.user.apiKey.slice(0, 20) + '...' : 'Not available'}</span> |
| {store.user.apiKey && ( |
| <div className="tooltip-wrapper"> |
| <button |
| onClick={() => { |
| navigator.clipboard.writeText(store.user.apiKey || ''); |
| toast.success('Copied to clipboard'); |
| }} |
| className="p-1 rounded hover:bg-white/[0.06] transition-colors shrink-0" |
| aria-label="Copy API key" |
| > |
| <Copy className="w-3.5 h-3.5" /> |
| </button> |
| <span className="tooltip-text">Copy</span> |
| </div> |
| )} |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| {/* Change Password */} |
| <div className="glass-card p-6 mb-6"> |
| <h3 className="text-sm font-semibold mb-4">Change Password</h3> |
| <form |
| onSubmit={async (e) => { |
| e.preventDefault(); |
| if (!profileCurrentPw || !profileNewPw || !profileConfirmPw) { |
| toast.error('Please fill in all password fields'); |
| return; |
| } |
| setProfilePwLoading(true); |
| try { |
| const res = await fetch('/api/auth/password', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| currentPassword: profileCurrentPw, |
| newPassword: profileNewPw, |
| confirmPassword: profileConfirmPw, |
| }), |
| }); |
| const data = await res.json(); |
| if (!res.ok) { |
| toast.error(data.error || 'Failed to update password'); |
| return; |
| } |
| toast.success('Password updated successfully. Please log in again.'); |
| setProfileCurrentPw(''); |
| setProfileNewPw(''); |
| setProfileConfirmPw(''); |
| setTimeout(() => { fetchLogout(); }, 1500); |
| } catch { |
| toast.error('Network error. Please try again.'); |
| } finally { |
| setProfilePwLoading(false); |
| } |
| }} |
| className="space-y-4" |
| > |
| <div> |
| <label className="input-label" htmlFor="current-pw">Current Password</label> |
| <input |
| id="current-pw" |
| type="password" |
| value={profileCurrentPw} |
| onChange={(e) => setProfileCurrentPw(e.target.value)} |
| placeholder="Enter current password" |
| className="input-field input-field-glow" |
| /> |
| </div> |
| <div> |
| <label className="input-label" htmlFor="new-pw">New Password</label> |
| <input |
| id="new-pw" |
| type="password" |
| value={profileNewPw} |
| onChange={(e) => setProfileNewPw(e.target.value)} |
| placeholder="Enter new password" |
| className="input-field input-field-glow" |
| /> |
| {profileNewPw && (() => { |
| const strength = getPasswordStrength(profileNewPw); |
| return ( |
| <div className="mt-2"> |
| <div className="h-1.5 w-full bg-zinc-800 rounded-full overflow-hidden"> |
| <div className={`h-full ${strength.color} ${strength.width} rounded-full transition-all duration-300`} /> |
| </div> |
| <p className="text-xs text-muted-foreground mt-1">{strength.label}</p> |
| </div> |
| ); |
| })()} |
| </div> |
| <div> |
| <label className="input-label" htmlFor="confirm-new-pw">Confirm New Password</label> |
| <input |
| id="confirm-new-pw" |
| type="password" |
| value={profileConfirmPw} |
| onChange={(e) => setProfileConfirmPw(e.target.value)} |
| placeholder="Confirm new password" |
| className="input-field input-field-glow" |
| /> |
| </div> |
| <button |
| type="submit" |
| disabled={profilePwLoading} |
| className="btn-primary text-sm flex items-center gap-2 disabled:opacity-50" |
| > |
| {profilePwLoading && <Loader2 className="w-4 h-4 animate-spin" />} |
| Update Password |
| </button> |
| </form> |
| </div> |
| |
| {/* Danger Zone */} |
| <div className="glass-card p-6 border border-red-500/20"> |
| <h3 className="text-sm font-semibold mb-2 text-red-400">Danger Zone</h3> |
| <p className="text-sm text-muted-foreground mb-4"> |
| Permanently delete your account and all associated data. This action cannot be undone. |
| </p> |
| <button |
| onClick={() => { |
| if (window.confirm('Are you sure you want to delete your account? All your invoices and data will be permanently removed.')) { |
| toast.info('Account deletion feature coming soon'); |
| } |
| }} |
| className="px-6 py-2.5 rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 font-medium text-sm hover:bg-red-500/20 transition-colors" |
| > |
| Delete Account |
| </button> |
| </div> |
| </section> |
| )} |
|
|
| {} |
| {} |
| {} |
| {store.activeDashTab === 'settings' && ( |
| <section className="fade-in tab-content-area"> |
| <div className="mb-6"> |
| <h2 className="text-xl font-bold">Settings</h2> |
| <p className="text-sm text-muted-foreground mt-1">Customize your dashboard experience</p> |
| </div> |
| |
| <div className="space-y-6"> |
| {/* Appearance */} |
| <div className="glass-card card-depth p-6 card-enter"> |
| <h3 className="text-sm font-semibold mb-4 flex items-center gap-2"> |
| <Sun className="w-4 h-4 text-amber-500" /> |
| Appearance |
| </h3> |
| <div className="space-y-4"> |
| <div className="flex items-center justify-between"> |
| <div> |
| <div className="text-sm font-medium">Theme</div> |
| <div className="text-xs text-muted-foreground mt-0.5">Switch between light and dark mode</div> |
| </div> |
| <button |
| onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} |
| className="flex items-center gap-2 px-4 py-2 rounded-lg bg-white/[0.04] border border-white/[0.08] hover:border-white/[0.15] transition-colors text-sm" |
| > |
| {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />} |
| {theme === 'dark' ? 'Light Mode' : 'Dark Mode'} |
| </button> |
| </div> |
| <div className="flex items-center justify-between"> |
| <div> |
| <div className="text-sm font-medium">Compact Table View</div> |
| <div className="text-xs text-muted-foreground mt-0.5">Reduce row height in invoice tables</div> |
| </div> |
| <button |
| onClick={() => setCompactView(!compactView)} |
| className={`toggle-switch ${compactView ? 'active' : ''}`} |
| > |
| <span className="toggle-switch-knob" /> |
| </button> |
| </div> |
| </div> |
| </div> |
| |
| {/* Regional */} |
| <div className="glass-card card-depth p-6 card-enter" style={{ animationDelay: '0.1s' }}> |
| <h3 className="text-sm font-semibold mb-4 flex items-center gap-2"> |
| <Globe className="w-4 h-4 text-teal-500" /> |
| Regional |
| </h3> |
| <div className="space-y-4"> |
| <div> |
| <label className="text-sm font-medium block mb-1.5">Date Format</label> |
| <div className="flex gap-2"> |
| {(['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD'] as const).map((fmt) => ( |
| <button |
| key={fmt} |
| onClick={() => setDateFormat(fmt)} |
| className={`px-3 py-2 rounded-lg text-xs font-medium transition-colors ${ |
| dateFormat === fmt |
| ? 'bg-amber-500 text-zinc-900' |
| : 'bg-white/[0.04] text-muted-foreground hover:text-foreground border border-white/[0.08]' |
| }`} |
| > |
| {fmt} |
| </button> |
| ))} |
| </div> |
| </div> |
| <div> |
| <label className="text-sm font-medium block mb-1.5">Default Currency</label> |
| <select |
| value={defaultCurrency} |
| onChange={(e) => setDefaultCurrency(e.target.value)} |
| className="select-field w-full sm:w-48" |
| > |
| <option value="USD">USD - US Dollar</option> |
| <option value="EUR">EUR - Euro</option> |
| <option value="GBP">GBP - British Pound</option> |
| <option value="CAD">CAD - Canadian Dollar</option> |
| <option value="AUD">AUD - Australian Dollar</option> |
| <option value="JPY">JPY - Japanese Yen</option> |
| <option value="CNY">CNY - Chinese Yuan</option> |
| </select> |
| </div> |
| </div> |
| </div> |
| |
| {/* Notifications & Keyboard */} |
| <div className="glass-card card-depth p-6 card-enter" style={{ animationDelay: '0.2s' }}> |
| <h3 className="text-sm font-semibold mb-4 flex items-center gap-2"> |
| <Bell className="w-4 h-4 text-amber-500" /> |
| Notifications & Shortcuts |
| </h3> |
| <div className="space-y-3"> |
| <div className="flex items-center justify-between py-2"> |
| <span className="text-sm text-muted-foreground">Global Search</span> |
| <kbd className="kbd">Ctrl+K</kbd> |
| </div> |
| <div className="flex items-center justify-between py-2"> |
| <span className="text-sm text-muted-foreground">Keyboard Shortcuts</span> |
| <kbd className="kbd">Ctrl+/</kbd> |
| </div> |
| <div className="flex items-center justify-between py-2"> |
| <span className="text-sm text-muted-foreground">Go to Invoices</span> |
| <kbd className="kbd">Ctrl+K</kbd> |
| </div> |
| </div> |
| </div> |
| |
| {/* Save Button */} |
| <div className="flex justify-end"> |
| <button |
| onClick={saveSettings} |
| className={`btn-primary px-6 py-2.5 text-sm font-medium ${settingsSaved ? '!bg-green-500' : ''}`} |
| > |
| {settingsSaved ? ( |
| <span className="flex items-center gap-2"><Check className="w-4 h-4" /> Saved!</span> |
| ) : 'Save Settings'} |
| </button> |
| </div> |
| </div> |
| </section> |
| )} |
|
|
| {} |
| {} |
| {} |
| {store.activeDashTab === 'activity' && ( |
| <section className="fade-in tab-content-area"> |
| <div className="mb-6"> |
| <h2 className="text-xl font-bold">Activity</h2> |
| <p className="text-sm text-muted-foreground mt-1">Recent actions and events</p> |
| </div> |
| <div className="glass-card p-6"> |
| {activityLoading ? ( |
| <div className="space-y-6"> |
| {Array.from({ length: 3 }).map((_, i) => ( |
| <div key={i} className="flex gap-4"> |
| <div className="w-8 h-8 rounded-full bg-white/[0.04] border border-white/[0.08] shrink-0 skeleton-shimmer" /> |
| <div className="flex-1 space-y-2 pt-0.5"> |
| <div className="skeleton-shimmer h-4 w-2/5" /> |
| <div className="skeleton-shimmer h-3 w-3/5" /> |
| </div> |
| </div> |
| ))} |
| </div> |
| ) : activityLogs.length > 0 ? ( |
| <div className="relative"> |
| {/* Vertical line */} |
| <div className="absolute left-[15px] top-2 bottom-2 w-px bg-white/[0.06]" /> |
| <div className="space-y-6"> |
| {activityLogs.map((log) => { |
| const iconMap: Record<string, LucideIcon> = { |
| login: LogIn, |
| upload: Upload, |
| delete: Trash2, |
| export: Download, |
| edit: Pencil, |
| signup: UserPlus, |
| password_change: Lock, |
| }; |
| const colorMap: Record<string, string> = { |
| login: 'text-amber-500', |
| upload: 'text-teal-500', |
| delete: 'text-red-400', |
| export: 'text-amber-500', |
| edit: 'text-blue-400', |
| signup: 'text-green-400', |
| password_change: 'text-amber-500', |
| }; |
| const ItemIcon = iconMap[log.type] || Clock; |
| const itemColor = colorMap[log.type] || 'text-muted-foreground'; |
| return ( |
| <div key={log.id} className="relative flex gap-4"> |
| <div className="relative z-10 w-8 h-8 rounded-full bg-white/[0.04] border border-white/[0.08] flex items-center justify-center shrink-0"> |
| <ItemIcon className={`w-3.5 h-3.5 ${itemColor}`} /> |
| </div> |
| <div className="min-w-0 flex-1 pt-0.5"> |
| <div className="flex items-center justify-between gap-2"> |
| <h4 className="text-sm font-medium">{log.title}</h4> |
| <span className="text-xs text-muted-foreground shrink-0">{relativeTime(log.createdAt)}</span> |
| </div> |
| <p className="text-xs text-muted-foreground mt-0.5">{log.description}</p> |
| </div> |
| </div> |
| ); |
| })} |
| </div> |
| </div> |
| ) : ( |
| <div className="text-center py-8 text-muted-foreground"> |
| <Clock className="w-10 h-10 mx-auto mb-3 opacity-40" /> |
| <p className="text-sm">No activity yet. Actions will appear here as you use the app.</p> |
| </div> |
| )} |
| </div> |
| </section> |
| )} |
| </div> |
| </main> |
|
|
| {showCommandPalette && ( |
| <div className="command-palette-backdrop" onClick={() => setShowCommandPalette(false)}> |
| <div className="command-palette" onClick={e => e.stopPropagation()}> |
| <div className="command-palette-input"> |
| <Command className="w-5 h-5 text-muted-foreground shrink-0" /> |
| <input |
| autoFocus |
| type="text" |
| value={commandSearch} |
| onChange={e => setCommandSearch(e.target.value)} |
| placeholder="Search invoices, navigate tabs..." |
| onKeyDown={e => { if (e.key === 'Escape') setShowCommandPalette(false); }} |
| /> |
| <kbd className="kbd">ESC</kbd> |
| </div> |
| <div className="command-palette-results scrollbar-thin"> |
| <div className="command-palette-section-title">Quick Actions</div> |
| {DASH_TABS.filter(t => !commandSearch || t.label.toLowerCase().includes(commandSearch.toLowerCase())).map(tab => { |
| const Icon = tab.icon; |
| return ( |
| <button |
| key={tab.id} |
| onClick={() => { store.setActiveDashTab(tab.id); setShowCommandPalette(false); setCommandSearch(''); }} |
| className="command-palette-item" |
| > |
| <Icon className="w-4 h-4" /> |
| {tab.label} |
| </button> |
| ); |
| })} |
| {commandSearch && ( |
| <> |
| <div className="command-palette-section-title" style={{ marginTop: 8 }}>Invoice Results</div> |
| {store.invoices |
| .filter(inv => !commandSearch || inv.vendor?.toLowerCase().includes(commandSearch.toLowerCase()) || inv.invNumber?.toLowerCase().includes(commandSearch.toLowerCase())) |
| .slice(0, 5) |
| .map(inv => ( |
| <button |
| key={inv.id} |
| onClick={() => { store.setActiveDashTab('invoices'); setExpandedInvoice(inv.id); setShowCommandPalette(false); setCommandSearch(''); }} |
| className="command-palette-item justify-between" |
| > |
| <div> |
| <div className="font-medium text-foreground">{inv.vendor || 'Unknown'}</div> |
| <div className="command-palette-result-sub">{inv.invNumber || 'No invoice #'}</div> |
| </div> |
| <div className="text-right"> |
| <div className="font-medium">{formatCurrency(inv.total, inv.currency)}</div> |
| <div className="command-palette-result-sub">{inv.invDate || ''}</div> |
| </div> |
| </button> |
| )) |
| } |
| {store.invoices.filter(inv => inv.vendor?.toLowerCase().includes(commandSearch.toLowerCase()) || inv.invNumber?.toLowerCase().includes(commandSearch.toLowerCase())).length === 0 && ( |
| <div className="px-3 py-4 text-center text-sm text-muted-foreground">No matching invoices</div> |
| )} |
| </> |
| )} |
| </div> |
| </div> |
| </div> |
| )} |
|
|
| <footer className="dash-footer py-4 pb-[env(safe-area-inset-bottom)] px-4 sm:px-6 lg:px-8"> |
| <div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-muted-foreground"> |
| <div className="flex items-center gap-2"> |
| <div className="w-4 h-4 rounded-md bg-amber-500 flex items-center justify-center"> |
| <ScanLine className="w-2.5 h-2.5 text-zinc-900" /> |
| </div> |
| <span>OmniParse AI</span> |
| </div> |
| <div className="flex items-center gap-4"> |
| <span>Privacy</span> |
| <span>Terms</span> |
| <span>Support</span> |
| </div> |
| </div> |
| </footer> |
|
|
| {showShortcuts && ( |
| <div className="modal-overlay" onClick={() => setShowShortcuts(false)}> |
| <div className="modal-content" onClick={(e) => e.stopPropagation()}> |
| <div className="flex items-center justify-between mb-6"> |
| <h2 className="text-lg font-bold">Keyboard Shortcuts</h2> |
| <button |
| onClick={() => setShowShortcuts(false)} |
| className="p-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors" |
| aria-label="Close" |
| > |
| <X className="w-4 h-4" /> |
| </button> |
| </div> |
| <div className="space-y-3"> |
| {[ |
| { keys: ['Ctrl', 'K'], desc: 'Go to Invoices' }, |
| { keys: ['Ctrl', '/'], desc: 'Show shortcuts' }, |
| { keys: ['Enter'], desc: 'Send chat message' }, |
| ].map((s) => ( |
| <div key={s.desc} className="flex items-center justify-between py-2"> |
| <span className="text-sm text-muted-foreground">{s.desc}</span> |
| <div className="flex items-center gap-1"> |
| {s.keys.map((k, i) => ( |
| <span key={k}> |
| <span className="kbd">{k}</span> |
| {i < s.keys.length - 1 && <span className="text-muted-foreground mx-1">+</span>} |
| </span> |
| ))} |
| </div> |
| </div> |
| ))} |
| </div> |
| </div> |
| </div> |
| )} |
|
|
| {editingInvoice && ( |
| <div className="modal-overlay" onClick={() => setEditingInvoice(null)}> |
| <div className="modal-content max-w-lg" onClick={(e) => e.stopPropagation()}> |
| <div className="flex items-center justify-between mb-6"> |
| <h2 className="text-lg font-bold">Edit Invoice</h2> |
| <button onClick={() => setEditingInvoice(null)} className="p-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors" aria-label="Close"> |
| <X className="w-4 h-4" /> |
| </button> |
| </div> |
| <div className="space-y-4"> |
| {[ |
| { key: 'vendor', label: 'Vendor' }, |
| { key: 'invNumber', label: 'Invoice Number' }, |
| { key: 'invDate', label: 'Invoice Date' }, |
| { key: 'dueDate', label: 'Due Date' }, |
| { key: 'amount', label: 'Amount' }, |
| { key: 'vatAmount', label: 'VAT Amount' }, |
| { key: 'total', label: 'Total' }, |
| { key: 'currency', label: 'Currency' }, |
| ].map(({ key, label }) => ( |
| <div key={key}> |
| <label className="input-label">{label}</label> |
| <input |
| type="text" |
| value={editForm[key] || ''} |
| onChange={(e) => setEditForm(prev => ({ ...prev, [key]: e.target.value }))} |
| className="input-field input-field-glow" |
| /> |
| </div> |
| ))} |
| <div> |
| <label className="input-label">Status</label> |
| <div className="flex gap-3"> |
| {['done', 'review'].map((s) => ( |
| <button |
| key={s} |
| onClick={() => setEditForm(prev => ({ ...prev, status: s }))} |
| className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-colors ${ |
| editForm.status === s |
| ? s === 'done' ? 'bg-teal-500 text-zinc-900' : 'bg-amber-500 text-zinc-900' |
| : 'bg-white/[0.04] text-muted-foreground border border-white/[0.08]' |
| }`} |
| > |
| {s === 'done' ? 'Done' : 'Review'} |
| </button> |
| ))} |
| </div> |
| </div> |
| </div> |
| <div className="flex gap-3 mt-6"> |
| <button onClick={() => setEditingInvoice(null)} className="btn-ghost text-sm flex-1">Cancel</button> |
| <button onClick={saveEdit} disabled={editSaving} className="btn-primary text-sm flex-1 flex items-center justify-center gap-2 disabled:opacity-50"> |
| {editSaving && <Loader2 className="w-4 h-4 animate-spin" />} |
| Save Changes |
| </button> |
| </div> |
| </div> |
| </div> |
| )} |
|
|
| {} |
| <a |
| href="/omniparse-hf-space.zip" |
| download |
| className="fixed bottom-5 right-5 z-50 bg-amber-500 hover:bg-amber-400 text-black font-semibold text-xs px-3 py-2 rounded-lg shadow-lg transition-colors" |
| > |
| ⬇ Download HF Space Files (.zip) |
| </a> |
| </div> |
| ); |
| } |
| return null; |
| } |
|
|
| |
| |
|
|
| const CHART_COLORS = { |
| amber: '#F59E0B', |
| teal: '#14B8A6', |
| red: '#EF4444', |
| green: '#22C55E', |
| amberLight: 'rgba(245, 158, 11, 0.15)', |
| }; |
|
|
| function AnalyticsTab({ invoices }: { invoices: InvoiceRow[] }) { |
| |
| const monthlyData = useMemo(() => { |
| const map = new Map<string, number>(); |
| invoices.forEach((inv) => { |
| const dateStr = inv.invDate || inv.createdAt; |
| if (!dateStr) return; |
| const monthKey = dateStr.substring(0, 7); |
| map.set(monthKey, (map.get(monthKey) || 0) + (inv.total ?? 0)); |
| }); |
| return Array.from(map.entries()) |
| .sort(([a], [b]) => a.localeCompare(b)) |
| .map(([month, total]) => { |
| const [y, m] = month.split('-'); |
| const label = new Date(Number(y), Number(m) - 1).toLocaleString('en-US', { |
| month: 'short', year: '2-digit', |
| }); |
| return { month: label, total: Math.round(total * 100) / 100 }; |
| }); |
| }, [invoices]); |
|
|
| const vendorData = useMemo(() => { |
| const map = new Map<string, number>(); |
| invoices.forEach((inv) => { |
| const vendor = inv.vendor || 'Unknown'; |
| map.set(vendor, (map.get(vendor) || 0) + (inv.total ?? 0)); |
| }); |
| return Array.from(map.entries()) |
| .sort((a, b) => b[1] - a[1]) |
| .slice(0, 8) |
| .map(([name, total]) => ({ name: name.length > 18 ? name.slice(0, 16) + '…' : name, total: Math.round(total * 100) / 100 })); |
| }, [invoices]); |
|
|
| const statusData = useMemo(() => { |
| const counts = { Done: 0, Review: 0, Duplicate: 0 }; |
| invoices.forEach((inv) => { |
| if (inv.status === 'done') counts.Done++; |
| else if (inv.status === 'review') counts.Review++; |
| else if (inv.status === 'duplicate' || inv.isDuplicate) counts.Duplicate++; |
| }); |
| return [ |
| { name: 'Done', value: counts.Done, color: CHART_COLORS.teal }, |
| { name: 'Review', value: counts.Review, color: CHART_COLORS.amber }, |
| { name: 'Duplicate', value: counts.Duplicate, color: CHART_COLORS.red }, |
| ].filter((d) => d.value > 0); |
| }, [invoices]); |
|
|
| const currencyData = useMemo(() => { |
| const map = new Map<string, number>(); |
| invoices.forEach((inv) => map.set(inv.currency, (map.get(inv.currency) || 0) + 1)); |
| const entries = Array.from(map.entries()).sort((a, b) => b[1] - a[1]); |
| const maxCount = entries.length > 0 ? entries[0][1] : 1; |
| return entries.map(([currency, count]) => ({ currency, count, pct: Math.round((count / maxCount) * 100) })); |
| }, [invoices]); |
|
|
| const quickStats = useMemo(() => { |
| const totals = invoices.map((inv) => inv.total ?? 0).filter((v) => v > 0); |
| const vendors = invoices.filter((inv) => inv.vendor); |
| const vendorCounts = new Map<string, number>(); |
| vendors.forEach((inv) => { |
| const v = inv.vendor as string; |
| vendorCounts.set(v, (vendorCounts.get(v) || 0) + 1); |
| }); |
| let mostActive = '--'; |
| let maxCount = 0; |
| vendorCounts.forEach((count, name) => { |
| if (count > maxCount) { mostActive = name; maxCount = count; } |
| }); |
| return { |
| highest: totals.length > 0 ? Math.max(...totals) : null, |
| lowest: totals.length > 0 ? Math.min(...totals) : null, |
| mostActiveVendor: mostActive, |
| average: totals.length > 0 ? totals.reduce((a, b) => a + b, 0) / totals.length : null, |
| }; |
| }, [invoices]); |
|
|
| const hasData = invoices.length > 0; |
|
|
| return ( |
| <section className="fade-in"> |
| {/* Section Header */} |
| <div className="mb-6"> |
| <h2 className="text-xl font-bold">Analytics</h2> |
| <p className="text-sm text-muted-foreground mt-1">Visual insights into your invoice data</p> |
| </div> |
| |
| {!hasData ? ( |
| <div className="glass-card p-12 text-center empty-state"> |
| <BarChart3 className="w-12 h-12" /> |
| <p className="text-sm mt-2">No invoice data yet</p> |
| <p className="text-xs text-muted-foreground mt-1">Upload invoices to see analytics here</p> |
| </div> |
| ) : ( |
| <> |
| {/* ── Quick Stats Summary ── */} |
| <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6" aria-label="Quick statistics"> |
| <div className="stat-card-glass card-accent-amber card-enter"> |
| <div className="flex items-center gap-2 mb-1"> |
| <TrendingUp className="w-4 h-4 text-amber-500" /> |
| <span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Highest Invoice</span> |
| </div> |
| <div className="text-lg font-bold text-foreground"> |
| {quickStats.highest !== null ? formatCurrency(quickStats.highest) : '--'} |
| </div> |
| </div> |
| <div className="stat-card-glass card-accent-teal card-enter"> |
| <div className="flex items-center gap-2 mb-1"> |
| <TrendingUp className="w-4 h-4 text-teal-500" /> |
| <span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Lowest Invoice</span> |
| </div> |
| <div className="text-lg font-bold text-foreground"> |
| {quickStats.lowest !== null ? formatCurrency(quickStats.lowest) : '--'} |
| </div> |
| </div> |
| <div className="stat-card-glass card-accent-green card-enter"> |
| <div className="flex items-center gap-2 mb-1"> |
| <Building2 className="w-4 h-4 text-green-400" /> |
| <span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Most Active Vendor</span> |
| </div> |
| <div className="text-lg font-bold text-foreground truncate" title={quickStats.mostActiveVendor}> |
| {quickStats.mostActiveVendor} |
| </div> |
| </div> |
| <div className="stat-card-glass card-accent-amber card-enter"> |
| <div className="flex items-center gap-2 mb-1"> |
| <CircleDollarSign className="w-4 h-4 text-amber-500" /> |
| <span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Average Amount</span> |
| </div> |
| <div className="text-lg font-bold text-foreground"> |
| {quickStats.average !== null ? formatCurrency(quickStats.average) : '--'} |
| </div> |
| </div> |
| </div> |
| |
| {/* ── Charts Grid ── */} |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> |
| {/* a) Monthly Spending Trend */} |
| <div className="glass-card p-6"> |
| <h3 className="text-sm font-semibold mb-3">Monthly Spending Trend</h3> |
| {monthlyData.length === 0 ? ( |
| <div className="h-48 flex items-center justify-center text-muted-foreground text-xs"> |
| No date data available |
| </div> |
| ) : ( |
| <ResponsiveContainer width="100%" height={220}> |
| <AreaChart data={monthlyData} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}> |
| <defs> |
| <linearGradient id="amberGrad" x1="0" y1="0" x2="0" y2="1"> |
| <stop offset="0%" stopColor={CHART_COLORS.amber} stopOpacity={0.3} /> |
| <stop offset="100%" stopColor={CHART_COLORS.amber} stopOpacity={0.02} /> |
| </linearGradient> |
| </defs> |
| <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" /> |
| <XAxis dataKey="month" tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={{ stroke: 'rgba(255,255,255,0.1)' }} /> |
| <YAxis tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={{ stroke: 'rgba(255,255,255,0.1)' }} tickFormatter={(v: number) => `$${(v / 1000).toFixed(v >= 1000 ? 1 : 0)}k`} /> |
| <Tooltip |
| contentStyle={{ className: 'recharts-tooltip-custom' }} |
| wrapperStyle={{ outline: 'none' }} |
| formatter={(value: number) => [formatCurrency(value), 'Total']} |
| /> |
| <Area type="monotone" dataKey="total" stroke={CHART_COLORS.amber} strokeWidth={2} fill="url(#amberGrad)" /> |
| </AreaChart> |
| </ResponsiveContainer> |
| )} |
| </div> |
| |
| {/* b) Vendor Spending Breakdown */} |
| <div className="glass-card p-6"> |
| <h3 className="text-sm font-semibold mb-3">Top Vendors by Spending</h3> |
| {vendorData.length === 0 ? ( |
| <div className="h-48 flex items-center justify-center text-muted-foreground text-xs"> |
| No vendor data available |
| </div> |
| ) : ( |
| <ResponsiveContainer width="100%" height={220}> |
| <BarChart data={vendorData} layout="vertical" margin={{ top: 5, right: 10, left: 0, bottom: 0 }}> |
| <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" horizontal={false} /> |
| <XAxis type="number" tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={{ stroke: 'rgba(255,255,255,0.1)' }} tickFormatter={(v: number) => `$${(v / 1000).toFixed(v >= 1000 ? 1 : 0)}k`} /> |
| <YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#a1a1aa' }} width={110} axisLine={{ stroke: 'rgba(255,255,255,0.1)' }} /> |
| <Tooltip |
| contentStyle={{ className: 'recharts-tooltip-custom' }} |
| wrapperStyle={{ outline: 'none' }} |
| formatter={(value: number) => [formatCurrency(value), 'Total']} |
| /> |
| <Bar dataKey="total" fill={CHART_COLORS.teal} radius={[0, 4, 4, 0]} barSize={16} /> |
| </BarChart> |
| </ResponsiveContainer> |
| )} |
| </div> |
| </div> |
| |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> |
| {/* c) Status Distribution */} |
| <div className="glass-card p-6"> |
| <h3 className="text-sm font-semibold mb-3">Status Distribution</h3> |
| {statusData.length === 0 ? ( |
| <div className="h-48 flex items-center justify-center text-muted-foreground text-xs"> |
| No status data available |
| </div> |
| ) : ( |
| <div className="flex items-center gap-6"> |
| <div className="flex-1" style={{ minHeight: 180 }}> |
| <ResponsiveContainer width="100%" height={180}> |
| <PieChart> |
| <Pie |
| data={statusData} |
| cx="50%" |
| cy="50%" |
| innerRadius={50} |
| outerRadius={75} |
| paddingAngle={3} |
| dataKey="value" |
| stroke="none" |
| > |
| {statusData.map((entry, idx) => ( |
| <Cell key={`cell-${idx}`} fill={entry.color} /> |
| ))} |
| </Pie> |
| <Tooltip |
| contentStyle={{ className: 'recharts-tooltip-custom' }} |
| wrapperStyle={{ outline: 'none' }} |
| formatter={(value: number) => [`${value} invoices`, 'Count']} |
| /> |
| </PieChart> |
| </ResponsiveContainer> |
| </div> |
| <div className="flex flex-col gap-3 min-w-[120px]"> |
| {statusData.map((entry) => ( |
| <div key={entry.name} className="flex items-center gap-2"> |
| <span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} /> |
| <div> |
| <span className="text-sm text-foreground font-medium">{entry.name}</span> |
| <span className="text-xs text-muted-foreground ml-2">{entry.value}</span> |
| </div> |
| </div> |
| ))} |
| </div> |
| </div> |
| )} |
| </div> |
| |
| {/* d) Currency Distribution */} |
| <div className="glass-card p-6"> |
| <h3 className="text-sm font-semibold mb-3">Currency Distribution</h3> |
| {currencyData.length === 0 ? ( |
| <div className="h-48 flex items-center justify-center text-muted-foreground text-xs"> |
| No currency data available |
| </div> |
| ) : ( |
| <div className="space-y-3 mt-1"> |
| {currencyData.map(({ currency, count, pct }) => ( |
| <div key={currency} className="flex items-center gap-3"> |
| <span className="text-sm text-foreground font-medium w-16 text-right">{currency}</span> |
| <div className="flex-1 h-6 bg-white/[0.04] rounded overflow-hidden"> |
| <div |
| className="progress-bar h-full rounded" |
| style={{ width: `${pct}%`, backgroundColor: CHART_COLORS.teal }} |
| /> |
| </div> |
| <span className="text-xs text-muted-foreground w-8 text-right">{count}</span> |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| </div> |
| </> |
| )} |
| </section> |
| ); |
| } |
|
|
| |
| |
|
|
| function InvoiceTableBody({ |
| invoice, |
| isExpanded, |
| onToggle, |
| onDelete, |
| isSelected, |
| onSelect, |
| onEdit, |
| }: { |
| invoice: InvoiceRow; |
| isExpanded: boolean; |
| onToggle: () => void; |
| onDelete: () => void; |
| isSelected: boolean; |
| onSelect: () => void; |
| onEdit?: () => void; |
| }) { |
| const statusBadgeClass = invoice.isDuplicate ? 'badge-amber-enhanced' : invoice.status === 'review' ? 'badge-amber-enhanced' : 'badge-teal-enhanced'; |
| const statusLabel = invoice.isDuplicate ? 'Duplicate' : invoice.status === 'review' ? 'Review' : 'Done'; |
|
|
| return ( |
| <> |
| <tr |
| className="invoice-row-enhanced border-b border-white/[0.04]" |
| onClick={onToggle} |
| role="button" |
| tabIndex={0} |
| onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onToggle(); }} |
| aria-expanded={isExpanded} |
| > |
| <td className="px-3 py-3 w-10"> |
| <input |
| type="checkbox" |
| checked={isSelected} |
| onChange={(e) => { e.stopPropagation(); onSelect(); }} |
| onClick={(e) => e.stopPropagation()} |
| className="invoice-checkbox" |
| aria-label={`Select invoice ${invoice.vendor || invoice.invNumber}`} |
| /> |
| </td> |
| <td className="px-4 py-3"> |
| <div className="flex items-center gap-2"> |
| {isExpanded ? <ChevronUp className="w-3.5 h-3.5 text-muted-foreground shrink-0" /> : <ChevronDown className="w-3.5 h-3.5 text-muted-foreground shrink-0" />} |
| <span className="truncate max-w-[120px] sm:max-w-none">{invoice.vendor || '--'}</span> |
| </div> |
| </td> |
| <td className="px-4 py-3 text-muted-foreground hidden sm:table-cell">{invoice.invNumber || '--'}</td> |
| <td className="px-4 py-3 text-muted-foreground hidden md:table-cell">{invoice.invDate || '--'}</td> |
| <td className="px-4 py-3 text-right font-medium">{formatCurrency(invoice.amount, invoice.currency)}</td> |
| <td className="px-4 py-3 text-right font-medium hidden sm:table-cell">{formatCurrency(invoice.total, invoice.currency)}</td> |
| <td className="px-4 py-3 text-center"> |
| <span className={`badge-pill ${statusBadgeClass}`}> |
| <span className={`badge-pill-dot ${invoice.isDuplicate ? 'bg-amber-500' : invoice.status === 'review' ? 'bg-amber-500' : 'bg-teal-500'}`} /> |
| {statusLabel} |
| </span> |
| </td> |
| <td className="px-4 py-3 text-right hidden lg:table-cell"> |
| {invoice.confidence ? `${(invoice.confidence * 100).toFixed(1)}%` : '--'} |
| </td> |
| <td className="px-4 py-3 text-center"> |
| <div className="flex items-center justify-center gap-1"> |
| <div className="tooltip-wrapper"> |
| <button |
| onClick={(e) => { e.stopPropagation(); onEdit?.(); }} |
| className="p-1.5 rounded-lg text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors" |
| aria-label="Edit invoice" |
| > |
| <Pencil className="w-4 h-4" /> |
| </button> |
| <span className="tooltip-text">Edit</span> |
| </div> |
| <div className="tooltip-wrapper"> |
| <button |
| onClick={(e) => { e.stopPropagation(); onDelete(); }} |
| className="p-1.5 rounded-lg text-muted-foreground hover:text-red-400 hover:bg-red-500/10 transition-colors" |
| aria-label="Delete invoice" |
| > |
| <Trash2 className="w-4 h-4" /> |
| </button> |
| <span className="tooltip-text">Delete</span> |
| </div> |
| </div> |
| </td> |
| </tr> |
| {isExpanded && ( |
| <tr> |
| <td colSpan={10} className="p-0"> |
| <div className="px-4 py-4 bg-white/[0.02] border-b border-white/[0.04] fade-in"> |
| <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Invoice Details</h4> |
| <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4"> |
| <div> |
| <div className="text-xs text-muted-foreground">Filename</div> |
| <div className="text-sm mt-0.5">{invoice.filename || '--'}</div> |
| </div> |
| <div> |
| <div className="text-xs text-muted-foreground">Due Date</div> |
| <div className="text-sm mt-0.5">{invoice.dueDate || '--'}</div> |
| </div> |
| <div> |
| <div className="text-xs text-muted-foreground">VAT Amount</div> |
| <div className="text-sm mt-0.5">{formatCurrency(invoice.vatAmount, invoice.currency)}</div> |
| </div> |
| <div> |
| <div className="text-xs text-muted-foreground">Created</div> |
| <div className="text-sm mt-0.5">{new Date(invoice.createdAt).toLocaleDateString()}</div> |
| </div> |
| </div> |
| <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Raw JSON</h4> |
| <div className="bg-zinc-950 rounded-lg p-4 font-mono text-xs text-muted-foreground max-h-48 overflow-y-auto"> |
| <pre>{JSON.stringify({ |
| vendor: invoice.vendor, |
| invNumber: invoice.invNumber, |
| invDate: invoice.invDate, |
| dueDate: invoice.dueDate, |
| amount: invoice.amount, |
| vatAmount: invoice.vatAmount, |
| total: invoice.total, |
| currency: invoice.currency, |
| confidence: invoice.confidence, |
| status: invoice.status, |
| isDuplicate: invoice.isDuplicate, |
| filename: invoice.filename, |
| }, null, 2)}</pre> |
| </div> |
| </div> |
| </td> |
| </tr> |
| )} |
| </> |
| ); |
| } |