541 lines
16 KiB
JavaScript
541 lines
16 KiB
JavaScript
import React, { useState, useEffect, useMemo } from "react";
|
||
import {
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Select,
|
||
Button,
|
||
Space,
|
||
Card,
|
||
Typography,
|
||
message,
|
||
Popconfirm,
|
||
Modal,
|
||
Divider,
|
||
} from "antd";
|
||
import {
|
||
PlusOutlined,
|
||
ArrowLeftOutlined,
|
||
SaveOutlined,
|
||
DeleteOutlined,
|
||
} from "@ant-design/icons";
|
||
import { supabase } from "@/config/supabase";
|
||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||
import { v4 as uuidv4 } from "uuid";
|
||
import SectionList from '@/components/SectionList'
|
||
import ChatAIDrawer from '@/components/ChatAi';
|
||
|
||
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 templateId = searchParams.get("templateId");
|
||
const isView = id && !isEdit;
|
||
const [form] = Form.useForm();
|
||
const navigate = useNavigate();
|
||
const [dataSource, setDataSource] = useState([{ id: Date.now() }]);
|
||
const [totalAmount, setTotalAmount] = useState(0);
|
||
const [loading, setLoading] = useState(false);
|
||
const [currentCurrency, setCurrentCurrency] = useState("TWD");
|
||
const [customers, setCustomers] = useState([]);
|
||
const [selectedCustomers, setSelectedCustomers] = useState([]);
|
||
const [formValues, setFormValues] = useState({});
|
||
const [templateModalVisible, setTemplateModalVisible] = useState(false);
|
||
const [availableSections, setAvailableSections] = useState([]);
|
||
const [editingSectionIndex, setEditingSectionIndex] = useState(null);
|
||
const [editingSectionName, setEditingSectionName] = useState("");
|
||
const [taxRate, setTaxRate] = useState(0);
|
||
const [discount, setDiscount] = useState(0);
|
||
|
||
// 计算单项金额
|
||
const calculateItemAmount = useMemo(
|
||
() => (quantity, price) => {
|
||
const safeQuantity = Number(quantity) || 0;
|
||
const safePrice = Number(price) || 0;
|
||
return safeQuantity * safePrice;
|
||
},
|
||
[]
|
||
);
|
||
|
||
// 计算小节总额
|
||
const calculateSectionTotal = useMemo(
|
||
() =>
|
||
(items = []) => {
|
||
if (!Array.isArray(items)) return 0;
|
||
return items.reduce((sum, item) => {
|
||
if (!item) return sum;
|
||
return sum + calculateItemAmount(item.quantity, item.price);
|
||
}, 0);
|
||
},
|
||
[calculateItemAmount]
|
||
);
|
||
|
||
// 计算总金额
|
||
const calculateTotalAmount = useMemo(
|
||
() =>
|
||
(sections = []) => {
|
||
if (!Array.isArray(sections)) return 0;
|
||
return sections.reduce((sum, section) => {
|
||
if (!section) return sum;
|
||
return sum + calculateSectionTotal(section.items);
|
||
}, 0);
|
||
},
|
||
[calculateSectionTotal]
|
||
);
|
||
|
||
// 格式化货币
|
||
const formatCurrency = useMemo(
|
||
() =>
|
||
(amount, currency = currentCurrency) => {
|
||
const safeAmount = Number(amount) || 0;
|
||
return `${CURRENCY_SYMBOLS[currency] || ""}${safeAmount.toLocaleString(
|
||
"zh-CN",
|
||
{
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
}
|
||
)}`;
|
||
},
|
||
[currentCurrency]
|
||
);
|
||
useEffect(() => {
|
||
console.log(currentCurrency, 'currency');
|
||
|
||
}, [currentCurrency])
|
||
// 处理表单值变化
|
||
const handleValuesChange = (changedValues, allValues) => {
|
||
setFormValues(allValues);
|
||
if (changedValues.currency) {
|
||
setCurrentCurrency(changedValues.currency);
|
||
}
|
||
};
|
||
|
||
// 修改初始值,确保每个项目都有唯一ID
|
||
const initialValues = {
|
||
currency: "TWD",
|
||
sections: [
|
||
{
|
||
key: uuidv4(),
|
||
sectionName: "服务类型 1",
|
||
items: [
|
||
{
|
||
key: uuidv4(),
|
||
name: "",
|
||
quantity: 1,
|
||
price: 0,
|
||
description: "",
|
||
unit: "",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
};
|
||
|
||
|
||
|
||
|
||
const fetchQuotationDetail = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const { data, error } = await supabase
|
||
.from("resources")
|
||
.select("*")
|
||
.eq("id", id)
|
||
.single();
|
||
|
||
if (error) throw error;
|
||
|
||
if (data?.attributes) {
|
||
const formData = {
|
||
quataName: data.attributes.quataName,
|
||
customers: data.attributes.customers.map((customer) => customer.id) || [],
|
||
description: data.attributes.description,
|
||
currency: data.attributes.currency || "TWD",
|
||
sections: data.attributes.sections.map((section) => ({
|
||
key: uuidv4(),
|
||
sectionName: section.sectionName,
|
||
items: section.items.map((item) => ({
|
||
key: uuidv4(),
|
||
name: item.name,
|
||
quantity: Number(item.quantity) || 0,
|
||
price: Number(item.price) || 0,
|
||
description: item.description || "",
|
||
unit: item.unit || "",
|
||
})),
|
||
})),
|
||
taxRate: data.attributes.taxRate || 0,
|
||
discount: data.attributes.discount || 0,
|
||
};
|
||
|
||
form.setFieldsValue(formData);
|
||
setFormValues(formData);
|
||
setCurrentCurrency(data.attributes.currency || "TWD");
|
||
setTaxRate(data.attributes.taxRate || 0);
|
||
setDiscount(data.attributes.discount || 0);
|
||
|
||
if (data.attributes.customers) {
|
||
setSelectedCustomers(data.attributes.customers);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("获取报价单详情失败:", error);
|
||
message.error("获取报价单详情失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
|
||
|
||
|
||
|
||
const fetchTemplateData = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const { data: template, error } = await supabase
|
||
.from("resources")
|
||
.select("*")
|
||
.eq("type", "serviceTemplate")
|
||
.eq("id", templateId)
|
||
.single();
|
||
|
||
if (error) throw error;
|
||
|
||
if (template?.attributes) {
|
||
const quotationData = {
|
||
quataName: template.attributes.templateName,
|
||
description: template.attributes.description,
|
||
currency: template.attributes.currency || "TWD",
|
||
category: template.attributes.category,
|
||
sections: template.attributes.sections.map((section) => ({
|
||
key: uuidv4(),
|
||
sectionName: section.sectionName,
|
||
items: section.items.map((item) => ({
|
||
key: uuidv4(),
|
||
name: item.name,
|
||
quantity: item.quantity,
|
||
price: item.price,
|
||
description: item.description,
|
||
unit: item.unit,
|
||
})),
|
||
})),
|
||
};
|
||
setCurrentCurrency(template.attributes.currency || "TWD");
|
||
form.setFieldsValue(quotationData);
|
||
setFormValues(quotationData);
|
||
}
|
||
} catch (error) {
|
||
console.error("获取模板数据失败:", error);
|
||
message.error("获取模板数据失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 使用 useMemo 计算税后金额
|
||
const afterTaxAmount = useMemo(() => {
|
||
const beforeTaxAmount = calculateTotalAmount(formValues?.sections) || 0;
|
||
const taxAmount = beforeTaxAmount * (taxRate / 100);
|
||
return beforeTaxAmount + taxAmount;
|
||
}, [formValues?.sections, taxRate, calculateTotalAmount]);
|
||
|
||
// 修改保存函数
|
||
const onFinish = async (values) => {
|
||
try {
|
||
setLoading(true);
|
||
const beforeTaxAmount = calculateTotalAmount(values.sections);
|
||
|
||
const quotationData = {
|
||
type: "quota",
|
||
attributes: {
|
||
quataName: values.quataName,
|
||
customers: customers
|
||
.filter(customer => values.customers.includes(customer.id))
|
||
.map(customer => ({
|
||
id: customer.id,
|
||
name: customer.attributes.name
|
||
})),
|
||
description: values.description,
|
||
currency: currentCurrency,
|
||
sections: values.sections.map((section) => ({
|
||
sectionName: section.sectionName,
|
||
items: section.items.map((item) => ({
|
||
name: item.name,
|
||
unit: item.unit,
|
||
price: item.price,
|
||
quantity: item.quantity,
|
||
description: item.description,
|
||
})),
|
||
})),
|
||
beforeTaxAmount,
|
||
taxRate,
|
||
afterTaxAmount,
|
||
discount,
|
||
finalAmount: discount || afterTaxAmount,
|
||
},
|
||
};
|
||
|
||
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;
|
||
|
||
message.success("保存成功");
|
||
navigate("/company/quotation");
|
||
} catch (error) {
|
||
console.error("保存失败:", error);
|
||
message.error("保存失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchCustomers = async () => {
|
||
try {
|
||
const { data, error } = await supabase
|
||
.from("resources")
|
||
.select("*")
|
||
.eq("type", "customer");
|
||
|
||
if (error) throw error;
|
||
setCustomers(data || []);
|
||
} catch (error) {
|
||
console.error("获取客户列表失败:", error);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchCustomers();
|
||
}, []);
|
||
|
||
|
||
// 确保在组件加载时正确获取数据
|
||
useEffect(() => {
|
||
if (id) {
|
||
fetchQuotationDetail();
|
||
} else if (templateId) {
|
||
fetchTemplateData();
|
||
} else {
|
||
// 如果既不是编辑也不是从模板创建,则设置初始值
|
||
form.setFieldsValue(initialValues);
|
||
setFormValues(initialValues);
|
||
}
|
||
}, [id, templateId]);
|
||
|
||
const [open, setOpen] = useState(false);
|
||
|
||
const handleExport = (data) => {
|
||
if(data?.activityName&&data?.currency){
|
||
const quotationData = {
|
||
quataName: data.activityName,
|
||
description: data.description,
|
||
currency: data.currency || "TWD",
|
||
sections: data.sections.map((section) => ({
|
||
key: uuidv4(),
|
||
sectionName: section.sectionName,
|
||
items: section.items.map((item) => ({
|
||
key: uuidv4(),
|
||
name: item.name,
|
||
quantity: item.quantity,
|
||
price: item.price,
|
||
description: item.description,
|
||
unit: item.unit,
|
||
})),
|
||
})),
|
||
};
|
||
setCurrentCurrency(data.currency || "TWD");
|
||
form.setFieldsValue(quotationData);
|
||
setFormValues(quotationData);
|
||
setTaxRate(data.taxRate || 0);
|
||
message.success('已添加报价单');
|
||
|
||
}else{
|
||
const _data={
|
||
...data,
|
||
key: uuidv4(),
|
||
}
|
||
const newSections = [...formValues.sections, _data];
|
||
form.setFieldValue('sections', newSections);
|
||
const currentFormValues = form.getFieldsValue();
|
||
setFormValues({
|
||
...currentFormValues,
|
||
sections: newSections
|
||
});
|
||
message.success('已添加新的服务项目');
|
||
}
|
||
|
||
setOpen(false);
|
||
};
|
||
|
||
return (
|
||
<div className="bg-gradient-to-b from-gray-50 to-white dark:from-gray-800 dark:to-gray-900/90 min-h-screen p-2">
|
||
<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()}
|
||
loading={loading}
|
||
>
|
||
保存
|
||
</Button>
|
||
|
||
<Button onClick={() => setOpen(true)}>
|
||
AI 助手
|
||
</Button>
|
||
</>
|
||
)}
|
||
|
||
</Space>
|
||
</div>
|
||
}
|
||
>
|
||
<Form
|
||
form={form}
|
||
onFinish={onFinish}
|
||
onValuesChange={handleValuesChange}
|
||
layout="vertical"
|
||
disabled={isView}
|
||
initialValues={initialValues}
|
||
>
|
||
{/* 基本信息卡片 */}
|
||
<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=" hover:border-blue-400 focus:border-blue-500"
|
||
/>
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="currency"
|
||
label={<span className="text-gray-700 font-medium">货币类型</span>}
|
||
rules={[{ required: true, message: "请选择货币类型" }]}
|
||
>
|
||
<Select className="w-full">
|
||
<Select.Option value="CNY">RMB</Select.Option>
|
||
<Select.Option value="TWD">台币</Select.Option>
|
||
<Select.Option value="USD">美元</Select.Option>
|
||
</Select>
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="customers"
|
||
label={<span className="text-gray-700 font-medium">客户名称</span>}
|
||
rules={[{ required: true, message: "请选择至少一个客户" }]}
|
||
>
|
||
<Select
|
||
mode="multiple"
|
||
placeholder="请选择客户"
|
||
className=" hover:border-blue-400 focus:border-blue-500"
|
||
showSearch
|
||
optionFilterProp="children"
|
||
filterOption={(input, option) =>
|
||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||
}
|
||
options={customers.map((customer) => ({
|
||
value: customer.id,
|
||
label: customer.attributes.name,
|
||
}))}
|
||
value={(formValues.customers || []).map(customer => customer.id)}
|
||
/>
|
||
</Form.Item>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card
|
||
className="shadow-sm rounded-lg mt-6"
|
||
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}
|
||
>
|
||
|
||
<SectionList
|
||
type="quotation"
|
||
form={form}
|
||
isView={isView}
|
||
formValues={formValues}
|
||
currentCurrency={currentCurrency}
|
||
taxRate={taxRate}
|
||
setTaxRate={setTaxRate}
|
||
/>
|
||
</Card>
|
||
</Form>
|
||
</Card>
|
||
<ChatAIDrawer
|
||
open={open}
|
||
onClose={() => setOpen(false)}
|
||
onExport={handleExport}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default QuotationForm;
|