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$",
@@ -110,7 +110,7 @@ const QuotationPage = () => {
const getFilteredTemplates = () => { const getFilteredTemplates = () => {
if (selectedCategory === 'all') return templates; if (selectedCategory === 'all') return templates;
return templates.filter(template => return templates.filter(template =>
template.attributes.category?.some(cat => cat.id === selectedCategory) template.attributes.category?.some(cat => cat.id === selectedCategory)
); );
}; };
@@ -126,21 +126,21 @@ 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
key={customer.id} key={customer.id}
color="blue" color="blue"
className='cursor-pointer' className='cursor-pointer'
onClick={() => { onClick={() => {
navigate(`/company/customerInfo/${customer.id}`) navigate(`/company/customerInfo/${customer.id}`)
}} }}
> >
{customer.name} {customer.name}
</Tag> </Tag>
))} ))}
</Space> </Space>
), ),
}, },
{ {
@@ -148,11 +148,10 @@ 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] || '¥';
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex justify-between items-center text-sm"> <div className="flex justify-between items-center text-sm">
@@ -161,11 +160,11 @@ const QuotationPage = () => {
</Typography.Text> </Typography.Text>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Statistic <Statistic
value={attributes.afterTaxAmount} value={attributes.afterTaxAmount}
prefix={currencySymbol} prefix={currencySymbol}
precision={2} precision={2}
valueStyle={{ valueStyle={{
fontSize: '16px', fontSize: '16px',
fontWeight: 600, fontWeight: 600,
color: '#1890ff' color: '#1890ff'
@@ -173,7 +172,7 @@ const QuotationPage = () => {
className="!mb-0" className="!mb-0"
/> />
</div> </div>
</div> </div>
); );
}, },
@@ -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,20 +195,19 @@ 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>
<Button <Button
size='small' size='small'
type="link" type="link"
icon={<EyeOutlined />} icon={<EyeOutlined />}
onClick={() => navigate(`/company/quotaInfo/preview/${record.id}`)} onClick={() => navigate(`/company/quotaInfo/preview/${record.id}`)}
@@ -233,7 +230,7 @@ const QuotationPage = () => {
cancelText="取消" cancelText="取消"
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
> >
<Button size='small' type="link" danger icon={<DeleteOutlined />}> <Button size='small' type="link" danger icon={<DeleteOutlined />}>
删除 删除
</Button> </Button>
</Popconfirm> </Popconfirm>
@@ -244,7 +241,7 @@ const QuotationPage = () => {
const getTemplatesByCategory = () => { const getTemplatesByCategory = () => {
const groups = new Map(); const groups = new Map();
// 添加未分类组 // 添加未分类组
groups.set('uncategorized', { groups.set('uncategorized', {
name: '未分类', name: '未分类',
@@ -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"
@@ -316,8 +344,8 @@ const QuotationPage = () => {
<Button key="custom" onClick={() => handleConfirm()}> <Button key="custom" onClick={() => handleConfirm()}>
<FileAddOutlined /> 自定义创建 <FileAddOutlined /> 自定义创建
</Button>, </Button>,
<Button <Button
key="submit" key="submit"
type="primary" type="primary"
disabled={!selectedTemplateId} disabled={!selectedTemplateId}
onClick={handleConfirm} onClick={handleConfirm}
@@ -353,8 +381,8 @@ const QuotationPage = () => {
key={template.id} key={template.id}
className={` className={`
p-3 border rounded-lg cursor-pointer transition-all p-3 border rounded-lg cursor-pointer transition-all
${selectedTemplateId === template.id ${selectedTemplateId === template.id
? 'border-blue-500 bg-blue-50/50' ? 'border-blue-500 bg-blue-50/50'
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50/50' : 'border-gray-200 hover:border-blue-300 hover:bg-gray-50/50'
} }
`} `}
@@ -376,8 +404,8 @@ const QuotationPage = () => {
<div className="space-y-1"> <div className="space-y-1">
{template.attributes.sections.map((section, index) => ( {template.attributes.sections.map((section, index) => (
<div <div
key={index} key={index}
className="bg-white/80 px-2 py-1 rounded text-xs border border-gray-100" className="bg-white/80 px-2 py-1 rounded text-xs border border-gray-100"
> >
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">

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>
<Button
icon={<EditOutlined />}
type="primary"
onClick={() => navigate(`/company/quotaInfo/${data.id}?edit=true`)}
>
编辑报价单
</Button>
<Button
icon={<PrinterOutlined />}
onClick={handlePrint}
>
打印报价单
</Button>
</Space> </Space>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleExportPDF}
>
导出PDF
</Button>
</div> </div>
}
>
<div id="quotation-content" className="p-6">
{/* 报价单标题 */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<Title level={2} className="!mb-2">{data.attributes.quataName}</Title> <Title level={2}>{attributes.quataName}</Title>
<Text type="secondary">报价单号{data.id}</Text> <Text type="secondary">创建日期{new Date(quotation.created_at).toLocaleDateString()}</Text>
</div> </div>
<div className="bg-gray-50 p-6 rounded-lg mb-8"> {/* 基本信息 */}
<Descriptions column={2} bordered> <div className="bg-gray-50 p-4 rounded-lg mb-6">
<Descriptions.Item label="客户公司" span={1}> <Title level={4}>基本信息</Title>
{data.attributes.customerName} <div className="grid grid-cols-2 gap-4">
</Descriptions.Item> <div>
<Descriptions.Item label="报价日期" span={1}> <Text type="secondary">客户</Text>
{new Date(data.created_at).toLocaleDateString('zh-CN')} <Space>
</Descriptions.Item> {attributes.customers?.map(customer => (
<Descriptions.Item label="报价有效期" span={1}> <Tag key={customer.id} color="blue">{customer.name}</Tag>
{new Date(Date.now() + 30*24*60*60*1000).toLocaleDateString('zh-CN')} ))}
</Descriptions.Item> </Space>
</Descriptions> </div>
<div>
<Text type="secondary">货币类型</Text>
<Text>{attributes.currency}</Text>
</div>
</div>
</div> </div>
<div className="mb-8"> {/* 报价明细 */}
<Title level={4} className="mb-4">报价明细</Title> {attributes.sections?.map((section, sIndex) => (
<Table <div key={sIndex} className="mb-6">
columns={columns} <div className="flex items-center gap-2 mb-3">
dataSource={data.attributes.items} <div className="h-4 w-1 bg-blue-500 rounded-full" />
pagination={false} <Title level={4} className="!mb-0">{section.sectionName}</Title>
rowKey={(record, index) => index} </div>
className="border rounded-lg"
summary={() => ( <div className="overflow-x-auto">
<Table.Summary fixed> <table className="min-w-full divide-y divide-gray-200">
<Table.Summary.Row className="bg-gray-50 font-bold"> <thead className="bg-gray-50">
<Table.Summary.Cell index={0} colSpan={4} align="right"> <tr>
总计{data.attributes.currency} <th className="px-4 py-3 text-left text-sm font-medium text-gray-500">项目明细</th>
</Table.Summary.Cell> <th className="px-4 py-3 text-left text-sm font-medium text-gray-500">描述/备注</th>
<Table.Summary.Cell index={1} align="right"> <th className="px-4 py-3 text-left text-sm font-medium text-gray-500">单位</th>
{data.attributes.totalAmount?.toLocaleString('zh-CN', { <th className="px-4 py-3 text-right text-sm font-medium text-gray-500">数量</th>
minimumFractionDigits: 2, <th className="px-4 py-3 text-right text-sm font-medium text-gray-500">单价</th>
style: 'currency', <th className="px-4 py-3 text-right text-sm font-medium text-gray-500">小计</th>
currency: data.attributes.currency </tr>
})} </thead>
</Table.Summary.Cell> <tbody className="bg-white divide-y divide-gray-200">
</Table.Summary.Row> {section.items.map((item, iIndex) => (
</Table.Summary> <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>
))}
{/* 金额汇总 */}
<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>
)}
<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>
</div>
</div>
</div>
</div> </div>
{data.attributes.description && ( {/* 补充说明 */}
<div className="bg-gray-50 p-6 rounded-lg"> {attributes.description && (
<Title level={4} className="mb-4">补充说明</Title> <div className="mt-8">
<Text>{data.attributes.description}</Text> <Title level={4}>补充说明</Title>
<div className="bg-gray-50 p-4 rounded-lg">
<Text>{attributes.description}</Text>
</div>
</div> </div>
)} )}
</div>
<div className="mt-8 pt-8 border-t border-gray-200"> </Card>
<Text type="secondary" className="block mb-2"> </div>
注意事项
</Text>
<ul className="text-gray-500 text-sm">
<li>1. 本报价单有效期为30天</li>
<li>2. 最终解释权归本公司所有</li>
<li>3. 如有疑问请及时联系我们</li>
</ul>
</div>
</Card>
</div>
</>
); );
}; };
export default QuotationView; export default QuotationPreview;