Merge branch 'main' of github.com:xuqssq/uppmkt-admin

This commit is contained in:
xuqssq
2024-12-23 19:28:21 +08:00
5 changed files with 232 additions and 249 deletions

View File

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

8
pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Card, Table, Button, message, Popconfirm, Tag, Space, Spin, Modal, Empty, Select, Typography, Statistic, Divider } from 'antd'; 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 { useResources } from '@/hooks/resource/useResource';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import {supabase}from '@/config/supabase' import { supabase } from '@/config/supabase'
const CURRENCY_SYMBOLS = { const CURRENCY_SYMBOLS = {
CNY: "¥", CNY: "¥",
TWD: "NT$", TWD: "NT$",
@@ -126,7 +126,7 @@ const QuotationPage = () => {
title: '客户信息', title: '客户信息',
dataIndex: ['attributes', 'customers'], dataIndex: ['attributes', 'customers'],
key: 'customers', key: 'customers',
render: (customers,record) => ( render: (customers, record) => (
<Space> <Space>
{customers?.map(customer => ( {customers?.map(customer => (
<Tag <Tag
@@ -148,7 +148,6 @@ const QuotationPage = () => {
dataIndex: ['attributes'], dataIndex: ['attributes'],
key: 'totalAmount', key: 'totalAmount',
align: 'right', align: 'right',
width: 200,
render: (attributes) => { render: (attributes) => {
// 获取货币符号 // 获取货币符号
const currencySymbol = CURRENCY_SYMBOLS[attributes.currency] || '¥'; const currencySymbol = CURRENCY_SYMBOLS[attributes.currency] || '¥';
@@ -183,7 +182,6 @@ const QuotationPage = () => {
dataIndex: 'created_at', dataIndex: 'created_at',
key: 'created_at', key: 'created_at',
sorter: true, sorter: true,
width: 120,
render: (text) => ( render: (text) => (
<span>{new Date(text).toLocaleString('zh-CN', { <span>{new Date(text).toLocaleString('zh-CN', {
year: 'numeric', year: 'numeric',
@@ -197,15 +195,14 @@ const QuotationPage = () => {
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width:250, width: 280,
render: (_, record) => ( render: (_, record) => (
<Space size={0}> <Space size={0}>
<Button <Button
size='small' size='small'
type="link" type="link"
icon={<CopyOutlined />} icon={<CopyOutlined />}
onClick={() => copyItem(record)}
// onClick={() => navigate(`/company/quotaInfo/preview/${record.id}`)}
> >
复制 复制
</Button> </Button>
@@ -268,7 +265,37 @@ const QuotationPage = () => {
return Array.from(groups.values()).filter(group => group.templates.length > 0); 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 ( return (
<> <>
<Card <Card
@@ -290,6 +317,7 @@ const QuotationPage = () => {
} }
> >
<Table <Table
className='w-full'
columns={columns} columns={columns}
dataSource={quotations} dataSource={quotations}
rowKey="id" rowKey="id"

View File

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