542 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Head from 'next/head';
import { toast } from 'react-hot-toast';
import {
MagnifyingGlassIcon,
ReceiptPercentIcon,
CloudArrowDownIcon,
EyeIcon,
PrinterIcon,
DocumentTextIcon,
CheckCircleIcon,
ClockIcon,
ExclamationCircleIcon,
ChevronLeftIcon,
ChevronRightIcon,
DocumentArrowDownIcon,
CalendarIcon,
BuildingOfficeIcon,
CurrencyDollarIcon
} from '@heroicons/react/24/outline';
import { supabase, TABLES } from '@/lib/supabase';
import { getDemoData } from '@/lib/demo-data';
import { formatTime, formatCurrency } from '@/utils';
import Layout from '@/components/Layout';
interface Invoice {
id: string;
invoice_number: string;
user_id: string;
user_name: string;
user_email: string;
order_id: string;
invoice_type: 'individual' | 'enterprise';
// 个人发票信息
personal_name?: string;
// 企业发票信息
company_name?: string;
tax_number?: string;
company_address?: string;
company_phone?: string;
bank_name?: string;
bank_account?: string;
// 发票金额信息
subtotal: number;
tax_amount: number;
total_amount: number;
currency: string;
// 发票状态
status: 'draft' | 'issued' | 'paid' | 'cancelled';
issue_date?: string;
due_date?: string;
paid_date?: string;
// 服务明细
items: InvoiceItem[];
created_at: string;
updated_at: string;
}
interface InvoiceItem {
service_type: string;
service_name: string;
quantity: number;
unit: string;
unit_price: number;
amount: number;
}
interface InvoiceFilters {
search: string;
status: 'all' | 'draft' | 'issued' | 'paid' | 'cancelled';
invoice_type: 'all' | 'individual' | 'enterprise';
date_range: 'all' | 'today' | 'week' | 'month' | 'quarter';
sortBy: 'created_at' | 'total_amount' | 'issue_date';
sortOrder: 'asc' | 'desc';
}
export default function InvoicesPage() {
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [isDemoMode, setIsDemoMode] = useState(false);
const [filters, setFilters] = useState<InvoiceFilters>({
search: '',
status: 'all',
invoice_type: 'all',
date_range: 'all',
sortBy: 'created_at',
sortOrder: 'desc'
});
const router = useRouter();
const pageSize = 20;
// 获取发票数据
const fetchInvoices = async (page = 1) => {
try {
setLoading(true);
// 检查是否为演示模式
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const isDemo = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
setIsDemoMode(isDemo);
if (isDemo) {
// 使用演示数据
const result = await getDemoData.invoices();
setInvoices(result);
setTotalCount(result.length);
setTotalPages(Math.ceil(result.length / pageSize));
setCurrentPage(page);
} else {
// 使用真实数据 - 这里需要根据实际数据库结构调整
// 暂时使用演示数据
const result = await getDemoData.invoices();
setInvoices(result);
setTotalCount(result.length);
setTotalPages(Math.ceil(result.length / pageSize));
setCurrentPage(page);
}
} catch (error) {
console.error('Error fetching invoices:', error);
toast.error('获取发票列表失败');
} finally {
setLoading(false);
}
};
// 处理筛选变更
const handleFilterChange = (key: keyof InvoiceFilters, value: any) => {
setFilters(prev => ({
...prev,
[key]: value
}));
};
// 应用筛选
const applyFilters = () => {
setCurrentPage(1);
fetchInvoices(1);
};
// 重置筛选
const resetFilters = () => {
setFilters({
search: '',
status: 'all',
invoice_type: 'all',
date_range: 'all',
sortBy: 'created_at',
sortOrder: 'desc'
});
setCurrentPage(1);
fetchInvoices(1);
};
// 获取状态颜色
const getStatusColor = (status: string) => {
switch (status) {
case 'paid':
return 'bg-green-100 text-green-800';
case 'issued':
return 'bg-blue-100 text-blue-800';
case 'draft':
return 'bg-yellow-100 text-yellow-800';
case 'cancelled':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
// 获取状态文本
const getStatusText = (status: string) => {
switch (status) {
case 'paid':
return '已付款';
case 'issued':
return '已开具';
case 'draft':
return '草稿';
case 'cancelled':
return '已取消';
default:
return '未知';
}
};
// 获取状态图标
const getStatusIcon = (status: string) => {
switch (status) {
case 'paid':
return <CheckCircleIcon className="h-4 w-4 text-green-600" />;
case 'issued':
return <ClockIcon className="h-4 w-4 text-blue-600" />;
case 'draft':
return <DocumentTextIcon className="h-4 w-4 text-yellow-600" />;
case 'cancelled':
return <ExclamationCircleIcon className="h-4 w-4 text-red-600" />;
default:
return <ClockIcon className="h-4 w-4 text-gray-600" />;
}
};
// 下载发票
const handleDownloadInvoice = async (invoiceId: string) => {
try {
// 这里应该调用实际的发票下载API
toast.success('发票下载已开始');
// 模拟下载过程
const invoice = invoices.find(inv => inv.id === invoiceId);
if (invoice) {
// 创建一个虚拟的下载链接
const element = document.createElement('a');
const file = new Blob([`发票号: ${invoice.invoice_number}\n金额: ${formatCurrency(invoice.total_amount)}`],
{ type: 'text/plain' });
element.href = URL.createObjectURL(file);
element.download = `invoice-${invoice.invoice_number}.txt`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
} catch (error) {
console.error('Error downloading invoice:', error);
toast.error('发票下载失败');
}
};
// 打印发票
const handlePrintInvoice = (invoiceId: string) => {
try {
// 这里应该打开打印预览
toast.success('正在准备打印...');
window.print();
} catch (error) {
console.error('Error printing invoice:', error);
toast.error('发票打印失败');
}
};
// 生成发票
const handleGenerateInvoice = async (orderId: string) => {
try {
// 这里应该调用生成发票API
toast.success('发票生成成功');
fetchInvoices();
} catch (error) {
toast.error('生成发票失败');
}
};
useEffect(() => {
fetchInvoices();
}, []);
return (
<Layout>
<Head>
<title> - </title>
</Head>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
{/* 页面标题 */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<button
onClick={() => handleGenerateInvoice('new')}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<ReceiptPercentIcon className="h-4 w-4 mr-2" />
</button>
</div>
{/* 搜索和筛选 */}
<div className="bg-white shadow rounded-lg mb-6">
<div className="px-4 py-5 sm:p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
{/* 搜索框 */}
<div className="lg:col-span-2">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="搜索发票号、用户名或公司名..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* 状态筛选 */}
<div>
<select
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all"></option>
<option value="draft">稿</option>
<option value="issued"></option>
<option value="paid"></option>
<option value="cancelled"></option>
</select>
</div>
{/* 类型筛选 */}
<div>
<select
value={filters.invoice_type}
onChange={(e) => handleFilterChange('invoice_type', e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all"></option>
<option value="individual"></option>
<option value="enterprise"></option>
</select>
</div>
{/* 日期范围筛选 */}
<div>
<select
value={filters.date_range}
onChange={(e) => handleFilterChange('date_range', e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all"></option>
<option value="today"></option>
<option value="week"></option>
<option value="month"></option>
<option value="quarter"></option>
</select>
</div>
</div>
<div className="mt-4 flex space-x-3">
<button
onClick={applyFilters}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
</button>
<button
onClick={resetFilters}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
</button>
</div>
</div>
</div>
{/* 发票列表 */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="loading-spinner"></div>
</div>
) : (
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">
({totalCount} )
</h3>
</div>
<div className="space-y-4">
{invoices.map((invoice) => (
<div key={invoice.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-3">
<ReceiptPercentIcon className="h-8 w-8 text-gray-400" />
<div>
<h4 className="text-sm font-medium text-gray-900">
: {invoice.invoice_number}
</h4>
<p className="text-sm text-gray-500">
{invoice.invoice_type === 'enterprise' ? (
<span className="inline-flex items-center">
<BuildingOfficeIcon className="h-4 w-4 mr-1" />
{invoice.company_name}
</span>
) : (
<span className="inline-flex items-center">
<CalendarIcon className="h-4 w-4 mr-1" />
{invoice.personal_name || invoice.user_name}
</span>
)}
</p>
<p className="text-xs text-gray-400">
: ¥{invoice.total_amount.toFixed(2)} | : ¥{invoice.tax_amount.toFixed(2)} |
: ¥{invoice.total_amount.toFixed(2)}
</p>
{invoice.tax_number && (
<p className="text-xs text-gray-400">
: {invoice.tax_number}
</p>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-4">
{/* 状态 */}
<div className="flex items-center space-x-2">
{getStatusIcon(invoice.status)}
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(invoice.status)}`}>
{getStatusText(invoice.status)}
</span>
</div>
{/* 操作按钮 */}
<div className="flex space-x-2">
<button
className="text-indigo-600 hover:text-indigo-900"
title="查看详情"
>
<EyeIcon className="h-4 w-4" />
</button>
<button
onClick={() => handleDownloadInvoice(invoice.id)}
className="text-green-600 hover:text-green-900"
title="下载发票"
>
<DocumentArrowDownIcon className="h-4 w-4" />
</button>
<button
onClick={() => handlePrintInvoice(invoice.id)}
className="text-blue-600 hover:text-blue-900"
title="打印发票"
>
<PrinterIcon className="h-4 w-4" />
</button>
</div>
</div>
</div>
{/* 日期信息 */}
<div className="mt-3 text-xs text-gray-400">
<div>: {formatTime(invoice.created_at)}</div>
{invoice.issue_date && (
<div>: {formatTime(invoice.issue_date)}</div>
)}
{invoice.paid_date && (
<div>: {formatTime(invoice.paid_date)}</div>
)}
</div>
</div>
))}
{invoices.length === 0 && (
<div className="text-center py-12">
<ReceiptPercentIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900"></h3>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
)}
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="mt-6 flex items-center justify-between">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => fetchInvoices(currentPage - 1)}
disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
</button>
<button
onClick={() => fetchInvoices(currentPage + 1)}
disabled={currentPage === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
<span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> {' '}
<span className="font-medium">
{Math.min(currentPage * pageSize, totalCount)}
</span>{' '}
<span className="font-medium">{totalCount}</span>
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => fetchInvoices(currentPage - 1)}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
<ChevronLeftIcon className="h-5 w-5" />
</button>
{[...Array(Math.min(totalPages, 5))].map((_, i) => {
const page = i + 1;
return (
<button
key={page}
onClick={() => fetchInvoices(page)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
page === currentPage
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{page}
</button>
);
})}
<button
onClick={() => fetchInvoices(currentPage + 1)}
disabled={currentPage === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
<ChevronRightIcon className="h-5 w-5" />
</button>
</nav>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
</Layout>
);
}