diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 753f517..55189c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: styled-components: specifier: ^6.1.0 version: 6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + uuid: + specifier: ^11.0.3 + version: 11.0.3 devDependencies: '@types/react': specifier: ^18.2.15 @@ -2420,6 +2423,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.0.3: + resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==} + hasBin: true + victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} @@ -5318,6 +5325,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.0.3: {} + victory-vendor@36.9.2: dependencies: '@types/d3-array': 3.2.1 diff --git a/src/App.jsx b/src/App.jsx index 30ca29b..15b7e90 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -26,6 +26,33 @@ const ThemedApp = () => { ? "rgba(255, 255, 255, 0.45)" : "rgba(0, 0, 0, 0.45)", }, + components: { + // 为所有支持 variant 的组件设置 filled 模式 + Form: { + variant: 'filled', + }, + Input: { + variant: 'filled', + }, + Select: { + variant: 'filled', + }, + TreeSelect: { + variant: 'filled', + }, + DatePicker: { + variant: 'filled', + }, + TimePicker: { + variant: 'filled', + }, + Cascader: { + variant: 'filled', + }, + AutoComplete: { + variant: 'filled', + }, + } }} >
diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index 95ab210..3a24367 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -103,7 +103,6 @@ export const AuthProvider = ({ children }) => { }, }); - console.log(data, error, "data"); if (error) { message.error(error.message || "Google 登录失败,请稍后重试"); return; diff --git a/src/pages/Profile.jsx b/src/pages/Profile.jsx deleted file mode 100644 index a650b3f..0000000 --- a/src/pages/Profile.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Card, Avatar, Typography, Descriptions } from 'antd'; -import { UserOutlined } from '@ant-design/icons'; - -const { Title } = Typography; - -const Profile = () => { - return ( - -
- } /> - John Doe -
- - johndoe - john@example.com - Administrator - Active - 2023-10-25 - -
- ); -}; - -export default Profile; \ No newline at end of file diff --git a/src/pages/company/quotation/detail/index.jsx b/src/pages/company/quotation/detail/index.jsx index a05e920..222df4d 100644 --- a/src/pages/company/quotation/detail/index.jsx +++ b/src/pages/company/quotation/detail/index.jsx @@ -1,243 +1,520 @@ -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'; +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, + CloseOutlined, + EditOutlined, + CheckOutlined, +} from "@ant-design/icons"; +import { supabase } from "@/config/supabase"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { v4 as uuidv4 } from "uuid"; const { TextArea } = Input; -const { Title } = Typography; +const { Title, Text } = Typography; // 添加货币符号映射 const CURRENCY_SYMBOLS = { - CNY: '¥', - TWD: 'NT$', - USD: '$' + CNY: "¥", + TWD: "NT$", + USD: "$", }; const QuotationForm = () => { const { id } = useParams(); const [searchParams] = useSearchParams(); - const isEdit = searchParams.get('edit') === 'true'; + 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 [currentCurrency, setCurrentCurrency] = useState('CNY'); + const [loading, setLoading] = useState(false); + const [currentCurrency, setCurrentCurrency] = useState("CNY"); 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 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 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] + ); + + // 处理表单值变化 const handleValuesChange = (changedValues, allValues) => { + console.log("Form values changed:", allValues); // 调试用 + setFormValues(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) => ( - - - - ), - }, - - { - title: '数量', - dataIndex: 'quantity', - width: '20%', - render: (_, record, index) => ( - - { - const values = form.getFieldValue('items'); - values[index].quantity = value; - calculateTotal(values); - }} - /> - - ), - }, - { - title: '单价', - dataIndex: 'price', - width: '20%', - render: (_, record, index) => ( - - { - const values = form.getFieldValue('items'); - values[index].price = value; - calculateTotal(values); - }} - /> - - ), - }, - { - title: '说明', - dataIndex: 'note', - width: '40%', - render: (_, record, index) => ( - - - - ), - }, - { - title: '操作', - width: 100, - render: (_, record, index) => ( - - ), - }, - ]; + // 修改初始值,确保每个项目都有唯一ID + const initialValues = { + currency: "CNY", + sections: [ + { + key: uuidv4(), + sectionName: "服务类型 1", + items: [ + { + key: uuidv4(), + productName: "", + quantity: 1, + price: 0, + note: "", + unit: "", + }, + ], + }, + ], + }; + // 改添加小节的函数 + const handleAddSection = () => { + setTemplateModalVisible(true); + fetchAvailableSections(); + }; + + // 修改添加项目的函数 + const handleAddItem = (add, sectionIndex) => { + add({ + key: uuidv4(), + productName: "", + quantity: 1, + price: 0, + note: "", + }); + }; + + // 修改获取详情的函数 const fetchQuotationDetail = async () => { try { + setLoading(true); const { data, error } = await supabase - .from('resources') - .select('*') - .eq('id', id) + .from("resources") + .select("*") + .eq("id", id) .single(); if (error) throw error; - - if (isEdit) { - form.setFieldsValue({ + + if (data?.attributes) { + const formData = { quataName: data.attributes.quataName, - customer: { - id: data.attributes.customerId, - name: data.attributes.customerName - }, + customers: data.attributes.customers || [], 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); + currency: data.attributes.currency || "CNY", + sections: data.attributes.sections.map((section) => ({ + key: uuidv4(), + sectionName: section.sectionName, + items: section.items.map((item) => ({ + key: uuidv4(), + productName: item.name, + quantity: Number(item.quantity) || 0, + price: Number(item.price) || 0, + note: item.description || "", + unit: item.unit || "", + })), + })), + taxRate: data.attributes.taxRate || 0, + discount: data.attributes.discount || 0, + }; + + form.setFieldsValue(formData); + setFormValues(formData); + setCurrentCurrency(data.attributes.currency || "CNY"); + setTaxRate(data.attributes.taxRate || 0); + setDiscount(data.attributes.discount || 0); + + if (data.attributes.customers) { + setSelectedCustomers(data.attributes.customers); + } } } catch (error) { - console.error('获取报价单详情失败:', error); + console.error("获取报价单详情失败:", error); + message.error("获取报价单详情失败"); + } finally { + setLoading(false); } }; - useEffect(() => { - if (id) { - fetchQuotationDetail(); - } - }, [id]); + // 添加获取可用小节模板的方法 + const fetchAvailableSections = async () => { + try { + const { data: sections, error } = await supabase + .from("resources") + .select("*") + .eq("type", "sections") + .order("created_at", { ascending: false }); + if (error) throw error; + setAvailableSections(sections || []); + } catch (error) { + message.error("获取小节模版失败"); + console.error(error); + } + }; + + // 使用选中的模版 + const handleUseTemplate = (template) => { + const sections = form.getFieldValue("sections") || []; + const newSection = { + key: uuidv4(), + sectionName: template.attributes.name, + items: (template.attributes.items || []).map((item) => ({ + key: uuidv4(), + productName: item.name || "", + note: item.description || "", + price: item.price || 0, + quantity: item.quantity || 1, + unit: item.unit || "", + })), + }; + + const newSections = [...sections, newSection]; + form.setFieldValue("sections", newSections); + + // 更新 formValues 以触发重新计算 + const currentFormValues = form.getFieldsValue(); + setFormValues({ + ...currentFormValues, + sections: newSections, + }); + + setTemplateModalVisible(false); + message.success("套用模版成功"); + }; + + // 创建自定义小节 + const handleCreateCustom = () => { + const sections = form.getFieldValue("sections") || []; + const newSection = { + key: uuidv4(), + sectionName: `服务类型 ${sections.length + 1}`, + items: [ + { + key: uuidv4(), + productName: "", + note: "", + price: 0, + quantity: 1, + unit: "", + }, + ], + }; + + form.setFieldValue("sections", [...sections, newSection]); + setTemplateModalVisible(false); + }; + + // 添加模板选择弹窗内容渲染方法 + const renderTemplateModalContent = () => ( +
+
+ {availableSections.map((section) => ( +
handleUseTemplate(section)} + > +
+ {/* 标题和项目数量 */} +
+

+ {section.attributes.name} +

+
+ {section.attributes.items?.length || 0} 个项目 +
+
+ + {/* 项目列表预览 */} +
+ {(section.attributes.items || []) + .slice(0, 3) + .map((item, index) => ( +
+ + {item.name} + + + {formatCurrency(item.price)} + +
+ ))} + {(section.attributes.items || []).length > 3 && ( +
+ 还有 {section.attributes.items.length - 3} 个项目... +
+ )} +
+ + {/* 小节总金额 */} +
+ + 总金额 + + + {formatCurrency( + (section.attributes.items || []).reduce( + (sum, item) => + sum + (item.price * (item.quantity || 1) || 0), + 0 + ) + )} + +
+
+ + {/* 悬边框效果 */} +
+ + {/* 选择指示器 */} +
+ +
+
+ ))} +
+ + {availableSections.length === 0 && ( +
+
暂无可用模版
+
+ )} + + + +
+ +
+
+ ); + + 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: "CNY", + category: template.attributes.category, + sections: template.attributes.sections.map((section) => ({ + key: uuidv4(), + sectionName: section.sectionName, + items: section.items.map((item) => ({ + key: uuidv4(), + productName: item.name, + quantity: item.quantity, + price: item.price, + note: item.description, + unit: item.unit, + })), + })), + }; + + 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', + type: "quota", attributes: { quataName: values.quataName, - customerId: values.customer.id, - customerName: values.customer.name, + customers: customers + .filter(customer => values.customers.includes(customer.id)) + .map(customer => ({ + id: customer.id, + name: customer.attributes.name + })), description: values.description, - currency: values.currency, - items: values.items, - totalAmount - } + currency: currentCurrency, + sections: values.sections.map((section) => ({ + sectionName: section.sectionName, + items: section.items.map((item) => ({ + name: item.productName, + unit: item.unit, + price: item.price, + quantity: item.quantity, + description: item.note, + })), + })), + beforeTaxAmount, + taxRate, + afterTaxAmount, + discount, + finalAmount: discount || afterTaxAmount, + }, }; let result; if (id) { result = await supabase - .from('resources') + .from("resources") .update(quotationData) - .eq('id', id) + .eq("id", id) .select(); } else { result = await supabase - .from('resources') + .from("resources") .insert([quotationData]) .select(); } if (result.error) throw result.error; - navigate('/company/quotation'); + + message.success("保存成功"); + navigate("/company/quotation"); } catch (error) { - console.error('保存失败:', error); + console.error("保存失败:", error); + message.error("保存失败"); + } finally { + setLoading(false); } }; const fetchCustomers = async () => { try { const { data, error } = await supabase - .from('resources') - .select('*') - .eq('type', 'customer') - .eq('attributes->>status', 'active'); // 只获取启用状态的客户 + .from("resources") + .select("*") + .eq("type", "customer"); if (error) throw error; - setCustomers(data || []); } catch (error) { - console.error('获取客户列表失败:', error); + console.error("获取客户列表失败:", error); } }; @@ -245,32 +522,143 @@ const QuotationForm = () => { fetchCustomers(); }, []); + const renderItemFields = (itemField, itemIndex, sectionIndex) => ( +
+ + + + + + + + + + + + + + + +
+ + {formatCurrency( + calculateItemAmount( + formValues?.sections?.[sectionIndex]?.items?.[itemIndex] + ?.quantity, + formValues?.sections?.[sectionIndex]?.items?.[itemIndex]?.price + ) + )} + +
+ {!isView && itemFields.length > 1 && ( +
+ ); + + // 确保在组件加载时正确获取数据 + useEffect(() => { + if (id) { + fetchQuotationDetail(); + } else if (templateId) { + fetchTemplateData(); + } else { + // 如果既不是编辑也不是从模板创建,则设置初始值 + form.setFieldsValue(initialValues); + setFormValues(initialValues); + } + }, [id, templateId]); + + // 处理小节名称编辑 + const handleSectionNameEdit = (sectionIndex, initialValue) => { + setEditingSectionIndex(sectionIndex); + setEditingSectionName(initialValue || ""); + }; + + // 保存小节名称 + const handleSectionNameSave = () => { + if (!editingSectionName.trim()) { + message.error("请输入小节名称"); + return; + } + + const sections = form.getFieldValue("sections"); + const newSections = [...sections]; + newSections[editingSectionIndex] = { + ...newSections[editingSectionIndex], + sectionName: editingSectionName.trim(), + }; + form.setFieldValue("sections", newSections); + setEditingSectionIndex(null); + setEditingSectionName(""); + }; + + // 取消编辑 + const handleSectionNameCancel = () => { + setEditingSectionIndex(null); + setEditingSectionName(""); + }; + return (
-
- {id ? (isEdit ? '编辑报价单' : '查看报价单') : '新建报价单'} + {id ? (isEdit ? "编辑报价单" : "查看报价单") : "新建报价单"} - {id ? (isEdit ? '请修改报价单信息' : '报价单详情') : '请填写报价单信息'} + {id + ? isEdit + ? "请修改报价单信息" + : "报价单详情" + : "请填写报价单信息"}
- {!isView && ( - @@ -278,183 +666,375 @@ const QuotationForm = () => {
} - style={{ backgroundColor: '#fff' }} >
{/* 基本信息卡片 */} - - + 基本信息 } bordered={false} >
- 活动名称} - rules={[{ required: true, message: '活动名称' }]} + label={ + 活动名称 + } + rules={[{ required: true, message: "活动名称" }]} > - - {/* 公司名称} - rules={[{ required: true, message: '请输入公司名称' }]} - > - - */} - 客户名称} - rules={[{ required: true, message: '请选择客户' }]} + name="currency" + label={货币类型} + rules={[{ required: true, message: "请选择货币类型" }]} > - - - - -
-
- - {/* 报价明细卡片 */} - - - 报价明细 - - } - bordered={false} - extra={ - !isView && ( - - ) - } - > - - -
- 总金额: - - {CURRENCY_SYMBOLS[currentCurrency]} - {totalAmount.toLocaleString('zh-CN', { - minimumFractionDigits: 2, - maximumFractionDigits: 2 - })} - - - RMB 台币 美元 + + 客户名称} + rules={[{ required: true, message: "请选择至少一个客��" }]} + > +