Merge branch 'main' of github.com:xuqssq/uppmkt-admin

This commit is contained in:
xuqssq
2024-12-23 01:16:22 +08:00
13 changed files with 2820 additions and 608 deletions

9
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
styled-components: styled-components:
specifier: ^6.1.0 specifier: ^6.1.0
version: 6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
uuid:
specifier: ^11.0.3
version: 11.0.3
devDependencies: devDependencies:
'@types/react': '@types/react':
specifier: ^18.2.15 specifier: ^18.2.15
@@ -2420,6 +2423,10 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@11.0.3:
resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==}
hasBin: true
victory-vendor@36.9.2: victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
@@ -5318,6 +5325,8 @@ snapshots:
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
uuid@11.0.3: {}
victory-vendor@36.9.2: victory-vendor@36.9.2:
dependencies: dependencies:
'@types/d3-array': 3.2.1 '@types/d3-array': 3.2.1

View File

@@ -26,6 +26,33 @@ const ThemedApp = () => {
? "rgba(255, 255, 255, 0.45)" ? "rgba(255, 255, 255, 0.45)"
: "rgba(0, 0, 0, 0.45)", : "rgba(0, 0, 0, 0.45)",
}, },
components: {
// 为所有支持 variant 的组件设置 filled 模式
Form: {
variant: 'filled',
},
Input: {
variant: 'filled',
},
Select: {
variant: 'filled',
},
TreeSelect: {
variant: 'filled',
},
DatePicker: {
variant: 'filled',
},
TimePicker: {
variant: 'filled',
},
Cascader: {
variant: 'filled',
},
AutoComplete: {
variant: 'filled',
},
}
}} }}
> >
<div className={isDarkMode ? "dark" : ""}> <div className={isDarkMode ? "dark" : ""}>

View File

@@ -103,7 +103,6 @@ export const AuthProvider = ({ children }) => {
}, },
}); });
console.log(data, error, "data");
if (error) { if (error) {
message.error(error.message || "Google 登录失败,请稍后重试"); message.error(error.message || "Google 登录失败,请稍后重试");
return; return;

View File

@@ -1,24 +0,0 @@
import { Card, Avatar, Typography, Descriptions } from 'antd';
import { UserOutlined } from '@ant-design/icons';
const { Title } = Typography;
const Profile = () => {
return (
<Card>
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
<Avatar size={64} icon={<UserOutlined />} />
<Title level={3} style={{ marginTop: '16px' }}>John Doe</Title>
</div>
<Descriptions bordered>
<Descriptions.Item label="Username">johndoe</Descriptions.Item>
<Descriptions.Item label="Email">john@example.com</Descriptions.Item>
<Descriptions.Item label="Role">Administrator</Descriptions.Item>
<Descriptions.Item label="Status">Active</Descriptions.Item>
<Descriptions.Item label="Join Date">2023-10-25</Descriptions.Item>
</Descriptions>
</Card>
);
};
export default Profile;

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,28 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Card, Table, Button, message, Popconfirm, Tag, Space, Tooltip } from 'antd'; import { Card, Table, Button, message, Popconfirm, Tag, Space, Spin, Modal, Empty, Select, Typography, Statistic, Divider } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined,CopyOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined,CopyOutlined, FileAddOutlined, AppstoreOutlined } from '@ant-design/icons';
import { useResources } from '@/hooks/resource/useResource'; import { useResources } from '@/hooks/resource/useResource';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { formatCurrency } from '@/utils/format'; // 假设你有这个工具函数,如果没有我会提供 import {supabase}from '@/config/supabase'
const CURRENCY_SYMBOLS = {
CNY: "¥",
TWD: "NT$",
USD: "$",
};
const QuotationPage = () => { const QuotationPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 }); const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
const [sorter, setSorter] = useState({ field: 'created_at', order: 'descend' }); 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 [selectedCategory, setSelectedCategory] = useState('all');
const [categories, setCategories] = useState([]);
const { const {
resources: quotations, resources: quotations,
loading, loading: loadingQuotations,
total, total,
fetchResources: fetchQuotations, fetchResources: fetchQuotations,
deleteResource: deleteQuotation deleteResource: deleteQuotation
@@ -43,6 +53,68 @@ const QuotationPage = () => {
} }
}; };
const fetchTemplates = async () => {
try {
setLoading(true);
const { data: services, error } = await supabase
.from("resources")
.select("*")
.eq("type", "serviceTemplate")
.order("created_at", { ascending: false });
if (error) throw error;
setTemplates(services);
} catch (error) {
console.error("获取服务模板失败:", error);
message.error("获取服务模板失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isModalVisible) {
fetchTemplates();
}
}, [isModalVisible]);
useEffect(() => {
if (templates.length > 0) {
setCategories(getAllCategories(templates));
}
}, [templates]);
const handleTemplateSelect = (templateId) => {
setSelectedTemplateId(templateId);
};
const handleConfirm = () => {
if (selectedTemplateId) {
navigate(`/company/quotaInfo?templateId=${selectedTemplateId}`);
} else {
navigate('/company/quotaInfo');
}
setIsModalVisible(false);
setSelectedTemplateId(null);
};
const getAllCategories = (templates) => {
const categorySet = new Set();
templates.forEach(template => {
template.attributes.category?.forEach(cat => {
categorySet.add(JSON.stringify(cat));
});
});
return Array.from(categorySet).map(cat => JSON.parse(cat));
};
const getFilteredTemplates = () => {
if (selectedCategory === 'all') return templates;
return templates.filter(template =>
template.attributes.category?.some(cat => cat.id === selectedCategory)
);
};
const columns = [ const columns = [
{ {
title: '报价单名称', title: '报价单名称',
@@ -52,24 +124,59 @@ const QuotationPage = () => {
}, },
{ {
title: '客户信息', title: '客户信息',
dataIndex: ['attributes', 'customerName'], dataIndex: ['attributes', 'customers'],
key: 'customerName', key: 'customers',
render: (customerName,record) => ( render: (customers,record) => (
<Tag color="blue" className='cursor-pointer' onClick={() => { <Space>
navigate(`/company/customerInfo/${record.attributes.customerId}`) {customers?.map(customer => (
}}>{customerName}</Tag> <Tag
key={customer.id}
color="blue"
className='cursor-pointer'
onClick={() => {
navigate(`/company/customerInfo/${customer.id}`)
}}
>
{customer.name}
</Tag>
))}
</Space>
), ),
}, },
{ {
title: '报价总额', title: '报价总额',
dataIndex: ['attributes', 'totalAmount'], dataIndex: ['attributes'],
key: 'totalAmount', key: 'totalAmount',
align: 'right', align: 'right',
render: (amount) => ( width: 200,
<span style={{ color: '#f50', fontWeight: 'bold' }}> render: (attributes) => {
¥{amount?.toLocaleString()} // 获取货币符号
</span> const currencySymbol = CURRENCY_SYMBOLS[attributes.currency] || '¥';
),
return (
<div className="space-y-1">
<div className="flex justify-between items-center text-sm">
<Typography.Text type="secondary" style={{ fontSize: '12px' }}>
税前{currencySymbol}{attributes.beforeTaxAmount?.toLocaleString()}
</Typography.Text>
</div>
<div className="flex justify-between items-center">
<Statistic
value={attributes.afterTaxAmount}
prefix={currencySymbol}
precision={2}
valueStyle={{
fontSize: '16px',
fontWeight: 600,
color: '#1890ff'
}}
className="!mb-0"
/>
</div>
</div>
);
},
}, },
{ {
title: '创建日期', title: '创建日期',
@@ -135,40 +242,164 @@ const QuotationPage = () => {
}, },
]; ];
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
<Space> title={
<span>报价单管理</span> <Space>
<Tag color="blue">{total} 报价单</Tag> <span>报价单管理</span>
</Space> <Tag color="blue">{total} 个报价单</Tag>
} </Space>
className='h-full w-full overflow-auto' }
extra={ className='h-full w-full overflow-auto'
<Button extra={
type="primary" <Button
icon={<PlusOutlined />} type="primary"
onClick={() => navigate('/company/quotaInfo')} icon={<PlusOutlined />}
> onClick={() => setIsModalVisible(true)}
新增报价单 >
</Button> 新增报价单
} </Button>
> }
<Table >
columns={columns} <Table
dataSource={quotations} columns={columns}
rowKey="id" dataSource={quotations}
loading={loading} rowKey="id"
onChange={handleTableChange} loading={loadingQuotations}
pagination={{ onChange={handleTableChange}
...pagination, pagination={{
total, ...pagination,
showSizeChanger: true, total,
showQuickJumper: true, showSizeChanger: true,
showTotal: (total) => `${total} 条记录`, showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
}}
/>
</Card>
<Modal
title="选择报价单模板"
open={isModalVisible}
onCancel={() => {
setIsModalVisible(false);
setSelectedTemplateId(null);
}} }}
/> footer={[
</Card> <Button key="custom" onClick={() => handleConfirm()}>
<FileAddOutlined /> 自定义创建
</Button>,
<Button
key="submit"
type="primary"
disabled={!selectedTemplateId}
onClick={handleConfirm}
>
<AppstoreOutlined /> 使用选中模板
</Button>,
]}
width={800}
>
{loading ? (
<div className="flex justify-center items-center h-[400px]">
<Spin size="large" />
</div>
) : templates.length === 0 ? (
<Empty description="暂无可用模板" />
) : (
<div className="max-h-[600px] overflow-y-auto px-1">
{getTemplatesByCategory().map((group, groupIndex) => (
<div key={groupIndex} className="mb-6 last:mb-2">
<div className="flex items-center gap-2 mb-3">
<div className="h-6 w-1 bg-blue-500 rounded-full"></div>
<h3 className="text-base font-medium text-gray-700">
{group.name}
<span className="ml-2 text-sm text-gray-400 font-normal">
({group.templates.length})
</span>
</h3>
</div>
<div className="grid grid-cols-3 gap-3">
{group.templates.map(template => (
<div
key={template.id}
className={`
p-3 border rounded-lg cursor-pointer transition-all
${selectedTemplateId === template.id
? 'border-blue-500 bg-blue-50/50'
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50/50'
}
`}
onClick={() => handleTemplateSelect(template.id)}
>
<div className="flex justify-between items-start gap-2 mb-2">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-800 truncate">
{template.attributes.templateName}
</h4>
<p className="text-xs text-gray-500 mt-1 line-clamp-1">
{template.attributes.description || '暂无描述'}
</p>
</div>
<span className="text-red-500 font-medium whitespace-nowrap text-sm">
¥{template.attributes.totalAmount?.toLocaleString()}
</span>
</div>
<div className="space-y-1">
{template.attributes.sections.map((section, index) => (
<div
key={index}
className="bg-white/80 px-2 py-1 rounded text-xs border border-gray-100"
>
<div className="flex justify-between items-center">
<span className="font-medium text-blue-600 truncate flex-1">
{section.sectionName}
</span>
<span className="text-gray-400 ml-1">
{section.items.length}
</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</Modal>
</>
); );
}; };

View File

@@ -0,0 +1,241 @@
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';
const CategoryDrawer = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [editingKey, setEditingKey] = useState('');
const [drawerVisible, setDrawerVisible] = useState(false);
const [form] = Form.useForm();
// 获取分类数据
const fetchCategories = async () => {
setLoading(true);
try {
const { data: categories, error } = await supabase
.from('resources')
.select('*')
.eq('type', 'categories')
.order('created_at', { ascending: false });
if (error) throw error;
setData(categories || []);
} catch (error) {
message.error('获取分类数据失败');
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (drawerVisible) {
fetchCategories();
}
}, [drawerVisible]);
// 新增分类
const handleAdd = () => {
const newData = {
id: Date.now().toString(),
attributes: { name: '' },
isNew: true
};
setData([newData, ...data]);
setEditingKey(newData.id);
form.setFieldsValue(newData.attributes);
};
// 保存分类数据
const handleSave = async (record) => {
try {
const values = await form.validateFields();
if (record.isNew) {
// 新增
const { error } = await supabase
.from('resources')
.insert([{
type: 'categories',
attributes: {
name: values.name
},
schema_version: 1
}]);
if (error) throw error;
} else {
// 更新
const { error } = await supabase
.from('resources')
.update({
attributes: {
name: values.name
},
updated_at: new Date().toISOString()
})
.eq('id', record.id);
if (error) throw error;
}
message.success('保存成功');
setEditingKey('');
fetchCategories();
} catch (error) {
message.error('保存失败');
console.error(error);
}
};
// 删除分类
const handleDelete = async (record) => {
try {
const { error } = await supabase
.from('resources')
.delete()
.eq('id', record.id);
if (error) throw error;
message.success('删除成功');
fetchCategories();
} catch (error) {
message.error('删除失败');
console.error(error);
}
};
const columns = [
{
title: '分类名称',
dataIndex: ['attributes', 'name'],
width: '60%',
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="text-gray-600">{text}</span>
);
},
},
{
title: '操作',
width: '40%',
render: (_, record) => {
const isEditing = record.id === editingKey;
return isEditing ? (
<Space>
<Button
type="link"
className="text-green-600 hover:text-green-500"
onClick={() => handleSave(record)}
>
保存
</Button>
<Button
type="link"
className="text-gray-600 hover:text-gray-500"
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"
disabled={editingKey !== ''}
onClick={() => {
setEditingKey(record.id);
form.setFieldsValue({
name: record.attributes.name,
});
}}
>
编辑
</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"
disabled={editingKey !== ''}
>
删除
</Button>
</Popconfirm>
</Space>
);
},
},
];
return (
<>
<Button
type="primary"
onClick={() => setDrawerVisible(true)}
icon={<PlusOutlined />}
>
分类管理
</Button>
<Drawer
title="分类管理"
placement="right"
width={800}
onClose={() => setDrawerVisible(false)}
open={drawerVisible}
className="category-drawer"
>
<div className="p-6">
<Button
type="primary"
onClick={handleAdd}
className="mb-4"
icon={<PlusOutlined />}
>
新增分类
</Button>
<Form form={form}>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showTotal: (total) => `${total}`,
}}
className="bg-white rounded-lg shadow"
/>
</Form>
</div>
</Drawer>
</>
);
};
export default CategoryDrawer;

View File

@@ -9,16 +9,20 @@ import {
Typography, Typography,
message, message,
Select, Select,
Modal,
Divider,
Popconfirm,
} from "antd"; } from "antd";
import { import {
PlusOutlined, PlusOutlined,
DeleteOutlined, DeleteOutlined,
ArrowLeftOutlined, ArrowLeftOutlined,
EditOutlined, EditOutlined,
CloseOutlined,
CheckOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useNavigate, useParams, useLocation } from "react-router-dom"; import { useNavigate, useParams, useLocation } from "react-router-dom";
import { supabase } from "@/config/supabase"; import { supabase } from "@/config/supabase";
const { Title, Text } = Typography; const { Title, Text } = Typography;
const ServiceForm = () => { const ServiceForm = () => {
@@ -34,11 +38,17 @@ const ServiceForm = () => {
sections: [{ items: [{}] }], sections: [{ items: [{}] }],
currency: "CNY" currency: "CNY"
}); });
const [templateModalVisible, setTemplateModalVisible] = useState(false);
const [categories, setCategories] = useState([]);
const [units, setUnits] = useState([]);
useEffect(() => { useEffect(() => {
if (id) { if (id) {
fetchServiceTemplate(); fetchServiceTemplate();
} }
fetchAvailableSections(); fetchAvailableSections();
fetchCategories();
fetchUnits();
}, [id]); }, [id]);
const fetchServiceTemplate = async () => { const fetchServiceTemplate = async () => {
@@ -54,6 +64,7 @@ const ServiceForm = () => {
const formData = { const formData = {
templateName: data.attributes.templateName, templateName: data.attributes.templateName,
description: data.attributes.description, description: data.attributes.description,
category:data.attributes.category.map(v=>v.id),
sections: data.attributes.sections, sections: data.attributes.sections,
}; };
form.setFieldsValue(formData); form.setFieldsValue(formData);
@@ -69,15 +80,60 @@ const ServiceForm = () => {
const fetchAvailableSections = async () => { const fetchAvailableSections = async () => {
try { try {
const { data: sections, error } = await supabase const { data: sections, error } = await supabase
.from("resources") .from('resources')
.select("*") .select('*')
.eq("type", "serviceSection"); .eq('type', 'sections')
.order('created_at', { ascending: false });
if (error) throw error; if (error) throw error;
setAvailableSections(sections); setAvailableSections(sections || []);
} catch (error) { } catch (error) {
console.error("获取服务小节失败:", error); message.error('获取小节模版失败');
message.error("获取服务小节失败"); console.error(error);
}
};
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 fetchUnits = async () => {
try {
const { data: unitsData, error } = await supabase
.from('resources')
.select('*')
.eq('type', 'units')
.order('created_at', { ascending: false });
if (error) throw error;
const formattedUnits = (unitsData || []).map(unit => ({
value: unit.attributes.name,
label: unit.attributes.name
}));
setUnits(formattedUnits);
} catch (error) {
console.error('获取单位数据失败:', error);
message.error('获取单位数据失败');
} }
}; };
@@ -92,14 +148,20 @@ const ServiceForm = () => {
}, 0) }, 0)
); );
}, 0); }, 0);
const categoryData = values.category.map(categoryId => {
const category = categories.find(c => c.value === categoryId);
return {
id: categoryId,
name: category.label
};
});
const serviceData = { const serviceData = {
type: "serviceTemplate", type: "serviceTemplate",
attributes: { attributes: {
templateName: values.templateName, templateName: values.templateName,
description: values.description, description: values.description,
sections: values.sections, sections: values.sections,
category: values.category || [], // 添加 category 字段 category: categoryData,
totalAmount, totalAmount,
}, },
}; };
@@ -241,6 +303,243 @@ const ServiceForm = () => {
setEditingSectionName(''); setEditingSectionName('');
}; };
// 使用选中的模版
const handleUseTemplate = (template) => {
const currentSections = form.getFieldValue('sections') || [];
// 确保所有必要的字段都存在
const newSection = {
sectionName: template.attributes.name,
items: (template.attributes.items || []).map(item => ({
name: item.name || '',
description: item.description || '',
price: item.price || 0,
quantity: item.quantity || 1,
unit: item.unit || '',
}))
};
const newSections = [...currentSections, newSection];
// 更新表单值
form.setFieldValue('sections', newSections);
// 更新 formValues 以触发金额计算
setFormValues(prev => ({
...prev,
sections: newSections
}));
setTemplateModalVisible(false);
message.success('套用模版成功');
};
// 创建自定义小节
const handleCreateCustom = () => {
const currentSections = form.getFieldValue('sections') || [];
const newSection = {
sectionName: `服务类型 ${currentSections.length + 1}`,
items: [{ name: '', description: '', price: 0, quantity: 1, unit: '' }]
};
const newSections = [...currentSections, newSection];
// 更新表单值
form.setFieldValue('sections', newSections);
// 更新 formValues 以触发金额计算
setFormValues(prev => ({
...prev,
sections: newSections
}));
setTemplateModalVisible(false);
};
// 修改新增小节按钮的点击事件处理
const handleAddSection = () => {
setTemplateModalVisible(true);
fetchAvailableSections();
};
// 修改移除小节的处理方法
const handleRemoveSection = (sectionIndex) => {
const currentSections = form.getFieldValue('sections') || [];
const newSections = currentSections.filter((_, index) => index !== sectionIndex);
// 更新表单值
form.setFieldValue('sections', newSections);
// 更新 formValues 以触发金额计算
setFormValues(prev => ({
...prev,
sections: newSections
}));
message.success('删除成功');
};
// 优化模版选择弹窗内容
const renderTemplateModalContent = () => (
<div className="space-y-6">
<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 dark:bg-gray-800 rounded-lg shadow-sm
border border-gray-200 dark:border-gray-700 hover:shadow-lg
transition-all duration-300 cursor-pointer"
onClick={() => handleUseTemplate(section)}
>
<div className="p-6">
{/* 标题和项目数量 */}
<div className="text-center mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100
group-hover:text-blue-500 transition-colors">
{section.attributes.name}
</h3>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{section.attributes.items?.length || 0} 个项目
</div>
</div>
{/* 项目列表预览 */}
<div className="space-y-2 mt-4 border-t dark:border-gray-700 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 dark:text-gray-300 truncate flex-1">
{item.name}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400 ml-2">
{formatCurrency(item.price)}
</span>
</div>
))}
{(section.attributes.items || []).length > 3 && (
<div className="text-sm text-gray-500 dark:text-gray-400 text-center">
还有 {section.attributes.items.length - 3} 个项目...
</div>
)}
</div>
{/* 小节总金额 */}
<div className="mt-4 pt-4 border-t dark:border-gray-700 flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-300">总金额</span>
<span className="text-base font-medium text-blue-500">
{formatCurrency(
(section.attributes.items || []).reduce(
(sum, item) => sum + (item.price * (item.quantity || 1) || 0),
0
)
)}
</span>
</div>
</div>
{/* 悬边框效果 */}
<div className="absolute inset-0 border-2 border-transparent
group-hover:border-blue-500 rounded-lg transition-colors duration-300"
/>
{/* 选择指示器 */}
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100
transition-opacity duration-300">
<Button
type="primary"
size="small"
className="flex items-center gap-1"
icon={<PlusOutlined />}
>
套用
</Button>
</div>
</div>
))}
</div>
{availableSections.length === 0 && (
<div className="text-center py-8">
<div className="text-gray-500 dark:text-gray-400">
暂无可用模版
</div>
</div>
)}
<Divider className="dark:border-gray-700" />
<div className="flex justify-center">
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={handleCreateCustom}
className="w-1/3 border-2 hover:border-blue-400 hover:text-blue-500
dark:border-gray-600 dark:text-gray-400 dark:hover:text-blue-400
transition-all duration-300"
>
自定义小节
</Button>
</div>
</div>
);
const renderUnitSelect = (itemField, sectionIndex, itemIndex) => {
const addUnit = async (unitName) => {
try {
const { error } = await supabase
.from('resources')
.insert([{
type: 'units',
attributes: {
name: unitName
},
schema_version: 1
}]);
if (error) throw error;
message.success('新增单位成功');
await fetchUnits();
// 自动选中新添加的单位
form.setFieldValue(['sections', sectionIndex, 'items', itemIndex, 'unit'], unitName);
} catch (error) {
message.error('新增单位失败');
console.error(error);
}
};
return (
<Form.Item
{...itemField}
name={[itemField.name, "unit"]}
className="!mb-0"
>
<Select
placeholder="选择或输入单位"
options={units}
showSearch
allowClear
onSearch={(value) => {
// 当输入的值不在现有选项中时,显示添加选项
if (value && !units.find(unit => unit.value === value)) {
const newOption = { value, label: `添加 "${value}"` };
setUnits([...units, newOption]);
}
}}
onSelect={(value, option) => {
// 如果选择的是新添加的选项
if (option.label.startsWith('添加 "')) {
addUnit(value);
}
}}
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
/>
</Form.Item>
);
};
return ( return (
<Card <Card
title={ title={
@@ -313,12 +612,8 @@ const ServiceForm = () => {
showSearch showSearch
allowClear allowClear
mode="tags" mode="tags"
options={[ options={categories}
{ value: "VI设计", label: "VI设计" }, loading={loading}
{ value: "平面设计", label: "平面设计" },
{ value: "网站建设", label: "网站建设" },
{ value: "营销推广", label: "营销推广" },
]}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@@ -340,48 +635,42 @@ const ServiceForm = () => {
</div> </div>
<Form.List name="sections"> <Form.List name="sections">
{(fields, { add: addSection, remove: removeSection }) => ( {(fields, { add, remove }) => (
<> <>
<div className="space-y-6"> <div className="space-y-6">
{fields.map((field, sectionIndex) => ( {fields.map((field, sectionIndex) => (
<Card <Card
key={field.key} key={field.key}
className="!border-gray-200 dark:!border-gray-700 dark:bg-gray-800 hover:shadow-md transition-shadow duration-300" className="!border-gray-200 dark:!border-gray-700
dark:bg-gray-800 hover:shadow-md transition-shadow duration-300"
> >
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 "> <div className="flex items-center gap-2">
{editingSectionIndex === sectionIndex ? ( {editingSectionIndex === sectionIndex ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
placeholder="请输入小节名称" value={editingSectionName}
className="!w-[200px]" onChange={(e) => setEditingSectionName(e.target.value)}
value={editingSectionName} onPressEnter={handleSectionNameSave}
onChange={e => setEditingSectionName(e.target.value)} autoFocus
onPressEnter={handleSectionNameSave} className="w-48"
autoFocus />
/>
<Space className="flex items-center gap-2">
<Button <Button
type="primary" type="link"
size="small" icon={<CheckOutlined />}
onClick={handleSectionNameSave} onClick={handleSectionNameSave}
> className="text-green-500 hover:text-green-600"
保存 />
</Button>
<Button <Button
size="small" type="link"
icon={<CloseOutlined />}
onClick={handleSectionNameCancel} onClick={handleSectionNameCancel}
> className="text-red-500 hover:text-red-600"
取消 />
</Button> </div>
</Space>
</div>
) : ( ) : (
<> <div className="flex items-center gap-2">
<Text <Text strong className="text-lg dark:text-gray-200">
strong
className="text-lg dark:text-gray-200"
>
{form.getFieldValue([ {form.getFieldValue([
"sections", "sections",
sectionIndex, sectionIndex,
@@ -390,7 +679,7 @@ const ServiceForm = () => {
</Text> </Text>
{(!id || isEdit) && ( {(!id || isEdit) && (
<Button <Button
type="text" type="link"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => onClick={() =>
handleSectionNameEdit( handleSectionNameEdit(
@@ -399,23 +688,37 @@ const ServiceForm = () => {
"sections", "sections",
sectionIndex, sectionIndex,
"sectionName", "sectionName",
]) ]) || `服务类型 ${sectionIndex + 1}`
) )
} }
size="small" className="text-gray-400 hover:text-blue-500"
/> />
)} )}
</> </div>
)} )}
</div> </div>
<Text className="text-gray-500 dark:text-gray-400"> <div className="flex items-center gap-4">
合计:{" "}
{formatCurrency( {(!id || isEdit) && (
calculateSectionTotal( <Popconfirm
formValues?.sections?.[sectionIndex]?.items title="确认删除"
) description="确定要删除这个小节吗?"
onConfirm={() => handleRemoveSection(sectionIndex)}
okText="确定"
cancelText="取消"
okButtonProps={{
className: "bg-red-500 hover:bg-red-600 border-red-500"
}}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
className="hover:text-red-500 transition-colors"
/>
</Popconfirm>
)} )}
</Text> </div>
</div> </div>
{/* 表头 */} {/* 表头 */}
@@ -463,13 +766,7 @@ const ServiceForm = () => {
className="w-full" className="w-full"
/> />
</Form.Item> </Form.Item>
<Form.Item {renderUnitSelect(itemField, sectionIndex, itemIndex)}
{...itemField}
name={[itemField.name, "unit"]}
className="!mb-0"
>
<Input placeholder="单位" />
</Form.Item>
<Form.Item <Form.Item
{...itemField} {...itemField}
name={[itemField.name, "price"]} name={[itemField.name, "price"]}
@@ -538,12 +835,14 @@ const ServiceForm = () => {
</div> </div>
{(!id || isEdit) && ( {(!id || isEdit) && (
<div className="mt-6 flex gap-4 justify-center"> <div className="mt-6 flex justify-center">
<Button <Button
type="dashed" type="dashed"
onClick={() => addSection({ items: [{}] })} onClick={handleAddSection}
icon={<PlusOutlined />} icon={<PlusOutlined />}
className="w-1/3 hover:border-blue-400 hover:text-blue-500 dark:border-gray-600 dark:text-gray-400 dark:hover:text-blue-400" className="w-1/3 border-2 hover:border-blue-400 hover:text-blue-500
dark:border-gray-600 dark:text-gray-400 dark:hover:text-blue-400
transition-all duration-300"
> >
新建小节 新建小节
</Button> </Button>
@@ -589,6 +888,25 @@ const ServiceForm = () => {
</Button> </Button>
</div> </div>
)} )}
{/* 模版选择弹窗 */}
<Modal
title={
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
选择小节模版
</h3>
}
open={templateModalVisible}
onCancel={() => setTemplateModalVisible(false)}
footer={null}
width={800}
className="dark:bg-gray-800"
closeIcon={
<CloseOutlined className="text-gray-500 dark:text-gray-400" />
}
>
{renderTemplateModalContent()}
</Modal>
</Form> </Form>
</Card> </Card>
); );

View File

@@ -1,16 +1,35 @@
import { Card, Table, Button, Space, Input, message, Popconfirm, Form, Tag, Typography } from "antd"; import {
import { DownOutlined, UpOutlined,EyeOutlined,EditOutlined } from "@ant-design/icons"; Card,
Table,
Button,
Space,
Input,
message,
Popconfirm,
Form,
Tag,
Typography,
} from "antd";
import {
DownOutlined,
UpOutlined,
EyeOutlined,
EditOutlined,
} from "@ant-design/icons";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { supabase } from "@/config/supabase"; import { supabase } from "@/config/supabase";
import UnitManagement from "@/pages/company/service/unit";
import Sections from "@/pages/company/service/sections";
import CategoryDrawer from "@/pages/company/service/classify";
const { Paragraph } = Typography; const { Paragraph } = Typography;
const ServicePage = () => { const ServicePage = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [data, setData] = useState([]); const [data, setData] = useState([]);
const [categories, setCategories] = useState([]);
const navigate = useNavigate(); const navigate = useNavigate();
const [editingKey, setEditingKey] = useState(''); const [editingKey, setEditingKey] = useState("");
// 获取服务模板列表 // 获取服务模板列表
const fetchServices = async () => { const fetchServices = async () => {
@@ -32,19 +51,35 @@ const ServicePage = () => {
} }
}; };
// 添加获取分类数据的方法
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;
setCategories(categoriesData || []);
} catch (error) {
console.error('获取分类数据失败:', error);
message.error('获取分类数据失败');
}
};
// 修改保存逻辑 // 修改保存逻辑
const handleSaveItem = async (record) => { const handleSaveItem = async (record) => {
try { try {
setLoading(true); setLoading(true);
const { serviceId, sectionIndex, itemIndex } = record; const { serviceId, sectionIndex, itemIndex } = record;
// 找到当前服务模板 // 找到当前服务模板
const currentService = data.find(item => item.id === serviceId); const currentService = data.find((item) => item.id === serviceId);
if (!currentService) { if (!currentService) {
throw new Error('Service not found'); throw new Error("Service not found");
} }
// 创建新的 attributes 对象,避免直接修改状态
const newAttributes = { const newAttributes = {
...currentService.attributes, ...currentService.attributes,
templateName: currentService.attributes.templateName, templateName: currentService.attributes.templateName,
@@ -60,47 +95,57 @@ const ServicePage = () => {
name: record.name, name: record.name,
price: Number(record.price) || 0, price: Number(record.price) || 0,
quantity: Number(record.quantity) || 0, quantity: Number(record.quantity) || 0,
description: record.description || '', description: record.description || "",
unit: record.unit || '' unit: record.unit || "",
}; };
} }
return item; return item;
}) }),
}; };
} }
return section; return section;
}) }),
}; };
// 重新计算总金额 // 重新计算总金额
newAttributes.totalAmount = newAttributes.sections.reduce((total, section) => { newAttributes.totalAmount = newAttributes.sections.reduce(
return total + section.items.reduce((sectionTotal, item) => (total, section) => {
sectionTotal + ((Number(item.price) || 0) * (Number(item.quantity) || 0)), 0); return (
}, 0); total +
section.items.reduce(
(sectionTotal, item) =>
sectionTotal +
(Number(item.price) || 0) * (Number(item.quantity) || 0),
0
)
);
},
0
);
// 调用 supabase 更新数据 // 调用 supabase 更新数据
const { error } = await supabase const { error } = await supabase
.from('resources') .from("resources")
.update({ .update({
type: "serviceTemplate", type: "serviceTemplate",
attributes: newAttributes attributes: newAttributes,
}) })
.eq('id', serviceId); .eq("id", serviceId);
if (error) throw error; if (error) throw error;
// 更新本地状态 // 更新本地状态
setData(prevData => prevData.map(item => setData((prevData) =>
item.id === serviceId prevData.map((item) =>
? { ...item, attributes: newAttributes } item.id === serviceId ? { ...item, attributes: newAttributes } : item
: item )
)); );
setEditingKey(''); setEditingKey("");
message.success('保存成功'); message.success("保存成功");
} catch (error) { } catch (error) {
console.error('保存失败:', error); console.error("保存失败:", error);
message.error('保存失败'); message.error("保存失败");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -111,11 +156,11 @@ const ServicePage = () => {
try { try {
setLoading(true); setLoading(true);
const { serviceId, sectionIndex, itemIndex } = record; const { serviceId, sectionIndex, itemIndex } = record;
// 找到目标服务 // 找到目标服务
const targetService = data.find(item => item.id === serviceId); const targetService = data.find((item) => item.id === serviceId);
if (!targetService) throw new Error('Service not found'); if (!targetService) throw new Error("Service not found");
// 创建新的 attributes 对象,避免直接修改状态 // 创建新的 attributes 对象,避免直接修改状态
const newAttributes = { const newAttributes = {
...targetService.attributes, ...targetService.attributes,
@@ -123,40 +168,51 @@ const ServicePage = () => {
if (secIdx === sectionIndex) { if (secIdx === sectionIndex) {
return { return {
...section, ...section,
items: section.items.filter((_, idx) => idx !== itemIndex) items: section.items.filter((_, idx) => idx !== itemIndex),
}; };
} }
return section; return section;
}) }),
}; };
// 重新计算总金额 // 重新计算总金额
newAttributes.totalAmount = newAttributes.sections.reduce((total, section) => { newAttributes.totalAmount = newAttributes.sections.reduce(
return total + section.items.reduce((sectionTotal, item) => (total, section) => {
sectionTotal + ((Number(item.price) || 0) * (Number(item.quantity) || 0)), 0); return (
}, 0); total +
section.items.reduce(
(sectionTotal, item) =>
sectionTotal +
(Number(item.price) || 0) * (Number(item.quantity) || 0),
0
)
);
},
0
);
// 调用 supabase 更新数据 // 调用 supabase 更新数据
const { error } = await supabase const { error } = await supabase
.from('resources') .from("resources")
.update({
attributes: newAttributes .update({
attributes: newAttributes,
}) })
.eq('id', serviceId); .eq("id", serviceId);
if (error) throw error; if (error) throw error;
// 更新本地状态 // 更新本地状态
setData(prevData => prevData.map(item => setData((prevData) =>
item.id === serviceId prevData.map((item) =>
? { ...item, attributes: newAttributes } item.id === serviceId ? { ...item, attributes: newAttributes } : item
: item )
)); );
message.success('删除成功'); message.success("删除成功");
} catch (error) { } catch (error) {
console.error('删除失败:', error); console.error("删除失败:", error);
message.error('删<><E588A0><EFBFBD>失败'); message.error("删除失败");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -166,21 +222,21 @@ const ServicePage = () => {
const handleDeleteService = async (serviceId) => { const handleDeleteService = async (serviceId) => {
try { try {
setLoading(true); setLoading(true);
// 调用 supabase 删除数据 // 调用 supabase 删除数据
const { error } = await supabase const { error } = await supabase
.from('resources') .from("resources")
.delete() .delete()
.eq('id', serviceId); .eq("id", serviceId);
if (error) throw error; if (error) throw error;
// 更新本地状态 // 更新本地状态
setData(prevData => prevData.filter(item => item.id !== serviceId)); setData((prevData) => prevData.filter((item) => item.id !== serviceId));
message.success('删除成功'); message.success("删除成功");
} catch (error) { } catch (error) {
console.error('删除失败:', error); console.error("删除失败:", error);
message.error('删除失败'); message.error("删除失败");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -198,38 +254,39 @@ const ServicePage = () => {
title: "分类", title: "分类",
dataIndex: ["attributes", "category"], dataIndex: ["attributes", "category"],
key: "category", key: "category",
render: (category) => { render: (categories) => {
if (!category || !Array.isArray(category)) return null; if (!categories || !Array.isArray(categories)) return null;
return ( return (
<Paragraph <Paragraph
ellipsis={{ ellipsis={{
rows: 1, rows: 1,
tooltip: ( tooltip: (
<Space size={[0, 8]} wrap> <Space size={[0, 8]} wrap>
{category.map((cat, index) => ( {categories.map((category) => (
<Tag <Tag
size="small" size="small"
key={`${cat}-${index}`} key={category.id}
color="blue" color="blue"
className="px-2 py-1 rounded-md" className="px-2 py-1 rounded-md"
> >
{cat} {category.name}
</Tag> </Tag>
))} ))}
</Space> </Space>
) ),
}} }}
className="mb-0" className="mb-0"
> >
<Space size={[0, 8]} nowrap> <Space size={[0, 8]} nowrap>
{category.map((cat, index) => ( {categories.map((category) => (
<Tag <Tag
size="small" size="small"
key={`${cat}-${index}`} key={category.id}
color="blue" color="blue"
className="px-2 py-1 rounded-md" className="px-2 py-1 rounded-md"
> >
{cat} {category.name}
</Tag> </Tag>
))} ))}
</Space> </Space>
@@ -241,18 +298,18 @@ const ServicePage = () => {
title: "描述", title: "描述",
dataIndex: ["attributes", "description"], dataIndex: ["attributes", "description"],
key: "description", key: "description",
width: 300, className: "min-w-[100px] ",
render: (text) => ( render: (text) => (
<Paragraph <Paragraph
ellipsis={{ ellipsis={{
rows: 2, rows: 2,
tooltip: text tooltip: text,
}} }}
className="mb-0" className="mb-0"
> >
{text} {text}
</Paragraph> </Paragraph>
) ),
}, },
{ {
title: "总金额", title: "总金额",
@@ -260,7 +317,7 @@ const ServicePage = () => {
key: "totalAmount", key: "totalAmount",
className: "min-w-[150px] text-right", className: "min-w-[150px] text-right",
render: (amount) => ( render: (amount) => (
<span style={{ color: '#f50', fontWeight: 'bold' }}> <span style={{ color: "#f50", fontWeight: "bold" }}>
¥ ¥
{amount?.toLocaleString("zh-CN", { {amount?.toLocaleString("zh-CN", {
minimumFractionDigits: 2, minimumFractionDigits: 2,
@@ -272,21 +329,25 @@ const ServicePage = () => {
{ {
title: "操作", title: "操作",
key: "action", key: "action",
fixed: 'right', fixed: "right",
width: 220, width: 220,
render: (_, record) => ( render: (_, record) => (
<Space onClick={e => e.stopPropagation()}> <Space onClick={(e) => e.stopPropagation()}>
<Button <Button
type="link" type="link"
icon={<EyeOutlined />} icon={<EyeOutlined />}
onClick={() => navigate(`/company/serviceTemplateInfo/${record.id}`)} onClick={() =>
navigate(`/company/serviceTemplateInfo/${record.id}`)
}
> >
查看 查看
</Button> </Button>
<Button <Button
type="link" type="link"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => navigate(`/company/serviceTemplateInfo/${record.id}?edit=true`)} onClick={() =>
navigate(`/company/serviceTemplateInfo/${record.id}?edit=true`)
}
> >
编辑 编辑
</Button> </Button>
@@ -298,11 +359,7 @@ const ServicePage = () => {
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
onConfirm={() => handleDeleteService(record.id)} onConfirm={() => handleDeleteService(record.id)}
> >
<Button <Button type="link" danger className="text-red-600">
type="link"
danger
className="text-red-600"
>
删除 删除
</Button> </Button>
</Popconfirm> </Popconfirm>
@@ -326,19 +383,26 @@ const ServicePage = () => {
name={[record.sectionIndex, "items", record.itemIndex, "name"]} name={[record.sectionIndex, "items", record.itemIndex, "name"]}
className="!mb-0" className="!mb-0"
> >
<Input <Input
value={text} value={text}
onChange={e => { onChange={(e) => {
const newData = [...data]; const newData = [...data];
const targetService = newData.find(item => item.id === record.serviceId); const targetService = newData.find(
const targetItem = targetService.attributes.sections[record.sectionIndex].items[record.itemIndex]; (item) => item.id === record.serviceId
);
const targetItem =
targetService.attributes.sections[record.sectionIndex].items[
record.itemIndex
];
targetItem.name = e.target.value; targetItem.name = e.target.value;
setData(newData); setData(newData);
}} }}
/> />
</Form.Item> </Form.Item>
) : text; ) : (
} text
);
},
}, },
{ {
title: "描述", title: "描述",
@@ -349,16 +413,25 @@ const ServicePage = () => {
const isEditing = record.key === editingKey; const isEditing = record.key === editingKey;
return isEditing ? ( return isEditing ? (
<Form.Item <Form.Item
name={[record.sectionIndex, "items", record.itemIndex, "description"]} name={[
record.sectionIndex,
"items",
record.itemIndex,
"description",
]}
className="!mb-0" className="!mb-0"
> >
<Input.TextArea <Input
rows={1} value={text}
value={text} onChange={(e) => {
onChange={e => {
const newData = [...data]; const newData = [...data];
const targetService = newData.find(item => item.id === record.serviceId); const targetService = newData.find(
const targetItem = targetService.attributes.sections[record.sectionIndex].items[record.itemIndex]; (item) => item.id === record.serviceId
);
const targetItem =
targetService.attributes.sections[record.sectionIndex].items[
record.itemIndex
];
targetItem.description = e.target.value; targetItem.description = e.target.value;
setData(newData); setData(newData);
}} }}
@@ -368,14 +441,14 @@ const ServicePage = () => {
<Paragraph <Paragraph
ellipsis={{ ellipsis={{
rows: 2, rows: 2,
tooltip: text tooltip: text,
}} }}
className="mb-0" className="mb-0"
> >
{text} {text}
</Paragraph> </Paragraph>
); );
} },
}, },
{ {
title: "单价", title: "单价",
@@ -385,26 +458,32 @@ const ServicePage = () => {
render: (price, record) => { render: (price, record) => {
const isEditing = record.key === editingKey; const isEditing = record.key === editingKey;
return isEditing ? ( return isEditing ? (
<Input <Input
type="number" type="number"
value={price} value={price}
onChange={e => { onChange={(e) => {
const newData = [...data]; const newData = [...data];
const targetService = newData.find(item => item.id === record.serviceId); const targetService = newData.find(
const targetItem = targetService.attributes.sections[record.sectionIndex].items[record.itemIndex]; (item) => item.id === record.serviceId
);
const targetItem =
targetService.attributes.sections[record.sectionIndex].items[
record.itemIndex
];
targetItem.price = Number(e.target.value); targetItem.price = Number(e.target.value);
setData(newData); setData(newData);
}} }}
/> />
) : ( ) : (
<span className="text-gray-600"> <span className="text-gray-600">
¥{price?.toLocaleString("zh-CN", { ¥
{price?.toLocaleString("zh-CN", {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
})} })}
</span> </span>
); );
} },
}, },
{ {
title: "数量", title: "数量",
@@ -414,19 +493,26 @@ const ServicePage = () => {
render: (quantity, record) => { render: (quantity, record) => {
const isEditing = record.key === editingKey; const isEditing = record.key === editingKey;
return isEditing ? ( return isEditing ? (
<Input <Input
type="number" type="number"
value={quantity} value={quantity}
onChange={e => { onChange={(e) => {
const newData = [...data]; const newData = [...data];
const targetService = newData.find(item => item.id === record.serviceId); const targetService = newData.find(
const targetItem = targetService.attributes.sections[record.sectionIndex].items[record.itemIndex]; (item) => item.id === record.serviceId
);
const targetItem =
targetService.attributes.sections[record.sectionIndex].items[
record.itemIndex
];
targetItem.quantity = Number(e.target.value); targetItem.quantity = Number(e.target.value);
setData(newData); setData(newData);
}} }}
/> />
) : <span className="text-gray-600">{quantity}</span>; ) : (
} <span className="text-gray-600">{quantity}</span>
);
},
}, },
{ {
title: "小计", title: "小计",
@@ -446,37 +532,39 @@ const ServicePage = () => {
), ),
}, },
{ {
title: '操作', title: "操作",
key: 'action', key: "action",
width: "150px", width: "150px",
render: (_, record) => { render: (_, record) => {
const isEditing = record.key === editingKey; const isEditing = record.key === editingKey;
return isEditing ? ( return isEditing ? (
<Space> <Space>
<Button <Button
type="link" type="link"
size="small" size="small"
className="text-green-600" className="text-green-600"
onClick={() => handleSaveItem(record, record.sectionIndex, record.itemIndex)} onClick={() =>
handleSaveItem(record, record.sectionIndex, record.itemIndex)
}
> >
保存 保存
</Button> </Button>
<Button <Button
type="link" type="link"
size="small" size="small"
className="text-gray-600" className="text-gray-600"
onClick={() => setEditingKey('')} onClick={() => setEditingKey("")}
> >
取消 取消
</Button> </Button>
</Space> </Space>
) : ( ) : (
<Space> <Space>
<Button <Button
type="link" type="link"
size="small" size="small"
className="text-blue-600" className="text-blue-600"
disabled={editingKey !== ''} disabled={editingKey !== ""}
onClick={() => setEditingKey(record.key)} onClick={() => setEditingKey(record.key)}
> >
编辑 编辑
@@ -489,19 +577,19 @@ const ServicePage = () => {
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
onConfirm={() => handleDeleteItem(record)} onConfirm={() => handleDeleteItem(record)}
> >
<Button <Button
type="link" type="link"
size="small" size="small"
className="text-red-600" className="text-red-600"
disabled={editingKey !== ''} disabled={editingKey !== ""}
> >
删除 删除
</Button> </Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
); );
} },
} },
]; ];
// 修改 expandedRowRender 函数,确保 key 的唯一性 // 修改 expandedRowRender 函数,确保 key 的唯一性
@@ -520,8 +608,9 @@ const ServicePage = () => {
总计: ¥ 总计: ¥
{section.items {section.items
.reduce( .reduce(
(total, item) => (total, item) =>
total + (Number(item.price) || 0) * (Number(item.quantity) || 0), total +
(Number(item.price) || 0) * (Number(item.quantity) || 0),
0 0
) )
.toLocaleString("zh-CN", { .toLocaleString("zh-CN", {
@@ -532,11 +621,11 @@ const ServicePage = () => {
</div> </div>
} }
headStyle={{ headStyle={{
background: 'rgba(59, 130, 246, 0.05)', background: "rgba(59, 130, 246, 0.05)",
borderBottom: '1px solid rgba(59, 130, 246, 0.1)' borderBottom: "1px solid rgba(59, 130, 246, 0.1)",
}} }}
bodyStyle={{ bodyStyle={{
background: 'rgba(59, 130, 246, 0.02)' background: "rgba(59, 130, 246, 0.02)",
}} }}
> >
<Table <Table
@@ -546,7 +635,7 @@ const ServicePage = () => {
key: `${record.id}-${sectionIndex}-${itemIndex}`, key: `${record.id}-${sectionIndex}-${itemIndex}`,
serviceId: record.id, serviceId: record.id,
sectionIndex, sectionIndex,
itemIndex itemIndex,
}))} }))}
pagination={false} pagination={false}
className="rounded-lg overflow-hidden" className="rounded-lg overflow-hidden"
@@ -559,6 +648,7 @@ const ServicePage = () => {
useEffect(() => { useEffect(() => {
fetchServices(); fetchServices();
fetchCategories();
}, []); }, []);
return ( return (
@@ -567,12 +657,18 @@ const ServicePage = () => {
title={<span className="text-xl font-medium">服务模版管理</span>} title={<span className="text-xl font-medium">服务模版管理</span>}
className="h-full w-full overflow-auto" className="h-full w-full overflow-auto"
extra={ extra={
<Button <Space>
type="primary" <Sections />
onClick={() => navigate("/company/serviceTemplateInfo")} <UnitManagement />
> <CategoryDrawer />
新建模版
</Button> <Button
type="primary"
onClick={() => navigate("/company/serviceTemplateInfo")}
>
新建模版
</Button>
</Space>
} }
> >
<Table <Table
@@ -587,29 +683,34 @@ const ServicePage = () => {
expandIcon: ({ expanded, onExpand, record }) => ( expandIcon: ({ expanded, onExpand, record }) => (
<Button <Button
type="link" type="link"
onClick={e => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onExpand(record, e); onExpand(record, e);
}} }}
icon={expanded ? <UpOutlined /> : <DownOutlined />} icon={expanded ? <UpOutlined /> : <DownOutlined />}
className={` className={`
transition-all duration-300 transition-all duration-300
${expanded ${
? 'text-blue-600' expanded
: 'text-gray-400 hover:text-blue-600' ? "text-blue-600"
: "text-gray-400 hover:text-blue-600"
} }
`} `}
/> />
), ),
rowExpandable: record => record.attributes.sections?.length > 0, rowExpandable: (record) => record.attributes.sections?.length > 0,
}} }}
className="rounded-lg overflow-hidden" className="rounded-lg overflow-hidden"
rowClassName={(record, index) => ` rowClassName={(record, index) => `
cursor-pointer transition-colors cursor-pointer transition-colors
${index % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-750'} ${
index % 2 === 0
? "bg-white dark:bg-gray-800"
: "bg-gray-50 dark:bg-gray-750"
}
hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:bg-blue-50 dark:hover:bg-blue-900/20
`} `}
expandedRowClassName={() => 'bg-blue-50/50 dark:bg-blue-900/10'} expandedRowClassName={() => "bg-blue-50/50 dark:bg-blue-900/10"}
/> />
</Card> </Card>
</div> </div>

View File

@@ -0,0 +1,446 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Drawer, Modal, Form, Input, InputNumber, Space, message, Popconfirm, Select } from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
import { supabase } from '@/config/supabase';
const SectionManagement = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [drawerVisible, setDrawerVisible] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingRecord, setEditingRecord] = useState(null);
const [form] = Form.useForm();
const [units, setUnits] = useState([]);
// 获取子模块数据
const fetchSections = async () => {
setLoading(true);
try {
const { data: sections, error } = await supabase
.from('resources')
.select('*')
.eq('type', 'sections');
if (error) throw error;
setData(sections || []);
} catch (error) {
message.error('获取子模块数据失败');
console.error(error);
} finally {
setLoading(false);
}
};
// 获取单位数据
const fetchUnits = async () => {
try {
const { data: unitsData, error } = await supabase
.from('resources')
.select('*')
.eq('type', 'units')
.order('created_at', { ascending: false });
if (error) throw error;
setUnits(unitsData || []);
} catch (error) {
message.error('获取单位数据失败');
console.error(error);
}
};
useEffect(() => {
if (drawerVisible) {
fetchSections();
}
fetchUnits();
}, [drawerVisible]);
// 打开新增/编辑模态框
const showModal = async (record = null) => {
setModalVisible(true);
setEditingRecord(record);
if (record) {
try {
const { data: section, error } = await supabase
.from('resources')
.select('*')
.eq('id', record.id)
.single();
if (error) throw error;
form.setFieldsValue({
name: section.attributes.name,
items: section.attributes.items
});
} catch (error) {
message.error('获取子模块详情失败');
console.error(error);
}
} else {
form.setFieldsValue({
name: '',
items: [{ name: '', description: '', price: 0, quantity: 1, unit: '' }]
});
}
};
// 保存子模块数据
const handleSave = async (values) => {
try {
if (editingRecord) {
// 更新
const { error } = await supabase
.from('resources')
.update({
attributes: {
name: values.name,
items: values.items
},
updated_at: new Date().toISOString()
})
.eq('id', editingRecord.id);
if (error) throw error;
} else {
// 新增
const { error } = await supabase
.from('resources')
.insert([{
type: 'sections',
attributes: {
name: values.name,
items: values.items
},
schema_version: 1
}]);
if (error) throw error;
}
message.success('保存成功');
setModalVisible(false);
form.resetFields();
fetchSections();
} catch (error) {
message.error('保存失败');
console.error(error);
}
};
// 删除子模块
const handleDelete = async (id) => {
try {
const { error } = await supabase
.from('resources')
.delete()
.eq('id', id);
if (error) throw error;
message.success('删除成功');
fetchSections();
} catch (error) {
message.error('删除失败');
console.error(error);
}
};
// Drawer 中表格的列定义
const drawerColumns = [
{
title: '项目名称',
dataIndex: 'name',
width: '20%',
},
{
title: '描述',
dataIndex: 'description',
width: '25%',
},
{
title: '单价',
dataIndex: 'price',
width: '15%',
render: (price) => `¥${price}`
},
{
title: '数量',
dataIndex: 'quantity',
width: '15%',
},
{
title: '单位',
dataIndex: 'unit',
width: '15%',
}
];
// 添加模态框内表格列定义
const modalColumns = [
{
title: '项目名称',
dataIndex: 'name',
width: '20%',
render: (_, __, index) => (
<Form.Item
name={[index, 'name']}
style={{ margin: 0 }}
>
<Input placeholder="项目名称" />
</Form.Item>
)
},
{
title: '描述',
dataIndex: 'description',
width: '25%',
render: (_, __, index) => (
<Form.Item
name={[index, 'description']}
style={{ margin: 0 }}
>
<Input placeholder="描述" />
</Form.Item>
)
},
{
title: '单价',
dataIndex: 'price',
width: '15%',
render: (_, __, index) => (
<Form.Item
name={[index, 'price']}
rules={[{ required: true, message: '请输入单价!' }]}
style={{ margin: 0 }}
>
<InputNumber
min={0}
placeholder="单价"
className="w-full"
/>
</Form.Item>
)
},
{
title: '数量',
dataIndex: 'quantity',
width: '15%',
render: (_, __, index) => (
<Form.Item
name={[index, 'quantity']}
rules={[{ required: true, message: '请输入数量!' }]}
style={{ margin: 0 }}
>
<InputNumber
min={1}
placeholder="数量"
className="w-full"
/>
</Form.Item>
)
},
{
title: '单位',
dataIndex: 'unit',
width: '15%',
render: (_, __, index) => (
<Form.Item
name={[index, 'unit']}
rules={[{ required: true, message: '请选择或输入单位!' }]}
style={{ margin: 0 }}
>
<Select
placeholder="请选择或输入单位"
className="w-full"
showSearch
allowClear
options={units.map(unit => ({
label: unit.attributes.name,
value: unit.attributes.name
}))}
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
/>
</Form.Item>
)
},
{
title: '操作',
width: '10%',
render: (_, __, index, { remove }) => (
<Button
type="link"
danger
icon={<DeleteOutlined />}
onClick={() => remove(index)}
/>
)
}
];
return (
<div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setDrawerVisible(true)}
className="flex items-center"
>
子模块管理
</Button>
<Drawer
title={
<span className="text-lg font-medium text-gray-800 dark:text-gray-200">
子模块管理
</span>
}
placement="right"
width={1000}
onClose={() => setDrawerVisible(false)}
open={drawerVisible}
className="dark:bg-gray-800"
>
<div className="flex flex-col h-full">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => showModal()}
className="mb-4 w-32 flex items-center justify-center"
>
新增子模块
</Button>
<div className="space-y-6">
{data.map((section) => (
<div
key={section.id}
className="bg-white rounded-lg shadow-sm dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
>
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-800 dark:text-gray-200">
{section.attributes.name}
</h3>
<Space>
<Button
type="link"
icon={<EditOutlined />}
onClick={() => showModal(section)}
className="text-blue-600 hover:text-blue-500"
>
编辑
</Button>
<Popconfirm
title="确定要删除吗?"
onConfirm={() => handleDelete(section.id)}
okButtonProps={{
className: "bg-red-500 hover:bg-red-600 border-red-500"
}}
>
<Button
type="link"
danger
icon={<DeleteOutlined />}
className="text-red-600 hover:text-red-500"
/>
</Popconfirm>
</Space>
</div>
<div className="p-4">
<Table
dataSource={section.attributes.items}
columns={drawerColumns}
pagination={false}
rowKey={(record, index) => `${section.id}-${index}`}
className="border dark:border-gray-700 rounded-lg"
rowClassName="hover:bg-gray-50 dark:hover:bg-gray-700/50"
size="small"
/>
</div>
</div>
))}
</div>
</div>
</Drawer>
<Modal
title={`${editingRecord ? '编辑' : '新增'}子模块`}
open={modalVisible}
onCancel={() => {
setModalVisible(false);
setEditingRecord(null);
form.resetFields();
}}
footer={null}
width={1200}
destroyOnClose={true}
>
<Form
form={form}
onFinish={handleSave}
layout="vertical"
className="mt-4"
>
<Form.Item
name="name"
label="子模块名称"
rules={[{ required: true, message: '请输入子模块名称!' }]}
>
<Input placeholder="请输入子模块名称" />
</Form.Item>
<Form.List name="items">
{(fields, { add, remove }) => (
<div className="bg-white rounded-lg border dark:bg-gray-800 dark:border-gray-700">
<Table
dataSource={fields}
columns={modalColumns.map(col => ({
...col,
render: (...args) => col.render(...args, { remove })
}))}
pagination={false}
rowKey="key"
className="mb-4"
/>
<div className="p-4 border-t dark:border-gray-700">
<Button
type="dashed"
onClick={() => add({
name: '',
description: '',
price: 0,
quantity: 1,
unit: ''
})}
icon={<PlusOutlined />}
className="w-full hover:border-blue-400 hover:text-blue-500
dark:border-gray-600 dark:text-gray-400 dark:hover:text-blue-400"
>
添加项目
</Button>
</div>
</div>
)}
</Form.List>
<div className="flex justify-end gap-4 mt-6">
<Button onClick={() => {
setModalVisible(false);
setEditingRecord(null);
form.resetFields();
}}>
取消
</Button>
<Button type="primary" htmlType="submit">
保存
</Button>
</div>
</Form>
</Modal>
</div>
);
};
export default SectionManagement;

View File

@@ -0,0 +1,262 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Drawer, Form, Input, Space, message, Popconfirm } from 'antd';
import { PlusOutlined, DeleteOutlined, SaveOutlined, CloseOutlined } from '@ant-design/icons';
import { supabase } from '@/config/supabase';
const UnitManagement = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [drawerVisible, setDrawerVisible] = useState(false);
const [editingKey, setEditingKey] = useState('');
const [form] = Form.useForm();
// 获取单位数据
const fetchUnits = async () => {
setLoading(true);
try {
const { data: units, error } = await supabase
.from('resources')
.select('*')
.eq('type', 'units')
.order('created_at', { ascending: false });
if (error) throw error;
setData(units || []);
} catch (error) {
message.error('获取单位数据失败');
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (drawerVisible) {
fetchUnits();
}
}, [drawerVisible]);
// 新增<E696B0><E5A29E><EFBFBD>
const handleAdd = () => {
const newData = {
id: Date.now().toString(),
attributes: { name: '' },
isNew: true
};
setData([newData, ...data]);
setEditingKey(newData.id);
form.setFieldsValue(newData.attributes);
};
// 保存单位数据
const handleSave = async (record) => {
try {
const values = await form.validateFields();
if (record.isNew) {
// 新增
const { error } = await supabase
.from('resources')
.insert([{
type: 'units',
attributes: {
name: values.name
},
schema_version: 1
}]);
if (error) throw error;
} else {
// 更新
const { error } = await supabase
.from('resources')
.update({
attributes: {
name: values.name
},
updated_at: new Date().toISOString()
})
.eq('id', record.id);
if (error) throw error;
}
message.success('保存成功');
setEditingKey('');
fetchUnits();
} catch (error) {
message.error('保存失败');
console.error(error);
}
};
// 删除单位
const handleDelete = async (id) => {
try {
const { error } = await supabase
.from('resources')
.delete()
.eq('id', id);
if (error) throw error;
message.success('删除成功');
fetchUnits();
} catch (error) {
message.error('删除失败');
console.error(error);
}
};
// 修改取消编辑的处理方法
const handleCancel = (record) => {
setEditingKey('');
if (record.isNew) {
// 如果是新增的记录,直接从数据中移除
setData(data.filter(item => item.id !== record.id));
}
form.resetFields();
};
const columns = [
{
title: '单位名称',
dataIndex: ['attributes', 'name'],
width: '60%',
render: (text, record) => {
const isEditing = record.id === editingKey;
return isEditing ? (
<Form.Item
name="name"
rules={[{ required: true, message: '请输入单位名称!' }]}
style={{ margin: 0 }}
initialValue={text}
>
<Input placeholder="请输入单位名称" />
</Form.Item>
) : (
text
);
}
},
{
title: '操作',
width: '40%',
render: (_, record) => {
const isEditing = record.id === editingKey;
return isEditing ? (
<Space>
<Button
type="link"
icon={<SaveOutlined />}
onClick={() => handleSave(record)}
className="text-green-600 hover:text-green-500"
>
保存
</Button>
<Button
type="link"
icon={<CloseOutlined />}
onClick={() => handleCancel(record)}
className="text-gray-600 hover:text-gray-500"
>
取消
</Button>
</Space>
) : (
<Space>
<Button
type="link"
disabled={editingKey !== ''}
onClick={() => {
setEditingKey(record.id);
form.setFieldsValue(record.attributes);
}}
className="text-blue-600 hover:text-blue-500 disabled:text-gray-400"
>
编辑
</Button>
<Popconfirm
title="确定要删除吗?"
onConfirm={() => handleDelete(record.id)}
okButtonProps={{
className: "bg-red-500 hover:bg-red-600 border-red-500"
}}
>
<Button
type="link"
danger
disabled={editingKey !== ''}
icon={<DeleteOutlined />}
className="text-red-600 hover:text-red-500 disabled:text-gray-400"
/>
</Popconfirm>
</Space>
);
}
}
];
return (
<div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setDrawerVisible(true)}
className="flex items-center"
>
单位管理
</Button>
<Drawer
title={
<span className="text-lg font-medium text-gray-800 dark:text-gray-200">
单位管理
</span>
}
placement="right"
width={600}
onClose={() => {
setDrawerVisible(false);
setEditingKey('');
form.resetFields();
}}
open={drawerVisible}
className="dark:bg-gray-800"
>
<div className="flex flex-col h-full">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
disabled={editingKey !== ''}
className="mb-4 w-32 flex items-center justify-center"
>
新增单位
</Button>
<Form form={form} className="flex-1">
<Table
dataSource={data}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
className: "dark:text-gray-300"
}}
className="bg-white dark:bg-gray-800 rounded-lg shadow-sm"
rowClassName={(record) =>
`transition-colors ${
record.id === editingKey
? 'bg-blue-50 dark:bg-blue-900/20'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`
}
/>
</Form>
</div>
</Drawer>
</div>
);
};
export default UnitManagement;

View File

@@ -1,5 +1,15 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es2015" "target": "es2015",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@pages/*": ["src/pages/*"],
"@utils/*": ["src/utils/*"],
"@config/*": ["src/config/*"],
"@contexts/*": ["src/contexts/*"],
"@assets/*": ["src/assets/*"]
}
} }
} }

View File

@@ -2,18 +2,30 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
// 使用 __dirname 需要添加 import.meta.url 的支持
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, 'src'),
'@components': path.resolve(__dirname, 'src/components'),
'@pages': path.resolve(__dirname, 'src/pages'),
'@utils': path.resolve(__dirname, 'src/utils'),
'@config': path.resolve(__dirname, 'src/config'),
'@contexts': path.resolve(__dirname, 'src/contexts'),
'@assets': path.resolve(__dirname, 'src/assets'),
}, },
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'] // 添加文件扩展名自动解析
}, },
server: { server: {
port: 3000, // 设置开发服务器端口为 3000 port: 3000,
open: true, // 自动打开浏览器 open: true,
host: true, // 监听所有地址,包括局域网和公网地址 host: true,
strictPort: true, // 如果端口被占用,直接退出 strictPort: true,
}, },
build: { build: {
outDir: 'dist', outDir: 'dist',