Files
manage/src/components/SectionList/index.jsx
2024-12-28 19:24:06 +08:00

663 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;