报价单魔魁

This commit is contained in:
‘Liammcl’
2024-12-18 02:01:29 +08:00
parent 585c9b7bf8
commit 9b4a7f5fd8
14 changed files with 1326 additions and 390 deletions

View File

@@ -0,0 +1,414 @@
import React, { useState, useEffect } from 'react';
import { Form, Input, InputNumber, Select, Button, Space, Card, Table, Typography } from 'antd';
import { PlusOutlined, ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons';
import { supabase } from '@/config/supabase';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { round } from 'lodash';
const { TextArea } = Input;
const { Title } = Typography;
// 添加货币符号映射
const CURRENCY_SYMBOLS = {
CNY: '¥',
TWD: 'NT$',
USD: '$'
};
const QuotationForm = () => {
const { id } = useParams();
const [searchParams] = useSearchParams();
const isEdit = searchParams.get('edit') === 'true';
const isView = id && !isEdit;
const [form] = Form.useForm();
const navigate = useNavigate();
const [dataSource, setDataSource] = useState([{ id: Date.now() }]);
const [totalAmount, setTotalAmount] = useState(0);
const [currentCurrency, setCurrentCurrency] = useState('CNY');
const calculateTotal = (items = []) => {
const total = items.reduce((sum, item) => {
const itemAmount = round(
Number(item?.quantity || 0) * Number(item?.price || 0),
2
);
return round(sum + itemAmount, 2);
}, 0);
setTotalAmount(total);
};
const handleValuesChange = (changedValues, allValues) => {
if (changedValues.currency) {
setCurrentCurrency(changedValues.currency);
}
if (changedValues.items) {
const changedField = Object.keys(changedValues.items[0])[0];
if (changedField === 'quantity' || changedField === 'price') {
calculateTotal(allValues.items);
}
}
};
const columns = [
{
title: '名称',
dataIndex: 'productName',
width: '40%',
render: (_, record, index) => (
<Form.Item
name={['items', index, 'productName']}
rules={[{ required: true, message: '请输入名称' }]}
style={{ margin: 0 }}
>
<Input placeholder="请输入名称" />
</Form.Item>
),
},
{
title: '数量',
dataIndex: 'quantity',
width: '20%',
render: (_, record, index) => (
<Form.Item
name={['items', index, 'quantity']}
rules={[{ required: true, message: '请输入数量' }]}
style={{ margin: 0 }}
>
<InputNumber
min={1}
precision={2}
placeholder="数量"
onChange={(value) => {
const values = form.getFieldValue('items');
values[index].quantity = value;
calculateTotal(values);
}}
/>
</Form.Item>
),
},
{
title: '单价',
dataIndex: 'price',
width: '20%',
render: (_, record, index) => (
<Form.Item
name={['items', index, 'price']}
rules={[{ required: true, message: '请输入单价' }]}
style={{ margin: 0 }}
>
<InputNumber
min={0}
precision={2}
placeholder="单价"
onChange={(value) => {
const values = form.getFieldValue('items');
values[index].price = value;
calculateTotal(values);
}}
/>
</Form.Item>
),
},
{
title: '说明',
dataIndex: 'note',
width: '40%',
render: (_, record, index) => (
<Form.Item
name={['items', index, 'note']}
style={{ margin: 0 }}
>
<Input placeholder="备注说明" />
</Form.Item>
),
},
{
title: '操作',
width: '10%',
render: (_, record, index) => (
<Button
type="link"
danger
onClick={() => {
const newData = dataSource.filter(item => item.id !== record.id);
setDataSource(newData);
const values = form.getFieldValue('items');
values.splice(index, 1);
form.setFieldValue('items', values);
calculateTotal(values);
}}
>
删除
</Button>
),
},
];
const fetchQuotationDetail = async () => {
try {
const { data, error } = await supabase
.from('resources')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
if (isEdit) {
// 设置表单初始值
form.setFieldsValue({
quataName: data.attributes.quataName,
companyName: data.attributes.companyName,
supplierName: data.attributes.supplierName,
description: data.attributes.description,
currency: data.attributes.currency,
items: data.attributes.items,
});
setDataSource(data.attributes.items.map((item, index) => ({
id: Date.now() + index,
...item
})));
setCurrentCurrency(data.attributes.currency);
calculateTotal(data.attributes.items);
}
} catch (error) {
console.error('获取报价单详情失败:', error);
}
};
useEffect(() => {
if (id) {
fetchQuotationDetail();
}
}, [id]);
const onFinish = async (values) => {
try {
const quotationData = {
type: 'quota',
attributes: {
quataName: values.quataName,
companyName: values.companyName,
supplierName: values.supplierName,
description: values.description,
currency: values.currency,
items: values.items,
totalAmount
}
};
let result;
if (id) {
result = await supabase
.from('resources')
.update(quotationData)
.eq('id', id)
.select();
} else {
result = await supabase
.from('resources')
.insert([quotationData])
.select();
}
if (result.error) throw result.error;
navigate('/company/quotation');
} catch (error) {
console.error('保存失败:', error);
}
};
return (
<div className="bg-gradient-to-b from-gray-50 to-white min-h-screen p-6">
<Card
className="shadow-lg rounded-lg border-0"
title={
<div className="flex justify-between items-center py-2">
<div className="flex items-center space-x-3">
<Title level={4} className="mb-0 text-gray-800">
{id ? (isEdit ? '编辑报价单' : '查看报价单') : '新建报价单'}
</Title>
<span className="text-gray-400 text-sm">
{id ? (isEdit ? '请修改报价单信息' : '报价单详情') : '请填写报价单信息'}
</span>
</div>
<Space size="middle">
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/company/quotation')}
>
返回
</Button>
{!isView && (
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => form.submit()}
>
保存
</Button>
)}
</Space>
</div>
}
bodyStyle={{ backgroundColor: '#fff' }}
>
<Form
form={form}
onFinish={onFinish}
layout="vertical"
onValuesChange={handleValuesChange}
initialValues={{
currency: 'CNY',
items: [{}]
}}
className="space-y-6"
disabled={isView}
>
{/* 基本信息卡片 */}
<Card
className="shadow-sm rounded-lg"
type="inner"
title={
<span className="flex items-center space-x-2 text-gray-700">
<span className="w-1 h-4 bg-blue-500 rounded-full"/>
<span>基本信息</span>
</span>
}
bordered={false}
>
<div className="grid grid-cols-2 gap-8">
<Form.Item
name="quataName"
label={<span className="text-gray-700 font-medium">活动名称</span>}
rules={[{ required: true, message: '活动名称' }]}
>
<Input
placeholder="请输入活动名称"
className="rounded-md hover:border-blue-400 focus:border-blue-500"
/>
</Form.Item>
<Form.Item
name="companyName"
label={<span className="text-gray-700 font-medium">公司名称</span>}
rules={[{ required: true, message: '请输入公司名称' }]}
>
<Input
placeholder="请输入公司名称"
className="rounded-md hover:border-blue-400 focus:border-blue-500"
/>
</Form.Item>
<Form.Item
name="supplierName"
label={<span className="text-gray-700 font-medium">供应商名称</span>}
rules={[{ required: true, message: '请输入供应商名称' }]}
>
<Input
placeholder="请输入供应商名称"
className="rounded-md hover:border-blue-400 focus:border-blue-500"
/>
</Form.Item>
</div>
</Card>
{/* 报价明细卡片 */}
<Card
className="shadow-sm rounded-lg"
type="inner"
title={
<span className="flex items-center space-x-2 text-gray-700">
<span className="w-1 h-4 bg-green-500 rounded-full"/>
<span>报价明细</span>
</span>
}
bordered={false}
extra={
!isView && (
<Button
type="dashed"
icon={<PlusOutlined className="text-green-500" />}
onClick={() => {
const newId = Date.now();
setDataSource([...dataSource, { id: newId }]);
}}
>
新增一栏
</Button>
)
}
>
<Table
dataSource={dataSource}
columns={columns}
pagination={false}
rowKey="id"
className="border-t border-gray-100"
rowClassName="hover:bg-gray-50 transition-colors"
/>
<div className="flex justify-end items-center mt-6 space-x-4 pt-4 border-t border-gray-100">
<span className="text-gray-600 font-medium">总金额:</span>
<span className="text-2xl font-semibold text-blue-600">
{CURRENCY_SYMBOLS[currentCurrency]}
{totalAmount.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</span>
<Form.Item
name="currency"
style={{ margin: 0 }}
rules={[{ required: true }]}
>
<Select
className="w-28 rounded-md"
onChange={(value) => setCurrentCurrency(value)}
dropdownClassName="rounded-md shadow-lg"
>
<Select.Option value="CNY">RMB</Select.Option>
<Select.Option value="TWD">台币</Select.Option>
<Select.Option value="USD">美元</Select.Option>
</Select>
</Form.Item>
</div>
</Card>
<Card
className="shadow-sm rounded-lg"
type="inner"
title={
<span className="flex items-center space-x-2 text-gray-700">
<span className="w-1 h-4 bg-purple-500 rounded-full"/>
<span>补充说明</span>
</span>
}
bordered={false}
>
<Form.Item
name="description"
className="mb-0"
>
<TextArea
rows={4}
placeholder="请输入补充说明信息"
className="rounded-md hover:border-purple-400 focus:border-purple-500"
/>
</Form.Item>
</Card>
</Form>
</Card>
</div>
);
};
export default QuotationForm;

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useState } from 'react';
import { Card, Table, Button, Modal, Form, Input, message, Popconfirm } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { Card, Table, Button, message, Popconfirm, Tag, Space, Tooltip } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
import { useResources } from '@/hooks/resource/useResource';
import { useNavigate } from 'react-router-dom';
import { formatCurrency } from '@/utils/format'; // 假设你有这个工具函数,如果没有我会提供
const QuotationPage = () => {
const [form] = Form.useForm();
const [visible, setVisible] = useState(false);
const [editingId, setEditingId] = useState(null);
const navigate = useNavigate();
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
const [sorter, setSorter] = useState({ field: 'created_at', order: 'descend' });
@@ -15,8 +15,6 @@ const QuotationPage = () => {
loading,
total,
fetchResources: fetchQuotations,
createResource: createQuotation,
updateResource: updateQuotation,
deleteResource: deleteQuotation
} = useResources(pagination, sorter);
@@ -35,109 +33,119 @@ const QuotationPage = () => {
});
};
const handleSubmit = async (values) => {
try {
const quotationData = {
external_id: values.id,
attributes: {
customerName: values.customerName,
status: values.status || '新建',
},
type: 'quota'
};
if (editingId) {
await updateQuotation(editingId, quotationData);
} else {
await createQuotation(quotationData);
}
setVisible(false);
form.resetFields();
fetchQuotations();
} catch (error) {
console.error('提交失败:', error);
}
};
const handleDelete = async (id) => {
try {
await deleteQuotation(id);
message.success('删除成功');
fetchQuotations();
} catch (error) {
console.error('删除失败:', error);
message.error('删除失败' + error.message);
}
};
const columns = [
{
title: '报价单',
dataIndex: ['external_id'],
key: 'external_id',
sorter: true,
title: '报价单名称',
dataIndex: ['attributes', 'quataName'],
key: 'quataName',
ellipsis: true,
},
{
title: '客户名称',
dataIndex: ['attributes', 'customerName'],
key: 'customerName',
title: '客户信息',
dataIndex: ['attributes', 'companyName'],
key: 'companyName',
render: (companyName) => (
<Tag color="blue">{companyName}</Tag>
),
},
{
title: '供应商',
dataIndex: ['attributes', 'supplierName'],
key: 'supplierName',
render: (supplierName) => (
<Tag color="green">{supplierName}</Tag>
),
},
{
title: '报价总额',
dataIndex: ['attributes', 'totalAmount'],
key: 'totalAmount',
align: 'right',
render: (amount) => (
<span style={{ color: '#f50', fontWeight: 'bold' }}>
¥{amount?.toLocaleString()}
</span>
),
},
{
title: '创建日期',
dataIndex: 'created_at',
key: 'created_at',
sorter: true,
render: (text) => new Date(text).toLocaleString(),
},
{
title: '状态',
dataIndex: ['attributes', 'status'],
key: 'status',
width: 180,
render: (text) => (
<span>{new Date(text).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}</span>
),
},
{
title: '操作',
key: 'action',
width:180,
render: (_, record) => (
<span>
<Space size="mini">
<Button
size='mini'
type="link"
onClick={() => {
setEditingId(record.id);
form.setFieldsValue({
id: record.external_id,
customerName: record.attributes?.customerName,
status: record.attributes?.status,
});
setVisible(true);
}}
icon={<EyeOutlined />}
onClick={() => navigate(`/company/quotaInfo/preview/${record.id}`)}
>
查看
</Button>
<Button
size='mini'
type="link"
icon={<EditOutlined />}
onClick={() => navigate(`/company/quotaInfo/${record.id}?edit=true`)}
>
编辑
</Button>
<Popconfirm
title="确定要删除这个报价单吗?"
description="删除后将无法恢复!"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button type="link" danger>
<Button size='mini' type="link" danger icon={<DeleteOutlined />}>
删除
</Button>
</Popconfirm>
</span>
</Space>
),
},
];
return (
<Card
title="报价单管理"
title={
<Space>
<span>报价单管理</span>
<Tag color="blue">{total} 个报价单</Tag>
</Space>
}
className='h-full w-full overflow-auto'
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingId(null);
form.resetFields();
setVisible(true);
}}
onClick={() => navigate('/company/quotaInfo')}
>
新增报价单
</Button>
@@ -152,55 +160,11 @@ const QuotationPage = () => {
pagination={{
...pagination,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
}}
/>
<Modal
title={editingId ? '编辑报价单' : '新增报价单'}
open={visible}
onCancel={() => {
setVisible(false);
setEditingId(null);
form.resetFields();
}}
footer={null}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
<Form.Item
name="id"
label="报价单号"
rules={[{ required: true, message: '请输入报价单号' }]}
>
<Input placeholder="请输入报价单号" />
</Form.Item>
<Form.Item
name="customerName"
label="客户名称"
rules={[{ required: true, message: '请输入客户名称' }]}
>
<Input placeholder="请输入客户名称" />
</Form.Item>
<Form.Item
name="status"
label="状态"
initialValue="新建"
>
<Input placeholder="请输入状态" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
提交
</Button>
</Form.Item>
</Form>
</Modal>
</Card>
);
};

View File

@@ -0,0 +1,264 @@
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 { supabase } from '@/config/supabase';
import ReactDOMServer from 'react-dom/server';
import PrintView from '@/components/PrintView';
const { Title, Text } = Typography;
const printStyles = `
@media print {
/* 隐藏所有按钮和不需要的元素 */
button, .no-print {
display: none !important;
}
/* 只打印卡片内容 */
.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 [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const fetchQuotationDetail = async () => {
try {
const { data: quotation, error } = await supabase
.from('resources')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
setData(quotation);
} catch (error) {
console.error('获取报价单<E4BBB7><E58D95>情失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (id) {
fetchQuotationDetail();
}
}, [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 })
),
},
];
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();
};
if (loading) {
return (
<div className="flex justify-center items-center min-h-screen">
<Spin size="large" />
</div>
);
}
if (!data) {
return null;
}
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">
<Space>
<Button
icon={<ArrowLeftOutlined />}
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>
</div>
<div className="text-center mb-8">
<Title level={2} className="!mb-2">{data.attributes.quataName}</Title>
<Text type="secondary">报价单号{data.id}</Text>
</div>
<div className="bg-gray-50 p-6 rounded-lg mb-8">
<Descriptions column={2} bordered>
<Descriptions.Item label="客户公司" span={1}>
{data.attributes.companyName}
</Descriptions.Item>
<Descriptions.Item label="供应商" span={1}>
{data.attributes.supplierName}
</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>
</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>
{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>
)}
<div className="mt-8 pt-8 border-t border-gray-200">
<Text type="secondary" className="block mb-2">
注意事项
</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;