服务模版
This commit is contained in:
609
src/pages/company/service/detail/index.jsx
Normal file
609
src/pages/company/service/detail/index.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user