feat:v1
This commit is contained in:
@@ -1,119 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Table } from 'antd';
|
|
||||||
|
|
||||||
const PrintView = ({ data }) => {
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: '序号',
|
|
||||||
dataIndex: 'index',
|
|
||||||
width: 60,
|
|
||||||
render: (_, __, index) => index + 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '名称',
|
|
||||||
dataIndex: 'productName',
|
|
||||||
width: '40%',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '数量',
|
|
||||||
dataIndex: 'quantity',
|
|
||||||
width: '20%',
|
|
||||||
align: 'right',
|
|
||||||
render: (value) => value?.toLocaleString('zh-CN', { minimumFractionDigits: 2 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '单价',
|
|
||||||
dataIndex: 'price',
|
|
||||||
width: '20%',
|
|
||||||
align: 'right',
|
|
||||||
render: (value) => value?.toLocaleString('zh-CN', { minimumFractionDigits: 2 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '小计',
|
|
||||||
width: '20%',
|
|
||||||
align: 'right',
|
|
||||||
render: (_, record) => (
|
|
||||||
(record.quantity * record.price)?.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="print-wrapper">
|
|
||||||
<div className="print-header">
|
|
||||||
<h1>{data.attributes.quataName}</h1>
|
|
||||||
<div className="quotation-id">报价单号:{data.id}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="info-grid">
|
|
||||||
<div className="info-item">
|
|
||||||
<span className="info-label">客户公司</span>
|
|
||||||
<span className="info-value">{data.attributes.companyName}</span>
|
|
||||||
</div>
|
|
||||||
<div className="info-item">
|
|
||||||
<span className="info-label">供应商</span>
|
|
||||||
<span className="info-value">{data.attributes.supplierName}</span>
|
|
||||||
</div>
|
|
||||||
<div className="info-item">
|
|
||||||
<span className="info-label">报价日期</span>
|
|
||||||
<span className="info-value">
|
|
||||||
{new Date(data.created_at).toLocaleDateString('zh-CN')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="info-item">
|
|
||||||
<span className="info-label">报价有效期</span>
|
|
||||||
<span className="info-value">
|
|
||||||
{new Date(Date.now() + 30*24*60*60*1000).toLocaleDateString('zh-CN')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="section-title">报价明细</div>
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data.attributes.items}
|
|
||||||
pagination={false}
|
|
||||||
rowKey={(record, index) => index}
|
|
||||||
bordered
|
|
||||||
summary={() => (
|
|
||||||
<Table.Summary fixed>
|
|
||||||
<Table.Summary.Row>
|
|
||||||
<Table.Summary.Cell index={0} colSpan={4} align="right">
|
|
||||||
总计({data.attributes.currency}):
|
|
||||||
</Table.Summary.Cell>
|
|
||||||
<Table.Summary.Cell index={1} align="right">
|
|
||||||
<span style={{ color: '#1890ff', fontSize: '16px' }}>
|
|
||||||
{data.attributes.totalAmount?.toLocaleString('zh-CN', {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
style: 'currency',
|
|
||||||
currency: data.attributes.currency
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</Table.Summary.Cell>
|
|
||||||
</Table.Summary.Row>
|
|
||||||
</Table.Summary>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.attributes.description && (
|
|
||||||
<div className="description-section">
|
|
||||||
<div className="section-title">补充说明</div>
|
|
||||||
<div>{data.attributes.description}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="notes-section">
|
|
||||||
<div className="notes-title">注意事项:</div>
|
|
||||||
<ul className="notes-list">
|
|
||||||
<li>本报价单有效期为30天</li>
|
|
||||||
<li>最终解释权归本公司所有</li>
|
|
||||||
<li>如有疑问请及时联系我们</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PrintView;
|
|
||||||
@@ -328,6 +328,49 @@ const SectionList = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
const addSectionFn = async (field) => {
|
||||||
|
try {
|
||||||
|
await form.validateFields(['sections', field.name]);
|
||||||
|
|
||||||
|
const allValues = form.getFieldsValue(true);
|
||||||
|
const currentSection = allValues.sections[field.name];
|
||||||
|
|
||||||
|
if (!currentSection) {
|
||||||
|
throw new Error('未找到section数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateData = {
|
||||||
|
type: 'sections',
|
||||||
|
attributes: {
|
||||||
|
name: currentSection.sectionName,
|
||||||
|
template_type: type,
|
||||||
|
items: currentSection.items.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
quantity: item.quantity,
|
||||||
|
price: item.price,
|
||||||
|
unit: item.unit
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
schema_version: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('准备保存的数据:', templateData);
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('resources')
|
||||||
|
.insert([templateData]);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
message.success('保存模板成功');
|
||||||
|
|
||||||
|
fetchAvailableSections();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
message.error('保存模板失败');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.List name="sections">
|
<Form.List name="sections">
|
||||||
@@ -400,12 +443,21 @@ const SectionList = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isView && (
|
{!isView && (
|
||||||
<Button
|
<div className="flex gap-3">
|
||||||
type="text"
|
<Button
|
||||||
danger
|
type="text"
|
||||||
icon={<DeleteOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => remove(field.name)}
|
onClick={() => addSectionFn(field)}
|
||||||
/>
|
>
|
||||||
|
添加到模版
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => remove(field.name)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,7 +289,48 @@ const SectionList = ({ form, isView, formValues, type }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
const addSectionFn = async (field) => {
|
||||||
|
try {
|
||||||
|
await form.validateFields(['sections', field.name]);
|
||||||
|
|
||||||
|
const allValues = form.getFieldsValue(true);
|
||||||
|
const currentSection = allValues.sections[field.name];
|
||||||
|
|
||||||
|
if (!currentSection) {
|
||||||
|
throw new Error('未找到section数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateData = {
|
||||||
|
type: 'sections',
|
||||||
|
attributes: {
|
||||||
|
name: currentSection.sectionName,
|
||||||
|
template_type: type,
|
||||||
|
items: currentSection.items.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
quantity: item.quantity,
|
||||||
|
price: item.price,
|
||||||
|
unit: item.unit
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
schema_version: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('resources')
|
||||||
|
.insert([templateData]);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
message.success('保存模板成功');
|
||||||
|
|
||||||
|
fetchAvailableSections();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
message.error('保存模板失败');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.List name="sections">
|
<Form.List name="sections">
|
||||||
@@ -362,12 +403,23 @@ const SectionList = ({ form, isView, formValues, type }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isView && (
|
{!isView && (
|
||||||
<Button
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => addSectionFn(field)}
|
||||||
|
className="ml-2"
|
||||||
|
>
|
||||||
|
添加到模版
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
danger
|
danger
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
onClick={() => remove(field.name)}
|
onClick={() => remove(field.name)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -516,13 +568,15 @@ const SectionList = ({ form, isView, formValues, type }) => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{!isView && itemFields.length > 1 && (
|
{!isView && itemFields.length > 1 && (
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
danger
|
danger
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
onClick={() => removeItem(itemField.name)}
|
onClick={() => removeItem(itemField.name)}
|
||||||
className="flex items-center justify-center"
|
className="flex items-center justify-center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ const TEMPLATE_TYPES = [
|
|||||||
icon: <FileTextOutlined className="text-2xl text-blue-500" />,
|
icon: <FileTextOutlined className="text-2xl text-blue-500" />,
|
||||||
description: '创建标准化的报价单模板,包含服务项目、价格等信息'
|
description: '创建标准化的报价单模板,包含服务项目、价格等信息'
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
key: 'project',
|
// key: 'project',
|
||||||
title: '专案模板',
|
// title: '专案模板',
|
||||||
icon: <ProjectOutlined className="text-2xl text-green-500" />,
|
// icon: <ProjectOutlined className="text-2xl text-green-500" />,
|
||||||
description: '创建专案流程模板,包含项目阶段、时间线等信息'
|
// description: '创建专案流程模板,包含项目阶段、时间线等信息'
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
key: 'task',
|
key: 'task',
|
||||||
title: '任务模板',
|
title: '任务模板',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div>测试:member可见,最低权限</div>
|
||||||
{/* <StatisticsOverview /> */}
|
{/* <StatisticsOverview /> */}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -105,10 +105,12 @@ const QuotationForm = () => {
|
|||||||
},
|
},
|
||||||
[currentCurrency]
|
[currentCurrency]
|
||||||
);
|
);
|
||||||
|
useEffect(()=>{
|
||||||
// 处理表单值变化
|
console.log(currentCurrency,'currency');
|
||||||
|
|
||||||
|
},[currentCurrency])
|
||||||
|
// 处理表单值变化
|
||||||
const handleValuesChange = (changedValues, allValues) => {
|
const handleValuesChange = (changedValues, allValues) => {
|
||||||
console.log("Form values changed:", allValues); // 调试用
|
|
||||||
setFormValues(allValues);
|
setFormValues(allValues);
|
||||||
if (changedValues.currency) {
|
if (changedValues.currency) {
|
||||||
setCurrentCurrency(changedValues.currency);
|
setCurrentCurrency(changedValues.currency);
|
||||||
@@ -153,7 +155,6 @@ const QuotationForm = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 修改获取详情的函数
|
|
||||||
const fetchQuotationDetail = async () => {
|
const fetchQuotationDetail = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -404,7 +405,7 @@ const QuotationForm = () => {
|
|||||||
const quotationData = {
|
const quotationData = {
|
||||||
quataName: template.attributes.templateName,
|
quataName: template.attributes.templateName,
|
||||||
description: template.attributes.description,
|
description: template.attributes.description,
|
||||||
currency: "CNY",
|
currency: template.attributes.currency || "CNY",
|
||||||
category: template.attributes.category,
|
category: template.attributes.category,
|
||||||
sections: template.attributes.sections.map((section) => ({
|
sections: template.attributes.sections.map((section) => ({
|
||||||
key: uuidv4(),
|
key: uuidv4(),
|
||||||
@@ -419,7 +420,7 @@ const QuotationForm = () => {
|
|||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
setCurrentCurrency(template.attributes.currency || "CNY");
|
||||||
form.setFieldsValue(quotationData);
|
form.setFieldsValue(quotationData);
|
||||||
setFormValues(quotationData);
|
setFormValues(quotationData);
|
||||||
}
|
}
|
||||||
@@ -518,68 +519,6 @@ const QuotationForm = () => {
|
|||||||
fetchCustomers();
|
fetchCustomers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderItemFields = (itemField, itemIndex, sectionIndex) => (
|
|
||||||
<div
|
|
||||||
key={`${sectionIndex}-${itemIndex}-${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, "productName"]}
|
|
||||||
className="!mb-0"
|
|
||||||
>
|
|
||||||
<Input placeholder="服务项目名称" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
{...itemField}
|
|
||||||
name={[itemField.name, "note"]}
|
|
||||||
className="!mb-0"
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入描述/备注" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
{...itemField}
|
|
||||||
name={[itemField.name, "unit"]}
|
|
||||||
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, "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>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 确保在组件加载时正确获取数据
|
// 确保在组件加载时正确获取数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -594,35 +533,7 @@ const QuotationForm = () => {
|
|||||||
}
|
}
|
||||||
}, [id, templateId]);
|
}, [id, templateId]);
|
||||||
|
|
||||||
// 处理小节名称编辑
|
|
||||||
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 (
|
return (
|
||||||
<div className="bg-gradient-to-b from-gray-50 to-white dark:from-gray-800 dark:to-gray-900/90 min-h-screen p-2">
|
<div className="bg-gradient-to-b from-gray-50 to-white dark:from-gray-800 dark:to-gray-900/90 min-h-screen p-2">
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ const QuotationPage = () => {
|
|||||||
.from("resources")
|
.from("resources")
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("type", "serviceTemplate")
|
.eq("type", "serviceTemplate")
|
||||||
|
.eq("attributes->>template_type",'quotation')
|
||||||
.order("created_at", { ascending: false });
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -309,13 +310,10 @@ const QuotationPage = () => {
|
|||||||
const copyItem = async (record) => {
|
const copyItem = async (record) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// 深拷贝原有数据的 attributes
|
|
||||||
const newAttributes = JSON.parse(JSON.stringify(record.attributes));
|
const newAttributes = JSON.parse(JSON.stringify(record.attributes));
|
||||||
|
|
||||||
// 修改报价单名称,添加"副本"标识
|
|
||||||
newAttributes.quataName = `${newAttributes.quataName} (副本)`;
|
newAttributes.quataName = `${newAttributes.quataName} (副本)`;
|
||||||
|
|
||||||
// 创建新的报价单记录
|
|
||||||
const { data, error } = await supabase.from("resources").insert([
|
const { data, error } = await supabase.from("resources").insert([
|
||||||
{
|
{
|
||||||
type: "quota",
|
type: "quota",
|
||||||
@@ -383,7 +381,11 @@ const QuotationPage = () => {
|
|||||||
setSelectedTemplateId(null);
|
setSelectedTemplateId(null);
|
||||||
}}
|
}}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="custom" onClick={() => handleConfirm()}>
|
<Button
|
||||||
|
key="custom"
|
||||||
|
onClick={() => handleConfirm()}
|
||||||
|
className=" gap-2 px-6 rounded-full hover:bg-gray-100"
|
||||||
|
>
|
||||||
<FileAddOutlined /> 自定义创建
|
<FileAddOutlined /> 自定义创建
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
@@ -391,12 +393,13 @@ const QuotationPage = () => {
|
|||||||
type="primary"
|
type="primary"
|
||||||
disabled={!selectedTemplateId}
|
disabled={!selectedTemplateId}
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
|
className="gap-2 px-6 rounded-full bg-blue-600 hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
<AppstoreOutlined /> 使用选中模板
|
<AppstoreOutlined /> 使用选中模板
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
width={900}
|
width={900}
|
||||||
className="template-modal dark:bg-gray-800"
|
className="template-modal"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center items-center h-[400px]">
|
<div className="flex justify-center items-center h-[400px]">
|
||||||
@@ -405,58 +408,62 @@ const QuotationPage = () => {
|
|||||||
) : templates.length === 0 ? (
|
) : templates.length === 0 ? (
|
||||||
<Empty description="暂无可用模板" />
|
<Empty description="暂无可用模板" />
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-[600px] overflow-y-auto px-2">
|
<div className="max-h-[600px] overflow-y-auto px-4">
|
||||||
{getTemplatesByCategory().map((group, groupIndex) => (
|
{getTemplatesByCategory().map((group, groupIndex) => (
|
||||||
<div key={groupIndex} className="mb-8 last:mb-2">
|
<div key={groupIndex} className="mb-10 last:mb-2">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
<h3 className="text-xl font-medium text-gray-900">
|
||||||
{group.name}
|
{group.name}
|
||||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
|
<span className="ml-2 text-sm text-gray-500">
|
||||||
({group.templates.length})
|
({group.templates.length})
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-6">
|
||||||
{group.templates.map((template) => (
|
{group.templates.map((template) => (
|
||||||
<div
|
<div
|
||||||
key={template.id}
|
key={template.id}
|
||||||
onClick={() => handleTemplateSelect(template.id)}
|
onClick={() => handleTemplateSelect(template.id)}
|
||||||
className={`
|
className={`
|
||||||
relative p-4 rounded-xl cursor-pointer transition-all duration-200
|
relative p-6 rounded-xl cursor-pointer transition-all duration-200
|
||||||
${
|
${
|
||||||
selectedTemplateId === template.id
|
selectedTemplateId === template.id
|
||||||
? "ring-2 ring-blue-500 bg-blue-50/40 dark:bg-blue-900/40"
|
? "ring-2 ring-blue-500 bg-blue-50/40"
|
||||||
: "hover:bg-gray-50 dark:hover:bg-gray-700/50 border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md"
|
: "hover:shadow-lg border border-gray-200 hover:border-blue-200"
|
||||||
}
|
}
|
||||||
dark:bg-gray-800
|
transform hover:-translate-y-1
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start gap-3 mb-3">
|
<div className="flex justify-between items-start gap-3 mb-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="text-base font-medium text-gray-900 dark:text-gray-100 truncate">
|
<h4 className="text-lg font-medium text-gray-900 truncate">
|
||||||
{template.attributes.templateName}
|
{template.attributes.templateName}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
|
<p className="text-sm text-gray-600 mt-2 line-clamp-2">
|
||||||
{template.attributes.description || "暂无描述"}
|
{template.attributes.description || "暂无描述"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-blue-600 dark:text-blue-400 font-medium whitespace-nowrap">
|
|
||||||
¥{template.attributes.totalAmount?.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{template.attributes.sections.map((section, index) => (
|
{template.attributes.sections.map((section, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="bg-white dark:bg-gray-700 rounded-lg p-2.5 text-sm border border-gray-100 dark:border-gray-600"
|
className={`
|
||||||
|
rounded-lg p-3 text-sm border
|
||||||
|
${
|
||||||
|
selectedTemplateId === template.id
|
||||||
|
? "bg-white border-blue-200"
|
||||||
|
: "bg-gray-50 border-gray-100"
|
||||||
|
}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="font-medium text-gray-700 dark:text-gray-200 truncate flex-1">
|
<span className="font-medium text-gray-700 truncate flex-1">
|
||||||
{section.sectionName}
|
{section.sectionName}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500 dark:text-gray-400 ml-2 text-xs">
|
<span className="text-gray-500 ml-2 text-xs px-2 py-0.5 bg-gray-100 rounded-full">
|
||||||
{section.items.length}项
|
{section.items.length}项
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -465,10 +472,10 @@ const QuotationPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedTemplateId === template.id && (
|
{selectedTemplateId === template.id && (
|
||||||
<div className="absolute top-3 right-3">
|
<div className="absolute top-4 right-4">
|
||||||
<div className="w-5 h-5 bg-blue-500 dark:bg-blue-600 rounded-full flex items-center justify-center">
|
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center shadow-md">
|
||||||
<svg
|
<svg
|
||||||
className="w-3 h-3 text-white"
|
className="w-4 h-4 text-white"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -143,13 +143,11 @@ const QuotationPreview = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div id="quotation-content" className="p-6">
|
<div id="quotation-content" className="p-6">
|
||||||
{/* 报价单标题 */}
|
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<Title level={2}>{attributes.quataName}</Title>
|
<Title level={2}>{attributes.quataName}</Title>
|
||||||
<Text type="secondary">创建日期:{new Date(quotation.created_at).toLocaleDateString()}</Text>
|
<Text type="secondary">创建日期:{new Date(quotation.created_at).toLocaleDateString()}</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 基本信息 */}
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg mb-6">
|
<div className="bg-gray-50 p-4 rounded-lg mb-6">
|
||||||
<Title level={4}>基本信息</Title>
|
<Title level={4}>基本信息</Title>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -168,12 +166,11 @@ const QuotationPreview = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 报价明细 */}
|
|
||||||
{attributes.sections?.map((section, sIndex) => (
|
{attributes.sections?.map((section, sIndex) => (
|
||||||
<div key={sIndex} className="mb-6">
|
<div key={sIndex} className="mb-6">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 h-full">
|
||||||
<div className="h-4 w-1 bg-blue-500 rounded-full" />
|
<div className="h-4 w-1 bg-blue-500 rounded-full" />
|
||||||
<Title level={4} className="!mb-0">{section.sectionName}</Title>
|
<h2>{section.sectionName}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ const TaskTemplate = ({ id, isView, onCancel,isEdit }) => {
|
|||||||
sections: [{ items: [{}] }],
|
sections: [{ items: [{}] }],
|
||||||
});
|
});
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
fetchServiceTemplate();
|
fetchServiceTemplate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
|
Select,
|
||||||
|
DatePicker,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -26,6 +28,9 @@ import {
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { supabaseService } from "@/hooks/supabaseService";
|
import { supabaseService } from "@/hooks/supabaseService";
|
||||||
import TemplateTypeModal from "@/components/TemplateTypeModal";
|
import TemplateTypeModal from "@/components/TemplateTypeModal";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const ServicePage = () => {
|
const ServicePage = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -34,9 +39,14 @@ const ServicePage = () => {
|
|||||||
const [editingKey, setEditingKey] = useState("");
|
const [editingKey, setEditingKey] = useState("");
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingItem, setEditingItem] = useState(null);
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
|
const [loadingUnits, setloadingUnits] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [units, setUnits] = useState([]);
|
||||||
// 模板类型配置
|
const unitsFilter = useMemo(() => (type) =>
|
||||||
|
units.filter(v =>
|
||||||
|
v.attributes.template_type === type || v.attributes.template_type === "common"
|
||||||
|
)
|
||||||
|
, [units]);
|
||||||
const TEMPLATE_TYPES = {
|
const TEMPLATE_TYPES = {
|
||||||
quotation: {
|
quotation: {
|
||||||
label: "报价单模板",
|
label: "报价单模板",
|
||||||
@@ -78,17 +88,38 @@ const ServicePage = () => {
|
|||||||
setData(services || []);
|
setData(services || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取服务模板失败:", error);
|
console.error("获取服务模板失败:", error);
|
||||||
message.error("获取服务模板失败");
|
message.error("获取服<EFBFBD><EFBFBD><EFBFBD>模板失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUnits();
|
||||||
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchServices();
|
fetchServices();
|
||||||
}, [selectedType]);
|
}, [selectedType]);
|
||||||
|
|
||||||
// 删除服务模板
|
const fetchUnits = async () => {
|
||||||
|
setloadingUnits(true);
|
||||||
|
try {
|
||||||
|
const { data: units } = await supabaseService.select("resources", {
|
||||||
|
filter: {
|
||||||
|
type: { eq: "units" },
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
column: "created_at",
|
||||||
|
ascending: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setUnits(units || []);
|
||||||
|
} catch (error) {
|
||||||
|
message.error("获取单位列表失败");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setloadingUnits(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const handleDeleteService = async (serviceId) => {
|
const handleDeleteService = async (serviceId) => {
|
||||||
try {
|
try {
|
||||||
await supabaseService.delete("resources", { id: serviceId });
|
await supabaseService.delete("resources", { id: serviceId });
|
||||||
@@ -224,301 +255,420 @@ const ServicePage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const table_warp=(record,section)=>{
|
const table_warp = (record, section) => {
|
||||||
|
switch (record.attributes.template_type) {
|
||||||
switch(record.attributes.template_type){
|
case "quotation":
|
||||||
case 'quotation':
|
return [
|
||||||
return [
|
{
|
||||||
{
|
title: "名称",
|
||||||
title: "名称",
|
dataIndex: "name",
|
||||||
dataIndex: "name",
|
key: "name",
|
||||||
key: "name",
|
width: "15%",
|
||||||
width: "15%",
|
render: (text, item, index) => {
|
||||||
render: (text, item, index) => {
|
const isEditing =
|
||||||
const isEditing = editingKey === `${record.id}-${section.key}-${index}`;
|
editingKey === `${record.id}-${section.key}-${index}`;
|
||||||
return isEditing ? (
|
return isEditing ? (
|
||||||
<Input
|
<Input
|
||||||
defaultValue={text}
|
defaultValue={text}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setEditingItem((prev) => ({
|
setEditingItem((prev) => ({
|
||||||
...prev || item,
|
...(prev || item),
|
||||||
name: e.target.value
|
name: e.target.value,
|
||||||
}));
|
}));
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{text}</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "状态",
|
|
||||||
dataIndex: "unit",
|
|
||||||
key: "unit",
|
|
||||||
width: "10%",
|
|
||||||
render: (text, item, index) => {
|
|
||||||
const isEditing = editingKey === `${record.id}-${section.key}-${index}`;
|
|
||||||
return isEditing ? (
|
|
||||||
<Input
|
|
||||||
defaultValue={text}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEditingItem((prev) => ({
|
|
||||||
...prev || item,
|
|
||||||
unit: e.target.value
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{text}</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "单价",
|
|
||||||
dataIndex: "price",
|
|
||||||
key: "price",
|
|
||||||
width: "10%",
|
|
||||||
render: (text, item, index) => {
|
|
||||||
const isEditing = editingKey === `${record.id}-${section.key}-${index}`;
|
|
||||||
return isEditing ? (
|
|
||||||
<InputNumber
|
|
||||||
defaultValue={text}
|
|
||||||
onChange={(value) => {
|
|
||||||
setEditingItem((prev) => ({
|
|
||||||
...prev || item,
|
|
||||||
price: value
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{text}</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "数量",
|
|
||||||
dataIndex: "quantity",
|
|
||||||
key: "quantity",
|
|
||||||
width: "10%",
|
|
||||||
render: (text, item, index) => {
|
|
||||||
const isEditing = editingKey === `${record.id}-${section.key}-${index}`;
|
|
||||||
return isEditing ? (
|
|
||||||
<InputNumber
|
|
||||||
defaultValue={text}
|
|
||||||
onChange={(value) => {
|
|
||||||
setEditingItem((prev) => ({
|
|
||||||
...prev || item,
|
|
||||||
quantity: value
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{text}</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "描述",
|
|
||||||
dataIndex: "description",
|
|
||||||
key: "description",
|
|
||||||
render: (text, item, index) => {
|
|
||||||
const isEditing = editingKey === `${record.id}-${section.key}-${index}`;
|
|
||||||
return isEditing ? (
|
|
||||||
<Input
|
|
||||||
defaultValue={text}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEditingItem((prev) => ({
|
|
||||||
...prev || item,
|
|
||||||
description: e.target.value
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{text}</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "操作",
|
|
||||||
key: "action",
|
|
||||||
width: 150,
|
|
||||||
render: (_, item, index) => {
|
|
||||||
const isEditing = editingKey === `${record.id}-${section.key}-${index}`;
|
|
||||||
return (
|
|
||||||
<Space>
|
|
||||||
{isEditing ? (
|
|
||||||
<>
|
|
||||||
<SaveOutlined
|
|
||||||
className="text-green-600 cursor-pointer"
|
|
||||||
onClick={() => handleSave(record, section.key, index)}
|
|
||||||
/>
|
|
||||||
<CloseOutlined
|
|
||||||
className="text-gray-600 cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingKey("");
|
|
||||||
setEditingItem(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<EditOutlined
|
|
||||||
className="text-blue-600 cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingKey(`${record.id}-${section.key}-${index}`);
|
|
||||||
setEditingItem(item); // 初始化编辑项
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Popconfirm
|
|
||||||
title="确定要删除这一项吗?"
|
|
||||||
onConfirm={() => handleDeleteItem(record, section.key, index)}
|
|
||||||
>
|
|
||||||
<DeleteOutlined className="text-red-500 cursor-pointer" />
|
|
||||||
</Popconfirm>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
case 'task':
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
title: "名称",
|
|
||||||
dataIndex: "name",
|
|
||||||
key: "name",
|
|
||||||
width: "15%",
|
|
||||||
render: (text, item, index) => {
|
|
||||||
const isEditing = editingKey === `${record.id}-${section.key}-${index}`;
|
|
||||||
return isEditing ? (
|
|
||||||
<Input
|
|
||||||
defaultValue={text}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEditingItem((prev) => ({
|
|
||||||
...prev || item,
|
|
||||||
name: e.target.value
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{text}</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "执行状态",
|
|
||||||
dataIndex: "unit",
|
|
||||||
key: "unit",
|
|
||||||
render: (text, item, index) => {
|
|
||||||
const isEditing = editingKey === `${record.id}-${section.key}-${index}`;
|
|
||||||
return isEditing ? (
|
|
||||||
<Input
|
|
||||||
defaultValue={text}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEditingItem((prev) => ({
|
|
||||||
...prev || item,
|
|
||||||
unit: e.target.value
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{text}</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
title: "开始时间",
|
|
||||||
dataIndex: "timeRange",
|
|
||||||
key: "startTime",
|
|
||||||
render: (timeRange) => timeRange?.[0] || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "结束时间",
|
|
||||||
dataIndex: "timeRange",
|
|
||||||
key: "endTime",
|
|
||||||
render: (timeRange) => timeRange?.[1] || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "描述",
|
|
||||||
dataIndex: "description",
|
|
||||||
key: "description",
|
|
||||||
render: (text, item, index) => {
|
|
||||||
const isEditing = editingKey === `${record.id}-${section.key}-${index}`;
|
|
||||||
return isEditing ? (
|
|
||||||
<Input
|
|
||||||
defaultValue={text}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEditingItem((prev) => ({
|
|
||||||
...prev || item,
|
|
||||||
description: e.target.value
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{text}</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "操作",
|
|
||||||
key: "action",
|
|
||||||
render: (_, item, index) => {
|
|
||||||
const isEditing = editingKey === `${record.id}-${section.key}-${index}`;
|
|
||||||
return (
|
|
||||||
<Space>
|
|
||||||
{isEditing ? (
|
|
||||||
<>
|
|
||||||
<SaveOutlined
|
|
||||||
className="text-green-600 cursor-pointer"
|
|
||||||
onClick={() => handleSave(record, section.key, index)}
|
|
||||||
/>
|
|
||||||
<CloseOutlined
|
|
||||||
className="text-gray-600 cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingKey("");
|
|
||||||
setEditingItem(null);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<span>{text}</span>
|
||||||
<>
|
);
|
||||||
{/* <EditOutlined
|
},
|
||||||
className="text-blue-600 cursor-pointer"
|
},
|
||||||
onClick={() => {
|
{
|
||||||
setEditingKey(`${record.id}-${section.key}-${index}`);
|
title: "单位",
|
||||||
setEditingItem(item); // 初始化编辑项
|
dataIndex: "unit",
|
||||||
|
key: "unit",
|
||||||
|
width: "10%",
|
||||||
|
render: (text, item, index) => {
|
||||||
|
const isEditing =
|
||||||
|
editingKey === `${record.id}-${section.key}-${index}`;
|
||||||
|
return isEditing ? (
|
||||||
|
<Select
|
||||||
|
defaultValue={text}
|
||||||
|
placeholder="选择单位"
|
||||||
|
loading={loadingUnits}
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
onChange={(value) => {
|
||||||
|
setEditingItem((prev) => ({
|
||||||
|
...(prev || item),
|
||||||
|
unit: value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
style={{ minWidth: "120px" }}
|
||||||
|
options={unitsFilter(record.attributes.template_type).map((unit) => ({
|
||||||
|
label: unit.attributes.name,
|
||||||
|
value: unit.attributes.name,
|
||||||
|
}))}
|
||||||
|
dropdownRender={(menu) => <>{menu}</>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
<span>{text}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "单价",
|
||||||
|
dataIndex: "price",
|
||||||
|
key: "price",
|
||||||
|
width: "10%",
|
||||||
|
render: (text, item, index) => {
|
||||||
|
const isEditing =
|
||||||
|
editingKey === `${record.id}-${section.key}-${index}`;
|
||||||
|
return isEditing ? (
|
||||||
|
<InputNumber
|
||||||
|
defaultValue={text}
|
||||||
|
onChange={(value) => {
|
||||||
|
setEditingItem((prev) => ({
|
||||||
|
...(prev || item),
|
||||||
|
price: value,
|
||||||
|
}));
|
||||||
}}
|
}}
|
||||||
/> */}
|
/>
|
||||||
<Popconfirm
|
) : (
|
||||||
title="确定要删除这一项吗?"
|
<span>{text}</span>
|
||||||
onConfirm={() => handleDeleteItem(record, section.key, index)}
|
);
|
||||||
>
|
},
|
||||||
<DeleteOutlined className="text-red-500 cursor-pointer" />
|
},
|
||||||
</Popconfirm>
|
{
|
||||||
</>
|
title: "数量",
|
||||||
)}
|
dataIndex: "quantity",
|
||||||
</Space>
|
key: "quantity",
|
||||||
);
|
width: "10%",
|
||||||
},
|
render: (text, item, index) => {
|
||||||
},
|
const isEditing =
|
||||||
]
|
editingKey === `${record.id}-${section.key}-${index}`;
|
||||||
case "project":
|
return isEditing ? (
|
||||||
default:[]
|
<InputNumber
|
||||||
}
|
defaultValue={text}
|
||||||
|
onChange={(value) => {
|
||||||
|
setEditingItem((prev) => ({
|
||||||
|
...(prev || item),
|
||||||
|
quantity: value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{text}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "描述",
|
||||||
|
dataIndex: "description",
|
||||||
|
key: "description",
|
||||||
|
render: (text, item, index) => {
|
||||||
|
const isEditing =
|
||||||
|
editingKey === `${record.id}-${section.key}-${index}`;
|
||||||
|
return isEditing ? (
|
||||||
|
<Input
|
||||||
|
defaultValue={text}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEditingItem((prev) => ({
|
||||||
|
...(prev || item),
|
||||||
|
description: e.target.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{text}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "action",
|
||||||
|
width: 150,
|
||||||
|
render: (_, item, index) => {
|
||||||
|
const isEditing =
|
||||||
|
editingKey === `${record.id}-${section.key}-${index}`;
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<SaveOutlined
|
||||||
|
className="text-green-600 cursor-pointer"
|
||||||
|
onClick={() => handleSave(record, section.key, index)}
|
||||||
|
/>
|
||||||
|
<CloseOutlined
|
||||||
|
className="text-gray-600 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingKey("");
|
||||||
|
setEditingItem(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<EditOutlined
|
||||||
|
className="text-blue-600 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingKey(`${record.id}-${section.key}-${index}`);
|
||||||
|
setEditingItem(item); // 初始化编辑项
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定要删除这一项吗?"
|
||||||
|
onConfirm={() =>
|
||||||
|
handleDeleteItem(record, section.key, index)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-red-500 cursor-pointer" />
|
||||||
|
</Popconfirm>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "task":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "名称",
|
||||||
|
dataIndex: "name",
|
||||||
|
key: "name",
|
||||||
|
width: "15%",
|
||||||
|
render: (text, item, index) => {
|
||||||
|
const isEditing =
|
||||||
|
editingKey === `${record.id}-${section.key}-${index}`;
|
||||||
|
return isEditing ? (
|
||||||
|
<Input
|
||||||
|
defaultValue={text}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEditingItem((prev) => ({
|
||||||
|
...(prev || item),
|
||||||
|
name: e.target.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{text}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "执行状态",
|
||||||
|
dataIndex: "unit",
|
||||||
|
key: "unit",
|
||||||
|
render: (text, item, index) => {
|
||||||
|
const isEditing =
|
||||||
|
editingKey === `${record.id}-${section.key}-${index}`;
|
||||||
|
return isEditing ? (
|
||||||
|
<Select
|
||||||
|
placeholder="选择执行状态"
|
||||||
|
loading={loadingUnits}
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
defaultValue={text}
|
||||||
|
onChange={(value) => {
|
||||||
|
setEditingItem((prev) => ({
|
||||||
|
...(prev || item),
|
||||||
|
unit: value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
style={{ minWidth: "120px" }}
|
||||||
|
options={unitsFilter(record.attributes.template_type).map((unit) => ({
|
||||||
|
label: unit.attributes.name,
|
||||||
|
value: unit.attributes.name,
|
||||||
|
}))}
|
||||||
|
dropdownRender={(menu) => <>{menu}</>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{text}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
}
|
title: "开始时间",
|
||||||
|
dataIndex: "timeRange",
|
||||||
|
key: "startTime",
|
||||||
|
width: "20%",
|
||||||
|
render: (timeRange, item, index) => {
|
||||||
|
const isEditing = editingKey === `${record.id}-${section.key}-${index}`;
|
||||||
|
return isEditing ? (
|
||||||
|
<DatePicker
|
||||||
|
showTime
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
placeholder="开始时间"
|
||||||
|
defaultValue={timeRange?.[0] ? dayjs(timeRange[0]) : null}
|
||||||
|
// 禁用晚于结束时间的日期
|
||||||
|
disabledDate={(current) => {
|
||||||
|
const endTime = editingItem?.timeRange?.[1];
|
||||||
|
return endTime ? current && current.isAfter(dayjs(endTime), 'day') : false;
|
||||||
|
}}
|
||||||
|
// 对于同一天,禁用晚于结束时间的时间点
|
||||||
|
disabledTime={(current) => {
|
||||||
|
const endTime = editingItem?.timeRange?.[1];
|
||||||
|
if (endTime && current && current.isSame(dayjs(endTime), 'day')) {
|
||||||
|
const endHour = dayjs(endTime).hour();
|
||||||
|
const endMinute = dayjs(endTime).minute();
|
||||||
|
return {
|
||||||
|
disabledHours: () => Array.from({ length: 24 - endHour - 1 }, (_, i) => i + endHour + 1),
|
||||||
|
disabledMinutes: (selectedHour) =>
|
||||||
|
selectedHour === endHour
|
||||||
|
? Array.from({ length: 60 - endMinute - 1 }, (_, i) => i + endMinute + 1)
|
||||||
|
: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}}
|
||||||
|
onChange={(date) => {
|
||||||
|
setEditingItem((prev) => ({
|
||||||
|
...prev || item,
|
||||||
|
timeRange: [
|
||||||
|
date ? date.format('YYYY-MM-DD HH:mm') : null,
|
||||||
|
prev?.timeRange?.[1] || null
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{timeRange?.[0] || '-'}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "结束时间",
|
||||||
|
dataIndex: "timeRange",
|
||||||
|
key: "endTime",
|
||||||
|
width: "20%",
|
||||||
|
render: (timeRange, item, index) => {
|
||||||
|
const isEditing = editingKey === `${record.id}-${section.key}-${index}`;
|
||||||
|
return isEditing ? (
|
||||||
|
<DatePicker
|
||||||
|
showTime
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
placeholder="结束时间"
|
||||||
|
defaultValue={timeRange?.[1] ? dayjs(timeRange[1]) : null}
|
||||||
|
// 禁用早于开始时间的日期
|
||||||
|
disabledDate={(current) => {
|
||||||
|
const startTime = editingItem?.timeRange?.[0];
|
||||||
|
return startTime ? current && current.isBefore(dayjs(startTime), 'day') : false;
|
||||||
|
}}
|
||||||
|
// 对于同一天,禁用早于开始时间的时间点
|
||||||
|
disabledTime={(current) => {
|
||||||
|
const startTime = editingItem?.timeRange?.[0];
|
||||||
|
if (startTime && current && current.isSame(dayjs(startTime), 'day')) {
|
||||||
|
const startHour = dayjs(startTime).hour();
|
||||||
|
const startMinute = dayjs(startTime).minute();
|
||||||
|
return {
|
||||||
|
disabledHours: () => Array.from({ length: startHour }, (_, i) => i),
|
||||||
|
disabledMinutes: (selectedHour) =>
|
||||||
|
selectedHour === startHour
|
||||||
|
? Array.from({ length: startMinute }, (_, i) => i)
|
||||||
|
: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}}
|
||||||
|
onChange={(date) => {
|
||||||
|
setEditingItem((prev) => ({
|
||||||
|
...prev || item,
|
||||||
|
timeRange: [
|
||||||
|
prev?.timeRange?.[0] || null,
|
||||||
|
date ? date.format('YYYY-MM-DD HH:mm') : null
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{timeRange?.[1] || '-'}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "描述",
|
||||||
|
dataIndex: "description",
|
||||||
|
key: "description",
|
||||||
|
render: (text, item, index) => {
|
||||||
|
const isEditing =
|
||||||
|
editingKey === `${record.id}-${section.key}-${index}`;
|
||||||
|
return isEditing ? (
|
||||||
|
<Input
|
||||||
|
defaultValue={text}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEditingItem((prev) => ({
|
||||||
|
...(prev || item),
|
||||||
|
description: e.target.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{text}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "action",
|
||||||
|
render: (_, item, index) => {
|
||||||
|
const isEditing =
|
||||||
|
editingKey === `${record.id}-${section.key}-${index}`;
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<SaveOutlined
|
||||||
|
className="text-green-600 cursor-pointer"
|
||||||
|
onClick={() => handleSave(record, section.key, index)}
|
||||||
|
/>
|
||||||
|
<CloseOutlined
|
||||||
|
className="text-gray-600 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingKey("");
|
||||||
|
setEditingItem(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<EditOutlined
|
||||||
|
className="text-blue-600 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingKey(`${record.id}-${section.key}-${index}`);
|
||||||
|
setEditingItem(item); // 初始化编辑项
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定要删除这一项吗?"
|
||||||
|
onConfirm={() =>
|
||||||
|
handleDeleteItem(record, section.key, index)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-red-500 cursor-pointer" />
|
||||||
|
</Popconfirm>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "project":
|
||||||
|
default:
|
||||||
|
[];
|
||||||
|
}
|
||||||
|
};
|
||||||
const expandedRowRender = (record) => {
|
const expandedRowRender = (record) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 dark:bg-gray-600 p-4 rounded-lg">
|
<div className="bg-gray-50 dark:bg-gray-600 p-4 rounded-lg">
|
||||||
{record.attributes.sections.map((section) => (
|
{record.attributes.sections.map((section) => (
|
||||||
<div key={section.key} className="mb-6 rounded-lg shadow-sm p-4">
|
<div key={section.key} className="mb-6 rounded-lg shadow-sm p-4">
|
||||||
<div className="flex items-center justify-between mb-3 border-b pb-2">
|
<div className="flex items-center justify-between mb-3 border-b pb-2">
|
||||||
<h3 className="text-lg font-medium text-gray-800">{section.sectionName}</h3>
|
<h3 className="text-lg font-medium text-gray-800">
|
||||||
|
{section.sectionName}
|
||||||
|
</h3>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确定要删除这个模块吗?"
|
title="确定要删除这个模块吗?"
|
||||||
onConfirm={() => handleDeleteSection(record, section.key)}
|
onConfirm={() => handleDeleteSection(record, section.key)}
|
||||||
@@ -527,12 +677,12 @@ case 'task':
|
|||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
<Table
|
<Table
|
||||||
scroll={{x:true}}
|
scroll={{ x: true }}
|
||||||
dataSource={section.items}
|
dataSource={section.items}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="small"
|
size="small"
|
||||||
className="nested-items-table border-t border-gray-100"
|
className="nested-items-table border-t border-gray-100"
|
||||||
columns={table_warp(record,section)}
|
columns={table_warp(record, section)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -708,7 +858,7 @@ case 'task':
|
|||||||
<Card className="shadow-lg rounded-lg">
|
<Card className="shadow-lg rounded-lg">
|
||||||
<TableHeader />
|
<TableHeader />
|
||||||
<Table
|
<Table
|
||||||
scroll={{x:true}}
|
scroll={{ x: true }}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -1,465 +1,17 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React from "react";
|
||||||
import {
|
import QuataSections from "./quotation";
|
||||||
Table,
|
import TaskSections from "./task";
|
||||||
Button,
|
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
Space,
|
|
||||||
message,
|
|
||||||
Popconfirm,
|
|
||||||
Select,
|
|
||||||
Divider,
|
|
||||||
InputNumber,
|
|
||||||
Card,
|
|
||||||
Typography
|
|
||||||
} from 'antd';
|
|
||||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
|
||||||
import { supabaseService } from '@/hooks/supabaseService';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
export default function SectionComponent({ activeType }) {
|
||||||
|
const renderFn = (type) => {
|
||||||
const SectionsManagement = ({ activeType = 'quotation' }) => {
|
switch (type) {
|
||||||
const [data, setData] = useState([]);
|
case "quotation":
|
||||||
const [loading, setLoading] = useState(false);
|
return <QuataSections />;
|
||||||
const [editingKey, setEditingKey] = useState('');
|
case "task":
|
||||||
const [form] = Form.useForm();
|
return <TaskSections />;
|
||||||
const [loadingUnits,setLoadingUnits]=useState(false)
|
default:
|
||||||
const [units,setUnit]=useState([])
|
return <div></div>;
|
||||||
const [formValues, setFormValues] = useState({});
|
|
||||||
const fetchSections = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
|
|
||||||
const { data: sections } = await supabaseService.select('resources', {
|
|
||||||
filter: {
|
|
||||||
'type': { eq: 'sections' },
|
|
||||||
'attributes->>template_type': {eq:activeType}
|
|
||||||
},
|
|
||||||
order: {
|
|
||||||
column: 'created_at',
|
|
||||||
ascending: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setData(sections || []);
|
|
||||||
} catch (error) {
|
|
||||||
message.error('获取模块数据失败');
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const fetchUnits = async () => {
|
return <>{renderFn(activeType)}</>;
|
||||||
setLoadingUnits(true);
|
}
|
||||||
try {
|
|
||||||
const { data: units } = await supabaseService.select("resources", {
|
|
||||||
filter: {
|
|
||||||
type: { eq: "units" },
|
|
||||||
"attributes->>template_type": { in: `(${activeType},common)` },
|
|
||||||
},
|
|
||||||
order: {
|
|
||||||
column: "created_at",
|
|
||||||
ascending: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setUnit(units || []);
|
|
||||||
} catch (error) {
|
|
||||||
message.error("获取单位列表失败");
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setLoadingUnits(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
useEffect(() => {
|
|
||||||
fetchSections();
|
|
||||||
|
|
||||||
}, [activeType]);
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
const newData = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
attributes: {
|
|
||||||
name: '',
|
|
||||||
template_type: activeType,
|
|
||||||
items: [{
|
|
||||||
key: uuidv4(),
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
quantity: 1,
|
|
||||||
price: 0,
|
|
||||||
unit: ''
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
isNew: true
|
|
||||||
};
|
|
||||||
setData([newData, ...data]);
|
|
||||||
setEditingKey(newData.id);
|
|
||||||
form.setFieldsValue(newData.attributes);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (record) => {
|
|
||||||
try {
|
|
||||||
const values = await form.validateFields();
|
|
||||||
const items = form.getFieldValue(['items']) || [];
|
|
||||||
|
|
||||||
// 验证items数组
|
|
||||||
if (!items.length || !items.some(item => item.name)) {
|
|
||||||
message.error('请至少添加一个有效的服务项目');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (record.isNew) {
|
|
||||||
await supabaseService.insert('resources', {
|
|
||||||
type: 'sections',
|
|
||||||
attributes: {
|
|
||||||
name: values.name,
|
|
||||||
template_type: activeType,
|
|
||||||
items: items.filter(item => item.name), // 只保存有名称的项目
|
|
||||||
},
|
|
||||||
schema_version: 1
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await supabaseService.update('resources',
|
|
||||||
{ id: record.id },
|
|
||||||
{
|
|
||||||
attributes: {
|
|
||||||
name: values.name,
|
|
||||||
template_type: activeType,
|
|
||||||
items: items.filter(item => item.name),
|
|
||||||
},
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
message.success('保存成功');
|
|
||||||
setEditingKey('');
|
|
||||||
fetchSections();
|
|
||||||
} catch (error) {
|
|
||||||
message.error('保存失败');
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (record) => {
|
|
||||||
try {
|
|
||||||
await supabaseService.delete('resources', { id: record.id });
|
|
||||||
message.success('删除成功');
|
|
||||||
fetchSections();
|
|
||||||
} catch (error) {
|
|
||||||
message.error('删除失败');
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleAddUnit = async (unitName) => {
|
|
||||||
try {
|
|
||||||
const { error } = await supabase.from("resources").insert([
|
|
||||||
{
|
|
||||||
type: "units",
|
|
||||||
attributes: {
|
|
||||||
name: unitName,
|
|
||||||
template_type: activeType,
|
|
||||||
},
|
|
||||||
schema_version: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
message.success("新增单位成功");
|
|
||||||
fetchUnits();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
message.error("新增单位失败");
|
|
||||||
console.error(error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleValuesChange = (changedValues, allValues) => {
|
|
||||||
setFormValues(allValues);
|
|
||||||
};
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: '模块名称',
|
|
||||||
dataIndex: ['attributes', 'name'],
|
|
||||||
width: 200,
|
|
||||||
render: (text, record) => {
|
|
||||||
const isEditing = record.id === editingKey;
|
|
||||||
return isEditing ? (
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
rules={[{ required: true, message: '请输入模块名称!' }]}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="请输入模块名称"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
) : (
|
|
||||||
<span className=" font-medium">{text}</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '服务项目',
|
|
||||||
dataIndex: ['attributes', 'items'],
|
|
||||||
render: (items, record) => {
|
|
||||||
const isEditing = record.id === editingKey;
|
|
||||||
if (isEditing) {
|
|
||||||
return (
|
|
||||||
<Form.List name="items">
|
|
||||||
{(fields, { add, remove }) => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{fields.map((field, index) => {
|
|
||||||
const items = formValues.items || [];
|
|
||||||
const currentItem = items[field.name] || {};
|
|
||||||
const subtotal = (currentItem.quantity || 0) * (currentItem.price || 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={field.key} size="small" className="bg-gray-50 dark:bg-gray-600 ">
|
|
||||||
<div className="grid grid-cols-6 gap-2">
|
|
||||||
<Form.Item
|
|
||||||
{...field}
|
|
||||||
name={[field.name, 'name']}
|
|
||||||
className="col-span-2 mb-0"
|
|
||||||
>
|
|
||||||
<Input placeholder="项目名称" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
{...field}
|
|
||||||
name={[field.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
|
|
||||||
{...field}
|
|
||||||
name={[field.name, 'quantity']}
|
|
||||||
className="mb-0"
|
|
||||||
>
|
|
||||||
<InputNumber
|
|
||||||
placeholder="数量"
|
|
||||||
min={0}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
{...field}
|
|
||||||
name={[field.name, 'price']}
|
|
||||||
className="mb-0"
|
|
||||||
>
|
|
||||||
<InputNumber
|
|
||||||
placeholder="单价 (NT$)"
|
|
||||||
min={0}
|
|
||||||
className="w-full"
|
|
||||||
prefix="NT$"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
onClick={() => remove(field.name)}
|
|
||||||
className="flex items-center justify-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Text type="secondary">小计: NT${subtotal.toLocaleString()}</Text>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() => add({
|
|
||||||
key: uuidv4(),
|
|
||||||
name: '',
|
|
||||||
unit: '',
|
|
||||||
quantity: 1,
|
|
||||||
price: 0
|
|
||||||
})}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<PlusOutlined /> 添加服务项目
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Form.List>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算总金额
|
|
||||||
const total = (items || []).reduce((sum, item) => {
|
|
||||||
return sum + (item.quantity * item.price || 0);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{(items || []).map((item, index) => (
|
|
||||||
<div key={index} className="flex justify-start text-sm">
|
|
||||||
<span >{item.name}</span>
|
|
||||||
<span className=" ml-2">
|
|
||||||
{item.quantity} {item.unit} × NT${item.price.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Divider className="my-2" />
|
|
||||||
<Text strong>总金额: NT${total.toLocaleString()}</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
width: 160,
|
|
||||||
fixed: 'right',
|
|
||||||
render: (_, record) => {
|
|
||||||
const isEditing = record.id === editingKey;
|
|
||||||
return isEditing ? (
|
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
className="text-green-600 hover:text-green-500 font-medium"
|
|
||||||
onClick={() => handleSave(record)}
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
className="text-gray-600 hover:text-gray-500 font-medium"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingKey('');
|
|
||||||
if (record.isNew) {
|
|
||||||
setData(data.filter(item => item.id !== record.id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
) : (
|
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
className="text-blue-600 hover:text-blue-500 font-medium"
|
|
||||||
disabled={editingKey !== ''}
|
|
||||||
onClick={() => {
|
|
||||||
setEditingKey(record.id);
|
|
||||||
form.setFieldsValue({
|
|
||||||
name: record.attributes.name,
|
|
||||||
items: record.attributes.items
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Popconfirm
|
|
||||||
title="确认删除"
|
|
||||||
description="确定要删除这个模块吗?"
|
|
||||||
onConfirm={() => handleDelete(record)}
|
|
||||||
okText="确定"
|
|
||||||
cancelText="取消"
|
|
||||||
okButtonProps={{
|
|
||||||
className: "bg-red-500 hover:bg-red-600 border-red-500"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
className="text-red-600 hover:text-red-500 font-medium"
|
|
||||||
disabled={editingKey !== ''}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 ">
|
|
||||||
<div className=" rounded-lg shadow-sm mb-6 p-4">
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
onClick={handleAdd}
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 border-0 shadow-sm h-10"
|
|
||||||
>
|
|
||||||
新增模块
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className=" rounded-lg shadow-sm">
|
|
||||||
<Form
|
|
||||||
form={form}
|
|
||||||
onValuesChange={handleValuesChange}
|
|
||||||
>
|
|
||||||
<Table
|
|
||||||
scroll={{ x: 1200 }}
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data}
|
|
||||||
rowKey="id"
|
|
||||||
loading={loading}
|
|
||||||
pagination={{
|
|
||||||
pageSize: 10,
|
|
||||||
showTotal: (total) => `共 ${total} 条`,
|
|
||||||
className: "px-4"
|
|
||||||
}}
|
|
||||||
className="rounded-lg"
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SectionsManagement;
|
|
||||||
465
src/pages/company/service/itemsManange/sections/quotation.jsx
Normal file
465
src/pages/company/service/itemsManange/sections/quotation.jsx
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Space,
|
||||||
|
message,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Divider,
|
||||||
|
InputNumber,
|
||||||
|
Card,
|
||||||
|
Typography
|
||||||
|
} from 'antd';
|
||||||
|
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { supabaseService } from '@/hooks/supabaseService';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const TYPE = 'quotation'
|
||||||
|
const SectionsManagement = () => {
|
||||||
|
const [data, setData] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editingKey, setEditingKey] = useState('');
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loadingUnits,setLoadingUnits]=useState(false)
|
||||||
|
const [units,setUnit]=useState([])
|
||||||
|
const [formValues, setFormValues] = useState({});
|
||||||
|
const fetchSections = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
|
||||||
|
const { data: sections } = await supabaseService.select('resources', {
|
||||||
|
filter: {
|
||||||
|
'type': { eq: 'sections' },
|
||||||
|
'attributes->>template_type': {eq:TYPE}
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
column: 'created_at',
|
||||||
|
ascending: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setData(sections || []);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取模块数据失败');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setUnit(units || []);
|
||||||
|
} catch (error) {
|
||||||
|
message.error("获取单位列表失败");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoadingUnits(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSections();
|
||||||
|
|
||||||
|
}, [TYPE]);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const newData = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
attributes: {
|
||||||
|
name: '',
|
||||||
|
template_type: TYPE,
|
||||||
|
items: [{
|
||||||
|
key: uuidv4(),
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
quantity: 1,
|
||||||
|
price: 0,
|
||||||
|
unit: ''
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
isNew: true
|
||||||
|
};
|
||||||
|
setData([newData, ...data]);
|
||||||
|
setEditingKey(newData.id);
|
||||||
|
form.setFieldsValue(newData.attributes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (record) => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const items = form.getFieldValue(['items']) || [];
|
||||||
|
|
||||||
|
// 验证items数组
|
||||||
|
if (!items.length || !items.some(item => item.name)) {
|
||||||
|
message.error('请至少添加一个有效的服务项目');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.isNew) {
|
||||||
|
await supabaseService.insert('resources', {
|
||||||
|
type: 'sections',
|
||||||
|
attributes: {
|
||||||
|
name: values.name,
|
||||||
|
template_type: TYPE,
|
||||||
|
items: items.filter(item => item.name), // 只保存有名称的项目
|
||||||
|
},
|
||||||
|
schema_version: 1
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await supabaseService.update('resources',
|
||||||
|
{ id: record.id },
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
name: values.name,
|
||||||
|
template_type: TYPE,
|
||||||
|
items: items.filter(item => item.name),
|
||||||
|
},
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success('保存成功');
|
||||||
|
setEditingKey('');
|
||||||
|
fetchSections();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('保存失败');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (record) => {
|
||||||
|
try {
|
||||||
|
await supabaseService.delete('resources', { id: record.id });
|
||||||
|
message.success('删除成功');
|
||||||
|
fetchSections();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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 handleValuesChange = (changedValues, allValues) => {
|
||||||
|
setFormValues(allValues);
|
||||||
|
};
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '模块名称',
|
||||||
|
dataIndex: ['attributes', 'name'],
|
||||||
|
width: 200,
|
||||||
|
render: (text, record) => {
|
||||||
|
const isEditing = record.id === editingKey;
|
||||||
|
return isEditing ? (
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
rules={[{ required: true, message: '请输入模块名称!' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入模块名称"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<span className=" font-medium">{text}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '服务项目',
|
||||||
|
dataIndex: ['attributes', 'items'],
|
||||||
|
render: (items, record) => {
|
||||||
|
const isEditing = record.id === editingKey;
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<Form.List name="items">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{fields.map((field, index) => {
|
||||||
|
const items = formValues.items || [];
|
||||||
|
const currentItem = items[field.name] || {};
|
||||||
|
const subtotal = (currentItem.quantity || 0) * (currentItem.price || 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={field.key} size="small" className="bg-gray-50 dark:bg-gray-600 ">
|
||||||
|
<div className="grid grid-cols-6 gap-2">
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'name']}
|
||||||
|
className="col-span-2 mb-0"
|
||||||
|
>
|
||||||
|
<Input placeholder="项目名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.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
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'quantity']}
|
||||||
|
className="mb-0"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
placeholder="数量"
|
||||||
|
min={0}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'price']}
|
||||||
|
className="mb-0"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
placeholder="单价 (NT$)"
|
||||||
|
min={0}
|
||||||
|
className="w-full"
|
||||||
|
prefix="NT$"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => remove(field.name)}
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Text type="secondary">小计: NT${subtotal.toLocaleString()}</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={() => add({
|
||||||
|
key: uuidv4(),
|
||||||
|
name: '',
|
||||||
|
unit: '',
|
||||||
|
quantity: 1,
|
||||||
|
price: 0
|
||||||
|
})}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<PlusOutlined /> 添加服务项目
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算总金额
|
||||||
|
const total = (items || []).reduce((sum, item) => {
|
||||||
|
return sum + (item.quantity * item.price || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{(items || []).map((item, index) => (
|
||||||
|
<div key={index} className="flex justify-start text-sm">
|
||||||
|
<span >{item.name}</span>
|
||||||
|
<span className=" ml-2">
|
||||||
|
{item.quantity} {item.unit} × NT${item.price.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Divider className="my-2" />
|
||||||
|
<Text strong>总金额: NT${total.toLocaleString()}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 160,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, record) => {
|
||||||
|
const isEditing = record.id === editingKey;
|
||||||
|
return isEditing ? (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
className="text-green-600 hover:text-green-500 font-medium"
|
||||||
|
onClick={() => handleSave(record)}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
className="text-gray-600 hover:text-gray-500 font-medium"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingKey('');
|
||||||
|
if (record.isNew) {
|
||||||
|
setData(data.filter(item => item.id !== record.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
className="text-blue-600 hover:text-blue-500 font-medium"
|
||||||
|
disabled={editingKey !== ''}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingKey(record.id);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: record.attributes.name,
|
||||||
|
items: record.attributes.items
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除"
|
||||||
|
description="确定要删除这个模块吗?"
|
||||||
|
onConfirm={() => handleDelete(record)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{
|
||||||
|
className: "bg-red-500 hover:bg-red-600 border-red-500"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
className="text-red-600 hover:text-red-500 font-medium"
|
||||||
|
disabled={editingKey !== ''}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 ">
|
||||||
|
<div className=" rounded-lg shadow-sm mb-6 p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleAdd}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 border-0 shadow-sm h-10"
|
||||||
|
>
|
||||||
|
新增模块
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className=" rounded-lg shadow-sm">
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onValuesChange={handleValuesChange}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 10,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
className: "px-4"
|
||||||
|
}}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SectionsManagement;
|
||||||
455
src/pages/company/service/itemsManange/sections/task.jsx
Normal file
455
src/pages/company/service/itemsManange/sections/task.jsx
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Space,
|
||||||
|
message,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Divider,
|
||||||
|
DatePicker,
|
||||||
|
Card,
|
||||||
|
Typography
|
||||||
|
} from 'antd';
|
||||||
|
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { supabaseService } from '@/hooks/supabaseService';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const TYPE = 'task';
|
||||||
|
|
||||||
|
const TaskSections = () => {
|
||||||
|
const [data, setData] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editingKey, setEditingKey] = useState('');
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loadingUnits, setLoadingUnits] = useState(false);
|
||||||
|
const [units, setUnit] = useState([]);
|
||||||
|
const [formValues, setFormValues] = useState({});
|
||||||
|
|
||||||
|
const fetchSections = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data: sections } = await supabaseService.select('resources', {
|
||||||
|
filter: {
|
||||||
|
'type': { eq: 'sections' },
|
||||||
|
'attributes->>template_type': { eq: TYPE }
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
column: 'created_at',
|
||||||
|
ascending: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setData(sections || []);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取模块数据失败');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setUnit(units || []);
|
||||||
|
} catch (error) {
|
||||||
|
message.error("获取状态列表失败");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoadingUnits(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSections();
|
||||||
|
fetchUnits();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const newData = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
attributes: {
|
||||||
|
name: '',
|
||||||
|
template_type: TYPE,
|
||||||
|
items: [{
|
||||||
|
key: uuidv4(),
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
timeRange: null,
|
||||||
|
unit: ''
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
isNew: true
|
||||||
|
};
|
||||||
|
setData([newData, ...data]);
|
||||||
|
setEditingKey(newData.id);
|
||||||
|
form.setFieldsValue(newData.attributes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (record) => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const items = form.getFieldValue(['items']) || [];
|
||||||
|
|
||||||
|
if (!items.length || !items.some(item => item.name)) {
|
||||||
|
message.error('请至少添加一个有效的任务项目');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedItems = items.filter(item => item.name).map(item => ({
|
||||||
|
...item,
|
||||||
|
timeRange: item.timeRange ? [
|
||||||
|
dayjs(item.timeRange[0]).format('YYYY-MM-DD HH:mm'),
|
||||||
|
dayjs(item.timeRange[1]).format('YYYY-MM-DD HH:mm')
|
||||||
|
] : null
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (record.isNew) {
|
||||||
|
await supabaseService.insert('resources', {
|
||||||
|
type: 'sections',
|
||||||
|
attributes: {
|
||||||
|
name: values.name,
|
||||||
|
template_type: TYPE,
|
||||||
|
items: formattedItems,
|
||||||
|
},
|
||||||
|
schema_version: 1
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await supabaseService.update('resources',
|
||||||
|
{ id: record.id },
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
name: values.name,
|
||||||
|
template_type: TYPE,
|
||||||
|
items: formattedItems,
|
||||||
|
},
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success('保存成功');
|
||||||
|
setEditingKey('');
|
||||||
|
fetchSections();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('保存失败');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (record) => {
|
||||||
|
try {
|
||||||
|
await supabaseService.delete('resources', { id: record.id });
|
||||||
|
message.success('删除成功');
|
||||||
|
fetchSections();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddUnit = async (unitName) => {
|
||||||
|
try {
|
||||||
|
await supabaseService.insert("resources", {
|
||||||
|
type: "units",
|
||||||
|
attributes: {
|
||||||
|
name: unitName,
|
||||||
|
template_type: TYPE,
|
||||||
|
},
|
||||||
|
schema_version: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success("新增状态成功");
|
||||||
|
fetchUnits();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
message.error("新增状态失败");
|
||||||
|
console.error(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValuesChange = (changedValues, allValues) => {
|
||||||
|
setFormValues(allValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '模块名称',
|
||||||
|
dataIndex: ['attributes', 'name'],
|
||||||
|
width: 200,
|
||||||
|
render: (text, record) => {
|
||||||
|
const isEditing = record.id === editingKey;
|
||||||
|
return isEditing ? (
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
rules={[{ required: true, message: '请输入模块名称!' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入模块名称" />
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">{text}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '任务项目',
|
||||||
|
dataIndex: ['attributes', 'items'],
|
||||||
|
render: (items, record) => {
|
||||||
|
const isEditing = record.id === editingKey;
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<Form.List name="items">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<Card key={field.key} size="small" className="bg-gray-50">
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'name']}
|
||||||
|
rules={[{ required: true, message: '请输入任务名称' }]}
|
||||||
|
className="mb-0"
|
||||||
|
>
|
||||||
|
<Input placeholder="任务名称" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'description']}
|
||||||
|
className="mb-0"
|
||||||
|
>
|
||||||
|
<Input placeholder="描述/备注" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'timeRange']}
|
||||||
|
className="mb-0"
|
||||||
|
>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
showTime
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
placeholder={['开始时间', '结束时间']}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'unit']}
|
||||||
|
className="mb-0 flex-1"
|
||||||
|
>
|
||||||
|
<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' }} />
|
||||||
|
<div style={{ padding: '4px 8px' }}>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="输入新状态"
|
||||||
|
enterButton={<PlusOutlined />}
|
||||||
|
onSearch={async (value) => {
|
||||||
|
if (!value.trim()) return;
|
||||||
|
if (await handleAddUnit(value.trim())) {
|
||||||
|
const currentItems = form.getFieldValue('items');
|
||||||
|
currentItems[index].unit = value.trim();
|
||||||
|
form.setFieldValue('items', currentItems);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => remove(field.name)}
|
||||||
|
disabled={fields.length === 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={() => add({
|
||||||
|
key: uuidv4(),
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
timeRange: null,
|
||||||
|
unit: ''
|
||||||
|
})}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
添加任务项目
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(items || []).map((item, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="font-medium">{item.name}</span>
|
||||||
|
{item.description && (
|
||||||
|
<span className="text-gray-500">({item.description})</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{item.timeRange ?
|
||||||
|
`${dayjs(item.timeRange[0]).format('YYYY-MM-DD HH:mm')} ~ ${dayjs(item.timeRange[1]).format('YYYY-MM-DD HH:mm')}`
|
||||||
|
: '未设置时间'}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">{item.unit || '未设置状态'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 160,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, record) => {
|
||||||
|
const isEditing = record.id === editingKey;
|
||||||
|
return isEditing ? (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
className="text-green-600 hover:text-green-500 font-medium"
|
||||||
|
onClick={() => handleSave(record)}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
className="text-gray-600 hover:text-gray-500 font-medium"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingKey('');
|
||||||
|
if (record.isNew) {
|
||||||
|
setData(data.filter(item => item.id !== record.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
className="text-blue-600 hover:text-blue-500 font-medium"
|
||||||
|
disabled={editingKey !== ''}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingKey(record.id);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: record.attributes.name,
|
||||||
|
items: record.attributes.items
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除"
|
||||||
|
description="确定要删除这个模块吗?"
|
||||||
|
onConfirm={() => handleDelete(record)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{
|
||||||
|
className: "bg-red-500 hover:bg-red-600 border-red-500"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
className="text-red-600 hover:text-red-500 font-medium"
|
||||||
|
disabled={editingKey !== ''}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="rounded-lg shadow-sm mb-6 p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleAdd}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 border-0 shadow-sm h-10"
|
||||||
|
>
|
||||||
|
新增模块
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg shadow-sm">
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onValuesChange={handleValuesChange}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 10,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
className: "px-4"
|
||||||
|
}}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskSections;
|
||||||
420
src/pages/company/task/detail/index.jsx
Normal file
420
src/pages/company/task/detail/index.jsx
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Card,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
DatePicker,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { supabase } from "@/config/supabase";
|
||||||
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import TaskList from '@/components/TaskList';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import {supabaseService} from '@/hooks/supabaseService'
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
const TYPE = "task";
|
||||||
|
|
||||||
|
export default function TaskForm() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const isEdit = searchParams.get("edit") === "true";
|
||||||
|
const templateId = searchParams.get("templateId");
|
||||||
|
const isView = id && !isEdit;
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [customers, setCustomers] = useState([]);
|
||||||
|
const [formValues, setFormValues] = useState({});
|
||||||
|
const [units, setUnits] = useState([]);
|
||||||
|
const [loadingUnits,setLoadingUnits]=useState(false)
|
||||||
|
const fetchUnits = async () => {
|
||||||
|
setLoadingUnits(true);
|
||||||
|
try {
|
||||||
|
const { data: units } = await supabaseService.select("resources", {
|
||||||
|
filter: {
|
||||||
|
type: { eq: "units" },
|
||||||
|
"attributes->>template_type": { in: `(${TYPE})` },
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
column: "created_at",
|
||||||
|
ascending: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setUnits(units || []);
|
||||||
|
} catch (error) {
|
||||||
|
message.error("获取单位列表失败");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoadingUnits(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUnits();
|
||||||
|
}, []);
|
||||||
|
const initialValues = {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
key: uuidv4(),
|
||||||
|
sectionName: "任务类型 1",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: uuidv4(),
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
timeRange: null,
|
||||||
|
unit: "未开始",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: "未开始",
|
||||||
|
timeRange: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理表单值变化
|
||||||
|
const handleValuesChange = (changedValues, allValues) => {
|
||||||
|
setFormValues(allValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取任务详情
|
||||||
|
const fetchTaskDetail = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
if (data?.attributes) {
|
||||||
|
const formData = {
|
||||||
|
taskName: data.attributes.taskName,
|
||||||
|
customers: data.attributes.customers.map((customer) => customer.id) || [],
|
||||||
|
description: data.attributes.description,
|
||||||
|
sections: data.attributes.sections.map((section) => ({
|
||||||
|
key: uuidv4(),
|
||||||
|
sectionName: section.sectionName,
|
||||||
|
items: section.items.map((item) => ({
|
||||||
|
key: uuidv4(),
|
||||||
|
name: item.name,
|
||||||
|
description: item.description || "",
|
||||||
|
timeRange: item.timeRange,
|
||||||
|
unit: item.unit || "未开始",
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
status: data.attributes.status || "未开始",
|
||||||
|
timeRange: data.attributes.timeRange
|
||||||
|
? [dayjs(data.attributes.timeRange[0]), dayjs(data.attributes.timeRange[1])]
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
form.setFieldsValue(formData);
|
||||||
|
setFormValues(formData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取任务详情失败:", error);
|
||||||
|
message.error("获取任务详情失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取模板数据
|
||||||
|
const fetchTemplateData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data: template, error } = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.select("*")
|
||||||
|
.eq("type", "serviceTemplate")
|
||||||
|
.eq("id", templateId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
if (template?.attributes) {
|
||||||
|
const taskData = {
|
||||||
|
taskName: template.attributes.templateName,
|
||||||
|
description: template.attributes.description,
|
||||||
|
sections: template.attributes.sections.map((section) => ({
|
||||||
|
key: uuidv4(),
|
||||||
|
sectionName: section.sectionName,
|
||||||
|
items: section.items.map((item) => ({
|
||||||
|
key: uuidv4(),
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
timeRange: null,
|
||||||
|
unit: "未开始",
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
form.setFieldsValue(taskData);
|
||||||
|
setFormValues(taskData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取模板数据失败:", error);
|
||||||
|
message.error("获取模板数据失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取客户列表
|
||||||
|
const fetchCustomers = async () => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.select("*")
|
||||||
|
.eq("type", "customer");
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setCustomers(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取客户列表失败:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存任务
|
||||||
|
const onFinish = async (values) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const taskData = {
|
||||||
|
type: "task",
|
||||||
|
attributes: {
|
||||||
|
taskName: values.taskName,
|
||||||
|
customers: customers
|
||||||
|
.filter(customer => values.customers.includes(customer.id))
|
||||||
|
.map(customer => ({
|
||||||
|
id: customer.id,
|
||||||
|
name: customer.attributes.name
|
||||||
|
})),
|
||||||
|
description: values.description,
|
||||||
|
sections: values.sections.map((section) => ({
|
||||||
|
sectionName: section.sectionName,
|
||||||
|
items: section.items.map((item) => ({
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
timeRange: item.timeRange,
|
||||||
|
unit: item.unit,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
status: values.status,
|
||||||
|
timeRange: values.timeRange
|
||||||
|
? [
|
||||||
|
values.timeRange[0].format('YYYY-MM-DD'),
|
||||||
|
values.timeRange[1].format('YYYY-MM-DD')
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (id) {
|
||||||
|
result = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.update(taskData)
|
||||||
|
.eq("id", id)
|
||||||
|
.select();
|
||||||
|
} else {
|
||||||
|
result = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.insert([taskData])
|
||||||
|
.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
|
||||||
|
message.success("保存成功");
|
||||||
|
navigate("/company/task");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("保存失败:", error);
|
||||||
|
message.error("保存失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCustomers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
fetchTaskDetail();
|
||||||
|
} else if (templateId) {
|
||||||
|
fetchTemplateData();
|
||||||
|
} else {
|
||||||
|
form.setFieldsValue(initialValues);
|
||||||
|
setFormValues(initialValues);
|
||||||
|
}
|
||||||
|
}, [id, templateId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-b from-gray-50 to-white dark:from-gray-800 dark:to-gray-900/90 min-h-screen p-2">
|
||||||
|
<Card
|
||||||
|
className="shadow-lg rounded-lg border-0"
|
||||||
|
title={
|
||||||
|
<div className="flex justify-between items-center py-2">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Title level={4} className="mb-0 text-gray-800">
|
||||||
|
{id ? (isEdit ? "编辑任务" : "查看任务") : "新建任务"}
|
||||||
|
</Title>
|
||||||
|
<span className="text-gray-400 text-sm">
|
||||||
|
{id
|
||||||
|
? isEdit
|
||||||
|
? "请修改任务信息"
|
||||||
|
: "任务详情"
|
||||||
|
: "请填写任务信息"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Space size="middle">
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate("/company/task")}
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
{!isView && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={() => form.submit()}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onFinish={onFinish}
|
||||||
|
onValuesChange={handleValuesChange}
|
||||||
|
layout="vertical"
|
||||||
|
disabled={isView}
|
||||||
|
initialValues={initialValues}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-8">
|
||||||
|
<Form.Item
|
||||||
|
name="taskName"
|
||||||
|
label={<span className="text-gray-700 font-medium">任务名称</span>}
|
||||||
|
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入任务名称"
|
||||||
|
className=" hover:border-blue-400 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="customers"
|
||||||
|
label={<span className="text-gray-700 font-medium">客户名称</span>}
|
||||||
|
rules={[{ required: true, message: "请选择至少一个客户" }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="请选择客户"
|
||||||
|
className="rounded-md hover:border-blue-400 focus:border-blue-500"
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="children"
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
options={customers.map((customer) => ({
|
||||||
|
value: customer.id,
|
||||||
|
label: customer.attributes.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="status"
|
||||||
|
label={<span className="text-gray-700 font-medium">任务状态</span>}
|
||||||
|
rules={[{ required: true, message: "请选择任务状态" }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
loading={loadingUnits}
|
||||||
|
placeholder="请选择任务状态"
|
||||||
|
className="rounded-md hover:border-blue-400 focus:border-blue-500"
|
||||||
|
options={units.map((unit) => ({
|
||||||
|
label: unit.attributes.name,
|
||||||
|
value: unit.attributes.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="timeRange"
|
||||||
|
label={<span className="text-gray-700 font-medium">时间范围</span>}
|
||||||
|
rules={[{ required: true, message: "请选择时间范围" }]}
|
||||||
|
>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
className="w-full rounded-md hover:border-blue-400 focus:border-blue-500"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
onChange={(dates) => {
|
||||||
|
if (dates) {
|
||||||
|
form.setFieldValue('timeRange', [
|
||||||
|
dayjs(dates[0]),
|
||||||
|
dayjs(dates[1])
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
form.setFieldValue('timeRange', null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className="shadow-sm rounded-lg mt-6"
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<TaskList
|
||||||
|
type={TYPE}
|
||||||
|
form={form}
|
||||||
|
isView={isView}
|
||||||
|
formValues={formValues}
|
||||||
|
onValuesChange={handleValuesChange}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,44 +1,467 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from "react";
|
||||||
import { Card, Table, Button } from 'antd';
|
import {
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
Card,
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
message,
|
||||||
|
Popconfirm,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Modal,
|
||||||
|
Empty,
|
||||||
|
Typography,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
FileAddOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { useResources } from "@/hooks/resource/useResource";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { supabase } from "@/config/supabase";
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
const TaskPage = () => {
|
const TaskPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
|
||||||
|
const [sorter, setSorter] = useState({
|
||||||
|
field: "created_at",
|
||||||
|
order: "descend",
|
||||||
|
});
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState(null);
|
||||||
|
const [templates, setTemplates] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
resources: tasks,
|
||||||
|
loading: loadingTasks,
|
||||||
|
total,
|
||||||
|
fetchResources: fetchTasks,
|
||||||
|
deleteResource: deleteTask,
|
||||||
|
} = useResources(pagination, sorter, "task");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '任务名称',
|
title: "任务名称",
|
||||||
dataIndex: 'name',
|
dataIndex: ["attributes", "taskName"],
|
||||||
key: 'name',
|
key: "taskName",
|
||||||
|
ellipsis: true,
|
||||||
|
className: "dark:text-gray-200",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '负责人',
|
title: "开始时间",
|
||||||
dataIndex: 'assignee',
|
dataIndex: ["attributes", "timeRange"],
|
||||||
key: 'assignee',
|
key: "startTime",
|
||||||
|
render: (timeRange) => (
|
||||||
|
<span className="dark:text-gray-300">
|
||||||
|
{timeRange?.[0] || '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '截止日期',
|
title: "结束时间",
|
||||||
dataIndex: 'dueDate',
|
dataIndex: ["attributes", "timeRange"],
|
||||||
key: 'dueDate',
|
key: "endTime",
|
||||||
|
render: (timeRange) => (
|
||||||
|
<span className="dark:text-gray-300">
|
||||||
|
{timeRange?.[1] || '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: "状态",
|
||||||
dataIndex: 'status',
|
dataIndex: ["attributes", "status"],
|
||||||
key: 'status',
|
key: "status",
|
||||||
|
render: (status) => (
|
||||||
|
<Tag
|
||||||
|
color={
|
||||||
|
status === '已完成' ? 'default' :
|
||||||
|
status === '进行中' ? 'processing' :
|
||||||
|
status === '未开始' ? 'success' : 'success'
|
||||||
|
}
|
||||||
|
className="dark:border-opacity-50"
|
||||||
|
>
|
||||||
|
{status || '未设置'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "创建时间",
|
||||||
|
dataIndex: "created_at",
|
||||||
|
key: "created_at",
|
||||||
|
sorter: true,
|
||||||
|
render: (text) => (
|
||||||
|
<span className="dark:text-gray-300">
|
||||||
|
{dayjs(text).format('YYYY-MM-DD HH:mm')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "action",
|
||||||
|
fixed: "right",
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size={0} className="dark:text-gray-300">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => copyItem(record)}
|
||||||
|
className="dark:text-gray-300 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => navigate(`/company/taskInfo/${record.id}?isView=true`)}
|
||||||
|
className="dark:text-gray-300 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => navigate(`/company/taskInfo/${record.id}?edit=true`)}
|
||||||
|
className="dark:text-gray-300 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定要删除这个任务吗?"
|
||||||
|
description="删除后将无法恢复!"
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
className="dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 获取模板列表
|
||||||
|
const fetchTemplates = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data: templates, error } = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.select("*")
|
||||||
|
.eq("type", "serviceTemplate")
|
||||||
|
.eq("attributes->>template_type", "task")
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setTemplates(templates);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取任务模板失败:", error);
|
||||||
|
message.error("获取任务模板失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当模态框打开时获取模板
|
||||||
|
useEffect(() => {
|
||||||
|
if (isModalVisible) {
|
||||||
|
fetchTemplates();
|
||||||
|
}
|
||||||
|
}, [isModalVisible]);
|
||||||
|
|
||||||
|
// 处理模板选择
|
||||||
|
const handleTemplateSelect = (templateId) => {
|
||||||
|
setSelectedTemplateId(templateId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedTemplateId) {
|
||||||
|
navigate(`/company/taskInfo?templateId=${selectedTemplateId}`);
|
||||||
|
} else {
|
||||||
|
navigate("/company/taskInfo");
|
||||||
|
}
|
||||||
|
setIsModalVisible(false);
|
||||||
|
setSelectedTemplateId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 复制任务
|
||||||
|
const copyItem = async (record) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const newAttributes = JSON.parse(JSON.stringify(record.attributes));
|
||||||
|
|
||||||
|
// 修改任务名称,添加"副本"标识
|
||||||
|
newAttributes.taskName = `${newAttributes.taskName} (副本)`;
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.insert([
|
||||||
|
{
|
||||||
|
type: "task",
|
||||||
|
attributes: newAttributes,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
message.success("复制成功");
|
||||||
|
fetchTasks(); // 刷新列表
|
||||||
|
} catch (error) {
|
||||||
|
console.error("复制任务失败:", error);
|
||||||
|
message.error("复制失败:" + error.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除任务
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
try {
|
||||||
|
await deleteTask(id);
|
||||||
|
fetchTasks();
|
||||||
|
} catch (error) {
|
||||||
|
message.error("删除失败:" + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 表格变化处理
|
||||||
|
const handleTableChange = (pagination, filters, sorter) => {
|
||||||
|
setPagination(pagination);
|
||||||
|
setSorter({
|
||||||
|
field: sorter.field || "created_at",
|
||||||
|
order: sorter.order || "descend",
|
||||||
|
});
|
||||||
|
fetchTasks({
|
||||||
|
current: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
field: sorter.field,
|
||||||
|
order: sorter.order,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取模板分类
|
||||||
|
const getTemplatesByCategory = () => {
|
||||||
|
const groups = new Map();
|
||||||
|
|
||||||
|
// 添加未分类组
|
||||||
|
groups.set("uncategorized", {
|
||||||
|
name: "未分类",
|
||||||
|
templates: templates.filter(
|
||||||
|
(t) => !t.attributes.category || t.attributes.category.length === 0
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按分类分组
|
||||||
|
templates.forEach((template) => {
|
||||||
|
if (template.attributes.category) {
|
||||||
|
template.attributes.category.forEach((cat) => {
|
||||||
|
if (!groups.has(cat.id)) {
|
||||||
|
groups.set(cat.id, {
|
||||||
|
name: cat.name,
|
||||||
|
templates: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
groups.get(cat.id).templates.push(template);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 返回非空分组
|
||||||
|
return Array.from(groups.values()).filter(
|
||||||
|
(group) => group.templates.length > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<>
|
||||||
title="任务管理"
|
<Card
|
||||||
extra={
|
title={
|
||||||
<div className="flex justify-between my-4">
|
<Space className="dark:text-gray-200 py-4 px-2">
|
||||||
<Button type="primary" icon={<PlusOutlined />}>
|
<span>任务管理</span>
|
||||||
|
<Tag color="blue" className="dark:border-opacity-50">
|
||||||
|
{total} 个任务
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
className="h-full w-full overflow-auto dark:bg-gray-800 dark:border-gray-700"
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setIsModalVisible(true)}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||||
|
>
|
||||||
新增任务
|
新增任务
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
}
|
>
|
||||||
>
|
<Table
|
||||||
<Table columns={columns} dataSource={[]} rowKey="id" />
|
className="w-full"
|
||||||
</Card>
|
columns={columns}
|
||||||
|
dataSource={tasks}
|
||||||
|
rowKey="id"
|
||||||
|
scroll={{ x: true }}
|
||||||
|
loading={loadingTasks}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
pagination={{
|
||||||
|
...pagination,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条记录`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 模板选择弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Typography.Title level={4} className="dark:text-gray-200">
|
||||||
|
选择任务模板
|
||||||
|
</Typography.Title>
|
||||||
|
}
|
||||||
|
open={isModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsModalVisible(false);
|
||||||
|
setSelectedTemplateId(null);
|
||||||
|
}}
|
||||||
|
footer={[
|
||||||
|
<Button
|
||||||
|
key="custom"
|
||||||
|
onClick={() => handleConfirm()}
|
||||||
|
className="gap-2 px-6 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-200"
|
||||||
|
>
|
||||||
|
<FileAddOutlined /> 自定义创建
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="submit"
|
||||||
|
type="primary"
|
||||||
|
disabled={!selectedTemplateId}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="gap-2 px-6 rounded-full bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<AppstoreOutlined /> 使用选中模板
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
width={900}
|
||||||
|
className="dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center items-center h-[400px]">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<Empty description={
|
||||||
|
<span className="dark:text-gray-400">暂无可用模板</span>
|
||||||
|
} />
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[600px] overflow-y-auto px-4">
|
||||||
|
{getTemplatesByCategory().map((group, groupIndex) => (
|
||||||
|
<div key={groupIndex} className="mb-10 last:mb-2">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<h3 className="text-xl font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
{group.name}
|
||||||
|
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
({group.templates.length})
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-6">
|
||||||
|
{group.templates.map((template) => (
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
onClick={() => handleTemplateSelect(template.id)}
|
||||||
|
className={`
|
||||||
|
relative p-6 rounded-xl cursor-pointer transition-all duration-200
|
||||||
|
${
|
||||||
|
selectedTemplateId === template.id
|
||||||
|
? "ring-2 ring-blue-500 bg-blue-50/40 dark:bg-blue-900/40"
|
||||||
|
: "hover:shadow-lg border border-gray-200 dark:border-gray-700 hover:border-blue-200 dark:hover:border-blue-700"
|
||||||
|
}
|
||||||
|
transform hover:-translate-y-1 dark:bg-gray-800
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start gap-3 mb-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-lg font-medium text-gray-900 dark:text-gray-200 truncate">
|
||||||
|
{template.attributes.templateName}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2 line-clamp-2">
|
||||||
|
{template.attributes.description || "暂无描述"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{template.attributes.sections.map((section, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`
|
||||||
|
rounded-lg p-3 text-sm border
|
||||||
|
${
|
||||||
|
selectedTemplateId === template.id
|
||||||
|
? "bg-white border-blue-200 dark:bg-gray-700 dark:border-blue-600"
|
||||||
|
: "bg-gray-50 border-gray-100 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-200 truncate flex-1">
|
||||||
|
{section.sectionName}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 ml-2 text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-600 rounded-full">
|
||||||
|
{section.items.length}项
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTemplateId === template.id && (
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<div className="w-6 h-6 bg-blue-600 dark:bg-blue-500 rounded-full flex items-center justify-center shadow-md">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-white"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Card, Steps, Form, Button, Select, DatePicker, Input } from 'antd';
|
|
||||||
|
|
||||||
const { Step } = Steps;
|
|
||||||
const { Option } = Select;
|
|
||||||
|
|
||||||
const CommunicationSend = () => {
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card title="发送管理">
|
|
||||||
<Steps
|
|
||||||
current={0}
|
|
||||||
items={[
|
|
||||||
{ title: '选择模板' },
|
|
||||||
{ title: '设置参数' },
|
|
||||||
{ title: '确认发送' },
|
|
||||||
]}
|
|
||||||
className="mb-8"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
<Form.Item name="template" label="选择模板">
|
|
||||||
<Select placeholder="请选择模板">
|
|
||||||
<Option value="template1">模板一</Option>
|
|
||||||
<Option value="template2">模板二</Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="sendTime" label="发送时间">
|
|
||||||
<DatePicker showTime />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="targetList" label="目标名单">
|
|
||||||
<Select placeholder="请选择目标名单">
|
|
||||||
<Option value="list1">名单一</Option>
|
|
||||||
<Option value="list2">名单二</Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item>
|
|
||||||
<Button type="primary">下一步</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CommunicationSend;
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Card, Table, Button, Tag } from 'antd';
|
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
|
||||||
|
|
||||||
const CommunicationTasks = () => {
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: '任务名称',
|
|
||||||
dataIndex: 'name',
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '负责人',
|
|
||||||
dataIndex: 'assignee',
|
|
||||||
key: 'assignee',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '截止日期',
|
|
||||||
dataIndex: 'dueDate',
|
|
||||||
key: 'dueDate',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
key: 'status',
|
|
||||||
render: status => (
|
|
||||||
<Tag color={status === '进行中' ? 'green' : 'default'}>
|
|
||||||
{status}
|
|
||||||
</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
title="沟通任务"
|
|
||||||
extra={
|
|
||||||
<div className="flex justify-between my-4">
|
|
||||||
<Button type="primary" icon={<PlusOutlined />}>
|
|
||||||
新增任务
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Table columns={columns} dataSource={[]} rowKey="id" />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CommunicationTasks;
|
|
||||||
@@ -35,13 +35,7 @@ const companyRoutes = [
|
|||||||
icon: "file",
|
icon: "file",
|
||||||
roles: ["ADMIN", "OWNER"],
|
roles: ["ADMIN", "OWNER"],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "task",
|
|
||||||
component: lazy(() => import("@/pages/resource/resourceTask")),
|
|
||||||
name: "任务管理",
|
|
||||||
icon: "appstore",
|
|
||||||
roles: ["OWNER"],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "quotaInfo/:id?", // 添加可选的 id 参数
|
path: "quotaInfo/:id?", // 添加可选的 id 参数
|
||||||
hidden: true,
|
hidden: true,
|
||||||
@@ -49,6 +43,21 @@ const companyRoutes = [
|
|||||||
name: "报价单详情",
|
name: "报价单详情",
|
||||||
icon: "file",
|
icon: "file",
|
||||||
roles: ["ADMIN", "OWNER"],
|
roles: ["ADMIN", "OWNER"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "task",
|
||||||
|
component: lazy(() => import("@/pages/company/task")),
|
||||||
|
name: "任务管理",
|
||||||
|
icon: "appstore",
|
||||||
|
roles: ["OWNER"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "taskInfo/:id?",
|
||||||
|
hidden:true,
|
||||||
|
component: lazy(() => import("@/pages/company/task/detail")),
|
||||||
|
name: "任务管理详情",
|
||||||
|
icon: "appstore",
|
||||||
|
roles: ["OWNER"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "serviceTemplate",
|
path: "serviceTemplate",
|
||||||
@@ -149,14 +158,14 @@ export const generateRoutes = (role) => {
|
|||||||
children: companyRoutes.filter((route) => route.roles.includes(role)),
|
children: companyRoutes.filter((route) => route.roles.includes(role)),
|
||||||
roles: ["ADMIN", "OWNER"],
|
roles: ["ADMIN", "OWNER"],
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
path: "marketing",
|
// path: "marketing",
|
||||||
component: lazy(() => import("@/pages/marketing")),
|
// component: lazy(() => import("@/pages/marketing")),
|
||||||
name: "行销中心",
|
// name: "行销中心",
|
||||||
icon: "shopping",
|
// icon: "shopping",
|
||||||
children: marketingRoutes.filter((route) => route.roles.includes(role)),
|
// children: marketingRoutes.filter((route) => route.roles.includes(role)),
|
||||||
roles: ["ADMIN", "OWNER"],
|
// roles: ["ADMIN", "OWNER"],
|
||||||
},
|
// },
|
||||||
|
|
||||||
// {
|
// {
|
||||||
// path: "role",
|
// path: "role",
|
||||||
|
|||||||
Reference in New Issue
Block a user