feat:服务模版 抽离
This commit is contained in:
@@ -2,7 +2,12 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
"@/*": ["src/*"],
|
||||
"@components/*": ["src/components/*"],
|
||||
"@pages/*": ["src/pages/*"],
|
||||
"@utils/*": ["src/utils/*"],
|
||||
"@assets/*": ["src/assets/*"],
|
||||
"@config/*": ["src/config/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Form, Input, InputNumber, Button, Card, Typography, Modal, message, Divider, Select } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -247,12 +247,53 @@ const SectionList = ({
|
||||
onClick={() => handleCreateCustom(add, fieldsLength)}
|
||||
className="w-1/3 border-2"
|
||||
>
|
||||
自定义小节
|
||||
自定义模块
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 修改项目小计的计算,将其封装为 memo 组件
|
||||
const ItemSubtotal = React.memo(({ quantity, price, currentCurrency }) => {
|
||||
const subtotal = useMemo(() => {
|
||||
const safeQuantity = Number(quantity) || 0;
|
||||
const safePrice = Number(price) || 0;
|
||||
return safeQuantity * safePrice;
|
||||
}, [quantity, price]);
|
||||
|
||||
return (
|
||||
<div className="text-right">
|
||||
<span className="text-gray-500">
|
||||
{formatCurrency(subtotal, currentCurrency)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// 修改小节总计的计算,将其封装为 memo 组件
|
||||
const SectionTotal = React.memo(({ items, currentCurrency }) => {
|
||||
const total = useMemo(() => {
|
||||
if (!Array.isArray(items)) return 0;
|
||||
return items.reduce((sum, item) => {
|
||||
if (!item) return sum;
|
||||
const safeQuantity = Number(item.quantity) || 0;
|
||||
const safePrice = Number(item.price) || 0;
|
||||
return sum + (safeQuantity * safePrice);
|
||||
}, 0);
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end border-t pt-4">
|
||||
<span className="text-gray-500">
|
||||
小计总额:
|
||||
<span className="text-blue-500 font-medium ml-2">
|
||||
{formatCurrency(total, currentCurrency)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.List name="sections">
|
||||
@@ -439,16 +480,11 @@ const SectionList = ({
|
||||
>
|
||||
<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>
|
||||
<ItemSubtotal
|
||||
quantity={formValues?.sections?.[sectionIndex]?.items?.[itemIndex]?.quantity}
|
||||
price={formValues?.sections?.[sectionIndex]?.items?.[itemIndex]?.price}
|
||||
currentCurrency={currentCurrency}
|
||||
/>
|
||||
{!isView && itemFields.length > 1 && (
|
||||
<Button
|
||||
type="text"
|
||||
@@ -472,18 +508,10 @@ const SectionList = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end border-t pt-4">
|
||||
<span className="text-gray-500">
|
||||
小计总额:
|
||||
<span className="text-blue-500 font-medium ml-2">
|
||||
{formatCurrency(
|
||||
calculateSectionTotal(
|
||||
formValues?.sections?.[sectionIndex]?.items
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<SectionTotal
|
||||
items={formValues?.sections?.[sectionIndex]?.items}
|
||||
currentCurrency={currentCurrency}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
55
src/components/TemplateTypeModal/index.jsx
Normal file
55
src/components/TemplateTypeModal/index.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import { Modal, Card, Space } from 'antd';
|
||||
import { FileTextOutlined, ProjectOutlined, CheckSquareOutlined } from '@ant-design/icons';
|
||||
|
||||
const TEMPLATE_TYPES = [
|
||||
{
|
||||
key: 'quotation',
|
||||
title: '报价单模板',
|
||||
icon: <FileTextOutlined className="text-2xl text-blue-500" />,
|
||||
description: '创建标准化的报价单模板,包含服务项目、价格等信息'
|
||||
},
|
||||
{
|
||||
key: 'project',
|
||||
title: '专案模板',
|
||||
icon: <ProjectOutlined className="text-2xl text-green-500" />,
|
||||
description: '创建专案流程模板,包含项目阶段、时间线等信息'
|
||||
},
|
||||
{
|
||||
key: 'task',
|
||||
title: '任务模板',
|
||||
icon: <CheckSquareOutlined className="text-2xl text-purple-500" />,
|
||||
description: '创建标准任务模板,包含检查项、执行步骤等信息'
|
||||
}
|
||||
];
|
||||
|
||||
const TemplateTypeModal = ({ open, onClose, onSelect }) => {
|
||||
return (
|
||||
<Modal
|
||||
title="选择模板类型"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<Space className="w-full" size="large">
|
||||
{TEMPLATE_TYPES.map(type => (
|
||||
<Card
|
||||
key={type.key}
|
||||
hoverable
|
||||
className="flex-1 cursor-pointer transition-all hover:shadow-lg"
|
||||
onClick={() => onSelect(type.key)}
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
{type.icon}
|
||||
<h3 className="text-lg font-medium">{type.title}</h3>
|
||||
<p className="text-gray-500 text-sm">{type.description}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateTypeModal;
|
||||
@@ -5,7 +5,7 @@ const ThemeContext = createContext();
|
||||
export const ThemeProvider = ({ children }) => {
|
||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
return savedTheme === 'dark' || (savedTheme === null && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
return savedTheme === 'dark';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -64,20 +64,28 @@ class SupabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// 通用 UPDATE 请求
|
||||
// 优化 UPDATE 请求
|
||||
async update(table, match, updates) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
let query = supabase
|
||||
.from(table)
|
||||
.update(updates)
|
||||
.match(match)
|
||||
.select()
|
||||
.update(updates);
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
// 如果是对象,使用 match 方法
|
||||
if (typeof match === 'object') {
|
||||
query = query.match(match);
|
||||
} else {
|
||||
// 如果是单个 id,使用 eq 方法
|
||||
query = query.eq('id', match);
|
||||
}
|
||||
|
||||
const { data, error } = await query.select();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error updating ${table}:`, error.message)
|
||||
throw error
|
||||
console.error(`Error updating ${table}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -629,7 +629,7 @@ const QuotationForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-b from-gray-50 to-white 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">
|
||||
<Card
|
||||
className="shadow-lg rounded-lg border-0"
|
||||
title={
|
||||
|
||||
@@ -1,26 +1,56 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Form, Input, Space, message, Popconfirm, Drawer } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { supabase } from '@/config/supabase';
|
||||
import { Table, Button, Form, Input, Space, message, Popconfirm, Drawer, Select, Tabs, Badge, Radio } from 'antd';
|
||||
import { PlusOutlined, FileTextOutlined, ProjectOutlined, CheckSquareOutlined } from '@ant-design/icons';
|
||||
import { supabaseService } from '@/hooks/supabaseService';
|
||||
|
||||
const CategoryDrawer = () => {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState('');
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [activeType, setActiveType] = useState('quotation'); // 当前选中的类型
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 模板类型配置
|
||||
const TEMPLATE_TYPES = [
|
||||
{
|
||||
value: 'quotation',
|
||||
label: '报价单模板',
|
||||
icon: <FileTextOutlined className="text-blue-500" />,
|
||||
color: 'blue',
|
||||
description: '用于创建标准化的报价单'
|
||||
},
|
||||
{
|
||||
value: 'project',
|
||||
label: '专案模板',
|
||||
icon: <ProjectOutlined className="text-green-500" />,
|
||||
color: 'green',
|
||||
description: '用于创建项目流程模板'
|
||||
},
|
||||
{
|
||||
value: 'task',
|
||||
label: '任务模板',
|
||||
icon: <CheckSquareOutlined className="text-purple-500" />,
|
||||
color: 'purple',
|
||||
description: '用于创建标准任务模板'
|
||||
}
|
||||
];
|
||||
|
||||
// 获取分类数据
|
||||
const fetchCategories = async () => {
|
||||
const fetchCategories = async (type = activeType) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: categories, error } = await supabase
|
||||
.from('resources')
|
||||
.select('*')
|
||||
.eq('type', 'categories')
|
||||
.order('created_at', { ascending: false });
|
||||
const { data: categories } = await supabaseService.select('resources', {
|
||||
filter: {
|
||||
type: { eq: 'categories' },
|
||||
'attributes->>type': { eq: type }
|
||||
},
|
||||
order: {
|
||||
column: 'created_at',
|
||||
ascending: false
|
||||
}
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
setData(categories || []);
|
||||
} catch (error) {
|
||||
message.error('获取分类数据失败');
|
||||
@@ -34,13 +64,23 @@ const CategoryDrawer = () => {
|
||||
if (drawerVisible) {
|
||||
fetchCategories();
|
||||
}
|
||||
}, [drawerVisible]);
|
||||
}, [drawerVisible, activeType]);
|
||||
|
||||
// 切换模板类型
|
||||
const handleTypeChange = (type) => {
|
||||
setActiveType(type);
|
||||
setEditingKey(''); // 清除编辑状态
|
||||
form.resetFields(); // 重置表单
|
||||
};
|
||||
|
||||
// 新增分类
|
||||
const handleAdd = () => {
|
||||
const handleAdd = (type) => {
|
||||
const newData = {
|
||||
id: Date.now().toString(),
|
||||
attributes: { name: '' },
|
||||
attributes: {
|
||||
name: '',
|
||||
type: type // 默认类型
|
||||
},
|
||||
isNew: true
|
||||
};
|
||||
setData([newData, ...data]);
|
||||
@@ -53,31 +93,26 @@ const CategoryDrawer = () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (record.isNew) {
|
||||
// 新增
|
||||
const { error } = await supabase
|
||||
.from('resources')
|
||||
.insert([{
|
||||
await supabaseService.insert('resources', {
|
||||
type: 'categories',
|
||||
attributes: {
|
||||
name: values.name
|
||||
name: values.name,
|
||||
type: values.type
|
||||
},
|
||||
schema_version: 1
|
||||
}]);
|
||||
|
||||
if (error) throw error;
|
||||
});
|
||||
} else {
|
||||
// 更新
|
||||
const { error } = await supabase
|
||||
.from('resources')
|
||||
.update({
|
||||
await supabaseService.update('resources',
|
||||
{ id: record.id },
|
||||
{
|
||||
attributes: {
|
||||
name: values.name
|
||||
name: values.name,
|
||||
type: values.type
|
||||
},
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', record.id);
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
message.success('保存成功');
|
||||
@@ -92,12 +127,7 @@ const CategoryDrawer = () => {
|
||||
// 删除分类
|
||||
const handleDelete = async (record) => {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('resources')
|
||||
.delete()
|
||||
.eq('id', record.id);
|
||||
|
||||
if (error) throw error;
|
||||
await supabaseService.delete('resources', { id: record.id });
|
||||
message.success('删除成功');
|
||||
fetchCategories();
|
||||
} catch (error) {
|
||||
@@ -125,6 +155,29 @@ const CategoryDrawer = () => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '模板类型',
|
||||
dataIndex: ['attributes', 'type'],
|
||||
render: (text, record) => {
|
||||
const isEditing = record.id === editingKey;
|
||||
return isEditing ? (
|
||||
<Form.Item
|
||||
name="type"
|
||||
style={{ margin: 0 }}
|
||||
rules={[{ required: true, message: '请选择模板类型!' }]}
|
||||
>
|
||||
<Select
|
||||
options={TEMPLATE_TYPES}
|
||||
placeholder="请选择模板类型"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<span className="text-gray-600">
|
||||
{TEMPLATE_TYPES.find(t => t.value === text)?.label || text}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_, record) => {
|
||||
@@ -161,13 +214,14 @@ const CategoryDrawer = () => {
|
||||
setEditingKey(record.id);
|
||||
form.setFieldsValue({
|
||||
name: record.attributes.name,
|
||||
type: record.attributes.type
|
||||
});
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
title="确认<EFBFBD><EFBFBD><EFBFBD>除"
|
||||
description="确定要删除这个分类吗?"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="确定"
|
||||
@@ -209,14 +263,56 @@ const CategoryDrawer = () => {
|
||||
className="category-drawer"
|
||||
>
|
||||
<div className="p-6">
|
||||
{/* 类型选择器 */}
|
||||
<div className="mb-6">
|
||||
<Radio.Group
|
||||
value={activeType}
|
||||
onChange={(e) => handleTypeChange(e.target.value)}
|
||||
className="flex space-x-4"
|
||||
>
|
||||
{TEMPLATE_TYPES.map(type => (
|
||||
<Radio.Button
|
||||
key={type.value}
|
||||
value={type.value}
|
||||
className={`flex items-center px-4 py-2 ${
|
||||
activeType === type.value ? `bg-${type.color}-50 border-${type.color}-500` : ''
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{type.icon}
|
||||
<span>{type.label}</span>
|
||||
<Badge
|
||||
count={data.filter(item => item.attributes.type === type.value).length}
|
||||
className={`bg-${type.color}-100 text-${type.color}-600`}
|
||||
/>
|
||||
</span>
|
||||
</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
{/* 当前类型说明 */}
|
||||
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<h3 className={`text-${TEMPLATE_TYPES.find(t => t.value === activeType)?.color}-500 text-lg font-medium mb-2 flex items-center`}>
|
||||
{TEMPLATE_TYPES.find(t => t.value === activeType)?.icon}
|
||||
<span className="ml-2">{TEMPLATE_TYPES.find(t => t.value === activeType)?.label}</span>
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{TEMPLATE_TYPES.find(t => t.value === activeType)?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 新增按钮 */}
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleAdd}
|
||||
onClick={() => handleAdd(activeType)}
|
||||
className="mb-4"
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
新增分类
|
||||
</Button>
|
||||
|
||||
{/* 分类列表 */}
|
||||
<Form form={form}>
|
||||
<Table
|
||||
scroll={{ x: true }}
|
||||
@@ -228,7 +324,7 @@ const CategoryDrawer = () => {
|
||||
pageSize: 10,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
className="bg-white rounded-lg shadow"
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow"
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Form, Card } from 'antd';
|
||||
|
||||
const ProjectTemplate = ({ form, id, isView }) => {
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
disabled={isView}
|
||||
>
|
||||
{/* 专案模板特有的字段和组件 */}
|
||||
<Card>
|
||||
{/* 项目阶段、时间线等内容 */}
|
||||
</Card>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectTemplate;
|
||||
@@ -0,0 +1,222 @@
|
||||
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 SectionList from '@/components/SectionList';
|
||||
|
||||
const QuotationTemplate = ({ id, isView, onCancel,isEdit }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formValues, setFormValues] = useState({
|
||||
sections: [{ items: [{}] }],
|
||||
currency: "CNY"
|
||||
});
|
||||
const [categories, setCategories] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(id,'id');
|
||||
|
||||
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->>type': { eq: 'quotation' }
|
||||
}
|
||||
});
|
||||
console.log(data,'data');
|
||||
|
||||
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->>type': { eq: 'quotation' }
|
||||
},
|
||||
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: {
|
||||
type: "quotation",
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
>
|
||||
<SectionList
|
||||
form={form}
|
||||
isView={isView}
|
||||
formValues={formValues}
|
||||
onValuesChange={handleValuesChange}
|
||||
/>
|
||||
</Card>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onCancel}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
{!isView && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => form.submit()}
|
||||
loading={loading}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotationTemplate;
|
||||
19
src/pages/company/service/detail/components/TaskTemplate.jsx
Normal file
19
src/pages/company/service/detail/components/TaskTemplate.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Form, Card } from 'antd';
|
||||
|
||||
const TaskTemplate = ({ form, id, isView }) => {
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
disabled={isView}
|
||||
>
|
||||
{/* 任务模板特有的字段和组件 */}
|
||||
<Card>
|
||||
{/* 任务步骤、检查项等内容 */}
|
||||
</Card>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskTemplate;
|
||||
@@ -1,248 +1,68 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate, useParams,useSearchParams, useLocation } from "react-router-dom";
|
||||
import { supabase } from "@/config/supabase";
|
||||
import SectionList from '@/components/SectionList';
|
||||
import React from 'react';
|
||||
import { Card, Typography } from 'antd';
|
||||
import { useNavigate, useSearchParams,useParams } from 'react-router-dom';
|
||||
import QuotationTemplate from './components/QuotationTemplate';
|
||||
import ProjectTemplate from './components/ProjectTemplate';
|
||||
import TaskTemplate from './components/TaskTemplate';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const ServiceForm = () => {
|
||||
const [form] = Form.useForm();
|
||||
const navigate = useNavigate();
|
||||
const { id} = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isView = searchParams.get("isView") === "true";
|
||||
const [loading, setLoading] = useState(false);
|
||||
const location = useLocation();
|
||||
const isEdit = location.search.includes("edit=true");
|
||||
const [formValues, setFormValues] = useState({
|
||||
sections: [{ items: [{}] }],
|
||||
currency: "CNY"
|
||||
});
|
||||
const [categories, setCategories] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchServiceTemplate();
|
||||
}
|
||||
fetchCategories();
|
||||
}, [id]);
|
||||
|
||||
const fetchServiceTemplate = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from("resources")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
const formData = {
|
||||
templateName: data.attributes.templateName,
|
||||
description: data.attributes.description,
|
||||
category: data.attributes.category.map(v=>v.id),
|
||||
sections: data.attributes.sections,
|
||||
};
|
||||
form.setFieldsValue(formData);
|
||||
setFormValues(formData);
|
||||
} catch (error) {
|
||||
console.error("获取服务模版失败:", error);
|
||||
message.error("获取服务模版失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const { data: categoriesData, error } = await supabase
|
||||
.from('resources')
|
||||
.select('*')
|
||||
.eq('type', 'categories')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const formattedCategories = (categoriesData || []).map(category => ({
|
||||
value: category.id,
|
||||
label: category.attributes.name
|
||||
}));
|
||||
|
||||
setCategories(formattedCategories);
|
||||
} catch (error) {
|
||||
message.error('获取分类数据失败');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
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: {
|
||||
templateName: values.templateName,
|
||||
description: values.description,
|
||||
sections: values.sections,
|
||||
category: categoryData,
|
||||
// 模板类型配置
|
||||
const TEMPLATE_CONFIG = {
|
||||
quotation: {
|
||||
title: '报价单模板',
|
||||
component: QuotationTemplate,
|
||||
},
|
||||
};
|
||||
|
||||
let result;
|
||||
if (id) {
|
||||
result = await supabase
|
||||
.from("resources")
|
||||
.update(serviceData)
|
||||
.eq("id", id)
|
||||
.select();
|
||||
} else {
|
||||
result = await supabase
|
||||
.from("resources")
|
||||
.insert([serviceData])
|
||||
.select();
|
||||
project: {
|
||||
title: '专案模板',
|
||||
component: ProjectTemplate,
|
||||
},
|
||||
task: {
|
||||
title: '任务模板',
|
||||
component: TaskTemplate,
|
||||
}
|
||||
};
|
||||
|
||||
if (result.error) throw result.error;
|
||||
message.success("保存成功");
|
||||
navigate("/company/serviceTeamplate");
|
||||
} catch (error) {
|
||||
console.error("保存失败:", error);
|
||||
message.error("保存失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const ServiceForm = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const type = searchParams.get('type') || 'quotation';
|
||||
const { id } = useParams();
|
||||
const isView = searchParams.get('isView') === 'true';
|
||||
|
||||
const currentTemplate = TEMPLATE_CONFIG[type];
|
||||
const TemplateComponent = currentTemplate?.component;
|
||||
|
||||
if (!currentTemplate) {
|
||||
return <div>无效的模板类型</div>;
|
||||
}
|
||||
};
|
||||
|
||||
const handleValuesChange = (changedValues, allValues) => {
|
||||
setFormValues(allValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-b from-gray-50 to-white 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">
|
||||
<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 level={4} className="mb-0 text-gray-800 dark:text-gray-200">
|
||||
{id ? (isView ? "查看" : "编辑") : "新建"}{currentTemplate.title}
|
||||
</Title>
|
||||
<span className="text-gray-400 text-sm">
|
||||
{id
|
||||
? isEdit
|
||||
? "请修改服务模版信息"
|
||||
: "服务模版详情"
|
||||
: "请填写服务模版信息"}
|
||||
? isView
|
||||
? `${currentTemplate.title}详情`
|
||||
: `请修改${currentTemplate.title}信息`
|
||||
: `请填写${currentTemplate.title}信息`}
|
||||
</span>
|
||||
</div>
|
||||
<Space size="middle">
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate("/company/serviceTeamplate")}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
{!isView&& <Button
|
||||
type="primary"
|
||||
onClick={() => form.submit()}
|
||||
loading={loading}
|
||||
>
|
||||
保存
|
||||
</Button>}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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}
|
||||
<TemplateComponent
|
||||
id={id}
|
||||
isView={isView}
|
||||
onCancel={() => navigate("/company/serviceTeamplate")}
|
||||
/>
|
||||
</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}
|
||||
>
|
||||
<SectionList
|
||||
form={form}
|
||||
isView={!isEdit && id}
|
||||
formValues={formValues}
|
||||
onValuesChange={handleValuesChange}
|
||||
/>
|
||||
</Card>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -83,7 +83,7 @@ const SupplierForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-b from-gray-50 to-white 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">
|
||||
<Card
|
||||
className="shadow-lg rounded-lg border-0"
|
||||
title={
|
||||
|
||||
@@ -172,7 +172,7 @@ const ResourceTaskForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-b from-gray-50 to-white 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">
|
||||
<Card
|
||||
className="shadow-lg rounded-lg border-0"
|
||||
title={
|
||||
|
||||
@@ -148,3 +148,39 @@ body {
|
||||
@apply text-2xl font-semibold mt-2;
|
||||
}
|
||||
}
|
||||
/* 滚动条基础样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px; /* 垂直滚动条宽度 */
|
||||
height: 8px; /* 水平滚动条高度 */
|
||||
}
|
||||
|
||||
/* 亮色模式滚动条样式 */
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100; /* 轨道背景色 */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded-full hover:bg-gray-400 transition-colors; /* 滑块样式 */
|
||||
}
|
||||
|
||||
/* 暗色模式滚动条样式 */
|
||||
.dark {
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-gray-800; /* 暗色模式轨道背景 */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-600 hover:bg-gray-500; /* 暗色模式滑块样式 */
|
||||
}
|
||||
}
|
||||
|
||||
/* Firefox 滚动条样式 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: theme('colors.gray.300') theme('colors.gray.100');
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: theme('colors.gray.600') theme('colors.gray.800');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user