Files
smart-crop-ui/crop-x/src/app/(app)/central-config/tenant/enterprise-management/page.tsx

708 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* filekorolheader: 企业管理 - 企业信息管理与维护页面
* 功能:企业列表查询、详情查看、状态管理、分页筛选
* 路径:/central-config/tenant/enterprise-management
* 规范遵循crop-x/docs/开发项目规范.md使用useReducer状态管理API集成shadcn语义化样式
*/
'use client';
import { useEffect, useMemo, useState, useCallback } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Building2, Eye, Power, PowerOff, Plus, FileText, CreditCard, User, Search } from 'lucide-react';
import { toast } from 'sonner';
import SearchFormPagination, {
type SearchFieldConfig,
type TableColumnConfig
} from '@/components/common/searchFormPagination';
import { fetchTenants, transformTenantData, enableTenant, disableTenant, createEnterprise, TenantsQueryParams, Enterprise } from './components/enterpriseApi';
import { CreateEnterpriseDialog } from './components/CreateEnterpriseDialog';
// Utility functions
const getStatusBadge = (status: 'active' | 'inactive') => {
if (status === 'active') {
return (
<div className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400 border border-green-200 dark:border-green-800">
</div>
);
}
return (
<div className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-800">
</div>
);
};
const getAuditStatusBadge = (auditStatus: Enterprise['auditStatus']) => {
switch (auditStatus) {
case 'draft':
return (
<div className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-800">
稿
</div>
);
case 'pending':
return (
<div className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-yellow-50 dark:bg-yellow-950 text-yellow-600 dark:text-yellow-400 border border-yellow-200 dark:border-yellow-800">
</div>
);
case 'approved':
return (
<div className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400 border border-green-200 dark:border-green-800">
</div>
);
case 'rejected':
return (
<div className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
</div>
);
default:
return (
<div className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-800">
稿
</div>
);
}
};
export default function EnterpriseManagement() {
// 对话框状态管理
const [dialogs, setDialogs] = useState({
showViewDialog: false,
showAddDialog: false,
showStatusDialog: false,
selectedEnterprise: null as Enterprise | null,
statusAction: 'enable' as 'enable' | 'disable'
});
const dispatch = (action: any) => {
switch (action.type) {
case 'SET_SELECTED_ENTERPRISE':
setDialogs(prev => ({ ...prev, selectedEnterprise: action.payload }));
break;
case 'TOGGLE_VIEW_DIALOG':
setDialogs(prev => ({ ...prev, showViewDialog: action.payload }));
break;
case 'TOGGLE_ADD_DIALOG':
setDialogs(prev => ({ ...prev, showAddDialog: action.payload }));
break;
case 'TOGGLE_STATUS_DIALOG':
setDialogs(prev => ({ ...prev, showStatusDialog: action.payload }));
break;
case 'SET_STATUS_ACTION':
setDialogs(prev => ({ ...prev, statusAction: action.payload }));
break;
case 'RESET_FORM_DATA':
setDialogs(prev => ({ ...prev, selectedEnterprise: null }));
break;
}
};
// 搜索字段配置
const searchFields: SearchFieldConfig[] = [
{
key: 'search',
label: '搜索',
type: 'text',
placeholder: '搜索企业名称、编码...',
},
{
key: 'audit_status',
label: '审核状态',
type: 'select',
placeholder: '审核状态',
defaultValue: 'all',
options: [
{ value: 'all', label: '全部状态' },
{ value: '草稿', label: '草稿' },
{ value: '待审核', label: '待审核' },
{ value: '已通过', label: '审核通过' },
{ value: '已拒绝', label: '已拒绝' },
],
},
];
// 表格列配置
const columns: TableColumnConfig[] = [
{
key: 'code',
label: '企业编码',
width: '120px',
},
{
key: 'name',
label: '企业名称',
render: (value: string) => (
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-blue-500" />
<span className="font-medium">{value}</span>
</div>
),
},
{
key: 'type',
label: '企业类型',
render: (value: string) => (
<div className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium border border-gray-200 dark:border-gray-800">
{value}
</div>
),
},
{
key: 'registrant',
label: '登记人',
render: (value?: string) => value || '-',
},
{
key: 'contactPhone',
label: '联系电话',
render: (value?: string) => value || '-',
},
{
key: 'createdAt',
label: '创建时间',
width: '160px',
},
{
key: 'auditStatus',
label: '审核状态',
render: (value: Enterprise['auditStatus']) => getAuditStatusBadge(value),
},
{
key: 'status',
label: '状态',
render: (value: Enterprise['status']) => getStatusBadge(value),
},
{
key: 'actions',
label: '操作',
render: (_: any, row: Enterprise) => (
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleView(row)}
>
<Eye className="w-3 h-3 mr-1" />
</Button>
{row.status === 'active' ? (
<Button
size="sm"
variant="outline"
className="text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600"
onClick={() => handleStatusChange(row, 'disable')}
>
<PowerOff className="w-3 h-3 mr-1" />
</Button>
) : (
<Button
size="sm"
variant="outline"
className="text-green-600 dark:text-green-400 border-green-300 dark:border-green-600"
onClick={() => handleStatusChange(row, 'enable')}
>
<Power className="w-3 h-3 mr-1" />
</Button>
)}
</div>
),
},
];
// 简化的状态管理 - 只需要存储数据和加载状态
const [enterprises, setEnterprises] = useState<Enterprise[]>([]);
const [pagination, setPagination] = useState({
page: 1,
size: 10,
total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchFilters, setSearchFilters] = useState<Record<string, string>>({
search: '',
audit_status: 'all'
});
// 数据加载函数 - 移除不必要的依赖避免重复调用
const loadEnterprises = useCallback(async (params?: {
filters?: Record<string, string>;
pagination?: { page: number; size: number };
sort?: { sortBy?: string; sortOrder?: 'asc' | 'desc' };
}) => {
try {
console.log('调用了loadEnterprises')
setLoading(true);
setError(null);
const finalParams: TenantsQueryParams = {
search: (params?.filters?.search ?? searchFilters.search) || undefined,
audit_status: params?.filters?.audit_status ?? searchFilters.audit_status,
page: params?.pagination?.page || pagination.page,
size: params?.pagination?.size || pagination.size,
order_by: params?.sort?.sortBy,
sort_order: params?.sort?.sortOrder,
};
// 处理audit_status如果为'all'则不传该参数
if (finalParams.audit_status === 'all') {
finalParams.audit_status = undefined;
}
const response = await fetchTenants(finalParams);
const transformedData = response.data.map(transformTenantData);
setEnterprises(transformedData);
setPagination({
page: response.page,
size: response.size,
total: response.total,
totalPages: response.total_pages,
hasNext: response.has_next,
hasPrev: response.has_prev,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '加载企业数据失败';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
}, []); // 移除所有依赖,使用参数传递状态变化
// 事件处理器
const handleSearch = useCallback((filters: Record<string, string>) => {
setSearchFilters(filters);
// 搜索时重置到第一页
loadEnterprises({
filters,
pagination: { page: 1, size: pagination.size }
});
}, [loadEnterprises, pagination.size]);
const handleSort = useCallback((sortBy: string, sortOrder: 'asc' | 'desc') => {
// 排序时重置到第一页
loadEnterprises({
pagination: { page: 1, size: pagination.size },
sort: { sortBy, sortOrder }
});
}, [loadEnterprises, pagination.size]);
const handlePageChange = useCallback((page: number) => {
setPagination(prev => ({ ...prev, page }));
loadEnterprises({
pagination: { page, size: pagination.size }
});
}, [loadEnterprises, pagination.size]);
const handleSizeChange = useCallback((size: number) => {
setPagination(prev => ({ ...prev, size, page: 1 }));
loadEnterprises({
pagination: { page: 1, size }
});
}, [loadEnterprises]);
// 初始化数据加载
// useEffect(() => {
// loadEnterprises();
// }, []);
// 计算统计数据
const stats = useMemo(() => {
if (enterprises.length === 0) {
return { total: pagination.total, active: 0, inactive: 0 };
}
const active = enterprises.filter(e => e.status === 'active').length;
const inactive = enterprises.filter(e => e.status === 'inactive').length;
return { total: pagination.total, active, inactive };
}, [enterprises, pagination.total]);
// 业务事件处理器
const handleView = (enterprise: Enterprise) => {
dispatch({ type: 'SET_SELECTED_ENTERPRISE', payload: enterprise });
dispatch({ type: 'TOGGLE_VIEW_DIALOG', payload: true });
};
const handleStatusChange = (enterprise: Enterprise, action: 'enable' | 'disable') => {
dispatch({ type: 'SET_SELECTED_ENTERPRISE', payload: enterprise });
dispatch({ type: 'SET_STATUS_ACTION', payload: action });
dispatch({ type: 'TOGGLE_STATUS_DIALOG', payload: true });
};
const confirmStatusChange = async () => {
if (!dialogs.selectedEnterprise) return;
try {
setLoading(true);
const tenantId = dialogs.selectedEnterprise.id;
let updatedTenant;
if (dialogs.statusAction === 'enable') {
updatedTenant = await enableTenant(tenantId);
toast.success('企业已启用');
} else {
updatedTenant = await disableTenant(tenantId);
toast.success('企业已禁用');
}
// 状态更新成功后关闭对话框
dispatch({ type: 'TOGGLE_STATUS_DIALOG', payload: false });
// 重新加载数据来反映状态变化
const reloadParams: any = {
filters: searchFilters,
pagination: {
page: pagination.page,
size: pagination.size
}
};
loadEnterprises(reloadParams);
} catch (error) {
console.error('Status change failed:', error);
const errorMessage = error instanceof Error ? error.message : '状态更新失败';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
const handleCreateNew = () => {
dispatch({ type: 'RESET_FORM_DATA' });
dispatch({ type: 'TOGGLE_ADD_DIALOG', payload: true });
};
const handleCreateSuccess = () => {
// 创建成功后需要手动刷新页面数据
window.location.reload();
};
// 操作按钮配置
const actionButtons = (
<Button onClick={handleCreateNew} disabled={loading}>
<Plus className="w-4 h-4 mr-2" />
</Button>
);
return (
<div className="space-y-6">
{/* Page Header - 自定义页面头部 */}
<Card className="p-6 bg-gradient-to-r from-blue-50 dark:from-blue-950 to-indigo-50 dark:to-indigo-950 border-blue-200 dark:border-blue-800">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<Building2 className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-1" />
<div className="flex-1">
<h2 className="mb-2"></h2>
<p className="text-sm text-muted-foreground mb-3">
/
</p>
<div className="flex flex-wrap gap-2">
<div className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-800">
<Search className="w-3 h-3 mr-1" />
</div>
<div className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-800">
<Power className="w-3 h-3 mr-1" />
</div>
<div className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-800">
<Eye className="w-3 h-3 mr-1" />
</div>
</div>
</div>
</div>
</div>
</Card>
{/* Statistics Cards - 保持原有统计功能 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"></div>
<Building2 className="w-5 h-5 text-blue-500" />
</div>
<div className="text-3xl font-bold mb-1">{pagination.total}</div>
<div className="text-xs text-muted-foreground">
</div>
</div>
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"></div>
<Power className="w-5 h-5 text-green-500" />
</div>
<div className="text-3xl font-bold mb-1 text-green-600 dark:text-green-400">{stats.active}</div>
<div className="text-xs text-green-600 dark:text-green-400">
</div>
</div>
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"></div>
<PowerOff className="w-5 h-5 text-gray-500" />
</div>
<div className="text-3xl font-bold mb-1 text-gray-600 dark:text-gray-400">{stats.inactive}</div>
<div className="text-xs text-muted-foreground">
使
</div>
</div>
</div>
{/* 使用SearchFormPagination组件替换原有的企业列表 */}
<SearchFormPagination
formTitle="企业列表"
formRightContent={actionButtons}
searchFields={searchFields}
columns={columns}
data={enterprises}
loading={loading}
error={error}
pagination={pagination}
onPageChange={handlePageChange}
onSizeChange={handleSizeChange}
onSearch={handleSearch}
onSort={handleSort}
emptyIcon={<Building2 className="w-12 h-12 mx-auto mb-4 opacity-20" />}
emptyText="暂无企业数据"
/>
{/* View Enterprise Details Dialog */}
<Dialog open={dialogs.showViewDialog} onOpenChange={(open) => dispatch({ type: 'TOGGLE_VIEW_DIALOG', payload: open })}>
<DialogContent className="w-[80vw] max-w-6xl max-h-[90vh]">
<DialogHeader>
<div className="flex items-center justify-between pr-8">
<DialogTitle></DialogTitle>
{dialogs.selectedEnterprise && (
<div className="flex gap-2">
{getAuditStatusBadge(dialogs.selectedEnterprise.auditStatus)}
{getStatusBadge(dialogs.selectedEnterprise.status)}
</div>
)}
</div>
<DialogDescription className="sr-only">
</DialogDescription>
</DialogHeader>
{dialogs.selectedEnterprise && (
<ScrollArea className="max-h-[calc(90vh-200px)]">
<Tabs defaultValue="basic" className="space-y-4">
<TabsList className="grid grid-cols-4 w-full">
<TabsTrigger value="basic">
<Building2 className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="other">
<FileText className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="bank">
<CreditCard className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="legal">
<User className="w-4 h-4 mr-2" />
</TabsTrigger>
</TabsList>
{/* Basic Information */}
<TabsContent value="basic" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.name}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.code}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.type}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">
{dialogs.selectedEnterprise.province || '-'} {dialogs.selectedEnterprise.city || ''} {dialogs.selectedEnterprise.district || ''}
</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.address || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.registrant || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.contactPhone || '-'}</div>
</div>
</div>
</TabsContent>
{/* Other Information */}
<TabsContent value="other" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.companySize || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.registeredCapital || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.establishmentDate || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.invoiceType || '-'}</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="field-value p-2 bg-muted rounded">
{dialogs.selectedEnterprise.socialCreditCode ? (
<code className="text-sm font-mono">
{dialogs.selectedEnterprise.socialCreditCode}
</code>
) : '-'}
</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.businessScope || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.submitTime || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.auditTime || '-'}</div>
</div>
</div>
</TabsContent>
{/* Bank Information */}
<TabsContent value="bank" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">
{dialogs.selectedEnterprise.bankAccount ? (
<code className="text-sm font-mono">
{dialogs.selectedEnterprise.bankAccount}
</code>
) : '-'}
</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.bankName || '-'}</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.bankFullName || '-'}</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.bankAddress || '-'}</div>
</div>
</div>
</TabsContent>
{/* Legal Person Information */}
<TabsContent value="legal" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.legalPerson || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.registrant || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.auditor || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value p-2 bg-muted rounded">{dialogs.selectedEnterprise.auditComment || '-'}</div>
</div>
</div>
</TabsContent>
</Tabs>
</ScrollArea>
)}
<DialogFooter>
<Button variant="outline" onClick={() => dispatch({ type: 'TOGGLE_VIEW_DIALOG', payload: false })}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Status Change Confirmation Dialog */}
<AlertDialog open={dialogs.showStatusDialog} onOpenChange={(open) => dispatch({ type: 'TOGGLE_STATUS_DIALOG', payload: open })}>
<AlertDialogContent className="w-[80vw] max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>
{dialogs.statusAction === 'enable' ? '启用' : '禁用'}
</AlertDialogTitle>
<AlertDialogDescription>
{dialogs.statusAction === 'enable' ? (
<>
<strong>{dialogs.selectedEnterprise?.name}</strong> 使
</>
) : (
<>
<strong>{dialogs.selectedEnterprise?.name}</strong>
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmStatusChange}
className={dialogs.statusAction === 'enable' ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-600 hover:bg-gray-700'}
>
{dialogs.statusAction === 'enable' ? '启用' : '禁用'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Create Enterprise Dialog */}
<CreateEnterpriseDialog
open={dialogs.showAddDialog}
onOpenChange={(open) => dispatch({ type: 'TOGGLE_ADD_DIALOG', payload: open })}
onSuccess={handleCreateSuccess}
/>
</div>
);
}