模板抽离复用
This commit is contained in:
527
src/components/SectionList/index.jsx
Normal file
527
src/components/SectionList/index.jsx
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Form, Input, InputNumber, Button, Card, Typography, Modal, message, Divider, Select } from 'antd';
|
||||||
|
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, CloseOutlined } from '@ant-design/icons';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { supabase } from "@/config/supabase";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const SectionList = ({
|
||||||
|
form,
|
||||||
|
isView,
|
||||||
|
formValues,
|
||||||
|
currentCurrency = 'CNY'
|
||||||
|
}) => {
|
||||||
|
const [editingSectionIndex, setEditingSectionIndex] = useState(null);
|
||||||
|
const [editingSectionName, setEditingSectionName] = useState('');
|
||||||
|
const [templateModalVisible, setTemplateModalVisible] = useState(false);
|
||||||
|
const [availableSections, setAvailableSections] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [units, setUnits] = useState([]);
|
||||||
|
const [loadingUnits, setLoadingUnits] = useState(false);
|
||||||
|
|
||||||
|
// 内部计算方法
|
||||||
|
const calculateItemAmount = (quantity, price) => {
|
||||||
|
const safeQuantity = Number(quantity) || 0;
|
||||||
|
const safePrice = Number(price) || 0;
|
||||||
|
return safeQuantity * safePrice;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateSectionTotal = (items = []) => {
|
||||||
|
if (!Array.isArray(items)) return 0;
|
||||||
|
return items.reduce((sum, item) => {
|
||||||
|
if (!item) return sum;
|
||||||
|
return sum + calculateItemAmount(item.quantity, item.price);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount) => {
|
||||||
|
const CURRENCY_SYMBOLS = {
|
||||||
|
CNY: "¥",
|
||||||
|
TWD: "NT$",
|
||||||
|
USD: "$",
|
||||||
|
};
|
||||||
|
const safeAmount = Number(amount) || 0;
|
||||||
|
return `${CURRENCY_SYMBOLS[currentCurrency] || ""}${safeAmount.toLocaleString("zh-CN", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取可用的小节模板
|
||||||
|
const fetchAvailableSections = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
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);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理小节名称编辑
|
||||||
|
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 handleAddItem = (add) => {
|
||||||
|
add({
|
||||||
|
key: uuidv4(),
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
quantity: 1,
|
||||||
|
price: 0,
|
||||||
|
unit: ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理使用模板
|
||||||
|
const handleUseTemplate = (template, add) => {
|
||||||
|
const newSection = {
|
||||||
|
key: uuidv4(),
|
||||||
|
sectionName: template.attributes.name,
|
||||||
|
items: (template.attributes.items || []).map(item => ({
|
||||||
|
key: uuidv4(),
|
||||||
|
name: item.name || '',
|
||||||
|
description: item.description || '',
|
||||||
|
price: item.price || 0,
|
||||||
|
quantity: item.quantity || 1,
|
||||||
|
unit: item.unit || '',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
add(newSection);
|
||||||
|
setTemplateModalVisible(false);
|
||||||
|
message.success('套用模版成功');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理创建自定义小节
|
||||||
|
const handleCreateCustom = (add, fieldsLength) => {
|
||||||
|
add({
|
||||||
|
key: uuidv4(),
|
||||||
|
sectionName: `服务类型 ${fieldsLength + 1}`,
|
||||||
|
items: [{
|
||||||
|
key: uuidv4(),
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
quantity: 1,
|
||||||
|
price: 0,
|
||||||
|
unit: ''
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
setTemplateModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取单位列表
|
||||||
|
const fetchUnits = async () => {
|
||||||
|
setLoadingUnits(true);
|
||||||
|
try {
|
||||||
|
const { data: unitsData, error } = await supabase
|
||||||
|
.from('resources')
|
||||||
|
.select('*')
|
||||||
|
.eq('type', 'units')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setUnits(unitsData || []);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取单位列表失败');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoadingUnits(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 在组件加载时获取单位列表
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUnits();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 新增单位
|
||||||
|
const handleAddUnit = async (unitName) => {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('resources')
|
||||||
|
.insert([{
|
||||||
|
type: 'units',
|
||||||
|
attributes: {
|
||||||
|
name: unitName
|
||||||
|
},
|
||||||
|
schema_version: 1
|
||||||
|
}]);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
message.success('新增单位成功');
|
||||||
|
fetchUnits();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
message.error('新增单位失败');
|
||||||
|
console.error(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 模板选择弹窗内容
|
||||||
|
const renderTemplateModalContent = (add, fieldsLength) => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{availableSections.map(section => (
|
||||||
|
<div
|
||||||
|
key={section.id}
|
||||||
|
className="group relative bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-lg transition-all duration-300 cursor-pointer"
|
||||||
|
onClick={() => handleUseTemplate(section, add)}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<h3 className="text-lg font-medium group-hover:text-blue-500 transition-colors">
|
||||||
|
{section.attributes.name}
|
||||||
|
</h3>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">
|
||||||
|
{section.attributes.items?.length || 0} 个项目
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mt-4 border-t pt-4">
|
||||||
|
{(section.attributes.items || []).slice(0, 3).map((item, index) => (
|
||||||
|
<div key={index} className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-600 truncate flex-1">{item.name}</span>
|
||||||
|
<span className="text-sm text-gray-500 ml-2">{formatCurrency(item.price)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(section.attributes.items || []).length > 3 && (
|
||||||
|
<div className="text-sm text-gray-500 text-center">
|
||||||
|
还有 {section.attributes.items.length - 3} 个项目...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-600">总金额</span>
|
||||||
|
<span className="text-base font-medium text-blue-500">
|
||||||
|
{formatCurrency(
|
||||||
|
(section.attributes.items || []).reduce(
|
||||||
|
(sum, item) => sum + (item.price * (item.quantity || 1) || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => handleCreateCustom(add, fieldsLength)}
|
||||||
|
className="w-1/3 border-2"
|
||||||
|
>
|
||||||
|
自定义小节
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.List name="sections">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{fields.map((field, sectionIndex) => (
|
||||||
|
<Card
|
||||||
|
key={field.key}
|
||||||
|
className="shadow-sm rounded-lg"
|
||||||
|
type="inner"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{editingSectionIndex === sectionIndex ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={editingSectionName}
|
||||||
|
onChange={(e) => setEditingSectionName(e.target.value)}
|
||||||
|
onPressEnter={handleSectionNameSave}
|
||||||
|
autoFocus
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
onClick={handleSectionNameSave}
|
||||||
|
className="text-green-500"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingSectionIndex(null);
|
||||||
|
setEditingSectionName('');
|
||||||
|
}}
|
||||||
|
className="text-red-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-1 h-4 bg-purple-500 rounded-full" />
|
||||||
|
<Text strong className="text-lg">
|
||||||
|
{form.getFieldValue(['sections', sectionIndex, 'sectionName'])
|
||||||
|
|| `服务类型 ${sectionIndex + 1}`}
|
||||||
|
</Text>
|
||||||
|
{!isView && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleSectionNameEdit(
|
||||||
|
sectionIndex,
|
||||||
|
form.getFieldValue(['sections', sectionIndex, 'sectionName'])
|
||||||
|
)}
|
||||||
|
className="text-gray-400 hover:text-blue-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isView && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => remove(field.name)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* 项目列表 */}
|
||||||
|
<Form.List name={[field.name, "items"]}>
|
||||||
|
{(itemFields, { add: addItem, remove: removeItem }) => (
|
||||||
|
<>
|
||||||
|
{/* 表头 */}
|
||||||
|
<div className="grid grid-cols-[3fr_4fr_1fr_1fr_2fr_1fr_40px] gap-4 mb-2 text-gray-500 px-2">
|
||||||
|
<div>项目明细</div>
|
||||||
|
<div>描述/备注</div>
|
||||||
|
<div>单位</div>
|
||||||
|
<div className="text-center">数量</div>
|
||||||
|
<div className="text-center">单价</div>
|
||||||
|
<div className="text-right">小计</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 项目列表 */}
|
||||||
|
{itemFields.map((itemField, itemIndex) => (
|
||||||
|
<div
|
||||||
|
key={itemField.key}
|
||||||
|
className="grid grid-cols-[3fr_4fr_1fr_1fr_2fr_1fr_40px] gap-4 mb-4 items-start"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
{...itemField}
|
||||||
|
name={[itemField.name, "name"]}
|
||||||
|
className="!mb-0"
|
||||||
|
>
|
||||||
|
<Input placeholder="服务项目名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
{...itemField}
|
||||||
|
name={[itemField.name, "description"]}
|
||||||
|
className="!mb-0"
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入描述/备注" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
{...itemField}
|
||||||
|
name={[itemField.name, "unit"]}
|
||||||
|
className="!mb-0"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="选择单位"
|
||||||
|
loading={loadingUnits}
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
options={units.map(unit => ({
|
||||||
|
label: unit.attributes.name,
|
||||||
|
value: unit.attributes.name
|
||||||
|
}))}
|
||||||
|
onDropdownVisibleChange={(open) => {
|
||||||
|
if (open) fetchUnits();
|
||||||
|
}}
|
||||||
|
dropdownRender={(menu) => (
|
||||||
|
<>
|
||||||
|
{menu}
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
<Select.Option value="ADD_NEW">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
block
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Modal.confirm({
|
||||||
|
title: '新增单位',
|
||||||
|
content: (
|
||||||
|
<Input
|
||||||
|
placeholder="请输入单位名称"
|
||||||
|
onChange={(e) => {
|
||||||
|
Modal.confirm.update({
|
||||||
|
okButtonProps: {
|
||||||
|
disabled: !e.target.value.trim()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
ref={(input) => {
|
||||||
|
if (input) {
|
||||||
|
setTimeout(() => input.focus(), 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
onOk: async (close) => {
|
||||||
|
const unitName = document.querySelector('.ant-modal-content input').value.trim();
|
||||||
|
if (await handleAddUnit(unitName)) {
|
||||||
|
const currentItems = form.getFieldValue(['sections', field.name, 'items']);
|
||||||
|
currentItems[itemField.name].unit = unitName;
|
||||||
|
form.setFieldValue(['sections', field.name, 'items'], currentItems);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新增单位
|
||||||
|
</Button>
|
||||||
|
</Select.Option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
{...itemField}
|
||||||
|
name={[itemField.name, "quantity"]}
|
||||||
|
className="!mb-0"
|
||||||
|
>
|
||||||
|
<InputNumber placeholder="数量" min={0} className="w-full" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
{...itemField}
|
||||||
|
name={[itemField.name, "price"]}
|
||||||
|
className="!mb-0"
|
||||||
|
>
|
||||||
|
<InputNumber placeholder="单价" min={0} className="w-full" />
|
||||||
|
</Form.Item>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{formatCurrency(
|
||||||
|
calculateItemAmount(
|
||||||
|
formValues?.sections?.[sectionIndex]?.items?.[itemIndex]?.quantity,
|
||||||
|
formValues?.sections?.[sectionIndex]?.items?.[itemIndex]?.price
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isView && itemFields.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => removeItem(itemField.name)}
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!isView && (
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={() => handleAddItem(addItem)}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
className="w-full hover:border-blue-400 hover:text-blue-500 mb-4"
|
||||||
|
>
|
||||||
|
添加服务项目
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t pt-4">
|
||||||
|
<span className="text-gray-500">
|
||||||
|
小计总额:
|
||||||
|
<span className="text-blue-500 font-medium ml-2">
|
||||||
|
{formatCurrency(
|
||||||
|
calculateSectionTotal(
|
||||||
|
formValues?.sections?.[sectionIndex]?.items
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isView && (
|
||||||
|
<div className="mt-6 flex justify-center">
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={() => {
|
||||||
|
setTemplateModalVisible(true);
|
||||||
|
fetchAvailableSections();
|
||||||
|
}}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
className="w-1/3 border-2 hover:border-blue-400 hover:text-blue-500"
|
||||||
|
>
|
||||||
|
新建模块
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={<h3 className="text-lg font-medium">选择模版</h3>}
|
||||||
|
open={templateModalVisible}
|
||||||
|
onCancel={() => setTemplateModalVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={800}
|
||||||
|
closeIcon={<CloseOutlined className="text-gray-500" />}
|
||||||
|
>
|
||||||
|
{renderTemplateModalContent(add, fields.length)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SectionList;
|
||||||
@@ -14,7 +14,7 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StatisticsOverview />
|
{/* <StatisticsOverview /> */}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
Select,
|
||||||
Button,
|
Button,
|
||||||
Space,
|
Space,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
Select,
|
|
||||||
Modal,
|
|
||||||
Divider,
|
|
||||||
Popconfirm,
|
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
EditOutlined,
|
|
||||||
CloseOutlined,
|
|
||||||
CheckOutlined,
|
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||||
import { supabase } from "@/config/supabase";
|
import { supabase } from "@/config/supabase";
|
||||||
const { Title, Text } = Typography;
|
import SectionList from '@/components/SectionList';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
const ServiceForm = () => {
|
const ServiceForm = () => {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
@@ -32,23 +25,17 @@ const ServiceForm = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isEdit = location.search.includes("edit=true");
|
const isEdit = location.search.includes("edit=true");
|
||||||
const [editingSectionIndex, setEditingSectionIndex] = useState(null);
|
|
||||||
const [availableSections, setAvailableSections] = useState([]);
|
|
||||||
const [formValues, setFormValues] = useState({
|
const [formValues, setFormValues] = useState({
|
||||||
sections: [{ items: [{}] }],
|
sections: [{ items: [{}] }],
|
||||||
currency: "CNY"
|
currency: "CNY"
|
||||||
});
|
});
|
||||||
const [templateModalVisible, setTemplateModalVisible] = useState(false);
|
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
const [units, setUnits] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
fetchServiceTemplate();
|
fetchServiceTemplate();
|
||||||
}
|
}
|
||||||
fetchAvailableSections();
|
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
fetchUnits();
|
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const fetchServiceTemplate = async () => {
|
const fetchServiceTemplate = async () => {
|
||||||
@@ -64,7 +51,7 @@ const ServiceForm = () => {
|
|||||||
const formData = {
|
const formData = {
|
||||||
templateName: data.attributes.templateName,
|
templateName: data.attributes.templateName,
|
||||||
description: data.attributes.description,
|
description: data.attributes.description,
|
||||||
category:data.attributes.category.map(v=>v.id),
|
category: data.attributes.category.map(v=>v.id),
|
||||||
sections: data.attributes.sections,
|
sections: data.attributes.sections,
|
||||||
};
|
};
|
||||||
form.setFieldsValue(formData);
|
form.setFieldsValue(formData);
|
||||||
@@ -77,22 +64,6 @@ const ServiceForm = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 fetchCategories = async () => {
|
const fetchCategories = async () => {
|
||||||
try {
|
try {
|
||||||
const { data: categoriesData, error } = await supabase
|
const { data: categoriesData, error } = await supabase
|
||||||
@@ -104,7 +75,7 @@ const ServiceForm = () => {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
const formattedCategories = (categoriesData || []).map(category => ({
|
const formattedCategories = (categoriesData || []).map(category => ({
|
||||||
value:category.id,
|
value: category.id,
|
||||||
label: category.attributes.name
|
label: category.attributes.name
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -115,39 +86,9 @@ const ServiceForm = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchUnits = async () => {
|
|
||||||
try {
|
|
||||||
const { data: unitsData, error } = await supabase
|
|
||||||
.from('resources')
|
|
||||||
.select('*')
|
|
||||||
.eq('type', 'units')
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
const formattedUnits = (unitsData || []).map(unit => ({
|
|
||||||
value: unit.attributes.name,
|
|
||||||
label: unit.attributes.name
|
|
||||||
}));
|
|
||||||
|
|
||||||
setUnits(formattedUnits);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取单位数据失败:', error);
|
|
||||||
message.error('获取单位数据失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFinish = async (values) => {
|
const onFinish = async (values) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const totalAmount = values.sections.reduce((sum, section) => {
|
|
||||||
return (
|
|
||||||
sum +
|
|
||||||
(section.items || []).reduce((sectionSum, item) => {
|
|
||||||
return sectionSum + (item.quantity * item.price || 0);
|
|
||||||
}, 0)
|
|
||||||
);
|
|
||||||
}, 0);
|
|
||||||
const categoryData = values.category.map(categoryId => {
|
const categoryData = values.category.map(categoryId => {
|
||||||
const category = categories.find(c => c.value === categoryId);
|
const category = categories.find(c => c.value === categoryId);
|
||||||
return {
|
return {
|
||||||
@@ -161,8 +102,7 @@ const ServiceForm = () => {
|
|||||||
templateName: values.templateName,
|
templateName: values.templateName,
|
||||||
description: values.description,
|
description: values.description,
|
||||||
sections: values.sections,
|
sections: values.sections,
|
||||||
category: categoryData,
|
category: categoryData,
|
||||||
totalAmount,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,724 +131,118 @@ const ServiceForm = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddExistingSection = (sectionId) => {
|
|
||||||
const section = availableSections.find((s) => s.id === sectionId);
|
|
||||||
if (section) {
|
|
||||||
const sections = form.getFieldValue("sections") || [];
|
|
||||||
form.setFieldsValue({
|
|
||||||
sections: [
|
|
||||||
...sections,
|
|
||||||
{
|
|
||||||
...section.attributes,
|
|
||||||
sectionId: section.id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const currencyOptions = [
|
|
||||||
{ value: "CNY", label: "人民币 (¥)" },
|
|
||||||
{ value: "TWD", label: "台币 (NT$)" },
|
|
||||||
{ value: "USD", label: "美元 ($)" },
|
|
||||||
];
|
|
||||||
|
|
||||||
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 = form.getFieldValue("currency")) => {
|
|
||||||
const safeAmount = Number(amount) || 0;
|
|
||||||
const currencySymbol =
|
|
||||||
{
|
|
||||||
CNY: "¥",
|
|
||||||
TWD: "NT$",
|
|
||||||
USD: "$",
|
|
||||||
}[currency] || "";
|
|
||||||
|
|
||||||
return `${currencySymbol}${safeAmount.toLocaleString("zh-CN", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
})}`;
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleValuesChange = (changedValues, allValues) => {
|
const handleValuesChange = (changedValues, allValues) => {
|
||||||
setFormValues(allValues);
|
setFormValues(allValues);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [editingSectionName, setEditingSectionName] = useState('');
|
|
||||||
|
|
||||||
// 处理小节名称编辑
|
|
||||||
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('');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 使用选中的模版
|
|
||||||
const handleUseTemplate = (template) => {
|
|
||||||
const currentSections = form.getFieldValue('sections') || [];
|
|
||||||
|
|
||||||
// 确保所有必要的字段都存在
|
|
||||||
const newSection = {
|
|
||||||
sectionName: template.attributes.name,
|
|
||||||
items: (template.attributes.items || []).map(item => ({
|
|
||||||
name: item.name || '',
|
|
||||||
description: item.description || '',
|
|
||||||
price: item.price || 0,
|
|
||||||
quantity: item.quantity || 1,
|
|
||||||
unit: item.unit || '',
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
|
|
||||||
const newSections = [...currentSections, newSection];
|
|
||||||
|
|
||||||
// 更新表单值
|
|
||||||
form.setFieldValue('sections', newSections);
|
|
||||||
|
|
||||||
// 更新 formValues 以触发金额计算
|
|
||||||
setFormValues(prev => ({
|
|
||||||
...prev,
|
|
||||||
sections: newSections
|
|
||||||
}));
|
|
||||||
|
|
||||||
setTemplateModalVisible(false);
|
|
||||||
message.success('套用模版成功');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 创建自定义小节
|
|
||||||
const handleCreateCustom = () => {
|
|
||||||
const currentSections = form.getFieldValue('sections') || [];
|
|
||||||
const newSection = {
|
|
||||||
sectionName: `服务类型 ${currentSections.length + 1}`,
|
|
||||||
items: [{ name: '', description: '', price: 0, quantity: 1, unit: '' }]
|
|
||||||
};
|
|
||||||
const newSections = [...currentSections, newSection];
|
|
||||||
|
|
||||||
// 更新表单值
|
|
||||||
form.setFieldValue('sections', newSections);
|
|
||||||
|
|
||||||
// 更新 formValues 以触发金额计算
|
|
||||||
setFormValues(prev => ({
|
|
||||||
...prev,
|
|
||||||
sections: newSections
|
|
||||||
}));
|
|
||||||
|
|
||||||
setTemplateModalVisible(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 修改新增小节按钮的点击事件处理
|
|
||||||
const handleAddSection = () => {
|
|
||||||
setTemplateModalVisible(true);
|
|
||||||
fetchAvailableSections();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 修改移除小节的处理方法
|
|
||||||
const handleRemoveSection = (sectionIndex) => {
|
|
||||||
const currentSections = form.getFieldValue('sections') || [];
|
|
||||||
const newSections = currentSections.filter((_, index) => index !== sectionIndex);
|
|
||||||
|
|
||||||
// 更新表单值
|
|
||||||
form.setFieldValue('sections', newSections);
|
|
||||||
|
|
||||||
// 更新 formValues 以触发金额计算
|
|
||||||
setFormValues(prev => ({
|
|
||||||
...prev,
|
|
||||||
sections: newSections
|
|
||||||
}));
|
|
||||||
|
|
||||||
message.success('删除成功');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 优化模版选择弹窗内容
|
|
||||||
const renderTemplateModalContent = () => (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{availableSections.map(section => (
|
|
||||||
<div
|
|
||||||
key={section.id}
|
|
||||||
className="group relative bg-white dark:bg-gray-800 rounded-lg shadow-sm
|
|
||||||
border border-gray-200 dark:border-gray-700 hover:shadow-lg
|
|
||||||
transition-all duration-300 cursor-pointer"
|
|
||||||
onClick={() => handleUseTemplate(section)}
|
|
||||||
>
|
|
||||||
<div className="p-6">
|
|
||||||
{/* 标题和项目数量 */}
|
|
||||||
<div className="text-center mb-4">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100
|
|
||||||
group-hover:text-blue-500 transition-colors">
|
|
||||||
{section.attributes.name}
|
|
||||||
</h3>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
{section.attributes.items?.length || 0} 个项目
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 项目列表预览 */}
|
|
||||||
<div className="space-y-2 mt-4 border-t dark:border-gray-700 pt-4">
|
|
||||||
{(section.attributes.items || []).slice(0, 3).map((item, index) => (
|
|
||||||
<div key={index} className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-300 truncate flex-1">
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400 ml-2">
|
|
||||||
{formatCurrency(item.price)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(section.attributes.items || []).length > 3 && (
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 text-center">
|
|
||||||
还有 {section.attributes.items.length - 3} 个项目...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 小节总金额 */}
|
|
||||||
<div className="mt-4 pt-4 border-t dark:border-gray-700 flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-300">总金额</span>
|
|
||||||
<span className="text-base font-medium text-blue-500">
|
|
||||||
{formatCurrency(
|
|
||||||
(section.attributes.items || []).reduce(
|
|
||||||
(sum, item) => sum + (item.price * (item.quantity || 1) || 0),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 悬边框效果 */}
|
|
||||||
<div className="absolute inset-0 border-2 border-transparent
|
|
||||||
group-hover:border-blue-500 rounded-lg transition-colors duration-300"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 选择指示器 */}
|
|
||||||
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100
|
|
||||||
transition-opacity duration-300">
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
className="flex items-center gap-1"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
>
|
|
||||||
套用
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{availableSections.length === 0 && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="text-gray-500 dark:text-gray-400">
|
|
||||||
暂无可用模版
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider className="dark:border-gray-700" />
|
|
||||||
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
自定义小节
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderUnitSelect = (itemField, sectionIndex, itemIndex) => {
|
|
||||||
const addUnit = async (unitName) => {
|
|
||||||
try {
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('resources')
|
|
||||||
.insert([{
|
|
||||||
type: 'units',
|
|
||||||
attributes: {
|
|
||||||
name: unitName
|
|
||||||
},
|
|
||||||
schema_version: 1
|
|
||||||
}]);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
message.success('新增单位成功');
|
|
||||||
await fetchUnits();
|
|
||||||
|
|
||||||
// 自动选中新添加的单位
|
|
||||||
form.setFieldValue(['sections', sectionIndex, 'items', itemIndex, 'unit'], unitName);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
message.error('新增单位失败');
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form.Item
|
|
||||||
{...itemField}
|
|
||||||
name={[itemField.name, "unit"]}
|
|
||||||
className="!mb-0"
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
placeholder="选择或输入单位"
|
|
||||||
options={units}
|
|
||||||
showSearch
|
|
||||||
allowClear
|
|
||||||
onSearch={(value) => {
|
|
||||||
// 当输入的值不在现有选项中时,显示添加选项
|
|
||||||
if (value && !units.find(unit => unit.value === value)) {
|
|
||||||
const newOption = { value, label: `添加 "${value}"` };
|
|
||||||
setUnits([...units, newOption]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSelect={(value, option) => {
|
|
||||||
// 如果选择的是新添加的选项
|
|
||||||
if (option.label.startsWith('添加 "')) {
|
|
||||||
addUnit(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
filterOption={(input, option) =>
|
|
||||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div className="bg-gradient-to-b from-gray-50 to-white min-h-screen p-6">
|
||||||
title={
|
<Card
|
||||||
<span className="text-gray-800 dark:text-gray-200">
|
className="shadow-lg rounded-lg border-0"
|
||||||
{id ? (isEdit ? "编辑服务模版" : "查看服务模版") : "新增服务模版"}
|
title={
|
||||||
</span>
|
<div className="flex justify-between items-center py-2">
|
||||||
}
|
<div className="flex items-center space-x-3">
|
||||||
className="dark:bg-gray-800"
|
<Title level={4} className="mb-0 text-gray-800">
|
||||||
extra={
|
{id ? (isEdit ? "编辑服务模版" : "查看服务模版") : "新建服务模版"}
|
||||||
<Space>
|
</Title>
|
||||||
<Button
|
<span className="text-gray-400 text-sm">
|
||||||
icon={<ArrowLeftOutlined />}
|
{id
|
||||||
onClick={() => navigate("/company/serviceTeamplate")}
|
? isEdit
|
||||||
className="flex items-center"
|
? "请修改服务模版信息"
|
||||||
>
|
: "服务模版详情"
|
||||||
返回列表
|
: "请填写服务模版信息"}
|
||||||
</Button>
|
</span>
|
||||||
{id && !isEdit && (
|
</div>
|
||||||
<Button
|
<Space size="middle">
|
||||||
type="primary"
|
<Button
|
||||||
onClick={() =>
|
icon={<ArrowLeftOutlined />}
|
||||||
navigate(`/company/serviceTemplateInfo/${id}?edit=true`)
|
onClick={() => navigate("/company/serviceTeamplate")}
|
||||||
}
|
>
|
||||||
className="flex items-center"
|
返回
|
||||||
>
|
</Button>
|
||||||
编辑
|
<Button
|
||||||
</Button>
|
type="primary"
|
||||||
)}
|
onClick={() => form.submit()}
|
||||||
</Space>
|
loading={loading}
|
||||||
}
|
>
|
||||||
>
|
保存
|
||||||
<Form
|
</Button>
|
||||||
form={form}
|
</Space>
|
||||||
layout="vertical"
|
</div>
|
||||||
onFinish={onFinish}
|
}
|
||||||
initialValues={{
|
|
||||||
sections: [{ items: [{}] }],
|
|
||||||
currency: "CNY",
|
|
||||||
}}
|
|
||||||
disabled={id && !isEdit}
|
|
||||||
onValuesChange={handleValuesChange}
|
|
||||||
>
|
>
|
||||||
<div className="bg-white dark:bg-gray-800 p-2 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 mb-6">
|
<Form
|
||||||
<Title level={5} className="mb-4 dark:text-gray-200">
|
form={form}
|
||||||
基本信息
|
onFinish={onFinish}
|
||||||
</Title>
|
onValuesChange={handleValuesChange}
|
||||||
<div className="grid grid-cols-2 gap-6">
|
layout="vertical"
|
||||||
<Form.Item
|
|
||||||
label="模版名称"
|
|
||||||
name="templateName"
|
|
||||||
rules={[{ required: true, message: "请输入模版名称" }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入模版名称" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label="货币单位"
|
|
||||||
name="currency"
|
|
||||||
rules={[{ required: true, message: "请选择货币单位" }]}
|
|
||||||
initialValue="CNY"
|
|
||||||
>
|
|
||||||
<Select options={currencyOptions} placeholder="请选择货币单位" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label="模版分类"
|
|
||||||
name="category"
|
|
||||||
rules={[{ required: true, message: "请选择或输入分类" }]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
placeholder="请选择或输入分类"
|
|
||||||
showSearch
|
|
||||||
allowClear
|
|
||||||
mode="tags"
|
|
||||||
options={categories}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label="模版描述"
|
|
||||||
name="description"
|
|
||||||
className="col-span-2"
|
|
||||||
>
|
|
||||||
<Input.TextArea rows={4} placeholder="请输入模版描述" />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-2 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<Title level={5} className="!mb-0 dark:text-gray-200">
|
|
||||||
报价明细
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form.List name="sections">
|
|
||||||
{(fields, { add, remove }) => (
|
|
||||||
<>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{fields.map((field, sectionIndex) => (
|
|
||||||
<Card
|
|
||||||
key={field.key}
|
|
||||||
className="!border-gray-200 dark:!border-gray-700
|
|
||||||
dark:bg-gray-800 hover:shadow-md transition-shadow duration-300"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{editingSectionIndex === sectionIndex ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={editingSectionName}
|
|
||||||
onChange={(e) => setEditingSectionName(e.target.value)}
|
|
||||||
onPressEnter={handleSectionNameSave}
|
|
||||||
autoFocus
|
|
||||||
className="w-48"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
icon={<CheckOutlined />}
|
|
||||||
onClick={handleSectionNameSave}
|
|
||||||
className="text-green-500 hover:text-green-600"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
icon={<CloseOutlined />}
|
|
||||||
onClick={handleSectionNameCancel}
|
|
||||||
className="text-red-500 hover:text-red-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Text strong className="text-lg dark:text-gray-200">
|
|
||||||
{form.getFieldValue([
|
|
||||||
"sections",
|
|
||||||
sectionIndex,
|
|
||||||
"sectionName",
|
|
||||||
]) || `服务类型 ${sectionIndex + 1}`}
|
|
||||||
</Text>
|
|
||||||
{(!id || isEdit) && (
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() =>
|
|
||||||
handleSectionNameEdit(
|
|
||||||
sectionIndex,
|
|
||||||
form.getFieldValue([
|
|
||||||
"sections",
|
|
||||||
sectionIndex,
|
|
||||||
"sectionName",
|
|
||||||
]) || `服务类型 ${sectionIndex + 1}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="text-gray-400 hover:text-blue-500"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
|
|
||||||
{(!id || isEdit) && (
|
|
||||||
<Popconfirm
|
|
||||||
title="确认删除"
|
|
||||||
description="确定要删除这个小节吗?"
|
|
||||||
onConfirm={() => handleRemoveSection(sectionIndex)}
|
|
||||||
okText="确定"
|
|
||||||
cancelText="取消"
|
|
||||||
okButtonProps={{
|
|
||||||
className: "bg-red-500 hover:bg-red-600 border-red-500"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
className="hover:text-red-500 transition-colors"
|
|
||||||
/>
|
|
||||||
</Popconfirm>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 表头 */}
|
|
||||||
<div className="grid grid-cols-[3fr_4fr_1fr_1fr_2fr_1fr_40px] gap-4 mb-2 text-gray-500 dark:text-gray-400 px-2">
|
|
||||||
<div>项目明细</div>
|
|
||||||
<div>描述/备注</div>
|
|
||||||
<div className="text-center">数量</div>
|
|
||||||
<div className="text-center">单位</div>
|
|
||||||
<div className="text-center">单价</div>
|
|
||||||
<div className="text-right">小计</div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form.List name={[field.name, "items"]}>
|
|
||||||
{(itemFields, { add: addItem, remove: removeItem }) => (
|
|
||||||
<>
|
|
||||||
{itemFields.map((itemField, itemIndex) => (
|
|
||||||
<div
|
|
||||||
key={itemField.key}
|
|
||||||
className="grid grid-cols-[3fr_4fr_1fr_1fr_2fr_1fr_40px] gap-4 mb-4 items-start"
|
|
||||||
>
|
|
||||||
<Form.Item
|
|
||||||
{...itemField}
|
|
||||||
name={[itemField.name, "name"]}
|
|
||||||
className="!mb-0"
|
|
||||||
|
|
||||||
>
|
|
||||||
<Input placeholder="服务项目名称" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
{...itemField}
|
|
||||||
name={[itemField.name, "description"]}
|
|
||||||
className="!mb-0"
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入描述/备注" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
{...itemField}
|
|
||||||
name={[itemField.name, "quantity"]}
|
|
||||||
className="!mb-0"
|
|
||||||
>
|
|
||||||
<InputNumber
|
|
||||||
placeholder="数量"
|
|
||||||
min={0}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
{renderUnitSelect(itemField, sectionIndex, itemIndex)}
|
|
||||||
<Form.Item
|
|
||||||
{...itemField}
|
|
||||||
name={[itemField.name, "price"]}
|
|
||||||
className="!mb-0"
|
|
||||||
>
|
|
||||||
<InputNumber
|
|
||||||
placeholder="单价"
|
|
||||||
min={0}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<div className="text-right">
|
|
||||||
<Text className="text-gray-500 dark:text-gray-400">
|
|
||||||
{formatCurrency(
|
|
||||||
calculateItemAmount(
|
|
||||||
formValues?.sections?.[sectionIndex]
|
|
||||||
?.items?.[itemIndex]?.quantity,
|
|
||||||
formValues?.sections?.[sectionIndex]
|
|
||||||
?.items?.[itemIndex]?.price
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(!id || isEdit) && itemFields.length > 1 && (
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
onClick={() => removeItem(itemField.name)}
|
|
||||||
className="flex items-center justify-center"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{(!id || isEdit) && (
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() => addItem()}
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
className="w-full hover:border-blue-400 hover:text-blue-500 mb-4 dark:border-gray-600 dark:text-gray-400 dark:hover:text-blue-400"
|
|
||||||
>
|
|
||||||
添加服务项目
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end border-t dark:border-gray-700 pt-4">
|
|
||||||
<Text className="text-gray-500 dark:text-gray-400">
|
|
||||||
小计总额:
|
|
||||||
<span className="text-blue-500 dark:text-blue-400 font-medium ml-2">
|
|
||||||
{formatCurrency(
|
|
||||||
calculateSectionTotal(
|
|
||||||
formValues?.sections?.[sectionIndex]
|
|
||||||
?.items
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form.List>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(!id || isEdit) && (
|
|
||||||
<div className="mt-6 flex justify-center">
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={handleAddSection}
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
新建小节
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 总金额统计 */}
|
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<span className="text-base font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
总金额
|
|
||||||
</span>
|
|
||||||
<span className="text-xl font-semibold text-blue-500 dark:text-blue-400">
|
|
||||||
{formatCurrency(
|
|
||||||
calculateTotalAmount(formValues?.sections)
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form.List>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(!id || isEdit) && (
|
|
||||||
<div className="mt-6 flex justify-end gap-4">
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate("/company/serviceTeamplate")}
|
|
||||||
className="px-6 hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-200"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
htmlType="submit"
|
|
||||||
loading={loading}
|
|
||||||
className="px-6 hover:opacity-90"
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 模版选择弹窗 */}
|
|
||||||
<Modal
|
|
||||||
title={
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
选择小节模版
|
|
||||||
</h3>
|
|
||||||
}
|
|
||||||
open={templateModalVisible}
|
|
||||||
onCancel={() => setTemplateModalVisible(false)}
|
|
||||||
footer={null}
|
|
||||||
width={800}
|
|
||||||
className="dark:bg-gray-800"
|
|
||||||
closeIcon={
|
|
||||||
<CloseOutlined className="text-gray-500 dark:text-gray-400" />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{renderTemplateModalContent()}
|
{/* 基本信息 */}
|
||||||
</Modal>
|
<Card
|
||||||
</Form>
|
className="shadow-sm rounded-lg mb-6"
|
||||||
</Card>
|
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-6">
|
||||||
|
<Form.Item
|
||||||
|
label="模版名称"
|
||||||
|
name="templateName"
|
||||||
|
rules={[{ required: true, message: "请输入模版名称" }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入模版名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="模版分类"
|
||||||
|
name="category"
|
||||||
|
rules={[{ required: true, message: "请选择或输入分类" }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择或输入分类"
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
mode="tags"
|
||||||
|
options={categories}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="模版描述"
|
||||||
|
name="description"
|
||||||
|
className="col-span-2"
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={4} placeholder="请输入模版描述" />
|
||||||
|
</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-blue-500 rounded-full" />
|
||||||
|
<span>服务明细</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
<SectionList
|
||||||
|
form={form}
|
||||||
|
isView={!isEdit && id}
|
||||||
|
formValues={formValues}
|
||||||
|
onValuesChange={handleValuesChange}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user