服务模版

This commit is contained in:
‘Liammcl’
2024-12-21 15:31:52 +08:00
parent aa5918aacf
commit 3812c9b7e9
7 changed files with 1678 additions and 26 deletions

View File

@@ -0,0 +1,609 @@
import React, { useState, useEffect, useMemo } from "react";
import {
Card,
Form,
Input,
InputNumber,
Button,
Space,
Typography,
message,
Select,
} from "antd";
import {
PlusOutlined,
DeleteOutlined,
ArrowLeftOutlined,
EditOutlined,
} from "@ant-design/icons";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { supabase } from "@/config/supabase";
const { Title, Text } = Typography;
const ServiceForm = () => {
const [form] = Form.useForm();
const navigate = useNavigate();
const { id } = useParams();
const [loading, setLoading] = useState(false);
const location = useLocation();
const isEdit = location.search.includes("edit=true");
const [editingSectionIndex, setEditingSectionIndex] = useState(null);
const [availableSections, setAvailableSections] = useState([]);
const [formValues, setFormValues] = useState({});
const [sectionNameForm] = Form.useForm();
useEffect(() => {
if (id) {
fetchServiceTemplate();
}
fetchAvailableSections();
}, [id]);
const fetchServiceTemplate = async () => {
try {
setLoading(true);
const { data, error } = await supabase
.from("resources")
.select("*")
.eq("id", id)
.single();
if (error) throw error;
form.setFieldsValue({
templateName: data.attributes.templateName,
description: data.attributes.description,
sections: data.attributes.sections,
});
} catch (error) {
console.error("获取服务模版失败:", error);
message.error("获取服务模版失败");
} finally {
setLoading(false);
}
};
const fetchAvailableSections = async () => {
try {
const { data: sections, error } = await supabase
.from("resources")
.select("*")
.eq("type", "serviceSection");
if (error) throw error;
setAvailableSections(sections);
} catch (error) {
console.error("获取服务小节失败:", error);
message.error("获取服务小节失败");
}
};
const onFinish = async (values) => {
try {
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 serviceData = {
type: "serviceTemplate",
attributes: {
templateName: values.templateName,
description: values.description,
sections: values.sections,
totalAmount,
},
};
let result;
if (id) {
result = await supabase
.from("resources")
.update(serviceData)
.eq("id", id)
.select();
} else {
result = await supabase
.from("resources")
.insert([serviceData])
.select();
}
if (result.error) throw result.error;
message.success("保存成功");
navigate("/company/serviceTeamplate");
} catch (error) {
console.error("保存失败:", error);
message.error("保存失败");
} finally {
setLoading(false);
}
};
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) => {
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('');
};
return (
<Card
title={
<span className="text-gray-800 dark:text-gray-200">
{id ? (isEdit ? "编辑服务模版" : "查看服务模版") : "新增服务模版"}
</span>
}
className="dark:bg-gray-800"
extra={
<Space>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/company/serviceTeamplate")}
className="flex items-center"
>
返回列表
</Button>
{id && !isEdit && (
<Button
type="primary"
onClick={() =>
navigate(`/company/serviceTemplateInfo/${id}?edit=true`)
}
className="flex items-center"
>
编辑
</Button>
)}
</Space>
}
>
<Form
form={form}
layout="vertical"
onFinish={onFinish}
initialValues={{
sections: [{ items: [{}] }],
currency: "CNY",
}}
disabled={id && !isEdit}
onValuesChange={handleValuesChange}
>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 mb-6">
<Title level={5} className="mb-4 dark:text-gray-200">
基本信息
</Title>
<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="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={[
{ value: "VI设计", label: "VI设计" },
{ value: "平面设计", label: "平面设计" },
{ value: "网站建设", label: "网站建设" },
{ value: "营销推广", label: "营销推广" },
]}
/>
</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-6 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>
{(!id || isEdit) && (
<Select
style={{ width: 200 }}
placeholder="选择已有小节"
onChange={handleAddExistingSection}
options={availableSections.map((section) => ({
value: section.id,
label: section.attributes.sectionName,
}))}
className="w-48"
/>
)}
</div>
<Form.List name="sections">
{(fields, { add: addSection, remove: removeSection }) => (
<>
<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
placeholder="请输入小节名称"
className="!w-[200px]"
value={editingSectionName}
onChange={e => setEditingSectionName(e.target.value)}
onPressEnter={handleSectionNameSave}
autoFocus
/>
<Space className="flex items-center gap-2">
<Button
type="primary"
size="small"
onClick={handleSectionNameSave}
>
保存
</Button>
<Button
size="small"
onClick={handleSectionNameCancel}
>
取消
</Button>
</Space>
</div>
) : (
<>
<Text
strong
className="text-lg dark:text-gray-200"
>
{form.getFieldValue([
"sections",
sectionIndex,
"sectionName",
]) || `服务类型 ${sectionIndex + 1}`}
</Text>
{(!id || isEdit) && (
<Button
type="text"
icon={<EditOutlined />}
onClick={() =>
handleSectionNameEdit(
sectionIndex,
form.getFieldValue([
"sections",
sectionIndex,
"sectionName",
])
)
}
size="small"
/>
)}
</>
)}
</div>
<Text className="text-gray-500 dark:text-gray-400">
合计:{" "}
{formatCurrency(
calculateSectionTotal(
formValues?.sections?.[sectionIndex]?.items
)
)}
</Text>
</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"
rules={[
{
required: true,
message: "请输入服务项目名称",
},
]}
>
<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>
<Form.Item
{...itemField}
name={[itemField.name, "unit"]}
className="!mb-0"
>
<Input placeholder="单位" />
</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">
<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 gap-4 justify-center">
<Button
type="dashed"
onClick={() => addSection({ items: [{}] })}
icon={<PlusOutlined />}
className="w-1/3 hover:border-blue-400 hover:text-blue-500 dark:border-gray-600 dark:text-gray-400 dark:hover:text-blue-400"
>
新建小节
</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>
)}
</Form>
</Card>
);
};
export default ServiceForm;