663 lines
25 KiB
JavaScript
663 lines
25 KiB
JavaScript
import React, { useState, useEffect, useMemo } 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";
|
||
import { supabaseService } from "@/hooks/supabaseService";
|
||
const { Text } = Typography;
|
||
import { defaultSymbol, formatExchangeRate } from "@/utils/exchange_rate";
|
||
const SectionList = ({
|
||
form,
|
||
isView,
|
||
formValues,
|
||
type,
|
||
currentCurrency = "TWD",
|
||
taxRate,
|
||
setTaxRate,
|
||
}) => {
|
||
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 fetchAvailableSections = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const { data: sections, error } = await supabase
|
||
.from("resources")
|
||
.select("*")
|
||
.eq("type", "sections")
|
||
.eq("attributes->>template_type", [type])
|
||
.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: units } = await supabaseService.select("resources", {
|
||
filter: {
|
||
type: { eq: "units" },
|
||
"attributes->>template_type": { in: `(${type},common)` },
|
||
},
|
||
order: {
|
||
column: "created_at",
|
||
ascending: false,
|
||
},
|
||
});
|
||
setUnits(units || []);
|
||
} 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,
|
||
template_type: type,
|
||
},
|
||
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">
|
||
{availableSections.length > 0 ? (
|
||
<div className="flex flex-col">
|
||
<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">
|
||
{formatExchangeRate(currentCurrency, 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">
|
||
{formatExchangeRate(
|
||
currentCurrency,
|
||
(section.attributes.items || []).reduce(
|
||
(sum, item) =>
|
||
sum + (item.price * (item.quantity || 1) || 0),
|
||
0
|
||
)
|
||
)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex justify-center">
|
||
<Button
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => handleCreateCustom(add, fieldsLength)}
|
||
className="bg-blue-600 hover:bg-blue-700 border-0 shadow-md hover:shadow-lg transition-all duration-200 h-10 px-6 rounded-lg flex items-center gap-2"
|
||
>
|
||
<span className="font-medium">自定义模块</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center py-16 px-4">
|
||
<div className="w-48 h-48 mb-8">
|
||
<svg
|
||
className="w-full h-full text-gray-200"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={1}
|
||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<h3 className="text-xl font-medium text-gray-900 mb-2">
|
||
暂无可用模板
|
||
</h3>
|
||
<p className="text-gray-500 text-center max-w-sm mb-8">
|
||
您可以选择创建一个自定义模块开始使用
|
||
</p>
|
||
<Button
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => handleCreateCustom(add, fieldsLength)}
|
||
size="large"
|
||
className="shadow-md hover:shadow-lg transition-shadow"
|
||
>
|
||
创建自定义模块
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const ItemSubtotal = React.memo(({ quantity, price, currentCurrency }) => {
|
||
const subtotal = useMemo(() => {
|
||
const safeQuantity = Number(quantity) || 0;
|
||
const safePrice = Number(price) || 0;
|
||
return safeQuantity * safePrice;
|
||
}, [quantity, price]);
|
||
|
||
return (
|
||
<div className="text-right">
|
||
<span className="text-gray-500">
|
||
{formatExchangeRate(currentCurrency, subtotal)}
|
||
</span>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
const SectionTotal = React.memo(({ items, currentCurrency }) => {
|
||
const total = useMemo(() => {
|
||
if (!Array.isArray(items)) return 0;
|
||
return items.reduce((sum, item) => {
|
||
if (!item) return sum;
|
||
const safeQuantity = Number(item.quantity) || 0;
|
||
const safePrice = Number(item.price) || 0;
|
||
return sum + safeQuantity * safePrice;
|
||
}, 0);
|
||
}, [items]);
|
||
|
||
return (
|
||
<div className="flex justify-end border-t pt-4">
|
||
<span className="text-gray-500">
|
||
小计总额:
|
||
<span className="text-blue-500 font-medium ml-2">
|
||
{formatExchangeRate(currentCurrency, total)}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
);
|
||
});
|
||
return (
|
||
<>
|
||
<Form.List name="sections">
|
||
{(fields, { add, remove }) => (
|
||
<>
|
||
<div className="space-y-4 overflow-auto">
|
||
{fields.map((field, sectionIndex) => (
|
||
<Card
|
||
key={field.key}
|
||
className="shadow-sm rounded-lg min-w-[1000px]"
|
||
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">
|
||
单价({defaultSymbol})
|
||
</div>
|
||
<div className="text-right">小计</div>
|
||
<div></div>
|
||
</div>
|
||
|
||
{itemFields.map((itemField, itemIndex) => {
|
||
const { key, ...restItemField } = itemField;
|
||
return (
|
||
<div
|
||
key={key}
|
||
className="grid grid-cols-[3fr_4fr_1fr_1fr_2fr_1fr_40px] gap-4 mb-4 items-start"
|
||
>
|
||
<Form.Item
|
||
{...restItemField}
|
||
name={[itemField.name, "name"]}
|
||
className="!mb-0"
|
||
>
|
||
<Input placeholder="服务项目名称" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
{...restItemField}
|
||
name={[itemField.name, "description"]}
|
||
className="!mb-0"
|
||
>
|
||
<Input placeholder="请输入描述/备注" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
{...restItemField}
|
||
name={[itemField.name, "unit"]}
|
||
className="!mb-0"
|
||
>
|
||
<Select
|
||
placeholder="选择单位"
|
||
loading={loadingUnits}
|
||
showSearch
|
||
allowClear
|
||
style={{ minWidth: "120px" }}
|
||
options={units.map((unit) => ({
|
||
label: unit.attributes.name,
|
||
value: unit.attributes.name,
|
||
}))}
|
||
onDropdownVisibleChange={(open) => {
|
||
if (open) fetchUnits();
|
||
}}
|
||
dropdownRender={(menu) => (
|
||
<>
|
||
{menu}
|
||
<Divider style={{ margin: "12px 0" }} />
|
||
<div style={{ padding: "4px" }}>
|
||
<Input.Search
|
||
placeholder="输入新单位名称"
|
||
enterButton={<PlusOutlined />}
|
||
onSearch={async (value) => {
|
||
if (!value.trim()) return;
|
||
if (
|
||
await handleAddUnit(value.trim())
|
||
) {
|
||
const currentItems =
|
||
form.getFieldValue([
|
||
"sections",
|
||
field.name,
|
||
"items",
|
||
]);
|
||
currentItems[
|
||
itemField.name
|
||
].unit = value.trim();
|
||
form.setFieldValue(
|
||
[
|
||
"sections",
|
||
field.name,
|
||
"items",
|
||
],
|
||
currentItems
|
||
);
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
{...restItemField}
|
||
name={[itemField.name, "quantity"]}
|
||
className="!mb-0"
|
||
>
|
||
<InputNumber
|
||
placeholder="数量"
|
||
min={0}
|
||
className="w-full"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
{...restItemField}
|
||
name={[itemField.name, "price"]}
|
||
className="!mb-0"
|
||
>
|
||
<InputNumber
|
||
placeholder="单价"
|
||
min={0}
|
||
className="w-full"
|
||
/>
|
||
</Form.Item>
|
||
<ItemSubtotal
|
||
quantity={
|
||
formValues?.sections?.[sectionIndex]?.items?.[
|
||
itemIndex
|
||
]?.quantity
|
||
}
|
||
price={
|
||
formValues?.sections?.[sectionIndex]?.items?.[
|
||
itemIndex
|
||
]?.price
|
||
}
|
||
currentCurrency={currentCurrency}
|
||
/>
|
||
{!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>
|
||
)}
|
||
|
||
<SectionTotal
|
||
items={formValues?.sections?.[sectionIndex]?.items}
|
||
currentCurrency={currentCurrency}
|
||
/>
|
||
</>
|
||
)}
|
||
</Form.List>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-center mt-6 flex-col ">
|
||
{!isView && (
|
||
<div className="w-full 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>
|
||
)}
|
||
|
||
<div className="flex items-end w-full flex-shrink-0 flex-col gap-1">
|
||
<span className="text-gray-500 flex items-center ">
|
||
税前总额:
|
||
<span className="text-blue-500 font-medium ml-2">
|
||
{formatExchangeRate(
|
||
currentCurrency,
|
||
formValues?.sections?.reduce((sum, section) => {
|
||
return (
|
||
sum +
|
||
section.items.reduce((sum, item) => {
|
||
return sum + item.price * item.quantity;
|
||
}, 0)
|
||
);
|
||
}, 0)
|
||
)}
|
||
</span>
|
||
</span>
|
||
|
||
<div className="text-gray-500 flex items-center">
|
||
税率:
|
||
<div className="text-blue-500 font-medium ml-2">
|
||
<InputNumber
|
||
suffix="%"
|
||
style={{ width: 120 }}
|
||
min={0}
|
||
max={100}
|
||
value={taxRate}
|
||
onChange={(value) => setTaxRate(value)}
|
||
disabled={isView}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<span className="text-gray-500 flex items-center">
|
||
税后总额:
|
||
<span className="text-blue-500 font-medium ml-2">
|
||
{formatExchangeRate(
|
||
currentCurrency,
|
||
formValues?.sections?.reduce((sum, section) => {
|
||
return (
|
||
sum +
|
||
section.items.reduce((sum, item) => {
|
||
return sum + item.price * item.quantity;
|
||
}, 0)
|
||
);
|
||
}, 0) *
|
||
(1 + taxRate / 100)
|
||
)}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</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;
|