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 (
-
-
-
- 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 && (
+
+ )}
+
+
+
+
+ }
+ onClick={handleCreateCustom}
+ className="w-1/3 border-2 hover:border-blue-400 hover:text-blue-500
+ dark:border-gray-600 dark:text-gray-400 dark:hover:text-blue-400
+ transition-all duration-300"
+ >
+ 自定义小节
+
+
+
+ );
+
+ 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 && (
+
}
+ onClick={() => removeItem(itemField.name)}
+ className="flex items-center justify-center"
+ />
+ )}
+
+ );
+
+ // 确保在组件加载时正确获取数据
+ 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
+ ? "请修改报价单信息"
+ : "报价单详情"
+ : "请填写报价单信息"}
- }
- onClick={() => navigate('/company/quotation')}
+ onClick={() => navigate("/company/quotation")}
>
返回
{!isView && (
- }
onClick={() => form.submit()}
+ loading={loading}
>
保存
@@ -278,183 +666,375 @@ const QuotationForm = () => {
}
- style={{ backgroundColor: '#fff' }}
>
+ {(fields, { add, remove }) => (
+ <>
+
+ {fields.map((field, sectionIndex) => (
+
+
+ {editingSectionIndex === sectionIndex ? (
+
+
+ setEditingSectionName(e.target.value)
+ }
+ onPressEnter={handleSectionNameSave}
+ autoFocus
+ className="w-48"
+ />
+ }
+ onClick={handleSectionNameSave}
+ className="text-green-500 hover:text-green-600"
+ />
+ }
+ onClick={handleSectionNameCancel}
+ className="text-red-500 hover:text-red-600"
+ />
+
+ ) : (
+
+
+
+ {form.getFieldValue([
+ "sections",
+ sectionIndex,
+ "sectionName",
+ ]) || `服务类�� ${sectionIndex + 1}`}
+
+ {(!id || isEdit) && (
+ }
+ onClick={() =>
+ handleSectionNameEdit(
+ sectionIndex,
+ form.getFieldValue([
+ "sections",
+ sectionIndex,
+ "sectionName",
+ ]) || `服务类型 ${sectionIndex + 1}`
+ )
+ }
+ className="text-gray-400 hover:text-blue-500"
+ />
+ )}
+
+ )}
+
+
+ }
+ >
+
+ {(itemFields, { add: addItem, remove: removeItem }) => (
+ <>
+ {/* 表头 */}
+
+
项目明细
+
描述/备注
+
单位
+
数量
+
单价
+
小计
+
操作
+
+ {itemFields.map((itemField, itemIndex) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatCurrency(
+ calculateItemAmount(
+ formValues?.sections?.[sectionIndex]
+ ?.items?.[itemIndex]?.quantity,
+ formValues?.sections?.[sectionIndex]
+ ?.items?.[itemIndex]?.price
+ )
+ )}
+
+
+ {!isView && (
+
}
+ onClick={() => removeItem(itemField.name)}
+ className="flex items-center justify-center"
+ />
+ )}
+
+ ))}
+
+ {!isView && (
+
+ )}
+
+
+
+ 小计总额:
+
+ {formatCurrency(
+ calculateSectionTotal(
+ formValues?.sections?.[sectionIndex]
+ ?.items
+ )
+ )}
+
+
+
+ >
+ )}
+
+
+ ))}
+
+
+ {/* Add section button */}
+ {!isView && (
+