'优化团队,pdf生成'

This commit is contained in:
liamzi
2024-12-23 17:18:02 +08:00
parent 98eb405cc5
commit 83a29882d7
5 changed files with 232 additions and 249 deletions

View File

@@ -20,6 +20,7 @@
"@supabase/supabase-js": "^2.38.4",
"antd": "^5.11.0",
"dnd-kit": "^0.0.2",
"html2pdf": "^0.0.11",
"lodash": "^4.17.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",

8
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ importers:
dnd-kit:
specifier: ^0.0.2
version: 0.0.2
html2pdf:
specifier: ^0.0.11
version: 0.0.11
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -1386,6 +1389,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
html2pdf@0.0.11:
resolution: {integrity: sha512-Kx3I5r7lazHZzZv5lDk59jQoTxqaEGlf2BcGNvzWGPQVu0hE8jYCk4hboksuaCvmMJPa0S0A+156ZbE71Wdo2w==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -4137,6 +4143,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
html2pdf@0.0.11: {}
ignore@5.3.2: {}
immutable@5.0.3: {}

View File

@@ -172,7 +172,7 @@ const QuotationForm = () => {
if (data?.attributes) {
const formData = {
quataName: data.attributes.quataName,
customers: data.attributes.customers || [],
customers: data.attributes.customers.map((customer) => customer.id) || [],
description: data.attributes.description,
currency: data.attributes.currency || "CNY",
sections: data.attributes.sections.map((section) => ({
@@ -716,7 +716,7 @@ const QuotationForm = () => {
<Form.Item
name="customers"
label={<span className="text-gray-700 font-medium">客户名称</span>}
rules={[{ required: true, message: "请选择至少一个客<EFBFBD><EFBFBD>" }]}
rules={[{ required: true, message: "请选择至少一个客" }]}
>
<Select
mode="multiple"

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react';
import { Card, Table, Button, message, Popconfirm, Tag, Space, Spin, Modal, Empty, Select, Typography, Statistic, Divider } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined,CopyOutlined, FileAddOutlined, AppstoreOutlined } from '@ant-design/icons';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, CopyOutlined, FileAddOutlined, AppstoreOutlined } from '@ant-design/icons';
import { useResources } from '@/hooks/resource/useResource';
import { useNavigate } from 'react-router-dom';
import {supabase}from '@/config/supabase'
import { supabase } from '@/config/supabase'
const CURRENCY_SYMBOLS = {
CNY: "¥",
TWD: "NT$",
@@ -126,7 +126,7 @@ const QuotationPage = () => {
title: '客户信息',
dataIndex: ['attributes', 'customers'],
key: 'customers',
render: (customers,record) => (
render: (customers, record) => (
<Space>
{customers?.map(customer => (
<Tag
@@ -148,7 +148,6 @@ const QuotationPage = () => {
dataIndex: ['attributes'],
key: 'totalAmount',
align: 'right',
width: 200,
render: (attributes) => {
// 获取货币符号
const currencySymbol = CURRENCY_SYMBOLS[attributes.currency] || '¥';
@@ -183,7 +182,6 @@ const QuotationPage = () => {
dataIndex: 'created_at',
key: 'created_at',
sorter: true,
width: 120,
render: (text) => (
<span>{new Date(text).toLocaleString('zh-CN', {
year: 'numeric',
@@ -197,15 +195,14 @@ const QuotationPage = () => {
{
title: '操作',
key: 'action',
width:250,
width: 280,
render: (_, record) => (
<Space size={0}>
<Button
size='small'
type="link"
icon={<CopyOutlined />}
// onClick={() => navigate(`/company/quotaInfo/preview/${record.id}`)}
onClick={() => copyItem(record)}
>
复制
</Button>
@@ -268,7 +265,37 @@ const QuotationPage = () => {
return Array.from(groups.values()).filter(group => group.templates.length > 0);
};
const copyItem = async (record) => {
try {
setLoading(true);
// 深拷贝原有数据的 attributes
const newAttributes = JSON.parse(JSON.stringify(record.attributes));
// 修改报价单名称,添加"副本"标识
newAttributes.quataName = `${newAttributes.quataName} (副本)`;
// 创建新的报价单记录
const { data, error } = await supabase
.from('resources')
.insert([
{
type: 'quota',
attributes: newAttributes
}
]);
if (error) throw error;
message.success('复制成功');
// 刷新列表
fetchQuotations();
} catch (error) {
console.error('复制报价单失败:', error);
message.error('复制失败:' + error.message);
} finally {
setLoading(false);
}
};
return (
<>
<Card
@@ -290,6 +317,7 @@ const QuotationPage = () => {
}
>
<Table
className='w-full'
columns={columns}
dataSource={quotations}
rowKey="id"

View File

@@ -1,69 +1,37 @@
import React, { useEffect, useState } from 'react';
import { Card, Typography, Descriptions, Table, Button, Space, Spin } from 'antd';
import { ArrowLeftOutlined, PrinterOutlined, EditOutlined } from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import { Card, Button, Typography, Space, Spin, Divider, Tag } from 'antd';
import { FileTextOutlined, DownloadOutlined } from '@ant-design/icons';
import { useParams } from 'react-router-dom';
import { supabase } from '@/config/supabase';
import ReactDOMServer from 'react-dom/server';
import PrintView from '@/components/PrintView';
import html2pdf from 'html2pdf.js';
const { Title, Text } = Typography;
const printStyles = `
@media print {
/* 隐藏所有按钮和不需要的元素 */
button, .no-print {
display: none !important;
}
const CURRENCY_SYMBOLS = {
CNY: "¥",
TWD: "NT$",
USD: "$",
};
/* 只打印卡片内容 */
.ant-card {
box-shadow: none !important;
margin: 0 !important;
padding: 0 !important;
}
/* 移除背景色 */
body, .bg-gray-50 {
background: white !important;
min-height: auto !important;
padding: 0 !important;
}
/* 确保内容完整打印 */
.ant-card-body {
padding: 24px !important;
page-break-inside: avoid;
}
/* 优化表格打印样式 */
.ant-table {
page-break-inside: auto !important;
}
.ant-table-row {
page-break-inside: avoid !important;
}
}
`;
const QuotationView = () => {
const navigate = useNavigate();
const QuotationPreview = () => {
const { id } = useParams();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(false);
const [quotation, setQuotation] = useState(null);
// 获取报价单详情
const fetchQuotationDetail = async () => {
try {
const { data: quotation, error } = await supabase
.from('resources')
.select('*')
.eq('id', id)
setLoading(true);
const { data, error } = await supabase
.from("resources")
.select("*")
.eq("id", id)
.single();
if (error) throw error;
setData(quotation);
setQuotation(data);
} catch (error) {
console.error('获取报价单失败:', error);
message.error("获取报价单详情失败");
} finally {
setLoading(false);
}
@@ -75,187 +43,165 @@ const QuotationView = () => {
}
}, [id]);
const columns = [
{
title: '序号',
dataIndex: 'index',
width: 100,
render: (_, __, index) => index + 1,
},
{
title: '名称',
dataIndex: 'productName',
width: '40%',
},
{
title: '数量',
dataIndex: 'quantity',
width: '20%',
align: 'right',
render: (value) => value?.toLocaleString('zh-CN', { minimumFractionDigits: 2 }),
},
{
title: '单价',
dataIndex: 'price',
width: '20%',
align: 'right',
render: (value) => value?.toLocaleString('zh-CN', { minimumFractionDigits: 2 }),
},
{
title: '小计',
width: '20%',
align: 'right',
render: (_, record) => (
(record.quantity * record.price)?.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
),
},
];
// 导出PDF
const handleExportPDF = () => {
const element = document.getElementById('quotation-content');
const opt = {
margin: [10, 10],
filename: `${quotation.attributes.quataName || '报价单'}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2 },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
};
const handlePrint = () => {
const printWindow = window.open('', '_blank');
const printContent = ReactDOMServer.renderToString(<PrintView data={data} />);
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>打印报价单</title>
<link rel="stylesheet" href="${window.location.origin}/antd.min.css">
<style>
@media print {
body {
padding: 0;
margin: 0;
}
}
</style>
</head>
<body>
${printContent}
<script>
window.onload = function() {
window.print();
window.onafterprint = function() {
window.close();
}
}
</script>
</body>
</html>
`);
printWindow.document.close();
html2pdf().set(opt).from(element).save();
};
if (loading) {
return (
<div className="flex justify-center items-center min-h-screen">
<div className="flex justify-center items-center h-full">
<Spin size="large" />
</div>
);
}
if (!data) {
return null;
}
if (!quotation) return null;
const { attributes } = quotation;
const currencySymbol = CURRENCY_SYMBOLS[attributes.currency] || '¥';
return (
<>
<style>{printStyles}</style>
<div className="bg-gray-50 min-h-screen ">
<Card className="max-w-5xl mx-auto shadow-lg">
<div className="flex justify-between items-center mb-6 no-print">
<div className="max-w-4xl mx-auto p-6">
<Card
title={
<div className="flex justify-between items-center">
<Space>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/company/quotation')}
>
返回列表
</Button>
<FileTextOutlined className="text-blue-500" />
<span>报价单预览</span>
</Space>
<Space>
<Button
icon={<EditOutlined />}
type="primary"
onClick={() => navigate(`/company/quotaInfo/${data.id}?edit=true`)}
icon={<DownloadOutlined />}
onClick={handleExportPDF}
>
编辑报价单
导出PDF
</Button>
<Button
icon={<PrinterOutlined />}
onClick={handlePrint}
</div>
}
>
打印报价单
</Button>
<div id="quotation-content" className="p-6">
{/* 报价单标题 */}
<div className="text-center mb-8">
<Title level={2}>{attributes.quataName}</Title>
<Text type="secondary">创建日期{new Date(quotation.created_at).toLocaleDateString()}</Text>
</div>
{/* 基本信息 */}
<div className="bg-gray-50 p-4 rounded-lg mb-6">
<Title level={4}>基本信息</Title>
<div className="grid grid-cols-2 gap-4">
<div>
<Text type="secondary">客户</Text>
<Space>
{attributes.customers?.map(customer => (
<Tag key={customer.id} color="blue">{customer.name}</Tag>
))}
</Space>
</div>
<div className="text-center mb-8">
<Title level={2} className="!mb-2">{data.attributes.quataName}</Title>
<Text type="secondary">报价单号{data.id}</Text>
<div>
<Text type="secondary">货币类型</Text>
<Text>{attributes.currency}</Text>
</div>
</div>
</div>
<div className="bg-gray-50 p-6 rounded-lg mb-8">
<Descriptions column={2} bordered>
<Descriptions.Item label="客户公司" span={1}>
{data.attributes.customerName}
</Descriptions.Item>
<Descriptions.Item label="报价日期" span={1}>
{new Date(data.created_at).toLocaleDateString('zh-CN')}
</Descriptions.Item>
<Descriptions.Item label="报价有效期" span={1}>
{new Date(Date.now() + 30*24*60*60*1000).toLocaleDateString('zh-CN')}
</Descriptions.Item>
</Descriptions>
{/* 报价明细 */}
{attributes.sections?.map((section, sIndex) => (
<div key={sIndex} className="mb-6">
<div className="flex items-center gap-2 mb-3">
<div className="h-4 w-1 bg-blue-500 rounded-full" />
<Title level={4} className="!mb-0">{section.sectionName}</Title>
</div>
<div className="mb-8">
<Title level={4} className="mb-4">报价明细</Title>
<Table
columns={columns}
dataSource={data.attributes.items}
pagination={false}
rowKey={(record, index) => index}
className="border rounded-lg"
summary={() => (
<Table.Summary fixed>
<Table.Summary.Row className="bg-gray-50 font-bold">
<Table.Summary.Cell index={0} colSpan={4} align="right">
总计{data.attributes.currency}
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
{data.attributes.totalAmount?.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
style: 'currency',
currency: data.attributes.currency
})}
</Table.Summary.Cell>
</Table.Summary.Row>
</Table.Summary>
)}
/>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">项目明细</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">描述/备注</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">单位</th>
<th className="px-4 py-3 text-right text-sm font-medium text-gray-500">数量</th>
<th className="px-4 py-3 text-right text-sm font-medium text-gray-500">单价</th>
<th className="px-4 py-3 text-right text-sm font-medium text-gray-500">小计</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{section.items.map((item, iIndex) => (
<tr key={iIndex}>
<td className="px-4 py-3 text-sm text-gray-900">{item.name}</td>
<td className="px-4 py-3 text-sm text-gray-500">{item.description}</td>
<td className="px-4 py-3 text-sm text-gray-500">{item.unit}</td>
<td className="px-4 py-3 text-sm text-gray-900 text-right">{item.quantity}</td>
<td className="px-4 py-3 text-sm text-gray-900 text-right">
{currencySymbol}{item.price?.toLocaleString()}
</td>
<td className="px-4 py-3 text-sm text-gray-900 text-right">
{currencySymbol}{(item.quantity * item.price)?.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
{data.attributes.description && (
<div className="bg-gray-50 p-6 rounded-lg">
<Title level={4} className="mb-4">补充说明</Title>
<Text>{data.attributes.description}</Text>
{/* 金额汇总 */}
<div className="mt-8 border-t pt-4">
<div className="flex justify-end space-y-2">
<div className="w-64 space-y-2">
<div className="flex justify-between">
<Text>税前总计</Text>
<Text>{currencySymbol}{attributes.beforeTaxAmount?.toLocaleString()}</Text>
</div>
<div className="flex justify-between">
<Text>税率</Text>
<Text>{attributes.taxRate}%</Text>
</div>
<div className="flex justify-between">
<Text>税后总计</Text>
<Text>{currencySymbol}{attributes.afterTaxAmount?.toLocaleString()}</Text>
</div>
{attributes.discount > 0 && (
<div className="flex justify-between">
<Text>折扣价</Text>
<Text type="success">{currencySymbol}{attributes.discount?.toLocaleString()}</Text>
</div>
)}
<div className="mt-8 pt-8 border-t border-gray-200">
<Text type="secondary" className="block mb-2">
注意事项
<Divider className="my-2" />
<div className="flex justify-between">
<Text strong>最终金额</Text>
<Text strong className="text-blue-500 text-xl">
{currencySymbol}{(attributes.discount || attributes.afterTaxAmount)?.toLocaleString()}
</Text>
<ul className="text-gray-500 text-sm">
<li>1. 本报价单有效期为30天</li>
<li>2. 最终解释权归本公司所有</li>
<li>3. 如有疑问请及时联系我们</li>
</ul>
</div>
</div>
</div>
</div>
{/* 补充说明 */}
{attributes.description && (
<div className="mt-8">
<Title level={4}>补充说明</Title>
<div className="bg-gray-50 p-4 rounded-lg">
<Text>{attributes.description}</Text>
</div>
</div>
)}
</div>
</Card>
</div>
</>
);
};
export default QuotationView;
export default QuotationPreview;