任务模块
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@supabase/supabase-js": "^2.38.4",
|
||||
"antd": "^5.11.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dnd-kit": "^0.0.2",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.2",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
||||
antd:
|
||||
specifier: ^5.11.0
|
||||
version: 5.22.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
dnd-kit:
|
||||
specifier: ^0.0.2
|
||||
version: 0.0.2
|
||||
|
||||
@@ -18,7 +18,6 @@ const Header = ({ collapsed, setCollapsed }) => {
|
||||
console.error("Logout error:", error);
|
||||
}
|
||||
};
|
||||
console.log(user);
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
|
||||
@@ -572,7 +572,7 @@ const SectionList = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center mt-6">
|
||||
<div className="flex items-center justify-center mt-6 flex-col ">
|
||||
{!isView && (
|
||||
<div className="w-full flex justify-center">
|
||||
<Button
|
||||
@@ -589,8 +589,8 @@ const SectionList = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex w-fit flex-shrink-0 flex-col gap-1">
|
||||
<span className="text-gray-500 flex items-center">
|
||||
<div className="flex items-end w-full flex-shrink-0 flex-col gap-1">
|
||||
<span className="text-gray-500 flex items-center ">
|
||||
税前总额:
|
||||
<span className="text-blue-500 font-medium ml-2">
|
||||
{formatExchangeRate(
|
||||
|
||||
583
src/components/TaskList/index.jsx
Normal file
583
src/components/TaskList/index.jsx
Normal file
@@ -0,0 +1,583 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
DatePicker,
|
||||
Button,
|
||||
Card,
|
||||
Typography,
|
||||
Modal,
|
||||
message,
|
||||
Divider,
|
||||
Select,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
CheckOutlined,
|
||||
CloseOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { supabase } from "@/config/supabase";
|
||||
import { supabaseService } from "@/hooks/supabaseService";
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const { Text } = Typography;
|
||||
const SectionList = ({ form, isView, formValues, type }) => {
|
||||
const [editingSectionIndex, setEditingSectionIndex] = useState(null);
|
||||
const [editingSectionName, setEditingSectionName] = useState("");
|
||||
const [templateModalVisible, setTemplateModalVisible] = useState(false);
|
||||
const [availableSections, setAvailableSections] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [loadingUnits, setLoadingUnits] = useState(false);
|
||||
|
||||
const fetchAvailableSections = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data: sections, error } = await supabase
|
||||
.from("resources")
|
||||
.select("*")
|
||||
.eq("type", "sections")
|
||||
.eq("attributes->>template_type", [type])
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setAvailableSections(sections || []);
|
||||
} catch (error) {
|
||||
message.error("获取小节模板失败");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSectionNameEdit = (sectionIndex, initialValue) => {
|
||||
setEditingSectionIndex(sectionIndex);
|
||||
setEditingSectionName(initialValue || "");
|
||||
};
|
||||
|
||||
const handleSectionNameSave = () => {
|
||||
if (!editingSectionName.trim()) {
|
||||
message.error("请输入名称");
|
||||
return;
|
||||
}
|
||||
|
||||
const sections = form.getFieldValue("sections");
|
||||
const newSections = [...sections];
|
||||
newSections[editingSectionIndex] = {
|
||||
...newSections[editingSectionIndex],
|
||||
sectionName: editingSectionName.trim(),
|
||||
};
|
||||
form.setFieldValue("sections", newSections);
|
||||
setEditingSectionIndex(null);
|
||||
setEditingSectionName("");
|
||||
};
|
||||
|
||||
const handleAddItem = (add) => {
|
||||
add({
|
||||
key: uuidv4(),
|
||||
name: "",
|
||||
description: "",
|
||||
timeRange: null,
|
||||
unit: "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleUseTemplate = (template, add) => {
|
||||
const newSection = {
|
||||
key: uuidv4(),
|
||||
sectionName: template.attributes.name,
|
||||
items: (template.attributes.items || []).map((item) => ({
|
||||
key: uuidv4(),
|
||||
name: item.name || "",
|
||||
description: item.description || "",
|
||||
timeRange: item.timeRange || null,
|
||||
unit: item.unit || "",
|
||||
})),
|
||||
};
|
||||
add(newSection);
|
||||
setTemplateModalVisible(false);
|
||||
message.success("套用模版成功");
|
||||
};
|
||||
|
||||
const handleCreateCustom = (add, fieldsLength) => {
|
||||
add({
|
||||
key: uuidv4(),
|
||||
sectionName: `任务类型 ${fieldsLength + 1}`,
|
||||
items: [
|
||||
{
|
||||
key: uuidv4(),
|
||||
name: "",
|
||||
description: "",
|
||||
timeRange: '',
|
||||
unit: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
setTemplateModalVisible(false);
|
||||
};
|
||||
|
||||
const fetchUnits = async () => {
|
||||
setLoadingUnits(true);
|
||||
try {
|
||||
const { data: units } = await supabaseService.select("resources", {
|
||||
filter: {
|
||||
type: { eq: "units" },
|
||||
"attributes->>template_type": { in: `(${type})` },
|
||||
},
|
||||
order: {
|
||||
column: "created_at",
|
||||
ascending: false,
|
||||
},
|
||||
});
|
||||
setUnits(units || []);
|
||||
} catch (error) {
|
||||
message.error("获取单位列表失败");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoadingUnits(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnits();
|
||||
}, []);
|
||||
|
||||
const handleAddUnit = async (unitName) => {
|
||||
try {
|
||||
const { error } = await supabase.from("resources").insert([
|
||||
{
|
||||
type: "units",
|
||||
attributes: {
|
||||
name: unitName,
|
||||
template_type: type,
|
||||
},
|
||||
schema_version: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
if (error) throw error;
|
||||
message.success("新增单位成功");
|
||||
fetchUnits();
|
||||
return true;
|
||||
} catch (error) {
|
||||
message.error("新增单位失败");
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateDuration = useMemo(() => (timeRange) => {
|
||||
if (!timeRange || !timeRange[0] || !timeRange[1]) return '';
|
||||
const start = dayjs(timeRange[0]);
|
||||
const end = dayjs(timeRange[1]);
|
||||
const durationObj = dayjs.duration(end.diff(start));
|
||||
|
||||
const days = Math.floor(durationObj.asDays());
|
||||
const hours = durationObj.hours();
|
||||
const minutes = durationObj.minutes();
|
||||
|
||||
let duration = '';
|
||||
if (days > 0) duration += `${days}天`;
|
||||
if (hours > 0) duration += `${hours}小时`;
|
||||
if (minutes > 0) duration += `${minutes}分钟`;
|
||||
return duration || '小于1分钟';
|
||||
}, []);
|
||||
|
||||
const renderTemplateModalContent = (add, fieldsLength) => (
|
||||
<div className="space-y-6">
|
||||
{availableSections.length > 0 ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{availableSections.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="group relative bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-lg transition-all duration-300 cursor-pointer"
|
||||
onClick={() => handleUseTemplate(section, add)}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-4">
|
||||
<h3 className="text-lg font-medium group-hover:text-blue-500 transition-colors">
|
||||
{section.attributes.name}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{section.attributes.items?.length || 0} 个项目
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-4 border-t pt-4">
|
||||
{(section.attributes.items || [])
|
||||
.slice(0, 3)
|
||||
.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex justify-between items-center"
|
||||
>
|
||||
<span className="text-sm text-gray-600 truncate flex-1">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 ml-2">
|
||||
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(section.attributes.items || []).length > 3 && (
|
||||
<div className="text-sm text-gray-500 text-center">
|
||||
还有 {section.attributes.items.length - 3} 个项目...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleCreateCustom(add, fieldsLength)}
|
||||
className="bg-blue-600 hover:bg-blue-700 border-0 shadow-md hover:shadow-lg transition-all duration-200 h-10 px-6 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<span className="font-medium">自定义模块</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4">
|
||||
<div className="w-48 h-48 mb-8">
|
||||
<svg
|
||||
className="w-full h-full text-gray-200"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-medium text-gray-900 mb-2">
|
||||
暂无可用模板
|
||||
</h3>
|
||||
<p className="text-gray-500 text-center max-w-sm mb-8">
|
||||
您可以选择创建一个自定义模块开始使用
|
||||
</p>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleCreateCustom(add, fieldsLength)}
|
||||
size="large"
|
||||
className="shadow-md hover:shadow-lg transition-shadow"
|
||||
>
|
||||
创建自定义模块
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.List name="sections">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<div className="space-y-4 overflow-auto">
|
||||
{fields.map((field, sectionIndex) => (
|
||||
<Card
|
||||
key={field.key}
|
||||
className="shadow-sm rounded-lg min-w-[1200px]"
|
||||
type="inner"
|
||||
title={
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{editingSectionIndex === sectionIndex ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={editingSectionName}
|
||||
onChange={(e) =>
|
||||
setEditingSectionName(e.target.value)
|
||||
}
|
||||
onPressEnter={handleSectionNameSave}
|
||||
autoFocus
|
||||
className="w-48"
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleSectionNameSave}
|
||||
className="text-green-500"
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => {
|
||||
setEditingSectionIndex(null);
|
||||
setEditingSectionName("");
|
||||
}}
|
||||
className="text-red-500"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1 h-4 bg-purple-500 rounded-full" />
|
||||
<Text strong className="text-lg">
|
||||
{form.getFieldValue([
|
||||
"sections",
|
||||
sectionIndex,
|
||||
"sectionName",
|
||||
]) || `任务类型 ${sectionIndex + 1}`}
|
||||
</Text>
|
||||
{!isView && (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() =>
|
||||
handleSectionNameEdit(
|
||||
sectionIndex,
|
||||
form.getFieldValue([
|
||||
"sections",
|
||||
sectionIndex,
|
||||
"sectionName",
|
||||
])
|
||||
)
|
||||
}
|
||||
className="text-gray-400 hover:text-blue-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isView && (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form.List name={[field.name, "items"]}>
|
||||
{(itemFields, { add: addItem, remove: removeItem }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-[2fr_2fr_3fr_1.5fr_1fr_1fr] gap-4 mb-2 text-gray-500 px-2">
|
||||
<div>任务名称</div>
|
||||
<div>描述/备注</div>
|
||||
<div>开始/结束时间</div>
|
||||
<div className="text-center">时间范围</div>
|
||||
<div className="text-center">状态</div>
|
||||
<div className="text-center">操作</div>
|
||||
</div>
|
||||
|
||||
{itemFields.map((itemField, itemIndex) => {
|
||||
const { key, ...restItemField } = itemField;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="grid grid-cols-[2fr_2fr_3fr_1.5fr_1fr_1fr] gap-4 mb-4 items-start"
|
||||
>
|
||||
<Form.Item
|
||||
{...restItemField}
|
||||
name={[itemField.name, "name"]}
|
||||
className="!mb-0"
|
||||
>
|
||||
<Input placeholder="任务名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...restItemField}
|
||||
name={[itemField.name, "description"]}
|
||||
className="!mb-0"
|
||||
>
|
||||
<Input placeholder="请输入描述/备注" />
|
||||
</Form.Item>
|
||||
<div className="flex items-center gap-2">
|
||||
<Form.Item
|
||||
{...restItemField}
|
||||
name={[itemField.name, "timeRange"]}
|
||||
className="!mb-0 flex-1"
|
||||
getValueFromEvent={(dates) => {
|
||||
if (!dates) return null;
|
||||
return [
|
||||
dates[0].format('YYYY-MM-DD HH:mm'),
|
||||
dates[1].format('YYYY-MM-DD HH:mm')
|
||||
];
|
||||
}}
|
||||
getValueProps={(value) => {
|
||||
if (!value) return {};
|
||||
return {
|
||||
value: [
|
||||
dayjs(value[0]),
|
||||
dayjs(value[1])
|
||||
]
|
||||
};
|
||||
}}
|
||||
>
|
||||
<DatePicker.RangePicker
|
||||
showTime
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
placeholder={["开始时间", "结束时间"]}
|
||||
className="w-full"
|
||||
disabled={isView}
|
||||
showNow
|
||||
showToday
|
||||
allowClear
|
||||
disabledDate={(current) => {
|
||||
return current && current < dayjs().startOf('day');
|
||||
}}
|
||||
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
</div>
|
||||
|
||||
<span className="text-gray-500 text-center whitespace-nowrap">
|
||||
{calculateDuration(
|
||||
form.getFieldValue([
|
||||
"sections",
|
||||
field.name,
|
||||
"items",
|
||||
itemField.name,
|
||||
"timeRange",
|
||||
])
|
||||
)}
|
||||
</span>
|
||||
|
||||
|
||||
<Form.Item
|
||||
{...restItemField}
|
||||
name={[itemField.name, "unit"]}
|
||||
className="!mb-0"
|
||||
>
|
||||
<Select
|
||||
placeholder="选择状态"
|
||||
loading={loadingUnits}
|
||||
showSearch
|
||||
allowClear
|
||||
style={{ minWidth: "120px" }}
|
||||
options={units.map((unit) => ({
|
||||
label: unit.attributes.name,
|
||||
value: unit.attributes.name,
|
||||
}))}
|
||||
onDropdownVisibleChange={(open) => {
|
||||
if (open) fetchUnits();
|
||||
}}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Divider style={{ margin: "12px 0" }} />
|
||||
<div style={{ padding: "4px" }}>
|
||||
<Input.Search
|
||||
placeholder="输入新状态"
|
||||
enterButton={<PlusOutlined />}
|
||||
onSearch={async (value) => {
|
||||
if (!value.trim()) return;
|
||||
if (
|
||||
await handleAddUnit(value.trim())
|
||||
) {
|
||||
const currentItems =
|
||||
form.getFieldValue([
|
||||
"sections",
|
||||
field.name,
|
||||
"items",
|
||||
]);
|
||||
currentItems[
|
||||
itemField.name
|
||||
].unit = value.trim();
|
||||
form.setFieldValue(
|
||||
[
|
||||
"sections",
|
||||
field.name,
|
||||
"items",
|
||||
],
|
||||
currentItems
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{!isView && itemFields.length > 1 && (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeItem(itemField.name)}
|
||||
className="flex items-center justify-center"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!isView && (
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => handleAddItem(addItem)}
|
||||
icon={<PlusOutlined />}
|
||||
className="w-full hover:border-blue-400 hover:text-blue-500 mb-4"
|
||||
>
|
||||
添加子任务
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center mt-6 flex-col ">
|
||||
{!isView && (
|
||||
<div className="w-full flex justify-center">
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => {
|
||||
setTemplateModalVisible(true);
|
||||
fetchAvailableSections();
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
className="w-1/3 border-2 hover:border-blue-400 hover:text-blue-500"
|
||||
>
|
||||
新建任务
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={<h3 className="text-lg font-medium">选择模版</h3>}
|
||||
open={templateModalVisible}
|
||||
onCancel={() => setTemplateModalVisible(false)}
|
||||
footer={null}
|
||||
width={800}
|
||||
closeIcon={<CloseOutlined className="text-gray-500" />}
|
||||
>
|
||||
{renderTemplateModalContent(add, fields.length)}
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionList;
|
||||
@@ -1,18 +1,217 @@
|
||||
import React from 'react';
|
||||
import { Form, Card } from 'antd';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Form, Card, Input, Select, message,Button } from 'antd';
|
||||
import { supabaseService } from '@/hooks/supabaseService';
|
||||
import {ArrowLeftOutlined}from '@ant-design/icons'
|
||||
import TaskList from '@/components/TaskList';
|
||||
const TYPE = 'task'
|
||||
const TaskTemplate = ({ id, isView, onCancel,isEdit }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formValues, setFormValues] = useState({
|
||||
sections: [{ items: [{}] }],
|
||||
});
|
||||
const [categories, setCategories] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (id) {
|
||||
fetchServiceTemplate();
|
||||
}
|
||||
fetchCategories();
|
||||
}, [id]);
|
||||
|
||||
const fetchServiceTemplate = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data } = await supabaseService.select('resources', {
|
||||
filter: {
|
||||
id: { eq: id },
|
||||
type: { eq: 'serviceTemplate' },
|
||||
'attributes->>template_type': { eq: TYPE }
|
||||
}
|
||||
});
|
||||
|
||||
if (data?.[0]) {
|
||||
const formData = {
|
||||
templateName: data[0].attributes.templateName,
|
||||
description: data[0].attributes.description,
|
||||
category: data[0].attributes.category?.map(v => v.id) || [],
|
||||
sections: data[0].attributes.sections || [{ items: [{}] }],
|
||||
currency: data[0].attributes.currency || "CNY",
|
||||
};
|
||||
form.setFieldsValue(formData);
|
||||
setFormValues(formData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取服务模版失败:", error);
|
||||
message.error("获取服务模版失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const { data } = await supabaseService.select('resources', {
|
||||
filter: {
|
||||
type: { eq: 'categories' },
|
||||
'attributes->>template_type': { in: `(${TYPE},common)` }
|
||||
},
|
||||
order: {
|
||||
column: 'created_at',
|
||||
ascending: false
|
||||
}
|
||||
});
|
||||
|
||||
const formattedCategories = (data || []).map(category => ({
|
||||
value: category.id,
|
||||
label: category.attributes.name
|
||||
}));
|
||||
|
||||
setCategories(formattedCategories);
|
||||
} catch (error) {
|
||||
message.error('获取分类数据失败');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValuesChange = (changedValues, allValues) => {
|
||||
setFormValues(allValues);
|
||||
};
|
||||
|
||||
const onFinish = async (values) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const categoryData = values.category.map(categoryId => {
|
||||
const category = categories.find(c => c.value === categoryId);
|
||||
return {
|
||||
id: categoryId,
|
||||
name: category.label
|
||||
};
|
||||
});
|
||||
const serviceData = {
|
||||
type: "serviceTemplate",
|
||||
attributes: {
|
||||
template_type: TYPE,
|
||||
templateName: values.templateName,
|
||||
description: values.description,
|
||||
sections: values.sections,
|
||||
category: categoryData,
|
||||
},
|
||||
};
|
||||
|
||||
if (id) {
|
||||
await supabaseService.update('resources',
|
||||
{ id },
|
||||
serviceData
|
||||
);
|
||||
} else {
|
||||
// 新增
|
||||
await supabaseService.insert('resources', serviceData);
|
||||
}
|
||||
|
||||
message.success("保存成功");
|
||||
onCancel();
|
||||
} catch (error) {
|
||||
console.error("保存失败:", error);
|
||||
message.error("保存失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const TaskTemplate = ({ form, id, isView }) => {
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
disabled={isView}
|
||||
>
|
||||
{/* 任务模板特有的字段和组件 */}
|
||||
<Card>
|
||||
{/* 任务步骤、检查项等内容 */}
|
||||
</Card>
|
||||
</Form>
|
||||
<>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={onFinish}
|
||||
onValuesChange={handleValuesChange}
|
||||
layout="vertical"
|
||||
variant="filled"
|
||||
disabled={isView}
|
||||
>
|
||||
<Card
|
||||
className="shadow-sm rounded-lg mb-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}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<Form.Item
|
||||
label="模版名称"
|
||||
name="templateName"
|
||||
rules={[{ required: true, message: "请输入模版名称" }]}
|
||||
>
|
||||
<Input placeholder="请输入模版名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="模版分类"
|
||||
name="category"
|
||||
rules={[{ required: true, message: "请选择或输入分类" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择或输入分类"
|
||||
showSearch
|
||||
allowClear
|
||||
mode="tags"
|
||||
options={categories}
|
||||
loading={loading}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="模版描述"
|
||||
name="description"
|
||||
className="col-span-2"
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="请输入模版描述" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<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}
|
||||
>
|
||||
<TaskList
|
||||
type={TYPE}
|
||||
form={form}
|
||||
isView={isView}
|
||||
formValues={formValues}
|
||||
onValuesChange={handleValuesChange}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
</Form>
|
||||
<div className="flex justify-end pt-4 space-x-4">
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onCancel}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
{!isView && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => form.submit()}
|
||||
loading={loading}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -224,8 +224,295 @@ const ServicePage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 子表格列定义
|
||||
const table_warp=(record,section)=>{
|
||||
|
||||
switch(record.attributes.template_type){
|
||||
case 'quotation':
|
||||
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",
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* <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) => {
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-gray-600 p-4 rounded-lg">
|
||||
{record.attributes.sections.map((section) => (
|
||||
@@ -240,164 +527,12 @@ const ServicePage = () => {
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<Table
|
||||
scroll={{x:true}}
|
||||
dataSource={section.items}
|
||||
pagination={false}
|
||||
size="small"
|
||||
className="nested-items-table border-t border-gray-100"
|
||||
columns={[
|
||||
{
|
||||
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",
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
columns={table_warp(record,section)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -411,7 +546,7 @@ const ServicePage = () => {
|
||||
|
||||
const path = id ? `${basePath}/${id}` : basePath;
|
||||
if (mode === "create") {
|
||||
navigate(`${basePath}`);
|
||||
navigate(`${basePath}?type=${type}`);
|
||||
} else {
|
||||
navigate(`${basePath}/${id}?type=${type}&${mode}=true`);
|
||||
}
|
||||
@@ -589,7 +724,6 @@ const ServicePage = () => {
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
||||
{/* 添加模板类型选择弹窗 */}
|
||||
<TemplateTypeModal
|
||||
open={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
|
||||
@@ -16,13 +16,7 @@ const resourceRoutes = [
|
||||
icon: "shop",
|
||||
roles: ["OWNER"],
|
||||
},
|
||||
{
|
||||
path: "task",
|
||||
component: lazy(() => import("@/pages/resource/resourceTask")),
|
||||
name: "任务管理",
|
||||
icon: "appstore",
|
||||
roles: ["OWNER"],
|
||||
},
|
||||
|
||||
{
|
||||
path: "task/edit/:id?",
|
||||
component: lazy(() => import("@/pages/resource/resourceTask/edit")),
|
||||
@@ -41,6 +35,13 @@ const companyRoutes = [
|
||||
icon: "file",
|
||||
roles: ["ADMIN", "OWNER"],
|
||||
},
|
||||
{
|
||||
path: "task",
|
||||
component: lazy(() => import("@/pages/resource/resourceTask")),
|
||||
name: "任务管理",
|
||||
icon: "appstore",
|
||||
roles: ["OWNER"],
|
||||
},
|
||||
{
|
||||
path: "quotaInfo/:id?", // 添加可选的 id 参数
|
||||
hidden: true,
|
||||
|
||||
8
src/utils/enum.js
Normal file
8
src/utils/enum.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export const STATUS_OPTIONS = [
|
||||
{ label: '未开始', value: 'not_started' },
|
||||
{ label: '进行中', value: 'in_progress' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
{ label: '已暂停', value: 'paused' },
|
||||
{ label: '延期中', value: 'paused' }
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user